merge(develop): clôture lot #91 Wear OS - fréquence cardiaque, notifications, finitions montre

Intègre le commit de clôture du lot #91 resté non fusionné. Conflits
attendus sur entities.dart, workout_execution_screen.dart et
watch_companion_projection_test.dart : arbitrage Architect - garder la
base feature/ticket91-wear-os-watch-sync sur ces 3 fichiers puis réinjecter
uniquement les invariants chronos utiles (Durée / Temps réalisé).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

# Conflicts:
#	lib/domain/entities.dart
#	lib/presentation/workout_execution_screen.dart
#	test/application/watch_companion_projection_test.dart
This commit is contained in:
2026-07-28 11:35:22 +02:00
80 changed files with 12296 additions and 891 deletions

View File

@ -681,26 +681,27 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
'Mode de saisie',
style: Theme.of(context).textTheme.titleSmall,
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Saisie libre'),
value: ScoreInputMode.manual,
groupValue: _scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => _scoreInputMode = value);
},
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Chrono intégré'),
subtitle: const Text('Temps réalisé'),
value: ScoreInputMode.stopwatch,
RadioGroup<ScoreInputMode>(
groupValue: _scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => _scoreInputMode = value);
},
child: const Column(
children: [
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: Text('Saisie libre'),
value: ScoreInputMode.manual,
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: Text('Chrono intégré'),
subtitle: Text('Temps réalisé'),
value: ScoreInputMode.stopwatch,
),
],
),
),
if (_scoreInputMode == ScoreInputMode.manual) ...[
const SizedBox(height: 12),
@ -865,7 +866,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: _stepDrafts.length,
onReorder: _reorderStep,
onReorderItem: _reorderStep,
itemBuilder: (context, index) {
return _buildStepCard(context, index, _stepDrafts[index]);
},
@ -901,6 +902,13 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
int index,
_ExerciseStepDraft draft,
) {
final canLinkToSeriesScore =
_hasScore && _scoreInputMode == ScoreInputMode.manual;
if ((!canLinkToSeriesScore ||
draft.scoreInputMode != ScoreInputMode.manual) &&
draft.linkedToSeriesScore) {
draft.linkedToSeriesScore = false;
}
final targetLabel = draft.type == ExerciseStepType.time
? 'Durée par défaut (s)'
: 'Répétitions par défaut';
@ -979,25 +987,26 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
),
const SizedBox(height: 8),
Text('Type détape', style: Theme.of(context).textTheme.titleSmall),
RadioListTile<ExerciseStepType>(
contentPadding: EdgeInsets.zero,
title: const Text('Temps'),
value: ExerciseStepType.time,
groupValue: draft.type,
onChanged: (value) {
if (value == null) return;
setState(() => draft.type = value);
},
),
RadioListTile<ExerciseStepType>(
contentPadding: EdgeInsets.zero,
title: const Text('Répétitions'),
value: ExerciseStepType.reps,
RadioGroup<ExerciseStepType>(
groupValue: draft.type,
onChanged: (value) {
if (value == null) return;
setState(() => draft.type = value);
},
child: const Column(
children: [
RadioListTile<ExerciseStepType>(
contentPadding: EdgeInsets.zero,
title: Text('Temps'),
value: ExerciseStepType.time,
),
RadioListTile<ExerciseStepType>(
contentPadding: EdgeInsets.zero,
title: Text('Répétitions'),
value: ExerciseStepType.reps,
),
],
),
),
TextFormField(
controller: draft.targetController,
@ -1021,79 +1030,103 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
'Mode de score',
style: Theme.of(context).textTheme.titleSmall,
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Saisie libre'),
value: ScoreInputMode.manual,
RadioGroup<ScoreInputMode>(
groupValue: draft.scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => draft.scoreInputMode = value);
},
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Chrono intégré'),
value: ScoreInputMode.stopwatch,
groupValue: draft.scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => draft.scoreInputMode = value);
setState(() {
draft.scoreInputMode = value;
draft.linkedToSeriesScore = false;
});
},
child: const Column(
children: [
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: Text('Saisie libre'),
value: ScoreInputMode.manual,
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: Text('Chrono intégré'),
value: ScoreInputMode.stopwatch,
),
],
),
),
if (draft.scoreInputMode == ScoreInputMode.manual) ...[
TextFormField(
controller: draft.scoreLabelController,
decoration: const InputDecoration(
labelText: 'Score à saisir',
if (canLinkToSeriesScore) ...[
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Utiliser le score de la série'),
subtitle: const Text(
'Le score de cette étape alimente directement le score '
'de la série.',
),
value: draft.linkedToSeriesScore,
onChanged: (value) {
setState(() => draft.linkedToSeriesScore = value);
},
),
validator: (value) {
if (!_stepsEnabled || !draft.hasScore) return null;
if (draft.scoreInputMode != ScoreInputMode.manual) {
return null;
}
return value == null || value.trim().isEmpty
? 'Le libellé du score est obligatoire.'
: null;
},
),
const SizedBox(height: 8),
TextFormField(
controller: draft.scoreUnitController,
decoration: const InputDecoration(labelText: 'Unité'),
validator: (value) {
if (!_stepsEnabled || !draft.hasScore) return null;
if (draft.scoreInputMode != ScoreInputMode.manual) {
return null;
}
return value == null || value.trim().isEmpty
? 'Lunité du score est obligatoire.'
: null;
},
),
const SizedBox(height: 8),
TextFormField(
controller: draft.scoreTargetController,
decoration: const InputDecoration(
labelText: 'Score par défaut',
const SizedBox(height: 8),
],
if (!draft.linkedToSeriesScore) ...[
TextFormField(
controller: draft.scoreLabelController,
decoration: const InputDecoration(
labelText: 'Score à saisir',
),
validator: (value) {
if (!_stepsEnabled || !draft.hasScore) return null;
if (draft.scoreInputMode != ScoreInputMode.manual ||
draft.linkedToSeriesScore) {
return null;
}
return value == null || value.trim().isEmpty
? 'Le libellé du score est obligatoire.'
: null;
},
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
const SizedBox(height: 8),
TextFormField(
controller: draft.scoreUnitController,
decoration: const InputDecoration(labelText: 'Unité'),
validator: (value) {
if (!_stepsEnabled || !draft.hasScore) return null;
if (draft.scoreInputMode != ScoreInputMode.manual ||
draft.linkedToSeriesScore) {
return null;
}
return value == null || value.trim().isEmpty
? 'Lunité du score est obligatoire.'
: null;
},
),
validator: (value) {
if (!_stepsEnabled ||
!draft.hasScore ||
draft.scoreInputMode != ScoreInputMode.manual ||
value == null ||
value.trim().isEmpty) {
return null;
}
return _nonNegativeDoubleValidator(
value,
'Saisis un score supérieur ou égal à 0.',
);
},
),
const SizedBox(height: 8),
TextFormField(
controller: draft.scoreTargetController,
decoration: const InputDecoration(
labelText: 'Score par défaut',
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
validator: (value) {
if (!_stepsEnabled ||
!draft.hasScore ||
draft.scoreInputMode != ScoreInputMode.manual ||
draft.linkedToSeriesScore ||
value == null ||
value.trim().isEmpty) {
return null;
}
return _nonNegativeDoubleValidator(
value,
'Saisis un score supérieur ou égal à 0.',
);
},
),
],
] else ...[
if (draft.type == ExerciseStepType.time) ...[
const SizedBox(height: 8),
@ -1163,9 +1196,6 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
void _reorderStep(int oldIndex, int newIndex) {
setState(() {
if (newIndex > oldIndex) {
newIndex -= 1;
}
final draft = _stepDrafts.removeAt(oldIndex);
_stepDrafts.insert(newIndex, draft);
});
@ -1254,6 +1284,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
scoreInputMode: step.scoreInputMode,
scoreLabel: step.scoreLabel,
scoreUnit: step.scoreUnit,
linkedToSeriesScore: step.linkedToSeriesScore,
scoreTarget: step.scoreInputMode == ScoreInputMode.stopwatch
? _optionalDoubleText(
_millisecondsToSeconds(step.defaultTargetScoreTimeMs),
@ -1268,6 +1299,10 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
for (var index = 0; index < _stepDrafts.length; index++)
_stepDrafts[index].toExerciseStep(
position: index,
seriesScoreLabel: _scoreLabelController.text.trim(),
seriesScoreUnit: _scoreUnitController.text.trim(),
canLinkToSeriesScore:
_hasScore && _scoreInputMode == ScoreInputMode.manual,
defaultTargetScoreTimeMs:
_stepDrafts[index].scoreInputMode == ScoreInputMode.stopwatch
? _optionalSecondsToMilliseconds(
@ -1386,32 +1421,34 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
}
setState(() => _saving = true);
final scoreInputMode = _hasScore ? _scoreInputMode : ScoreInputMode.manual;
final scoreLabel = _hasScore
? switch (scoreInputMode) {
ScoreInputMode.manual => _scoreLabelController.text.trim(),
ScoreInputMode.stopwatch => 'Temps réalisé',
}
: null;
final scoreUnit = _hasScore && scoreInputMode == ScoreInputMode.manual
? _scoreUnitController.text.trim()
: null;
final defaultTargetTimeSeconds = _hasTime
? int.parse(_defaultTimeController.text.trim())
: null;
final defaultTargetReps = _hasReps
? int.parse(_defaultRepsController.text.trim())
: null;
final defaultTargetScore =
_hasScore && scoreInputMode == ScoreInputMode.manual
? double.parse(_defaultScoreController.text.trim())
: null;
final defaultTargetScoreTimeMs =
_hasScore && scoreInputMode == ScoreInputMode.stopwatch
? _optionalSecondsToMilliseconds(_defaultScoreTimeController.text)
: null;
final steps = _buildSteps();
try {
final scoreInputMode = _hasScore
? _scoreInputMode
: ScoreInputMode.manual;
final scoreLabel = _hasScore
? switch (scoreInputMode) {
ScoreInputMode.manual => _scoreLabelController.text.trim(),
ScoreInputMode.stopwatch => 'Temps réalisé',
}
: null;
final scoreUnit = _hasScore && scoreInputMode == ScoreInputMode.manual
? _scoreUnitController.text.trim()
: null;
final defaultTargetTimeSeconds = _hasTime
? int.parse(_defaultTimeController.text.trim())
: null;
final defaultTargetReps = _hasReps
? int.parse(_defaultRepsController.text.trim())
: null;
final defaultTargetScore =
_hasScore && scoreInputMode == ScoreInputMode.manual
? double.parse(_defaultScoreController.text.trim())
: null;
final defaultTargetScoreTimeMs =
_hasScore && scoreInputMode == ScoreInputMode.stopwatch
? _optionalSecondsToMilliseconds(_defaultScoreTimeController.text)
: null;
final steps = _buildSteps();
if (exercise == null) {
await widget.exerciseUseCases.create(
name: _nameController.text.trim(),
@ -1462,8 +1499,10 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
navigator.pop(true);
}
}
} on Exception catch (error) {
_showSnackBar(error.toString());
} on Exception {
_showSnackBar(
'Impossible denregistrer lexercice. Vérifie les champs puis réessaie.',
);
} finally {
if (mounted) {
setState(() => _saving = false);
@ -1556,6 +1595,7 @@ final class _ExerciseStepDraft {
required String defaultTargetValue,
this.hasScore = false,
this.scoreInputMode = ScoreInputMode.manual,
this.linkedToSeriesScore = false,
String? scoreLabel,
String? scoreUnit,
String? scoreTarget,
@ -1569,6 +1609,7 @@ final class _ExerciseStepDraft {
ExerciseStepType type;
bool hasScore;
ScoreInputMode scoreInputMode;
bool linkedToSeriesScore;
final TextEditingController nameController;
final TextEditingController targetController;
final TextEditingController scoreLabelController;
@ -1584,6 +1625,7 @@ final class _ExerciseStepDraft {
return nameController.text.trim().isNotEmpty ||
targetController.text.trim().isNotEmpty ||
hasScore ||
linkedToSeriesScore ||
scoreLabelController.text.trim().isNotEmpty ||
scoreUnitController.text.trim().isNotEmpty ||
scoreTargetController.text.trim().isNotEmpty;
@ -1597,6 +1639,7 @@ final class _ExerciseStepDraft {
defaultTargetValue: targetController.text,
hasScore: hasScore,
scoreInputMode: scoreInputMode,
linkedToSeriesScore: linkedToSeriesScore,
scoreLabel: scoreLabelController.text,
scoreUnit: scoreUnitController.text,
scoreTarget: scoreTargetController.text,
@ -1605,9 +1648,17 @@ final class _ExerciseStepDraft {
ExerciseStep toExerciseStep({
required int position,
required String seriesScoreLabel,
required String seriesScoreUnit,
required bool canLinkToSeriesScore,
required int? defaultTargetScoreTimeMs,
}) {
final scoreTargetText = scoreTargetController.text.trim();
final linksToSeries =
hasScore &&
scoreInputMode == ScoreInputMode.manual &&
canLinkToSeriesScore &&
linkedToSeriesScore;
return ExerciseStep(
id: id,
position: position,
@ -1617,14 +1668,19 @@ final class _ExerciseStepDraft {
hasScore: hasScore,
scoreInputMode: hasScore ? scoreInputMode : ScoreInputMode.manual,
scoreLabel: hasScore && scoreInputMode == ScoreInputMode.manual
? scoreLabelController.text.trim()
? linksToSeries
? seriesScoreLabel
: scoreLabelController.text.trim()
: null,
scoreUnit: hasScore && scoreInputMode == ScoreInputMode.manual
? scoreUnitController.text.trim()
? linksToSeries
? seriesScoreUnit
: scoreUnitController.text.trim()
: null,
defaultTargetScore:
hasScore &&
scoreInputMode == ScoreInputMode.manual &&
!linksToSeries &&
scoreTargetText.isNotEmpty
? double.parse(scoreTargetText)
: null,
@ -1632,6 +1688,7 @@ final class _ExerciseStepDraft {
hasScore && scoreInputMode == ScoreInputMode.stopwatch
? defaultTargetScoreTimeMs
: null,
linkedToSeriesScore: linksToSeries,
);
}

View File

@ -15,6 +15,7 @@ final class HistoryListScreen extends StatefulWidget {
required this.closeUseCase,
this.mediaUseCases,
this.stepUseCases,
this.sensorUseCases,
this.performanceReferenceUseCase,
this.onOpenProgression,
super.key,
@ -24,6 +25,7 @@ final class HistoryListScreen extends StatefulWidget {
final WorkoutTemplateUseCases workoutTemplateUseCases;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final ActiveWorkoutSensorUseCases? sensorUseCases;
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final CloseWorkoutSessionUseCase closeUseCase;
final MediaUseCases? mediaUseCases;
@ -110,6 +112,7 @@ final class _HistoryListScreenState extends State<HistoryListScreen> {
workoutTemplateUseCases: widget.workoutTemplateUseCases,
activeUseCases: widget.activeUseCases,
stepUseCases: widget.stepUseCases,
sensorUseCases: widget.sensorUseCases,
performanceReferenceUseCase: widget.performanceReferenceUseCase,
closeUseCase: widget.closeUseCase,
mediaUseCases: widget.mediaUseCases,
@ -133,6 +136,7 @@ final class HistoryDetailScreen extends StatelessWidget {
required this.closeUseCase,
this.mediaUseCases,
this.stepUseCases,
this.sensorUseCases,
this.performanceReferenceUseCase,
super.key,
});
@ -142,6 +146,7 @@ final class HistoryDetailScreen extends StatelessWidget {
final WorkoutTemplateUseCases workoutTemplateUseCases;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final ActiveWorkoutSensorUseCases? sensorUseCases;
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final CloseWorkoutSessionUseCase closeUseCase;
final MediaUseCases? mediaUseCases;
@ -157,6 +162,14 @@ final class HistoryDetailScreen extends StatelessWidget {
Text(_formatDateTime(history.startedAt)),
const SizedBox(height: 4),
Text('Durée : ${_formatDurationMs(history.totalActiveMs)}'),
if (history.averageHeartRateBpm != null &&
history.maxHeartRateBpm != null) ...[
const SizedBox(height: 16),
_HistoryHeartRateSummary(
averageHeartRateBpm: history.averageHeartRateBpm!,
maxHeartRateBpm: history.maxHeartRateBpm!,
),
],
const SizedBox(height: 16),
for (final program in detail.programs) ...[
Text(program.name, style: Theme.of(context).textTheme.titleMedium),
@ -202,13 +215,28 @@ final class HistoryDetailScreen extends StatelessWidget {
}
Future<void> _restart(BuildContext context) async {
final hasBlockingOpenSession = await _hasBlockingOpenSession();
if (!context.mounted) return;
if (hasBlockingOpenSession) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Une séance est déjà en cours. Termine-la ou reprends-la avant '
'den lancer une nouvelle.',
),
),
);
return;
}
ActiveWorkoutSession session;
final sourceId = history.sourceWorkoutTemplateId;
if (sourceId != null &&
await workoutTemplateUseCases.findById(sourceId) != null) {
session = await activeUseCases.startFromTemplate(sourceId);
} else {
if (context.mounted) {
try {
if (sourceId != null &&
await workoutTemplateUseCases.findById(sourceId) != null) {
session = await activeUseCases.startFromTemplate(sourceId);
} else {
session = await activeUseCases.startFromHistory(history);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
@ -217,7 +245,16 @@ final class HistoryDetailScreen extends StatelessWidget {
),
);
}
session = await activeUseCases.startFromHistory(history);
} on DomainException {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Cette séance ne peut pas être relancée car elle ne contient aucun exercice.',
),
),
);
return;
}
if (!context.mounted) return;
await Navigator.of(context).push(
@ -226,6 +263,7 @@ final class HistoryDetailScreen extends StatelessWidget {
initialSession: session,
activeUseCases: activeUseCases,
stepUseCases: stepUseCases,
sensorUseCases: sensorUseCases,
closeUseCase: closeUseCase,
historyUseCases: historyUseCases,
workoutTemplateUseCases: workoutTemplateUseCases,
@ -236,6 +274,22 @@ final class HistoryDetailScreen extends StatelessWidget {
);
}
Future<bool> _hasBlockingOpenSession() async {
final openSession = await activeUseCases.findOpen();
if (openSession == null) {
return false;
}
if (WorkoutExecutionPlan.tryFromSession(openSession) != null) {
return true;
}
try {
await activeUseCases.abandon(openSession.metadata.id);
} on DomainException {
// Legacy invalid sessions must not block a restart.
}
return false;
}
Future<void> _confirmDelete(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
@ -261,6 +315,79 @@ final class HistoryDetailScreen extends StatelessWidget {
}
}
final class _HistoryHeartRateSummary extends StatelessWidget {
const _HistoryHeartRateSummary({
required this.averageHeartRateBpm,
required this.maxHeartRateBpm,
});
final double averageHeartRateBpm;
final int maxHeartRateBpm;
@override
Widget build(BuildContext context) {
return CourtBlazerAccentPanel(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Fréquence cardiaque',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _HistoryHeartRateMetric(
label: 'Moyenne',
value: '${averageHeartRateBpm.round()}',
),
),
const SizedBox(width: 12),
Expanded(
child: _HistoryHeartRateMetric(
label: 'Max',
value: '$maxHeartRateBpm',
),
),
],
),
],
),
);
}
}
final class _HistoryHeartRateMetric extends StatelessWidget {
const _HistoryHeartRateMetric({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.labelMedium),
RichText(
text: TextSpan(
style: AppTextStyles.scoreNumber(context),
children: [
TextSpan(text: value),
TextSpan(
text: ' bpm',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
);
}
}
final class HistoryDetailData {
const HistoryDetailData({required this.programs});

View File

@ -93,7 +93,10 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
if (session == null) {
return const SizedBox.shrink();
}
final plan = WorkoutExecutionPlan.fromSession(session);
final plan = WorkoutExecutionPlan.tryFromSession(session);
if (plan == null) {
return const SizedBox.shrink();
}
final position = ExecutionPosition(
programIndex: session.currentProgramIndex,
exerciseIndex: session.currentExerciseIndex,
@ -158,6 +161,7 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
programUseCases: widget.bootstrap.programUseCases,
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases,
performanceReferenceUseCase:
widget.bootstrap.exercisePerformanceReferenceUseCase,
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
@ -182,6 +186,7 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
widget.bootstrap.workoutTemplateUseCases,
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases,
performanceReferenceUseCase:
widget.bootstrap.exercisePerformanceReferenceUseCase,
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
@ -257,21 +262,33 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
}
Future<void> _resume(ActiveWorkoutSession session) async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => WorkoutExecutionScreen(
initialSession: session,
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
historyUseCases: widget.bootstrap.workoutHistoryUseCases,
workoutTemplateUseCases: widget.bootstrap.workoutTemplateUseCases,
performanceReferenceUseCase:
widget.bootstrap.exercisePerformanceReferenceUseCase,
mediaUseCases: widget.bootstrap.mediaUseCases,
try {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => WorkoutExecutionScreen(
initialSession: session,
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases,
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
historyUseCases: widget.bootstrap.workoutHistoryUseCases,
workoutTemplateUseCases: widget.bootstrap.workoutTemplateUseCases,
performanceReferenceUseCase:
widget.bootstrap.exercisePerformanceReferenceUseCase,
mediaUseCases: widget.bootstrap.mediaUseCases,
),
),
),
);
);
} on DomainException {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Cette séance ne peut plus être reprise. Elle a été abandonnée automatiquement.',
),
),
);
}
if (!mounted) return;
_reloadOpenSession();
}

View File

@ -89,14 +89,13 @@ final class _ProfileScreenState extends State<ProfileScreen> {
),
),
);
if (connected == true && mounted) {
_reloadSession();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Compte connecté. Synchronisation en arrière-plan.'),
),
);
}
if (connected != true || !mounted || !context.mounted) return;
_reloadSession();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Compte connecté. Synchronisation en arrière-plan.'),
),
);
}
Future<void> _openRegister(BuildContext context) async {
@ -109,14 +108,13 @@ final class _ProfileScreenState extends State<ProfileScreen> {
),
),
);
if (connected == true && mounted) {
_reloadSession();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Compte connecté. Synchronisation en arrière-plan.'),
),
);
}
if (connected != true || !mounted || !context.mounted) return;
_reloadSession();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Compte connecté. Synchronisation en arrière-plan.'),
),
);
}
Future<void> _confirmLogout(BuildContext context) async {
@ -189,13 +187,12 @@ final class _SignedOutProfile extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Compte optionnel',
'Compte GameTime',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
const Text(
'GameTime fonctionne entièrement sans compte. Connecte-toi '
'seulement si tu veux sauvegarder tes données en ligne ou '
'Connecte-toi pour sauvegarder tes données en ligne et '
'partager des programmes et séances.',
),
const SizedBox(height: 16),
@ -481,7 +478,8 @@ abstract interface class LocalBackupFileExporter {
});
}
final class SharePlusLocalBackupFileExporter implements LocalBackupFileExporter {
final class SharePlusLocalBackupFileExporter
implements LocalBackupFileExporter {
const SharePlusLocalBackupFileExporter();
@override
@ -950,11 +948,6 @@ final class _LoginScreenState extends State<LoginScreen> {
},
child: const Text('Créer un compte'),
),
const SizedBox(height: 16),
Text(
'Tu peux continuer à utiliser GameTime sans compte.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
@ -1103,7 +1096,7 @@ final class _RegisterScreenState extends State<RegisterScreen> {
const SizedBox(height: 16),
Text(
'Le compte sert à synchroniser tes données et partager tes '
"contenus. L'app reste utilisable sans compte.",
'contenus.',
style: Theme.of(context).textTheme.bodySmall,
),
],
@ -1211,6 +1204,8 @@ String _loginErrorMessage(RemoteAuthFailure failure) {
RemoteAuthFailure.invalidCredentials => 'Email ou mot de passe incorrect.',
RemoteAuthFailure.network =>
'Connexion impossible pour le moment. Réessaie plus tard.',
RemoteAuthFailure.server =>
'Serveur indisponible pour le moment. Réessaie plus tard.',
_ => 'Connexion impossible pour le moment. Réessaie plus tard.',
};
}
@ -1221,6 +1216,8 @@ String _registerErrorMessage(RemoteAuthFailure failure) {
'Un compte existe déjà avec cet email.',
RemoteAuthFailure.network =>
'Création impossible pour le moment. Réessaie plus tard.',
RemoteAuthFailure.server =>
'Serveur indisponible pour le moment. Réessaie plus tard.',
_ => 'Création impossible pour le moment. Réessaie plus tard.',
};
}

