chore(wip): consolidation intermédiaire multi-tickets (sprints Statistiques, UI, Bug resolution, Serveur-client)

Regroupe l'état de travail en cours réalisé dans un même worktree sur
plusieurs tickets/sprints (#85, #136, #145, #155-160, #162-164),
mélangeant des tickets QA et inProgress. Ne constitue pas une feature
terminée : commit de sauvegarde avant triage/split par ticket en
branches feature/* dédiées. Exclut les dossiers d'environnement de
build locaux et le heap dump parasite (.gitignore mis à jour).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 16:48:54 +02:00
parent 58272e354a
commit 917777e18b
279 changed files with 13546 additions and 674 deletions

View File

@ -1,4 +1,5 @@
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart';
abstract interface class ExerciseStepAudioCuePlayer {
Future<void> playShortCountdownBeep();
@ -13,9 +14,7 @@ final class AudioplayersExerciseStepAudioCuePlayer
AudioplayersExerciseStepAudioCuePlayer();
final AudioPlayer _player = AudioPlayer();
late final Future<void> _audioContextReady = _player.setAudioContext(
_stepCueAudioContext,
);
late final Future<void> _audioContextReady = _configureAudioContext();
@override
Future<void> playShortCountdownBeep() {
@ -33,6 +32,14 @@ final class AudioplayersExerciseStepAudioCuePlayer
await _player.play(AssetSource(assetPath));
}
Future<void> _configureAudioContext() async {
try {
await _player.setAudioContext(_stepCueAudioContext);
} on Object catch (error) {
debugPrint('Audio cue context ignored: $error');
}
}
@override
Future<void> dispose() {
return _player.dispose();

View File

@ -8,6 +8,7 @@ import 'package:share_plus/share_plus.dart';
import '../application/application.dart';
import '../domain/domain.dart';
import 'share_inbox_screen.dart';
import 'share_screen.dart';
import 'theme.dart';
final class ProfileScreen extends StatefulWidget {
@ -69,6 +70,7 @@ final class _ProfileScreenState extends State<ProfileScreen> {
syncUseCases: widget.syncUseCases,
onLogout: () => _confirmLogout(context),
onShares: () => _openReceivedShares(context),
onSharePack: () => _openSharePack(context),
dataExportUseCase: widget.dataExportUseCase,
dataImportUseCase: widget.dataImportUseCase,
backupFileExporter: widget.backupFileExporter,
@ -153,6 +155,18 @@ final class _ProfileScreenState extends State<ProfileScreen> {
);
}
void _openSharePack(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => SharePackSelectionScreen(
shareUseCases: widget.shareUseCases,
authUseCases: widget.authUseCases,
syncUseCases: widget.syncUseCases,
),
),
);
}
void _reloadSession() {
setState(() {
_session = widget.authUseCases.currentSession();
@ -258,6 +272,7 @@ final class _SignedInProfile extends StatelessWidget {
required this.syncUseCases,
required this.onLogout,
required this.onShares,
required this.onSharePack,
required this.dataExportUseCase,
required this.dataImportUseCase,
required this.backupFileExporter,
@ -268,6 +283,7 @@ final class _SignedInProfile extends StatelessWidget {
final SyncUseCases syncUseCases;
final VoidCallback onLogout;
final VoidCallback onShares;
final VoidCallback onSharePack;
final DataExportUseCase dataExportUseCase;
final DataImportUseCase dataImportUseCase;
final LocalBackupFileExporter backupFileExporter;
@ -320,18 +336,34 @@ final class _SignedInProfile extends StatelessWidget {
),
const SizedBox(height: 12),
CourtBlazerAccentPanel(
child: Material(
type: MaterialType.transparency,
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.inbox_outlined),
title: const Text('Partages reçus'),
subtitle: const Text(
"Programmes et séances reçus d'autres comptes",
child: Column(
children: [
Material(
type: MaterialType.transparency,
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.inventory_2_outlined),
title: const Text('Partager un pack'),
subtitle: const Text('Envoyer plusieurs séances ensemble'),
trailing: const Icon(Icons.chevron_right),
onTap: onSharePack,
),
),
trailing: const Icon(Icons.chevron_right),
onTap: onShares,
),
const Divider(height: 1),
Material(
type: MaterialType.transparency,
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.inbox_outlined),
title: const Text('Partages reçus'),
subtitle: const Text(
"Programmes, séances et packs reçus d'autres comptes",
),
trailing: const Icon(Icons.chevron_right),
onTap: onShares,
),
),
],
),
),
],

View File

@ -46,6 +46,9 @@ final class _ShareInboxScreenState extends State<ShareInboxScreen> {
final item = items[index];
return _ShareInboxTile(
item: item,
onOpen: item.resourceType == ShareResourceType.pack
? () => _openPack(item)
: null,
onAccept: item.status == ShareInboxStatus.pending
? () => _accept(item)
: null,
@ -80,6 +83,22 @@ final class _ShareInboxScreenState extends State<ShareInboxScreen> {
);
}
Future<void> _openPack(ShareInboxItem item) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (context) => _SharePackDetailScreen(
item: item,
onImport: item.status == ShareInboxStatus.pending
? () => _accept(item)
: null,
onDecline: item.status == ShareInboxStatus.pending
? () => _decline(item)
: null,
),
),
);
}
Future<void> _decline(ShareInboxItem item) async {
await widget.shareUseCases.declineShare(item.shareId);
if (!mounted) return;
@ -94,11 +113,13 @@ final class _ShareInboxScreenState extends State<ShareInboxScreen> {
final class _ShareInboxTile extends StatelessWidget {
const _ShareInboxTile({
required this.item,
required this.onOpen,
required this.onAccept,
required this.onDecline,
});
final ShareInboxItem item;
final VoidCallback? onOpen;
final VoidCallback? onAccept;
final VoidCallback? onDecline;
@ -112,11 +133,7 @@ final class _ShareInboxTile extends StatelessWidget {
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
item.resourceType == ShareResourceType.program
? Icons.list_alt
: Icons.event_note,
),
Icon(_resourceIcon(item.resourceType)),
const SizedBox(width: 12),
Expanded(
child: Column(
@ -140,31 +157,46 @@ final class _ShareInboxTile extends StatelessWidget {
],
),
),
Chip(
visualDensity: VisualDensity.compact,
label: Text(_statusLabel(item.status)),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Chip(
visualDensity: VisualDensity.compact,
label: Text(_statusLabel(item.status)),
),
if (item.resourceType == ShareResourceType.pack) ...[
const SizedBox(height: 4),
Chip(
visualDensity: VisualDensity.compact,
label: Text(_packBadge(summary.itemCount)),
),
],
],
),
],
),
if (item.status == ShareInboxStatus.pending) ...[
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: onDecline,
child: const Text('Refuser'),
if (item.resourceType == ShareResourceType.pack)
FilledButton(onPressed: onOpen, child: const Text('Voir le pack'))
else
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: onDecline,
child: const Text('Refuser'),
),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed: onAccept,
child: const Text('Accepter'),
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed: onAccept,
child: const Text('Accepter'),
),
),
),
],
),
],
),
],
],
),
@ -196,11 +228,91 @@ final class _EmptyInbox extends StatelessWidget {
}
}
final class _SharePackDetailScreen extends StatelessWidget {
const _SharePackDetailScreen({
required this.item,
required this.onImport,
required this.onDecline,
});
final ShareInboxItem item;
final VoidCallback? onImport;
final VoidCallback? onDecline;
@override
Widget build(BuildContext context) {
final summary = _summaryFromPayload(item);
final workouts = _packWorkoutNames(item);
return Scaffold(
appBar: AppBar(title: const Text('Pack reçu')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
CourtBlazerAccentPanel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
summary.name,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text('Envoyé par ${item.senderUserId}'),
const SizedBox(height: 8),
Chip(
visualDensity: VisualDensity.compact,
label: Text(_packBadge(summary.itemCount)),
),
const SizedBox(height: 16),
Text('Séances', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
for (final name in workouts)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.event_note),
title: Text(name),
),
if (item.status == ShareInboxStatus.pending) ...[
const SizedBox(height: 16),
FilledButton(
onPressed: onImport == null
? null
: () {
onImport!();
Navigator.of(context).pop();
},
child: const Text('Importer'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: onDecline == null
? null
: () {
onDecline!();
Navigator.of(context).pop();
},
child: const Text('Refuser'),
),
],
],
),
),
],
),
);
}
}
final class _InboxSummary {
const _InboxSummary({required this.name, required this.detail});
const _InboxSummary({
required this.name,
required this.detail,
this.itemCount = 0,
});
final String name;
final String detail;
final int itemCount;
}
_InboxSummary _summaryFromPayload(ShareInboxItem item) {
@ -216,6 +328,11 @@ _InboxSummary _summaryFromPayload(ShareInboxItem item) {
name: name,
detail: _templateDetail(payload),
),
ShareResourceType.pack => _InboxSummary(
name: name,
detail: _packDetail(payload),
itemCount: _packItemCount(payload),
),
};
} on Object {
return const _InboxSummary(name: 'Partage GameTime', detail: '');
@ -247,10 +364,45 @@ String _templateDetail(Map<String, dynamic> payload) {
'$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}';
}
String _packDetail(Map<String, dynamic> payload) {
final count = _packItemCount(payload);
return '$count séance${count > 1 ? 's' : ''}';
}
int _packItemCount(Map<String, dynamic> payload) {
return (payload['workouts'] as List<dynamic>? ?? const []).length;
}
List<String> _packWorkoutNames(ShareInboxItem item) {
try {
final payload = jsonDecode(item.payloadJson) as Map<String, dynamic>;
final workouts = payload['workouts'] as List<dynamic>? ?? const [];
return workouts
.whereType<Map>()
.map((raw) => raw['name'] as String? ?? 'Séance partagée')
.toList(growable: false);
} on Object {
return const [];
}
}
String _packBadge(int count) {
return 'Pack · $count séance${count > 1 ? 's' : ''}';
}
IconData _resourceIcon(ShareResourceType type) {
return switch (type) {
ShareResourceType.program => Icons.list_alt,
ShareResourceType.workoutTemplate => Icons.event_note,
ShareResourceType.pack => Icons.inventory_2_outlined,
};
}
String _resourceTitle(ShareResourceType type) {
return switch (type) {
ShareResourceType.program => 'Programme',
ShareResourceType.workoutTemplate => 'Séance',
ShareResourceType.pack => 'Pack',
};
}
@ -267,5 +419,6 @@ String _acceptedMessage(ShareResourceType type) {
return switch (type) {
ShareResourceType.program => 'Programme ajouté.',
ShareResourceType.workoutTemplate => 'Séance ajoutée.',
ShareResourceType.pack => 'Pack importé.',
};
}

View File

@ -12,6 +12,8 @@ final class ShareFormScreen extends StatefulWidget {
required this.localResourceId,
required this.resourceName,
required this.resourceSummary,
this.localResourceIds = const [],
this.packName,
this.authUseCases,
this.syncUseCases,
super.key,
@ -22,6 +24,8 @@ final class ShareFormScreen extends StatefulWidget {
final SyncUseCases? syncUseCases;
final ShareResourceType resourceType;
final String localResourceId;
final List<String> localResourceIds;
final String? packName;
final String resourceName;
final String resourceSummary;
@ -106,6 +110,8 @@ final class _ShareFormScreenState extends State<ShareFormScreen> {
final result = await widget.shareUseCases.sendShare(
resourceType: widget.resourceType,
localResourceId: widget.localResourceId,
localResourceIds: widget.localResourceIds,
packName: widget.packName,
recipientEmails: _parseEmails(_emailsController.text),
);
if (!mounted) return;
@ -154,6 +160,7 @@ final class ShareAccountRequiredScreen extends StatelessWidget {
final targetLabel = switch (resourceType) {
ShareResourceType.program => 'ce programme',
ShareResourceType.workoutTemplate => 'cette séance',
ShareResourceType.pack => 'ce pack',
};
final canOpenAuth = authUseCases != null && syncUseCases != null;
return Scaffold(
@ -254,6 +261,7 @@ String _shareTitle(ShareResourceType type) {
return switch (type) {
ShareResourceType.program => 'Partager le programme',
ShareResourceType.workoutTemplate => 'Partager la séance',
ShareResourceType.pack => 'Partager le pack',
};
}
@ -261,5 +269,156 @@ String _resourceTitle(ShareResourceType type) {
return switch (type) {
ShareResourceType.program => 'Programme',
ShareResourceType.workoutTemplate => 'Séance',
ShareResourceType.pack => 'Pack',
};
}
final class SharePackSelectionScreen extends StatefulWidget {
const SharePackSelectionScreen({
required this.shareUseCases,
this.authUseCases,
this.syncUseCases,
super.key,
});
final ShareUseCases shareUseCases;
final AuthUseCases? authUseCases;
final SyncUseCases? syncUseCases;
@override
State<SharePackSelectionScreen> createState() =>
_SharePackSelectionScreenState();
}
final class _SharePackSelectionScreenState
extends State<SharePackSelectionScreen> {
final _nameController = TextEditingController();
late Future<List<WorkoutTemplate>> _templates;
final _selectedTemplateIds = <String>{};
@override
void initState() {
super.initState();
_templates = widget.shareUseCases.listShareableWorkoutTemplates();
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Composer un pack')),
body: FutureBuilder<List<WorkoutTemplate>>(
future: _templates,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
final templates = snapshot.data ?? const <WorkoutTemplate>[];
if (templates.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'Aucune séance à partager.',
textAlign: TextAlign.center,
),
),
);
}
return ListView(
padding: const EdgeInsets.all(16),
children: [
CourtBlazerAccentPanel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Pack à partager',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Nom du pack',
),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 16),
Text(
'Séances',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
for (final template in templates)
Material(
type: MaterialType.transparency,
child: CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: _selectedTemplateIds.contains(
template.metadata.id,
),
title: Text(template.name),
subtitle: Text(_templatePackSummary(template)),
onChanged: (selected) {
setState(() {
if (selected == true) {
_selectedTemplateIds.add(template.metadata.id);
} else {
_selectedTemplateIds.remove(
template.metadata.id,
);
}
});
},
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _canContinue ? _continueToRecipients : null,
icon: const Icon(Icons.inventory_2_outlined),
label: const Text('Continuer'),
),
],
),
),
],
);
},
),
);
}
bool get _canContinue =>
_nameController.text.trim().isNotEmpty && _selectedTemplateIds.isNotEmpty;
Future<void> _continueToRecipients() async {
final name = _nameController.text.trim();
final ids = _selectedTemplateIds.toList(growable: false);
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (context) => ShareFormScreen(
shareUseCases: widget.shareUseCases,
authUseCases: widget.authUseCases,
syncUseCases: widget.syncUseCases,
resourceType: ShareResourceType.pack,
localResourceId: '',
localResourceIds: ids,
packName: name,
resourceName: name,
resourceSummary: '${ids.length} séance${ids.length > 1 ? 's' : ''}',
),
),
);
}
}
String _templatePackSummary(WorkoutTemplate template) {
return '${template.programs.length} programme'
'${template.programs.length > 1 ? 's' : ''}';
}

View File

@ -1634,7 +1634,13 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
final rest = await widget.activeUseCases.findActiveRest(
sessionId: _session.metadata.id,
);
if (!mounted || rest == null) {
if (!mounted) {
return false;
}
if (rest == null) {
_activeRestStateId = null;
_restTicker?.cancel();
_restTicker = null;
return false;
}
final after = ExecutionPosition(
@ -2706,6 +2712,96 @@ final class _CurrentStepPane extends StatelessWidget {
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final hasRepsWithStopwatchScore =
step.type == ExerciseStepType.reps &&
step.hasScore &&
step.scoreInputMode == ScoreInputMode.stopwatch;
final hasTimedWithManualScore =
step.type == ExerciseStepType.time &&
step.hasScore &&
step.scoreInputMode == ScoreInputMode.manual;
if (hasRepsWithStopwatchScore || hasTimedWithManualScore) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
step.name,
style: Theme.of(context).textTheme.headlineSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Expanded(
child: LayoutBuilder(
builder: (context, bodyConstraints) {
return FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.center,
child: SizedBox(
width: bodyConstraints.maxWidth,
height: hasRepsWithStopwatchScore ? 150 : 132,
child: hasRepsWithStopwatchScore
? _RepsWithStopwatchScoreStepBody(
step: step,
elapsedLabel: stepScoreElapsedLabel,
running: stepScoreRunning,
onStart: onStartStepScore,
onStop: onStopStepScore,
onReset: onResetStepScore,
)
: _TimedStepWithManualScoreBody(
step: step,
remainingLabel: remainingLabel,
running:
view.state.status ==
ActiveExerciseStepProgressStatus
.runningTimer,
readyToStart: _isNextTimedStepReady(view),
scoreController: stepScoreController,
onStartTimer: onStartTimer,
),
),
);
},
),
),
const SizedBox(height: 8),
if (hasRepsWithStopwatchScore)
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: onSkipStep,
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(44),
),
child: const Text('Passer létape'),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton.icon(
onPressed: onCompleteStep,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(44),
),
icon: const Icon(Icons.check),
label: const Text('Étape suivante'),
),
),
],
)
else
OutlinedButton(
onPressed: onSkipStep,
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(44),
),
child: const Text('Passer létape'),
),
],
);
}
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 8),
child: ConstrainedBox(
@ -2860,6 +2956,274 @@ final class _TimedStepBody extends StatelessWidget {
}
}
final class _RepsWithStopwatchScoreStepBody extends StatelessWidget {
const _RepsWithStopwatchScoreStepBody({
required this.step,
required this.elapsedLabel,
required this.running,
required this.onStart,
required this.onStop,
required this.onReset,
});
final ExerciseStep step;
final String elapsedLabel;
final bool running;
final VoidCallback onStart;
final VoidCallback onStop;
final VoidCallback onReset;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('RÉPÉTITIONS', style: theme.textTheme.labelLarge),
const SizedBox(height: 4),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'${step.defaultTargetValue}',
style: AppTextStyles.timer(
context,
).copyWith(color: theme.colorScheme.primary),
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('CHRONO SCORE', style: theme.textTheme.labelLarge),
const SizedBox(height: 6),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
elapsedLabel,
style: AppTextStyles.scoreNumber(context),
),
),
const SizedBox(height: 8),
_CompactStepScoreStopwatchControls(
running: running,
onStart: onStart,
onStop: onStop,
onReset: onReset,
),
],
),
),
],
);
}
}
final class _TimedStepWithManualScoreBody extends StatelessWidget {
const _TimedStepWithManualScoreBody({
required this.step,
required this.remainingLabel,
required this.running,
required this.readyToStart,
required this.scoreController,
required this.onStartTimer,
});
final ExerciseStep step;
final String remainingLabel;
final bool running;
final bool readyToStart;
final TextEditingController scoreController;
final VoidCallback onStartTimer;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 3,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('CHRONO', style: theme.textTheme.labelLarge),
const SizedBox(height: 4),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
remainingLabel,
style: theme.textTheme.displayMedium?.copyWith(
fontFamily: 'Anton',
fontFeatures: AppTextStyles.timer(context).fontFeatures,
color: theme.colorScheme.primary,
),
),
),
if (!running) ...[
const SizedBox(height: 8),
if (readyToStart) ...[
Text(
'Chrono prêt',
textAlign: TextAlign.center,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 4),
],
IconButton.filled(
tooltip: 'Lancer',
onPressed: onStartTimer,
icon: const Icon(Icons.play_arrow),
),
],
],
),
),
const SizedBox(width: 14),
Expanded(
flex: 2,
child: Center(
child: _StepManualScoreStepper(
controller: scoreController,
initialValue: step.defaultTargetScore,
),
),
),
],
);
}
}
final class _CompactStepScoreStopwatchControls extends StatelessWidget {
const _CompactStepScoreStopwatchControls({
required this.running,
required this.onStart,
required this.onStop,
required this.onReset,
});
final bool running;
final VoidCallback onStart;
final VoidCallback onStop;
final VoidCallback onReset;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filled(
tooltip: running ? 'Arrêter' : 'Lancer',
onPressed: running ? onStop : onStart,
icon: Icon(running ? Icons.stop : Icons.play_arrow),
),
const SizedBox(width: 8),
IconButton.outlined(
tooltip: 'Réinitialiser',
onPressed: onReset,
icon: const Icon(Icons.restart_alt),
),
],
);
}
}
final class _StepManualScoreStepper extends StatefulWidget {
const _StepManualScoreStepper({
required this.controller,
required this.initialValue,
});
final TextEditingController controller;
final double? initialValue;
@override
State<_StepManualScoreStepper> createState() =>
_StepManualScoreStepperState();
}
final class _StepManualScoreStepperState
extends State<_StepManualScoreStepper> {
double get _value {
final parsed = double.tryParse(widget.controller.text.trim());
return parsed ?? widget.initialValue ?? 0;
}
void _changeBy(double delta) {
final next = (_value + delta).clamp(0, 999).toDouble();
widget.controller.text = _formatManualScoreInput(next);
setState(() {});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('SCORE', style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Diminuer',
onPressed: () => _changeBy(-1),
constraints: const BoxConstraints.tightFor(width: 34, height: 34),
padding: EdgeInsets.zero,
iconSize: 20,
visualDensity: VisualDensity.compact,
style: IconButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
icon: const Icon(Icons.remove),
),
SizedBox(
width: 46,
child: Text(
_formatManualScoreInput(_value),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTextStyles.scoreNumber(context),
),
),
IconButton(
tooltip: 'Augmenter',
onPressed: () => _changeBy(1),
constraints: const BoxConstraints.tightFor(width: 34, height: 34),
padding: EdgeInsets.zero,
iconSize: 20,
visualDensity: VisualDensity.compact,
style: IconButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
icon: const Icon(Icons.add),
),
],
),
],
);
}
}
String _formatManualScoreInput(double value) {
return value == value.roundToDouble()
? value.toInt().toString()
: value.toStringAsFixed(1);
}
bool _isNextTimedStepReady(ActiveExerciseStepProgressView view) {
final step = view.currentStep;
if (step == null ||