diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index 97e182d..098275b 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -10,3 +10,4 @@ - [gametime-ux-score-chrono](gametime-ux-score-chrono.md) — memory note gametime-ux-score-chrono - [gametime-architecture-score-chrono](gametime-architecture-score-chrono.md) — memory note gametime-architecture-score-chrono - [gametime-resume-plan-2026-07-18](gametime-resume-plan-2026-07-18.md) — memory note gametime-resume-plan-2026-07-18 +- [gametime-ux-exercise-default-targets](gametime-ux-exercise-default-targets.md) — memory note gametime-ux-exercise-default-targets diff --git a/.ideai/memory/gametime-ux-exercise-default-targets.md b/.ideai/memory/gametime-ux-exercise-default-targets.md new file mode 100644 index 0000000..523a147 --- /dev/null +++ b/.ideai/memory/gametime-ux-exercise-default-targets.md @@ -0,0 +1,35 @@ +--- +name: gametime-ux-exercise-default-targets +description: memory note gametime-ux-exercise-default-targets +metadata: + type: project +--- +# GameTime — Valeurs cibles par défaut à la création d'exercice (ticket #30, UX 2026-07-18) + +Décision : ajouter les valeurs cibles par défaut au niveau **Exercice**, obligatoires pour les mesures activées (sauf Score chrono, optionnel). + +## Formulaire exercice +- Temps activé → champ "Temps par défaut (s)", obligatoire, validation "Saisis un temps supérieur à 0." +- Répétitions activé → champ "Répétitions par défaut", obligatoire, validation "Saisis un nombre de répétitions supérieur à 0." +- Score activé + mode Saisie libre → nouveau champ "Score par défaut" en plus de "Score à saisir"/"Unité", validation "Saisis un score supérieur à 0." +- Score activé + mode Chrono intégré → PAS de "Score par défaut" ; à la place "Objectif de chrono par défaut (optionnel)", validation seulement si rempli ("Saisis un objectif supérieur à 0."). +- Mesure désactivée → son champ disparaît, pas de validation dessus. Réactivée → champ revient, doit être rempli avant sauvegarde. + +## Champs domaine à ajouter sur Exercise +- `defaultTargetTimeSeconds` (obligatoire si hasTimeMeasure) +- `defaultTargetReps` (obligatoire si hasRepsMeasure) +- `defaultTargetScore` (obligatoire si hasScoreMeasure et scoreInputMode manual) +- `defaultTargetScoreTimeMs` (optionnel, seulement si scoreInputMode stopwatch) + +Contrainte : valeur obligatoire toujours > 0 ; optionnelle (score chrono) mais si présente > 0. + +## Utilisation dans programme +Quand un exercice est ajouté à un programme (ticket #20), les cibles sont préremplies depuis les valeurs par défaut de l'exercice au lieu des valeurs génériques actuelles : +- targetTimeSeconds ← defaultTargetTimeSeconds +- targetReps ← defaultTargetReps +- targetScore ← defaultTargetScore (score libre) +- targetScoreTimeMs ← defaultTargetScoreTimeMs si présent (score chrono) +Tout reste modifiable ensuite dans "Personnaliser l'exercice". + +## Migration +Exercices existants sans valeurs par défaut : l'utilisateur devra compléter les champs manquants au prochain enregistrement de l'exercice (pas de blocage rétroactif immédiat, mais validation bloquante dès la prochaine sauvegarde). \ No newline at end of file diff --git a/.ideai/tickets/30/carnet.md b/.ideai/tickets/30/carnet.md index 2539cfb..fab30dc 100644 --- a/.ideai/tickets/30/carnet.md +++ b/.ideai/tickets/30/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#30" -version: 2 -updatedBy: {"kind":"user"} -updatedAt: 1784328751807 +version: 4 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784362687149 --- diff --git a/.ideai/tickets/30/issue.md b/.ideai/tickets/30/issue.md index c69f683..3edb995 100644 --- a/.ideai/tickets/30/issue.md +++ b/.ideai/tickets/30/issue.md @@ -2,15 +2,15 @@ id: "d4793c3f-a308-42f3-89e6-335cb2f95310" number: 30 title: "Ajouter ajouter des valeurs par défaut à la création d'un exercice" -status: "open" +status: "closed" priority: "critical" sprint: null links: [] agentRefs: [] createdBy: {"kind":"user"} -updatedBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdAt: 1784328704260 -updatedAt: 1784328751807 -version: 2 +updatedAt: 1784362687149 +version: 4 --- Il faut qu'a la création d'un exercice, il soit obligatoire de donner des valeurs par défaut pour les cibles choisies pour l'exercice. Il faut que ces valeurs oient positives et différentes de 0 pour éviter tout soucis. \ No newline at end of file diff --git a/.ideai/tickets/39/carnet.md b/.ideai/tickets/39/carnet.md new file mode 100644 index 0000000..171a69c --- /dev/null +++ b/.ideai/tickets/39/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#39" +version: 3 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784362685685 +--- diff --git a/.ideai/tickets/39/issue.md b/.ideai/tickets/39/issue.md new file mode 100644 index 0000000..f2239d3 --- /dev/null +++ b/.ideai/tickets/39/issue.md @@ -0,0 +1,16 @@ +--- +id: "8f581a08-3963-495a-863c-22e8a58b3867" +number: 39 +title: "[DevBackend] Valeurs cibles par défaut sur Exercise" +status: "closed" +priority: "critical" +sprint: null +links: [{"target":"#30","kind":"relatesTo"}] +agentRefs: [{"agentId":"10ee045b-1c41-479e-ba03-dceed9edd495","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1784361529389 +updatedAt: 1784362685685 +version: 3 +--- +Ajouter defaultTargetTimeSeconds/defaultTargetReps/defaultTargetScore/defaultTargetScoreTimeMs sur Exercise (migration Drift), avec validation domaine : valeur obligatoire >0 si la mesure correspondante est active (sauf score chrono où l'objectif par défaut reste optionnel mais >0 si renseigné). Cf. mémoire "gametime-ux-exercise-default-targets" pour le détail complet. \ No newline at end of file diff --git a/.ideai/tickets/40/carnet.md b/.ideai/tickets/40/carnet.md new file mode 100644 index 0000000..584f5ae --- /dev/null +++ b/.ideai/tickets/40/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#40" +version: 4 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784362686414 +--- diff --git a/.ideai/tickets/40/issue.md b/.ideai/tickets/40/issue.md new file mode 100644 index 0000000..4a5e49f --- /dev/null +++ b/.ideai/tickets/40/issue.md @@ -0,0 +1,16 @@ +--- +id: "6bb171cc-2a3a-45b2-a952-5453de1cf1fc" +number: 40 +title: "[DevFrontend] Formulaire exercice : champs de valeurs par défaut + préremplissage programme" +status: "closed" +priority: "critical" +sprint: null +links: [{"target":"#30","kind":"relatesTo"},{"target":"#39","kind":"dependsOn"}] +agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1784361532790 +updatedAt: 1784362686414 +version: 4 +--- +Ajouter les champs de valeur par défaut dans le formulaire de création/édition d'exercice (Temps par défaut, Répétitions par défaut, Score par défaut, Objectif de chrono par défaut optionnel), avec validation bloquante >0. Utiliser ces valeurs par défaut pour préremplir les cibles quand l'exercice est ajouté à un programme (au lieu des valeurs génériques actuelles). Cf. mémoire "gametime-ux-exercise-default-targets" pour le détail complet. \ No newline at end of file diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index d6298b5..d1b41b9 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 39 + "nextNumber": 41 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 85776a2..38fc634 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -337,11 +337,11 @@ "issueRef": "#30", "path": "30", "title": "Ajouter ajouter des valeurs par défaut à la création d'un exercice", - "status": "open", + "status": "closed", "priority": "critical", "sprint": null, "assignedAgentIds": [], - "updatedAt": 1784328751807 + "updatedAt": 1784362687149 }, { "issueRef": "#31", @@ -436,6 +436,30 @@ "57695b92-24d0-4876-837c-76116e70a6ae" ], "updatedAt": 1784330204091 + }, + { + "issueRef": "#39", + "path": "39", + "title": "[DevBackend] Valeurs cibles par défaut sur Exercise", + "status": "closed", + "priority": "critical", + "sprint": null, + "assignedAgentIds": [ + "10ee045b-1c41-479e-ba03-dceed9edd495" + ], + "updatedAt": 1784362685685 + }, + { + "issueRef": "#40", + "path": "40", + "title": "[DevFrontend] Formulaire exercice : champs de valeurs par défaut + préremplissage programme", + "status": "closed", + "priority": "critical", + "sprint": null, + "assignedAgentIds": [ + "9933c93a-b8a1-4164-a3bb-7063fdad747d" + ], + "updatedAt": 1784362686414 } ] } \ No newline at end of file diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index 0b38037..248c4c6 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -25,12 +25,19 @@ final class AppBootstrap implements AppDependencies { }); final AppDatabase database; + @override final ExerciseUseCases exerciseUseCases; + @override final MediaUseCases mediaUseCases; + @override final ProgramUseCases programUseCases; + @override final WorkoutTemplateUseCases workoutTemplateUseCases; + @override final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases; + @override final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase; + @override final WorkoutHistoryUseCases workoutHistoryUseCases; final SyncGateway syncGateway; diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 5456401..38d4881 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -3,6 +3,8 @@ import 'dart:convert'; import '../domain/domain.dart'; import 'ports.dart'; +const Object _useCaseUnchanged = Object(); + final class ExerciseUseCases { const ExerciseUseCases({ required this.repository, @@ -27,7 +29,21 @@ final class ExerciseUseCases { ScoreInputMode scoreInputMode = ScoreInputMode.manual, String? scoreLabel, String? scoreUnit, + int? defaultTargetTimeSeconds, + int? defaultTargetReps, + double? defaultTargetScore, + int? defaultTargetScoreTimeMs, }) async { + _validateExerciseDefaultTargets( + hasTimeMeasure: hasTimeMeasure, + hasRepsMeasure: hasRepsMeasure, + hasScoreMeasure: hasScoreMeasure, + scoreInputMode: scoreInputMode, + defaultTargetTimeSeconds: defaultTargetTimeSeconds, + defaultTargetReps: defaultTargetReps, + defaultTargetScore: defaultTargetScore, + defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, + ); final now = clock.now(); final exercise = Exercise( metadata: _newMetadata(ids, originDeviceId, now), @@ -41,6 +57,10 @@ final class ExerciseUseCases { scoreInputMode: scoreInputMode, scoreLabel: scoreLabel, scoreUnit: scoreUnit, + defaultTargetTimeSeconds: defaultTargetTimeSeconds, + defaultTargetReps: defaultTargetReps, + defaultTargetScore: defaultTargetScore, + defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, ); await repository.save(exercise); return exercise; @@ -66,11 +86,39 @@ final class ExerciseUseCases { ScoreInputMode scoreInputMode = ScoreInputMode.manual, String? scoreLabel, String? scoreUnit, + Object? defaultTargetTimeSeconds = _useCaseUnchanged, + Object? defaultTargetReps = _useCaseUnchanged, + Object? defaultTargetScore = _useCaseUnchanged, + Object? defaultTargetScoreTimeMs = _useCaseUnchanged, }) async { final exercise = await repository.findById(id); if (exercise == null) { throw const DomainException('Exercise not found.'); } + final resolvedDefaultTargetTimeSeconds = + defaultTargetTimeSeconds == _useCaseUnchanged + ? exercise.defaultTargetTimeSeconds + : defaultTargetTimeSeconds as int?; + final resolvedDefaultTargetReps = defaultTargetReps == _useCaseUnchanged + ? exercise.defaultTargetReps + : defaultTargetReps as int?; + final resolvedDefaultTargetScore = defaultTargetScore == _useCaseUnchanged + ? exercise.defaultTargetScore + : (defaultTargetScore as num?)?.toDouble(); + final resolvedDefaultTargetScoreTimeMs = + defaultTargetScoreTimeMs == _useCaseUnchanged + ? exercise.defaultTargetScoreTimeMs + : defaultTargetScoreTimeMs as int?; + _validateExerciseDefaultTargets( + hasTimeMeasure: hasTimeMeasure, + hasRepsMeasure: hasRepsMeasure, + hasScoreMeasure: hasScoreMeasure, + scoreInputMode: scoreInputMode, + defaultTargetTimeSeconds: resolvedDefaultTargetTimeSeconds, + defaultTargetReps: resolvedDefaultTargetReps, + defaultTargetScore: resolvedDefaultTargetScore, + defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs, + ); final updated = exercise.copyWith( metadata: exercise.metadata.touch(clock.now()), name: name, @@ -83,6 +131,10 @@ final class ExerciseUseCases { scoreInputMode: scoreInputMode, scoreLabel: scoreLabel, scoreUnit: scoreUnit, + defaultTargetTimeSeconds: resolvedDefaultTargetTimeSeconds, + defaultTargetReps: resolvedDefaultTargetReps, + defaultTargetScore: resolvedDefaultTargetScore, + defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs, ); await repository.save(updated); return updated; @@ -1271,6 +1323,49 @@ final class SetResultPositionState { final ActiveSetResult? result; } +void _validateExerciseDefaultTargets({ + required bool hasTimeMeasure, + required bool hasRepsMeasure, + required bool hasScoreMeasure, + required ScoreInputMode scoreInputMode, + required int? defaultTargetTimeSeconds, + required int? defaultTargetReps, + required double? defaultTargetScore, + required int? defaultTargetScoreTimeMs, +}) { + if (hasTimeMeasure && + (defaultTargetTimeSeconds == null || defaultTargetTimeSeconds <= 0)) { + throw const DomainException( + 'Default target time seconds must be positive.', + ); + } + if (hasRepsMeasure && (defaultTargetReps == null || defaultTargetReps <= 0)) { + throw const DomainException('Default target reps must be positive.'); + } + if (hasScoreMeasure && + scoreInputMode == ScoreInputMode.manual && + (defaultTargetScore == null || defaultTargetScore <= 0)) { + throw const DomainException('Default target score must be positive.'); + } + if (defaultTargetScore != null && + (!hasScoreMeasure || scoreInputMode != ScoreInputMode.manual)) { + throw const DomainException( + 'Default target score requires manual score mode.', + ); + } + if (defaultTargetScoreTimeMs != null && defaultTargetScoreTimeMs <= 0) { + throw const DomainException( + 'Default target score time ms must be positive.', + ); + } + if (defaultTargetScoreTimeMs != null && + (!hasScoreMeasure || scoreInputMode != ScoreInputMode.stopwatch)) { + throw const DomainException( + 'Default target score time ms requires stopwatch score mode.', + ); + } +} + void _validateOverrideTargets( String programSnapshotJson, WorkoutTemplateExerciseOverrideConfig input, diff --git a/lib/domain/entities.dart b/lib/domain/entities.dart index fadfe16..f2031f1 100644 --- a/lib/domain/entities.dart +++ b/lib/domain/entities.dart @@ -132,6 +132,10 @@ final class Exercise { this.scoreInputMode = ScoreInputMode.manual, this.scoreLabel, this.scoreUnit, + this.defaultTargetTimeSeconds, + this.defaultTargetReps, + this.defaultTargetScore, + this.defaultTargetScoreTimeMs, this.archivedAt, }) : name = _nonBlank(name, 'Exercise name') { _requireAtLeastOneMeasure( @@ -148,6 +152,16 @@ final class Exercise { _nonBlank(scoreLabel, 'Score label'); _nonBlank(scoreUnit, 'Score unit'); } + _requireNullablePositive( + defaultTargetTimeSeconds, + 'Default target time seconds', + ); + _requireNullablePositive(defaultTargetReps, 'Default target reps'); + _requireNullablePositiveDouble(defaultTargetScore, 'Default target score'); + _requireNullablePositive( + defaultTargetScoreTimeMs, + 'Default target score time ms', + ); } final EntityMetadata metadata; @@ -161,6 +175,10 @@ final class Exercise { final ScoreInputMode scoreInputMode; final String? scoreLabel; final String? scoreUnit; + final int? defaultTargetTimeSeconds; + final int? defaultTargetReps; + final double? defaultTargetScore; + final int? defaultTargetScoreTimeMs; final DateTime? archivedAt; Set get availableMeasures => { @@ -185,6 +203,10 @@ final class Exercise { ScoreInputMode? scoreInputMode, Object? scoreLabel = _unchanged, Object? scoreUnit = _unchanged, + Object? defaultTargetTimeSeconds = _unchanged, + Object? defaultTargetReps = _unchanged, + Object? defaultTargetScore = _unchanged, + Object? defaultTargetScoreTimeMs = _unchanged, Object? archivedAt = _unchanged, }) { return Exercise( @@ -209,6 +231,18 @@ final class Exercise { scoreUnit: scoreUnit == _unchanged ? this.scoreUnit : scoreUnit as String?, + defaultTargetTimeSeconds: defaultTargetTimeSeconds == _unchanged + ? this.defaultTargetTimeSeconds + : defaultTargetTimeSeconds as int?, + defaultTargetReps: defaultTargetReps == _unchanged + ? this.defaultTargetReps + : defaultTargetReps as int?, + defaultTargetScore: defaultTargetScore == _unchanged + ? this.defaultTargetScore + : defaultTargetScore as double?, + defaultTargetScoreTimeMs: defaultTargetScoreTimeMs == _unchanged + ? this.defaultTargetScoreTimeMs + : defaultTargetScoreTimeMs as int?, archivedAt: archivedAt == _unchanged ? this.archivedAt : archivedAt as DateTime?, @@ -371,10 +405,10 @@ final class ProgramExercise { timeEnabled: enabledMeasures.contains(WorkoutMeasure.time), repsEnabled: enabledMeasures.contains(WorkoutMeasure.reps), scoreEnabled: enabledMeasures.contains(WorkoutMeasure.score), - targetTimeSeconds: targetTimeSeconds, - targetReps: targetReps, - targetScore: targetScore, - targetScoreTimeMs: targetScoreTimeMs, + targetTimeSeconds: targetTimeSeconds ?? exercise.defaultTargetTimeSeconds, + targetReps: targetReps ?? exercise.defaultTargetReps, + targetScore: targetScore ?? exercise.defaultTargetScore, + targetScoreTimeMs: targetScoreTimeMs ?? exercise.defaultTargetScoreTimeMs, restSecondsOverride: restSecondsOverride, ); } @@ -927,6 +961,12 @@ void _requireNullableNonNegativeDouble(double? value, String label) { } } +void _requireNullablePositiveDouble(double? value, String label) { + if (value != null && value <= 0) { + throw DomainException('$label must be positive.'); + } +} + void _requireScoreTargetShape({ required bool scoreEnabled, required ScoreInputMode scoreInputMode, diff --git a/lib/infrastructure/local/app_database.dart b/lib/infrastructure/local/app_database.dart index 43cfd1c..e6a9b42 100644 --- a/lib/infrastructure/local/app_database.dart +++ b/lib/infrastructure/local/app_database.dart @@ -36,7 +36,7 @@ final class AppDatabase extends _$AppDatabase { } @override - int get schemaVersion => 3; + int get schemaVersion => 4; @override MigrationStrategy get migration { @@ -60,6 +60,9 @@ final class AppDatabase extends _$AppDatabase { if (from < 3) { await _migrateToSchema3(migrator); } + if (from < 4) { + await _migrateToSchema4(); + } await _createIndexes(); }, beforeOpen: (details) async { @@ -202,4 +205,25 @@ extension on AppDatabase { ); await migrator.createTable(activeScoreStopwatchStates); } + + Future _migrateToSchema4() async { + await customStatement( + 'ALTER TABLE exercises ADD COLUMN default_target_time_seconds INTEGER ' + 'CHECK (default_target_time_seconds IS NULL OR ' + 'default_target_time_seconds > 0)', + ); + await customStatement( + 'ALTER TABLE exercises ADD COLUMN default_target_reps INTEGER ' + 'CHECK (default_target_reps IS NULL OR default_target_reps > 0)', + ); + await customStatement( + 'ALTER TABLE exercises ADD COLUMN default_target_score REAL ' + 'CHECK (default_target_score IS NULL OR default_target_score > 0)', + ); + await customStatement( + 'ALTER TABLE exercises ADD COLUMN default_target_score_time_ms INTEGER ' + 'CHECK (default_target_score_time_ms IS NULL OR ' + 'default_target_score_time_ms > 0)', + ); + } } diff --git a/lib/infrastructure/local/app_database.g.dart b/lib/infrastructure/local/app_database.g.dart index 6b01c63..8c32fb3 100644 --- a/lib/infrastructure/local/app_database.g.dart +++ b/lib/infrastructure/local/app_database.g.dart @@ -8063,6 +8063,50 @@ class $ExercisesTable extends Exercises type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _defaultTargetTimeSecondsMeta = + const VerificationMeta('defaultTargetTimeSeconds'); + @override + late final GeneratedColumn defaultTargetTimeSeconds = + GeneratedColumn( + 'default_target_time_seconds', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _defaultTargetRepsMeta = const VerificationMeta( + 'defaultTargetReps', + ); + @override + late final GeneratedColumn defaultTargetReps = GeneratedColumn( + 'default_target_reps', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _defaultTargetScoreMeta = + const VerificationMeta('defaultTargetScore'); + @override + late final GeneratedColumn defaultTargetScore = + GeneratedColumn( + 'default_target_score', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + static const VerificationMeta _defaultTargetScoreTimeMsMeta = + const VerificationMeta('defaultTargetScoreTimeMs'); + @override + late final GeneratedColumn defaultTargetScoreTimeMs = + GeneratedColumn( + 'default_target_score_time_ms', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); static const VerificationMeta _archivedAtMeta = const VerificationMeta( 'archivedAt', ); @@ -8097,6 +8141,10 @@ class $ExercisesTable extends Exercises scoreInputMode, scoreLabel, scoreUnit, + defaultTargetTimeSeconds, + defaultTargetReps, + defaultTargetScore, + defaultTargetScoreTimeMs, archivedAt, ]; @override @@ -8293,6 +8341,42 @@ class $ExercisesTable extends Exercises scoreUnit.isAcceptableOrUnknown(data['score_unit']!, _scoreUnitMeta), ); } + if (data.containsKey('default_target_time_seconds')) { + context.handle( + _defaultTargetTimeSecondsMeta, + defaultTargetTimeSeconds.isAcceptableOrUnknown( + data['default_target_time_seconds']!, + _defaultTargetTimeSecondsMeta, + ), + ); + } + if (data.containsKey('default_target_reps')) { + context.handle( + _defaultTargetRepsMeta, + defaultTargetReps.isAcceptableOrUnknown( + data['default_target_reps']!, + _defaultTargetRepsMeta, + ), + ); + } + if (data.containsKey('default_target_score')) { + context.handle( + _defaultTargetScoreMeta, + defaultTargetScore.isAcceptableOrUnknown( + data['default_target_score']!, + _defaultTargetScoreMeta, + ), + ); + } + if (data.containsKey('default_target_score_time_ms')) { + context.handle( + _defaultTargetScoreTimeMsMeta, + defaultTargetScoreTimeMs.isAcceptableOrUnknown( + data['default_target_score_time_ms']!, + _defaultTargetScoreTimeMsMeta, + ), + ); + } if (data.containsKey('archived_at')) { context.handle( _archivedAtMeta, @@ -8392,6 +8476,22 @@ class $ExercisesTable extends Exercises DriftSqlType.string, data['${effectivePrefix}score_unit'], ), + defaultTargetTimeSeconds: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}default_target_time_seconds'], + ), + defaultTargetReps: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}default_target_reps'], + ), + defaultTargetScore: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}default_target_score'], + ), + defaultTargetScoreTimeMs: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}default_target_score_time_ms'], + ), archivedAt: attachedDatabase.typeMapping.read( DriftSqlType.dateTime, data['${effectivePrefix}archived_at'], @@ -8427,6 +8527,10 @@ class Exercise extends DataClass implements Insertable { final String scoreInputMode; final String? scoreLabel; final String? scoreUnit; + final int? defaultTargetTimeSeconds; + final int? defaultTargetReps; + final double? defaultTargetScore; + final int? defaultTargetScoreTimeMs; final DateTime? archivedAt; const Exercise({ required this.id, @@ -8450,6 +8554,10 @@ class Exercise extends DataClass implements Insertable { required this.scoreInputMode, this.scoreLabel, this.scoreUnit, + this.defaultTargetTimeSeconds, + this.defaultTargetReps, + this.defaultTargetScore, + this.defaultTargetScoreTimeMs, this.archivedAt, }); @override @@ -8494,6 +8602,22 @@ class Exercise extends DataClass implements Insertable { if (!nullToAbsent || scoreUnit != null) { map['score_unit'] = Variable(scoreUnit); } + if (!nullToAbsent || defaultTargetTimeSeconds != null) { + map['default_target_time_seconds'] = Variable( + defaultTargetTimeSeconds, + ); + } + if (!nullToAbsent || defaultTargetReps != null) { + map['default_target_reps'] = Variable(defaultTargetReps); + } + if (!nullToAbsent || defaultTargetScore != null) { + map['default_target_score'] = Variable(defaultTargetScore); + } + if (!nullToAbsent || defaultTargetScoreTimeMs != null) { + map['default_target_score_time_ms'] = Variable( + defaultTargetScoreTimeMs, + ); + } if (!nullToAbsent || archivedAt != null) { map['archived_at'] = Variable(archivedAt); } @@ -8541,6 +8665,18 @@ class Exercise extends DataClass implements Insertable { scoreUnit: scoreUnit == null && nullToAbsent ? const Value.absent() : Value(scoreUnit), + defaultTargetTimeSeconds: defaultTargetTimeSeconds == null && nullToAbsent + ? const Value.absent() + : Value(defaultTargetTimeSeconds), + defaultTargetReps: defaultTargetReps == null && nullToAbsent + ? const Value.absent() + : Value(defaultTargetReps), + defaultTargetScore: defaultTargetScore == null && nullToAbsent + ? const Value.absent() + : Value(defaultTargetScore), + defaultTargetScoreTimeMs: defaultTargetScoreTimeMs == null && nullToAbsent + ? const Value.absent() + : Value(defaultTargetScoreTimeMs), archivedAt: archivedAt == null && nullToAbsent ? const Value.absent() : Value(archivedAt), @@ -8576,6 +8712,16 @@ class Exercise extends DataClass implements Insertable { scoreInputMode: serializer.fromJson(json['scoreInputMode']), scoreLabel: serializer.fromJson(json['scoreLabel']), scoreUnit: serializer.fromJson(json['scoreUnit']), + defaultTargetTimeSeconds: serializer.fromJson( + json['defaultTargetTimeSeconds'], + ), + defaultTargetReps: serializer.fromJson(json['defaultTargetReps']), + defaultTargetScore: serializer.fromJson( + json['defaultTargetScore'], + ), + defaultTargetScoreTimeMs: serializer.fromJson( + json['defaultTargetScoreTimeMs'], + ), archivedAt: serializer.fromJson(json['archivedAt']), ); } @@ -8604,6 +8750,14 @@ class Exercise extends DataClass implements Insertable { 'scoreInputMode': serializer.toJson(scoreInputMode), 'scoreLabel': serializer.toJson(scoreLabel), 'scoreUnit': serializer.toJson(scoreUnit), + 'defaultTargetTimeSeconds': serializer.toJson( + defaultTargetTimeSeconds, + ), + 'defaultTargetReps': serializer.toJson(defaultTargetReps), + 'defaultTargetScore': serializer.toJson(defaultTargetScore), + 'defaultTargetScoreTimeMs': serializer.toJson( + defaultTargetScoreTimeMs, + ), 'archivedAt': serializer.toJson(archivedAt), }; } @@ -8630,6 +8784,10 @@ class Exercise extends DataClass implements Insertable { String? scoreInputMode, Value scoreLabel = const Value.absent(), Value scoreUnit = const Value.absent(), + Value defaultTargetTimeSeconds = const Value.absent(), + Value defaultTargetReps = const Value.absent(), + Value defaultTargetScore = const Value.absent(), + Value defaultTargetScoreTimeMs = const Value.absent(), Value archivedAt = const Value.absent(), }) => Exercise( id: id ?? this.id, @@ -8657,6 +8815,18 @@ class Exercise extends DataClass implements Insertable { scoreInputMode: scoreInputMode ?? this.scoreInputMode, scoreLabel: scoreLabel.present ? scoreLabel.value : this.scoreLabel, scoreUnit: scoreUnit.present ? scoreUnit.value : this.scoreUnit, + defaultTargetTimeSeconds: defaultTargetTimeSeconds.present + ? defaultTargetTimeSeconds.value + : this.defaultTargetTimeSeconds, + defaultTargetReps: defaultTargetReps.present + ? defaultTargetReps.value + : this.defaultTargetReps, + defaultTargetScore: defaultTargetScore.present + ? defaultTargetScore.value + : this.defaultTargetScore, + defaultTargetScoreTimeMs: defaultTargetScoreTimeMs.present + ? defaultTargetScoreTimeMs.value + : this.defaultTargetScoreTimeMs, archivedAt: archivedAt.present ? archivedAt.value : this.archivedAt, ); Exercise copyWithCompanion(ExercisesCompanion data) { @@ -8710,6 +8880,18 @@ class Exercise extends DataClass implements Insertable { ? data.scoreLabel.value : this.scoreLabel, scoreUnit: data.scoreUnit.present ? data.scoreUnit.value : this.scoreUnit, + defaultTargetTimeSeconds: data.defaultTargetTimeSeconds.present + ? data.defaultTargetTimeSeconds.value + : this.defaultTargetTimeSeconds, + defaultTargetReps: data.defaultTargetReps.present + ? data.defaultTargetReps.value + : this.defaultTargetReps, + defaultTargetScore: data.defaultTargetScore.present + ? data.defaultTargetScore.value + : this.defaultTargetScore, + defaultTargetScoreTimeMs: data.defaultTargetScoreTimeMs.present + ? data.defaultTargetScoreTimeMs.value + : this.defaultTargetScoreTimeMs, archivedAt: data.archivedAt.present ? data.archivedAt.value : this.archivedAt, @@ -8740,6 +8922,10 @@ class Exercise extends DataClass implements Insertable { ..write('scoreInputMode: $scoreInputMode, ') ..write('scoreLabel: $scoreLabel, ') ..write('scoreUnit: $scoreUnit, ') + ..write('defaultTargetTimeSeconds: $defaultTargetTimeSeconds, ') + ..write('defaultTargetReps: $defaultTargetReps, ') + ..write('defaultTargetScore: $defaultTargetScore, ') + ..write('defaultTargetScoreTimeMs: $defaultTargetScoreTimeMs, ') ..write('archivedAt: $archivedAt') ..write(')')) .toString(); @@ -8768,6 +8954,10 @@ class Exercise extends DataClass implements Insertable { scoreInputMode, scoreLabel, scoreUnit, + defaultTargetTimeSeconds, + defaultTargetReps, + defaultTargetScore, + defaultTargetScoreTimeMs, archivedAt, ]); @override @@ -8795,6 +8985,10 @@ class Exercise extends DataClass implements Insertable { other.scoreInputMode == this.scoreInputMode && other.scoreLabel == this.scoreLabel && other.scoreUnit == this.scoreUnit && + other.defaultTargetTimeSeconds == this.defaultTargetTimeSeconds && + other.defaultTargetReps == this.defaultTargetReps && + other.defaultTargetScore == this.defaultTargetScore && + other.defaultTargetScoreTimeMs == this.defaultTargetScoreTimeMs && other.archivedAt == this.archivedAt); } @@ -8820,6 +9014,10 @@ class ExercisesCompanion extends UpdateCompanion { final Value scoreInputMode; final Value scoreLabel; final Value scoreUnit; + final Value defaultTargetTimeSeconds; + final Value defaultTargetReps; + final Value defaultTargetScore; + final Value defaultTargetScoreTimeMs; final Value archivedAt; final Value rowid; const ExercisesCompanion({ @@ -8844,6 +9042,10 @@ class ExercisesCompanion extends UpdateCompanion { this.scoreInputMode = const Value.absent(), this.scoreLabel = const Value.absent(), this.scoreUnit = const Value.absent(), + this.defaultTargetTimeSeconds = const Value.absent(), + this.defaultTargetReps = const Value.absent(), + this.defaultTargetScore = const Value.absent(), + this.defaultTargetScoreTimeMs = const Value.absent(), this.archivedAt = const Value.absent(), this.rowid = const Value.absent(), }); @@ -8869,6 +9071,10 @@ class ExercisesCompanion extends UpdateCompanion { this.scoreInputMode = const Value.absent(), this.scoreLabel = const Value.absent(), this.scoreUnit = const Value.absent(), + this.defaultTargetTimeSeconds = const Value.absent(), + this.defaultTargetReps = const Value.absent(), + this.defaultTargetScore = const Value.absent(), + this.defaultTargetScoreTimeMs = const Value.absent(), this.archivedAt = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), @@ -8903,6 +9109,10 @@ class ExercisesCompanion extends UpdateCompanion { Expression? scoreInputMode, Expression? scoreLabel, Expression? scoreUnit, + Expression? defaultTargetTimeSeconds, + Expression? defaultTargetReps, + Expression? defaultTargetScore, + Expression? defaultTargetScoreTimeMs, Expression? archivedAt, Expression? rowid, }) { @@ -8929,6 +9139,13 @@ class ExercisesCompanion extends UpdateCompanion { if (scoreInputMode != null) 'score_input_mode': scoreInputMode, if (scoreLabel != null) 'score_label': scoreLabel, if (scoreUnit != null) 'score_unit': scoreUnit, + if (defaultTargetTimeSeconds != null) + 'default_target_time_seconds': defaultTargetTimeSeconds, + if (defaultTargetReps != null) 'default_target_reps': defaultTargetReps, + if (defaultTargetScore != null) + 'default_target_score': defaultTargetScore, + if (defaultTargetScoreTimeMs != null) + 'default_target_score_time_ms': defaultTargetScoreTimeMs, if (archivedAt != null) 'archived_at': archivedAt, if (rowid != null) 'rowid': rowid, }); @@ -8956,6 +9173,10 @@ class ExercisesCompanion extends UpdateCompanion { Value? scoreInputMode, Value? scoreLabel, Value? scoreUnit, + Value? defaultTargetTimeSeconds, + Value? defaultTargetReps, + Value? defaultTargetScore, + Value? defaultTargetScoreTimeMs, Value? archivedAt, Value? rowid, }) { @@ -8981,6 +9202,12 @@ class ExercisesCompanion extends UpdateCompanion { scoreInputMode: scoreInputMode ?? this.scoreInputMode, scoreLabel: scoreLabel ?? this.scoreLabel, scoreUnit: scoreUnit ?? this.scoreUnit, + defaultTargetTimeSeconds: + defaultTargetTimeSeconds ?? this.defaultTargetTimeSeconds, + defaultTargetReps: defaultTargetReps ?? this.defaultTargetReps, + defaultTargetScore: defaultTargetScore ?? this.defaultTargetScore, + defaultTargetScoreTimeMs: + defaultTargetScoreTimeMs ?? this.defaultTargetScoreTimeMs, archivedAt: archivedAt ?? this.archivedAt, rowid: rowid ?? this.rowid, ); @@ -9054,6 +9281,22 @@ class ExercisesCompanion extends UpdateCompanion { if (scoreUnit.present) { map['score_unit'] = Variable(scoreUnit.value); } + if (defaultTargetTimeSeconds.present) { + map['default_target_time_seconds'] = Variable( + defaultTargetTimeSeconds.value, + ); + } + if (defaultTargetReps.present) { + map['default_target_reps'] = Variable(defaultTargetReps.value); + } + if (defaultTargetScore.present) { + map['default_target_score'] = Variable(defaultTargetScore.value); + } + if (defaultTargetScoreTimeMs.present) { + map['default_target_score_time_ms'] = Variable( + defaultTargetScoreTimeMs.value, + ); + } if (archivedAt.present) { map['archived_at'] = Variable(archivedAt.value); } @@ -9087,6 +9330,10 @@ class ExercisesCompanion extends UpdateCompanion { ..write('scoreInputMode: $scoreInputMode, ') ..write('scoreLabel: $scoreLabel, ') ..write('scoreUnit: $scoreUnit, ') + ..write('defaultTargetTimeSeconds: $defaultTargetTimeSeconds, ') + ..write('defaultTargetReps: $defaultTargetReps, ') + ..write('defaultTargetScore: $defaultTargetScore, ') + ..write('defaultTargetScoreTimeMs: $defaultTargetScoreTimeMs, ') ..write('archivedAt: $archivedAt, ') ..write('rowid: $rowid') ..write(')')) @@ -22641,6 +22888,10 @@ typedef $$ExercisesTableCreateCompanionBuilder = Value scoreInputMode, Value scoreLabel, Value scoreUnit, + Value defaultTargetTimeSeconds, + Value defaultTargetReps, + Value defaultTargetScore, + Value defaultTargetScoreTimeMs, Value archivedAt, Value rowid, }); @@ -22667,6 +22918,10 @@ typedef $$ExercisesTableUpdateCompanionBuilder = Value scoreInputMode, Value scoreLabel, Value scoreUnit, + Value defaultTargetTimeSeconds, + Value defaultTargetReps, + Value defaultTargetScore, + Value defaultTargetScoreTimeMs, Value archivedAt, Value rowid, }); @@ -22834,6 +23089,26 @@ class $$ExercisesTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get defaultTargetTimeSeconds => $composableBuilder( + column: $table.defaultTargetTimeSeconds, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get defaultTargetReps => $composableBuilder( + column: $table.defaultTargetReps, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get defaultTargetScore => $composableBuilder( + column: $table.defaultTargetScore, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get defaultTargetScoreTimeMs => $composableBuilder( + column: $table.defaultTargetScoreTimeMs, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get archivedAt => $composableBuilder( column: $table.archivedAt, builder: (column) => ColumnFilters(column), @@ -23015,6 +23290,26 @@ class $$ExercisesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get defaultTargetTimeSeconds => $composableBuilder( + column: $table.defaultTargetTimeSeconds, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get defaultTargetReps => $composableBuilder( + column: $table.defaultTargetReps, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get defaultTargetScore => $composableBuilder( + column: $table.defaultTargetScore, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get defaultTargetScoreTimeMs => $composableBuilder( + column: $table.defaultTargetScoreTimeMs, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get archivedAt => $composableBuilder( column: $table.archivedAt, builder: (column) => ColumnOrderings(column), @@ -23157,6 +23452,26 @@ class $$ExercisesTableAnnotationComposer GeneratedColumn get scoreUnit => $composableBuilder(column: $table.scoreUnit, builder: (column) => column); + GeneratedColumn get defaultTargetTimeSeconds => $composableBuilder( + column: $table.defaultTargetTimeSeconds, + builder: (column) => column, + ); + + GeneratedColumn get defaultTargetReps => $composableBuilder( + column: $table.defaultTargetReps, + builder: (column) => column, + ); + + GeneratedColumn get defaultTargetScore => $composableBuilder( + column: $table.defaultTargetScore, + builder: (column) => column, + ); + + GeneratedColumn get defaultTargetScoreTimeMs => $composableBuilder( + column: $table.defaultTargetScoreTimeMs, + builder: (column) => column, + ); + GeneratedColumn get archivedAt => $composableBuilder( column: $table.archivedAt, builder: (column) => column, @@ -23287,6 +23602,10 @@ class $$ExercisesTableTableManager Value scoreInputMode = const Value.absent(), Value scoreLabel = const Value.absent(), Value scoreUnit = const Value.absent(), + Value defaultTargetTimeSeconds = const Value.absent(), + Value defaultTargetReps = const Value.absent(), + Value defaultTargetScore = const Value.absent(), + Value defaultTargetScoreTimeMs = const Value.absent(), Value archivedAt = const Value.absent(), Value rowid = const Value.absent(), }) => ExercisesCompanion( @@ -23311,6 +23630,10 @@ class $$ExercisesTableTableManager scoreInputMode: scoreInputMode, scoreLabel: scoreLabel, scoreUnit: scoreUnit, + defaultTargetTimeSeconds: defaultTargetTimeSeconds, + defaultTargetReps: defaultTargetReps, + defaultTargetScore: defaultTargetScore, + defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, archivedAt: archivedAt, rowid: rowid, ), @@ -23337,6 +23660,10 @@ class $$ExercisesTableTableManager Value scoreInputMode = const Value.absent(), Value scoreLabel = const Value.absent(), Value scoreUnit = const Value.absent(), + Value defaultTargetTimeSeconds = const Value.absent(), + Value defaultTargetReps = const Value.absent(), + Value defaultTargetScore = const Value.absent(), + Value defaultTargetScoreTimeMs = const Value.absent(), Value archivedAt = const Value.absent(), Value rowid = const Value.absent(), }) => ExercisesCompanion.insert( @@ -23361,6 +23688,10 @@ class $$ExercisesTableTableManager scoreInputMode: scoreInputMode, scoreLabel: scoreLabel, scoreUnit: scoreUnit, + defaultTargetTimeSeconds: defaultTargetTimeSeconds, + defaultTargetReps: defaultTargetReps, + defaultTargetScore: defaultTargetScore, + defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, archivedAt: archivedAt, rowid: rowid, ), diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 8d5444a..2137681 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -966,6 +966,10 @@ db.ExercisesCompanion _exerciseCompanion(domain.Exercise exercise) { scoreInputMode: Value(_scoreInputModeToDb(exercise.scoreInputMode)), scoreLabel: Value(exercise.scoreLabel), scoreUnit: Value(exercise.scoreUnit), + defaultTargetTimeSeconds: Value(exercise.defaultTargetTimeSeconds), + defaultTargetReps: Value(exercise.defaultTargetReps), + defaultTargetScore: Value(exercise.defaultTargetScore), + defaultTargetScoreTimeMs: Value(exercise.defaultTargetScoreTimeMs), archivedAt: Value(_utcOrNull(exercise.archivedAt)), ); } @@ -983,6 +987,10 @@ domain.Exercise _exerciseFromRow(db.Exercise row) { scoreInputMode: _scoreInputModeFromDb(row.scoreInputMode), scoreLabel: row.scoreLabel, scoreUnit: row.scoreUnit, + defaultTargetTimeSeconds: row.defaultTargetTimeSeconds, + defaultTargetReps: row.defaultTargetReps, + defaultTargetScore: row.defaultTargetScore, + defaultTargetScoreTimeMs: row.defaultTargetScoreTimeMs, archivedAt: _utcOrNull(row.archivedAt), ); } diff --git a/lib/infrastructure/local/tables.dart b/lib/infrastructure/local/tables.dart index 399ecfe..b8e95ef 100644 --- a/lib/infrastructure/local/tables.dart +++ b/lib/infrastructure/local/tables.dart @@ -69,6 +69,10 @@ class Exercises extends SyncableTable { text().withDefault(const Constant('manual'))(); TextColumn get scoreLabel => text().nullable()(); TextColumn get scoreUnit => text().nullable()(); + IntColumn get defaultTargetTimeSeconds => integer().nullable()(); + IntColumn get defaultTargetReps => integer().nullable()(); + RealColumn get defaultTargetScore => real().nullable()(); + IntColumn get defaultTargetScoreTimeMs => integer().nullable()(); DateTimeColumn get archivedAt => dateTime().nullable()(); @override @@ -79,6 +83,12 @@ class Exercises extends SyncableTable { "CHECK (score_input_mode != 'manual' OR NOT has_score_measure OR " '(score_label IS NOT NULL AND length(trim(score_label)) > 0 ' 'AND score_unit IS NOT NULL AND length(trim(score_unit)) > 0))', + 'CHECK (default_target_time_seconds IS NULL OR ' + 'default_target_time_seconds > 0)', + 'CHECK (default_target_reps IS NULL OR default_target_reps > 0)', + 'CHECK (default_target_score IS NULL OR default_target_score > 0)', + 'CHECK (default_target_score_time_ms IS NULL OR ' + 'default_target_score_time_ms > 0)', ]; } diff --git a/lib/presentation/exercise_library_screen.dart b/lib/presentation/exercise_library_screen.dart index 1085b84..d6e8375 100644 --- a/lib/presentation/exercise_library_screen.dart +++ b/lib/presentation/exercise_library_screen.dart @@ -287,6 +287,10 @@ final class _ExerciseFormScreenState extends State { late final TextEditingController _descriptionController; late final TextEditingController _scoreLabelController; late final TextEditingController _scoreUnitController; + late final TextEditingController _defaultTimeController; + late final TextEditingController _defaultRepsController; + late final TextEditingController _defaultScoreController; + late final TextEditingController _defaultScoreTimeController; String? _imageMediaId; String? _videoMediaId; String? _selectedImageName; @@ -311,6 +315,20 @@ final class _ExerciseFormScreenState extends State { _descriptionController = TextEditingController(text: exercise?.description); _scoreLabelController = TextEditingController(text: exercise?.scoreLabel); _scoreUnitController = TextEditingController(text: exercise?.scoreUnit); + _defaultTimeController = TextEditingController( + text: _optionalIntText(exercise?.defaultTargetTimeSeconds), + ); + _defaultRepsController = TextEditingController( + text: _optionalIntText(exercise?.defaultTargetReps), + ); + _defaultScoreController = TextEditingController( + text: _optionalDoubleText(exercise?.defaultTargetScore), + ); + _defaultScoreTimeController = TextEditingController( + text: _optionalDoubleText( + _millisecondsToSeconds(exercise?.defaultTargetScoreTimeMs), + ), + ); _imageMediaId = exercise?.imageMediaId; _videoMediaId = exercise?.videoMediaId; _hasTime = exercise?.hasTimeMeasure ?? true; @@ -325,6 +343,10 @@ final class _ExerciseFormScreenState extends State { _descriptionController.dispose(); _scoreLabelController.dispose(); _scoreUnitController.dispose(); + _defaultTimeController.dispose(); + _defaultRepsController.dispose(); + _defaultScoreController.dispose(); + _defaultScoreTimeController.dispose(); super.dispose(); } @@ -389,12 +411,44 @@ final class _ExerciseFormScreenState extends State { value: _hasTime, onChanged: (value) => _setMeasure(() => _hasTime = value), ), + if (_hasTime) ...[ + const SizedBox(height: 8), + TextFormField( + controller: _defaultTimeController, + decoration: const InputDecoration( + labelText: 'Temps par défaut (s)', + ), + keyboardType: TextInputType.number, + validator: (value) => _hasTime + ? _positiveIntValidator( + value, + 'Saisis un temps supérieur à 0.', + ) + : null, + ), + ], _MeasureSwitch( title: 'Répétitions', subtitle: 'Compter le nombre de mouvements réalisés.', value: _hasReps, onChanged: (value) => _setMeasure(() => _hasReps = value), ), + if (_hasReps) ...[ + const SizedBox(height: 8), + TextFormField( + controller: _defaultRepsController, + decoration: const InputDecoration( + labelText: 'Répétitions par défaut', + ), + keyboardType: TextInputType.number, + validator: (value) => _hasReps + ? _positiveIntValidator( + value, + 'Saisis un nombre de répétitions supérieur à 0.', + ) + : null, + ), + ], _MeasureSwitch( title: 'Score', subtitle: 'Saisir une valeur libre avec son unité.', @@ -467,6 +521,49 @@ final class _ExerciseFormScreenState extends State { : null; }, ), + const SizedBox(height: 12), + TextFormField( + controller: _defaultScoreController, + decoration: const InputDecoration( + labelText: 'Score par défaut', + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + validator: (value) { + if (!_hasScore || + _scoreInputMode != ScoreInputMode.manual) { + return null; + } + return _positiveDoubleValidator( + value, + 'Saisis un score supérieur à 0.', + ); + }, + ), + ] else ...[ + const SizedBox(height: 12), + TextFormField( + controller: _defaultScoreTimeController, + decoration: const InputDecoration( + labelText: 'Objectif de chrono par défaut (optionnel)', + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + validator: (value) { + if (!_hasScore || + _scoreInputMode != ScoreInputMode.stopwatch || + value == null || + value.trim().isEmpty) { + return null; + } + return _positiveDoubleValidator( + value, + 'Saisis un objectif supérieur à 0.', + ); + }, + ), ], ], if (_warning != null) ...[ @@ -586,6 +683,20 @@ final class _ExerciseFormScreenState extends State { 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; try { if (exercise == null) { await widget.exerciseUseCases.create( @@ -599,6 +710,10 @@ final class _ExerciseFormScreenState extends State { scoreInputMode: scoreInputMode, scoreLabel: scoreLabel, scoreUnit: scoreUnit, + defaultTargetTimeSeconds: defaultTargetTimeSeconds, + defaultTargetReps: defaultTargetReps, + defaultTargetScore: defaultTargetScore, + defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, ); } else { await widget.exerciseUseCases.update( @@ -613,6 +728,10 @@ final class _ExerciseFormScreenState extends State { scoreInputMode: scoreInputMode, scoreLabel: scoreLabel, scoreUnit: scoreUnit, + defaultTargetTimeSeconds: defaultTargetTimeSeconds, + defaultTargetReps: defaultTargetReps, + defaultTargetScore: defaultTargetScore, + defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, ); } if (mounted) { @@ -642,6 +761,48 @@ final class _ExerciseFormScreenState extends State { return text.isEmpty ? null : text; } + String? _positiveIntValidator(String? value, String message) { + final number = int.tryParse(value?.trim() ?? ''); + if (number == null || number <= 0) { + return message; + } + return null; + } + + String? _positiveDoubleValidator(String? value, String message) { + final number = double.tryParse(value?.trim() ?? ''); + if (number == null || number <= 0) { + return message; + } + return null; + } + + String _optionalIntText(int? value) { + return value == null ? '' : '$value'; + } + + String _optionalDoubleText(double? value) { + if (value == null) { + return ''; + } + if (value == value.roundToDouble()) { + return '${value.round()}'; + } + return '$value'; + } + + double? _millisecondsToSeconds(int? milliseconds) { + return milliseconds == null ? null : milliseconds / 1000; + } + + int? _optionalSecondsToMilliseconds(String value) { + final text = value.trim(); + if (text.isEmpty) { + return null; + } + return (double.parse(text) * 1000).round(); + } + String _fileNameFromPath(String path) { final parts = path.split(RegExp(r'[/\\]')); return parts.isEmpty ? path : parts.last; diff --git a/lib/presentation/program_screen.dart b/lib/presentation/program_screen.dart index 84d4deb..f29537f 100644 --- a/lib/presentation/program_screen.dart +++ b/lib/presentation/program_screen.dart @@ -223,7 +223,7 @@ final class _ProgramFormScreenState extends State { buildDefaultDragHandles: false, padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), itemCount: _exercises.length, - onReorder: _reorderExercise, + onReorderItem: _reorderExercise, itemBuilder: (context, index) { final exercise = _exercises[index]; return _ProgramExerciseCard( @@ -517,7 +517,6 @@ final class _ProgramExerciseCustomizationScreen extends StatefulWidget { const _ProgramExerciseCustomizationScreen({ required _ProgramExerciseDraft draft, required this.defaultRestSeconds, - super.key, }) : _initialDraft = draft; final _ProgramExerciseDraft _initialDraft; @@ -853,6 +852,14 @@ final class _ProgramExerciseDraft { scoreUnitSnapshot: exercise.scoreUnit, setsCount: 3, enabledMeasures: exercise.availableMeasures, + targetTimeSeconds: exercise.defaultTargetTimeSeconds, + targetReps: exercise.defaultTargetReps, + targetScore: exercise.scoreInputMode == ScoreInputMode.manual + ? exercise.defaultTargetScore + : null, + targetScoreTimeMs: exercise.scoreInputMode == ScoreInputMode.stopwatch + ? exercise.defaultTargetScoreTimeMs + : null, restSecondsOverride: restSecondsOverride, ); } diff --git a/test/application/media_use_cases_test.dart b/test/application/media_use_cases_test.dart index f66caf7..aadf0b2 100644 --- a/test/application/media_use_cases_test.dart +++ b/test/application/media_use_cases_test.dart @@ -38,6 +38,7 @@ void main() { hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, + defaultTargetReps: 10, ); final mediaRepository = _FakeMediaAssetRepository() ..asset = MediaAsset( diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index 186a46f..1659246 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -18,6 +18,144 @@ void main() { ); }); + test( + 'Exercise use case rejects active measures without default targets', + () async { + final useCase = _exerciseUseCase(_FakeExerciseRepository()); + + await expectLater( + useCase.create( + name: 'Squat', + hasTimeMeasure: true, + hasRepsMeasure: false, + hasScoreMeasure: false, + ), + throwsA(isA()), + ); + await expectLater( + useCase.create( + name: 'Pompes', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + ), + throwsA(isA()), + ); + await expectLater( + useCase.create( + name: 'Charge', + hasTimeMeasure: false, + hasRepsMeasure: false, + hasScoreMeasure: true, + scoreLabel: 'Charge', + scoreUnit: 'kg', + ), + throwsA(isA()), + ); + }, + ); + + test('Exercise use case rejects non-positive default targets', () async { + final useCase = _exerciseUseCase(_FakeExerciseRepository()); + + await expectLater( + useCase.create( + name: 'Squat', + hasTimeMeasure: true, + hasRepsMeasure: false, + hasScoreMeasure: false, + defaultTargetTimeSeconds: 0, + ), + throwsA(isA()), + ); + await expectLater( + useCase.create( + name: 'Pompes', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + defaultTargetReps: -1, + ), + throwsA(isA()), + ); + await expectLater( + useCase.create( + name: 'Charge', + hasTimeMeasure: false, + hasRepsMeasure: false, + hasScoreMeasure: true, + scoreLabel: 'Charge', + scoreUnit: 'kg', + defaultTargetScore: 0, + ), + throwsA(isA()), + ); + }); + + test( + 'Exercise use case accepts stopwatch score without default target', + () async { + final repository = _FakeExerciseRepository(); + final useCase = _exerciseUseCase(repository); + + final exercise = await useCase.create( + name: 'Sprint', + hasTimeMeasure: false, + hasRepsMeasure: false, + hasScoreMeasure: true, + scoreInputMode: ScoreInputMode.stopwatch, + scoreLabel: 'Temps réalisé', + ); + + expect(exercise.defaultTargetScoreTimeMs, isNull); + expect(repository.saved.single.metadata.id, exercise.metadata.id); + }, + ); + + test( + 'Exercise use case rejects non-positive stopwatch score default target', + () async { + final useCase = _exerciseUseCase(_FakeExerciseRepository()); + + await expectLater( + useCase.create( + name: 'Sprint', + hasTimeMeasure: false, + hasRepsMeasure: false, + hasScoreMeasure: true, + scoreInputMode: ScoreInputMode.stopwatch, + scoreLabel: 'Temps réalisé', + defaultTargetScoreTimeMs: 0, + ), + throwsA(isA()), + ); + }, + ); + + test('Exercise update validates default targets too', () async { + final repository = _FakeExerciseRepository() + ..exercise = Exercise( + metadata: _metadata('exercise-1'), + name: 'Squat', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + defaultTargetReps: 10, + ); + final useCase = _exerciseUseCase(repository); + + await expectLater( + useCase.update( + id: 'exercise-1', + name: 'Squat', + hasTimeMeasure: true, + hasRepsMeasure: false, + hasScoreMeasure: false, + ), + throwsA(isA()), + ); + }); + test('Workout template use case rejects active measure overrides', () async { final useCase = WorkoutTemplateUseCases( templateRepository: _FakeWorkoutTemplateRepository(), @@ -474,6 +612,38 @@ final class _FakeIds implements IdGenerator { } } +ExerciseUseCases _exerciseUseCase(_FakeExerciseRepository repository) { + return ExerciseUseCases( + repository: repository, + clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), + ids: _FakeIds(), + originDeviceId: 'device-1', + ); +} + +final class _FakeExerciseRepository implements ExerciseRepository { + Exercise? exercise; + final saved = []; + + @override + Future findById(String id) async { + return exercise?.metadata.id == id ? exercise : null; + } + + @override + Future isReferencedByProgram(String id) async => false; + + @override + Future> listActive() async => + exercise == null ? const [] : [exercise!]; + + @override + Future save(Exercise exercise) async { + this.exercise = exercise; + saved.add(exercise); + } +} + final class _FakeWorkoutTemplateRepository implements WorkoutTemplateRepository { @override diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart index 663da31..0d4d836 100644 --- a/test/infrastructure/drift_repositories_test.dart +++ b/test/infrastructure/drift_repositories_test.dart @@ -36,6 +36,7 @@ void main() { hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, + defaultTargetReps: 10, ); await exerciseRepository.save(exercise); diff --git a/test/presentation/exercise_library_screen_test.dart b/test/presentation/exercise_library_screen_test.dart index e12a6ea..c57f5e7 100644 --- a/test/presentation/exercise_library_screen_test.dart +++ b/test/presentation/exercise_library_screen_test.dart @@ -51,6 +51,9 @@ void main() { hasScoreMeasure: true, scoreLabel: 'Charge', scoreUnit: 'kg', + defaultTargetTimeSeconds: 45, + defaultTargetReps: 10, + defaultTargetScore: 80, ); await tester.pumpWidget( @@ -71,6 +74,9 @@ void main() { ) async { final exerciseRepository = _FakeExerciseRepository(); + await tester.binding.setSurfaceSize(const Size(400, 2400)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( MaterialApp( home: ExerciseFormScreen( @@ -81,43 +87,28 @@ void main() { ); await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Run'); - await tester.dragUntilVisible( - find.text('Score'), - find.byType(ListView), - const Offset(0, -200), + await tester.enterText( + find.widgetWithText(TextFormField, 'Temps par défaut (s)'), + '30', ); await tester.tap(find.widgetWithText(SwitchListTile, 'Score')); await tester.pump(); - await tester.dragUntilVisible( - find.text('Unité'), - find.byType(ListView), - const Offset(0, -200), - ); expect(find.widgetWithText(TextFormField, 'Unité'), findsOneWidget); - await tester.dragUntilVisible( - find.text('Chrono intégré'), - find.byType(ListView), - const Offset(0, 200), - ); await tester.tap(find.text('Chrono intégré')); await tester.pump(); - await tester.dragUntilVisible( - find.text('Temps réalisé'), - find.byType(ListView), - const Offset(0, -200), - ); expect(find.text('Temps réalisé'), findsOneWidget); expect(find.widgetWithText(TextFormField, 'Unité'), findsNothing); - await tester.ensureVisible(find.text('Enregistrer')); await tester.tap(find.text('Enregistrer')); await tester.pumpAndSettle(); final saved = exerciseRepository.saved.single; expect(saved.scoreInputMode, ScoreInputMode.stopwatch); + expect(saved.defaultTargetTimeSeconds, 30); + expect(saved.defaultTargetScoreTimeMs, isNull); await tester.pumpWidget( MaterialApp( diff --git a/test/presentation/program_screen_test.dart b/test/presentation/program_screen_test.dart index 7d7129e..2f371a1 100644 --- a/test/presentation/program_screen_test.dart +++ b/test/presentation/program_screen_test.dart @@ -17,6 +17,8 @@ void main() { hasTimeMeasure: true, hasRepsMeasure: true, hasScoreMeasure: false, + defaultTargetTimeSeconds: 45, + defaultTargetReps: 10, ), ); @@ -55,6 +57,8 @@ void main() { expect(savedExercise.timeEnabled, isTrue); expect(savedExercise.repsEnabled, isTrue); expect(savedExercise.scoreEnabled, isFalse); + expect(savedExercise.targetTimeSeconds, 45); + expect(savedExercise.targetReps, 10); expect(savedExercise.restSecondsOverride, 60); }, ); @@ -70,6 +74,7 @@ void main() { hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, + defaultTargetReps: 12, ), ); @@ -114,6 +119,7 @@ void main() { hasTimeMeasure: true, hasRepsMeasure: false, hasScoreMeasure: false, + defaultTargetTimeSeconds: 45, ), ); @@ -171,6 +177,9 @@ void main() { hasScoreMeasure: true, scoreInputMode: ScoreInputMode.stopwatch, scoreLabel: 'Temps réalisé', + defaultTargetTimeSeconds: 30, + defaultTargetReps: 5, + defaultTargetScoreTimeMs: 12000, ), );