View File

@ -420,6 +420,7 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
@override
Widget build(BuildContext context) {
final canSave = !_saving && _exercises.isNotEmpty;
return Scaffold(
appBar: AppBar(
title: Text(
@ -436,15 +437,21 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
),
bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.all(16),
child: FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
label: const Text('Enregistrer'),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton.icon(
onPressed: canSave ? _save : null,
icon: _saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
label: const Text('Enregistrer'),
),
],
),
),
body: Column(
@ -495,7 +502,8 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
child: _exercises.isEmpty
? const _CenteredMessage(
title: 'Aucun exercice ajouté',
message: 'Ajoute un exercice depuis la bibliothèque.',
message:
'Ajoute au moins un exercice pour enregistrer ce programme.',
)
: ReorderableListView.builder(
buildDefaultDragHandles: false,
@ -595,6 +603,9 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
}
Future<void> _save() async {
if (_exercises.isEmpty) {
return;
}
if (!_formKey.currentState!.validate()) {
return;
}
@ -1402,7 +1413,7 @@ final class _CenteredMessage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,

View File

@ -172,7 +172,7 @@ final class ShareAccountRequiredScreen extends StatelessWidget {
const SizedBox(height: 8),
Text(
'Connecte-toi pour envoyer $targetLabel à un autre compte '
'GameTime. Le reste de lapp reste utilisable sans compte.',
'GameTime.',
),
const SizedBox(height: 16),
FilledButton(

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget {
required this.historyUseCases,
this.mediaUseCases,
this.stepUseCases,
this.sensorUseCases,
this.performanceReferenceUseCase,
this.shareUseCases,
this.authUseCases,
@ -30,6 +31,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget {
final ProgramUseCases programUseCases;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final ActiveWorkoutSensorUseCases? sensorUseCases;
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final CloseWorkoutSessionUseCase closeUseCase;
final WorkoutHistoryUseCases historyUseCases;
@ -264,9 +266,16 @@ final class _WorkoutTemplateListScreenState
}
Future<void> _start(WorkoutTemplate template) async {
final openSession = await widget.activeUseCases.findOpen();
if (_templateExerciseCount(template) == 0) {
_showSnackBar(
'Cette séance ne contient aucun exercice. Ajoute un programme avec '
'au moins un exercice avant de la lancer.',
);
return;
}
final hasBlockingOpenSession = await _hasBlockingOpenSession();
if (!mounted) return;
if (openSession != null) {
if (hasBlockingOpenSession) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
@ -277,9 +286,18 @@ final class _WorkoutTemplateListScreenState
);
return;
}
final session = await widget.activeUseCases.startFromTemplate(
template.metadata.id,
);
final ActiveWorkoutSession session;
try {
session = await widget.activeUseCases.startFromTemplate(
template.metadata.id,
);
} on DomainException {
_showSnackBar(
'Cette séance ne contient aucun exercice. Ajoute un programme avec '
'au moins un exercice avant de la lancer.',
);
return;
}
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute(
@ -292,11 +310,28 @@ final class _WorkoutTemplateListScreenState
workoutTemplateUseCases: widget.workoutTemplateUseCases,
performanceReferenceUseCase: widget.performanceReferenceUseCase,
mediaUseCases: widget.mediaUseCases,
sensorUseCases: widget.sensorUseCases,
),
),
);
}
Future<bool> _hasBlockingOpenSession() async {
final openSession = await widget.activeUseCases.findOpen();
if (openSession == null) {
return false;
}
if (WorkoutExecutionPlan.tryFromSession(openSession) != null) {
return true;
}
try {
await widget.activeUseCases.abandon(openSession.metadata.id);
} on DomainException {
// Legacy invalid sessions must not block a new start.
}
return false;
}
Future<void> _confirmDelete(WorkoutTemplate template) async {
final confirmed = await showDialog<bool>(
context: context,
@ -458,6 +493,7 @@ final class _WorkoutTemplateFormScreenState
@override
Widget build(BuildContext context) {
final canSave = !_saving && _programs.isNotEmpty;
return Scaffold(
appBar: AppBar(
title: Text(_isEditing ? 'Modifier la séance' : 'Créer une séance'),
@ -472,15 +508,21 @@ final class _WorkoutTemplateFormScreenState
),
bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.all(16),
child: FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
label: const Text('Enregistrer'),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton.icon(
onPressed: canSave ? _save : null,
icon: _saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
label: const Text('Enregistrer'),
),
],
),
),
body: Column(
@ -521,13 +563,14 @@ final class _WorkoutTemplateFormScreenState
child: _programs.isEmpty
? const _CenteredMessage(
title: 'Aucun programme ajouté',
message: 'Ajoute un programme pour composer la séance.',
message:
'Ajoute au moins un programme pour enregistrer cette séance.',
)
: ReorderableListView.builder(
buildDefaultDragHandles: false,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
itemCount: _programs.length,
onReorder: _reorderProgram,
onReorderItem: _reorderProgram,
itemBuilder: (context, index) {
final program = _programs[index];
return Card(
@ -579,9 +622,6 @@ final class _WorkoutTemplateFormScreenState
void _reorderProgram(int oldIndex, int newIndex) {
setState(() {
if (newIndex > oldIndex) {
newIndex -= 1;
}
final item = _programs.removeAt(oldIndex);
_programs.insert(newIndex, item);
});
@ -599,6 +639,9 @@ final class _WorkoutTemplateFormScreenState
}
Future<void> _save() async {
if (_programs.isEmpty) {
return;
}
if (!_formKey.currentState!.validate()) {
return;
}
@ -1050,6 +1093,7 @@ List<ExerciseStep> _parseExerciseSteps(Object? value) {
scoreUnit: step['scoreUnit'] as String?,
defaultTargetScore: (step['defaultTargetScore'] as num?)?.toDouble(),
defaultTargetScoreTimeMs: step['defaultTargetScoreTimeMs'] as int?,
linkedToSeriesScore: step['linkedToSeriesScore'] == true,
);
}).toList();
}
@ -1062,12 +1106,16 @@ String _autoStartHelpText(bool active) {
String _templateSummary(WorkoutTemplate template) {
final programCount = template.programs.length;
final exerciseCount = template.programs.fold<int>(
final exerciseCount = _templateExerciseCount(template);
return '$programCount programme${programCount > 1 ? 's' : ''} · '
'$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}';
}
int _templateExerciseCount(WorkoutTemplate template) {
return template.programs.fold<int>(
0,
(total, program) => total + _exerciseCount(program.programSnapshotJson),
);
return '$programCount programme${programCount > 1 ? 's' : ''} · '
'$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}';
}
String _programDraftSummary(WorkoutTemplateProgramDraft program) {
@ -1099,8 +1147,12 @@ String _exerciseDraftSummary(WorkoutTemplateExerciseDraft exercise) {
}
int _exerciseCount(String programSnapshotJson) {
final snapshot = jsonDecode(programSnapshotJson) as Map<String, dynamic>;
return (snapshot['exercises'] as List<dynamic>? ?? const []).length;
try {
final snapshot = jsonDecode(programSnapshotJson) as Map<String, dynamic>;
return (snapshot['exercises'] as List<dynamic>? ?? const []).length;
} on Exception {
return 0;
}
}
final class _CenteredMessage extends StatelessWidget {
@ -1119,7 +1171,7 @@ final class _CenteredMessage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,