merge(main): valeurs par défaut d'exercice (tickets #39/#40, parent #30)

Fusionne feature/#30-valeurs-defaut-exercice — analyze propre, 51/51
tests verts, build APK debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 10:18:44 +02:00
24 changed files with 996 additions and 37 deletions

View File

@ -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

View File

@ -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).

View File

@ -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
---

View File

@ -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.

View File

@ -0,0 +1,6 @@
---
issueRef: "#39"
version: 3
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784362685685
---

View File

@ -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.

View File

@ -0,0 +1,6 @@
---
issueRef: "#40"
version: 4
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784362686414
---

View File

@ -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.

View File

@ -1,3 +1,3 @@
{
"nextNumber": 39
"nextNumber": 41
}

View File

@ -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
}
]
}

View File

@ -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;

View File

@ -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,

View File

@ -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<WorkoutMeasure> 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,

View File

@ -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<void> _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)',
);
}
}

View File

@ -8063,6 +8063,50 @@ class $ExercisesTable extends Exercises
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _defaultTargetTimeSecondsMeta =
const VerificationMeta('defaultTargetTimeSeconds');
@override
late final GeneratedColumn<int> defaultTargetTimeSeconds =
GeneratedColumn<int>(
'default_target_time_seconds',
aliasedName,
true,
type: DriftSqlType.int,
requiredDuringInsert: false,
);
static const VerificationMeta _defaultTargetRepsMeta = const VerificationMeta(
'defaultTargetReps',
);
@override
late final GeneratedColumn<int> defaultTargetReps = GeneratedColumn<int>(
'default_target_reps',
aliasedName,
true,
type: DriftSqlType.int,
requiredDuringInsert: false,
);
static const VerificationMeta _defaultTargetScoreMeta =
const VerificationMeta('defaultTargetScore');
@override
late final GeneratedColumn<double> defaultTargetScore =
GeneratedColumn<double>(
'default_target_score',
aliasedName,
true,
type: DriftSqlType.double,
requiredDuringInsert: false,
);
static const VerificationMeta _defaultTargetScoreTimeMsMeta =
const VerificationMeta('defaultTargetScoreTimeMs');
@override
late final GeneratedColumn<int> defaultTargetScoreTimeMs =
GeneratedColumn<int>(
'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<Exercise> {
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<Exercise> {
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<Exercise> {
if (!nullToAbsent || scoreUnit != null) {
map['score_unit'] = Variable<String>(scoreUnit);
}
if (!nullToAbsent || defaultTargetTimeSeconds != null) {
map['default_target_time_seconds'] = Variable<int>(
defaultTargetTimeSeconds,
);
}
if (!nullToAbsent || defaultTargetReps != null) {
map['default_target_reps'] = Variable<int>(defaultTargetReps);
}
if (!nullToAbsent || defaultTargetScore != null) {
map['default_target_score'] = Variable<double>(defaultTargetScore);
}
if (!nullToAbsent || defaultTargetScoreTimeMs != null) {
map['default_target_score_time_ms'] = Variable<int>(
defaultTargetScoreTimeMs,
);
}
if (!nullToAbsent || archivedAt != null) {
map['archived_at'] = Variable<DateTime>(archivedAt);
}
@ -8541,6 +8665,18 @@ class Exercise extends DataClass implements Insertable<Exercise> {
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<Exercise> {
scoreInputMode: serializer.fromJson<String>(json['scoreInputMode']),
scoreLabel: serializer.fromJson<String?>(json['scoreLabel']),
scoreUnit: serializer.fromJson<String?>(json['scoreUnit']),
defaultTargetTimeSeconds: serializer.fromJson<int?>(
json['defaultTargetTimeSeconds'],
),
defaultTargetReps: serializer.fromJson<int?>(json['defaultTargetReps']),
defaultTargetScore: serializer.fromJson<double?>(
json['defaultTargetScore'],
),
defaultTargetScoreTimeMs: serializer.fromJson<int?>(
json['defaultTargetScoreTimeMs'],
),
archivedAt: serializer.fromJson<DateTime?>(json['archivedAt']),
);
}
@ -8604,6 +8750,14 @@ class Exercise extends DataClass implements Insertable<Exercise> {
'scoreInputMode': serializer.toJson<String>(scoreInputMode),
'scoreLabel': serializer.toJson<String?>(scoreLabel),
'scoreUnit': serializer.toJson<String?>(scoreUnit),
'defaultTargetTimeSeconds': serializer.toJson<int?>(
defaultTargetTimeSeconds,
),
'defaultTargetReps': serializer.toJson<int?>(defaultTargetReps),
'defaultTargetScore': serializer.toJson<double?>(defaultTargetScore),
'defaultTargetScoreTimeMs': serializer.toJson<int?>(
defaultTargetScoreTimeMs,
),
'archivedAt': serializer.toJson<DateTime?>(archivedAt),
};
}
@ -8630,6 +8784,10 @@ class Exercise extends DataClass implements Insertable<Exercise> {
String? scoreInputMode,
Value<String?> scoreLabel = const Value.absent(),
Value<String?> scoreUnit = const Value.absent(),
Value<int?> defaultTargetTimeSeconds = const Value.absent(),
Value<int?> defaultTargetReps = const Value.absent(),
Value<double?> defaultTargetScore = const Value.absent(),
Value<int?> defaultTargetScoreTimeMs = const Value.absent(),
Value<DateTime?> archivedAt = const Value.absent(),
}) => Exercise(
id: id ?? this.id,
@ -8657,6 +8815,18 @@ class Exercise extends DataClass implements Insertable<Exercise> {
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<Exercise> {
? 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<Exercise> {
..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<Exercise> {
scoreInputMode,
scoreLabel,
scoreUnit,
defaultTargetTimeSeconds,
defaultTargetReps,
defaultTargetScore,
defaultTargetScoreTimeMs,
archivedAt,
]);
@override
@ -8795,6 +8985,10 @@ class Exercise extends DataClass implements Insertable<Exercise> {
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<Exercise> {
final Value<String> scoreInputMode;
final Value<String?> scoreLabel;
final Value<String?> scoreUnit;
final Value<int?> defaultTargetTimeSeconds;
final Value<int?> defaultTargetReps;
final Value<double?> defaultTargetScore;
final Value<int?> defaultTargetScoreTimeMs;
final Value<DateTime?> archivedAt;
final Value<int> rowid;
const ExercisesCompanion({
@ -8844,6 +9042,10 @@ class ExercisesCompanion extends UpdateCompanion<Exercise> {
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<Exercise> {
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<Exercise> {
Expression<String>? scoreInputMode,
Expression<String>? scoreLabel,
Expression<String>? scoreUnit,
Expression<int>? defaultTargetTimeSeconds,
Expression<int>? defaultTargetReps,
Expression<double>? defaultTargetScore,
Expression<int>? defaultTargetScoreTimeMs,
Expression<DateTime>? archivedAt,
Expression<int>? rowid,
}) {
@ -8929,6 +9139,13 @@ class ExercisesCompanion extends UpdateCompanion<Exercise> {
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<Exercise> {
Value<String>? scoreInputMode,
Value<String?>? scoreLabel,
Value<String?>? scoreUnit,
Value<int?>? defaultTargetTimeSeconds,
Value<int?>? defaultTargetReps,
Value<double?>? defaultTargetScore,
Value<int?>? defaultTargetScoreTimeMs,
Value<DateTime?>? archivedAt,
Value<int>? rowid,
}) {
@ -8981,6 +9202,12 @@ class ExercisesCompanion extends UpdateCompanion<Exercise> {
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<Exercise> {
if (scoreUnit.present) {
map['score_unit'] = Variable<String>(scoreUnit.value);
}
if (defaultTargetTimeSeconds.present) {
map['default_target_time_seconds'] = Variable<int>(
defaultTargetTimeSeconds.value,
);
}
if (defaultTargetReps.present) {
map['default_target_reps'] = Variable<int>(defaultTargetReps.value);
}
if (defaultTargetScore.present) {
map['default_target_score'] = Variable<double>(defaultTargetScore.value);
}
if (defaultTargetScoreTimeMs.present) {
map['default_target_score_time_ms'] = Variable<int>(
defaultTargetScoreTimeMs.value,
);
}
if (archivedAt.present) {
map['archived_at'] = Variable<DateTime>(archivedAt.value);
}
@ -9087,6 +9330,10 @@ class ExercisesCompanion extends UpdateCompanion<Exercise> {
..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<String> scoreInputMode,
Value<String?> scoreLabel,
Value<String?> scoreUnit,
Value<int?> defaultTargetTimeSeconds,
Value<int?> defaultTargetReps,
Value<double?> defaultTargetScore,
Value<int?> defaultTargetScoreTimeMs,
Value<DateTime?> archivedAt,
Value<int> rowid,
});
@ -22667,6 +22918,10 @@ typedef $$ExercisesTableUpdateCompanionBuilder =
Value<String> scoreInputMode,
Value<String?> scoreLabel,
Value<String?> scoreUnit,
Value<int?> defaultTargetTimeSeconds,
Value<int?> defaultTargetReps,
Value<double?> defaultTargetScore,
Value<int?> defaultTargetScoreTimeMs,
Value<DateTime?> archivedAt,
Value<int> rowid,
});
@ -22834,6 +23089,26 @@ class $$ExercisesTableFilterComposer
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get defaultTargetTimeSeconds => $composableBuilder(
column: $table.defaultTargetTimeSeconds,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get defaultTargetReps => $composableBuilder(
column: $table.defaultTargetReps,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<double> get defaultTargetScore => $composableBuilder(
column: $table.defaultTargetScore,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get defaultTargetScoreTimeMs => $composableBuilder(
column: $table.defaultTargetScoreTimeMs,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get archivedAt => $composableBuilder(
column: $table.archivedAt,
builder: (column) => ColumnFilters(column),
@ -23015,6 +23290,26 @@ class $$ExercisesTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get defaultTargetTimeSeconds => $composableBuilder(
column: $table.defaultTargetTimeSeconds,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get defaultTargetReps => $composableBuilder(
column: $table.defaultTargetReps,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<double> get defaultTargetScore => $composableBuilder(
column: $table.defaultTargetScore,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get defaultTargetScoreTimeMs => $composableBuilder(
column: $table.defaultTargetScoreTimeMs,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get archivedAt => $composableBuilder(
column: $table.archivedAt,
builder: (column) => ColumnOrderings(column),
@ -23157,6 +23452,26 @@ class $$ExercisesTableAnnotationComposer
GeneratedColumn<String> get scoreUnit =>
$composableBuilder(column: $table.scoreUnit, builder: (column) => column);
GeneratedColumn<int> get defaultTargetTimeSeconds => $composableBuilder(
column: $table.defaultTargetTimeSeconds,
builder: (column) => column,
);
GeneratedColumn<int> get defaultTargetReps => $composableBuilder(
column: $table.defaultTargetReps,
builder: (column) => column,
);
GeneratedColumn<double> get defaultTargetScore => $composableBuilder(
column: $table.defaultTargetScore,
builder: (column) => column,
);
GeneratedColumn<int> get defaultTargetScoreTimeMs => $composableBuilder(
column: $table.defaultTargetScoreTimeMs,
builder: (column) => column,
);
GeneratedColumn<DateTime> get archivedAt => $composableBuilder(
column: $table.archivedAt,
builder: (column) => column,
@ -23287,6 +23602,10 @@ class $$ExercisesTableTableManager
Value<String> scoreInputMode = const Value.absent(),
Value<String?> scoreLabel = const Value.absent(),
Value<String?> scoreUnit = const Value.absent(),
Value<int?> defaultTargetTimeSeconds = const Value.absent(),
Value<int?> defaultTargetReps = const Value.absent(),
Value<double?> defaultTargetScore = const Value.absent(),
Value<int?> defaultTargetScoreTimeMs = const Value.absent(),
Value<DateTime?> archivedAt = const Value.absent(),
Value<int> 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<String> scoreInputMode = const Value.absent(),
Value<String?> scoreLabel = const Value.absent(),
Value<String?> scoreUnit = const Value.absent(),
Value<int?> defaultTargetTimeSeconds = const Value.absent(),
Value<int?> defaultTargetReps = const Value.absent(),
Value<double?> defaultTargetScore = const Value.absent(),
Value<int?> defaultTargetScoreTimeMs = const Value.absent(),
Value<DateTime?> archivedAt = const Value.absent(),
Value<int> 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,
),

View File

@ -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),
);
}

View File

@ -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)',
];
}

View File

@ -287,6 +287,10 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
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<ExerciseFormScreen> {
_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<ExerciseFormScreen> {
_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<ExerciseFormScreen> {
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<ExerciseFormScreen> {
: 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<ExerciseFormScreen> {
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<ExerciseFormScreen> {
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<ExerciseFormScreen> {
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<ExerciseFormScreen> {
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;

View File

@ -223,7 +223,7 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
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,
);
}

View File

@ -38,6 +38,7 @@ void main() {
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
);
final mediaRepository = _FakeMediaAssetRepository()
..asset = MediaAsset(

View File

@ -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<DomainException>()),
);
await expectLater(
useCase.create(
name: 'Pompes',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
),
throwsA(isA<DomainException>()),
);
await expectLater(
useCase.create(
name: 'Charge',
hasTimeMeasure: false,
hasRepsMeasure: false,
hasScoreMeasure: true,
scoreLabel: 'Charge',
scoreUnit: 'kg',
),
throwsA(isA<DomainException>()),
);
},
);
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<DomainException>()),
);
await expectLater(
useCase.create(
name: 'Pompes',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: -1,
),
throwsA(isA<DomainException>()),
);
await expectLater(
useCase.create(
name: 'Charge',
hasTimeMeasure: false,
hasRepsMeasure: false,
hasScoreMeasure: true,
scoreLabel: 'Charge',
scoreUnit: 'kg',
defaultTargetScore: 0,
),
throwsA(isA<DomainException>()),
);
});
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<DomainException>()),
);
},
);
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<DomainException>()),
);
});
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 = <Exercise>[];
@override
Future<Exercise?> findById(String id) async {
return exercise?.metadata.id == id ? exercise : null;
}
@override
Future<bool> isReferencedByProgram(String id) async => false;
@override
Future<List<Exercise>> listActive() async =>
exercise == null ? const [] : [exercise!];
@override
Future<void> save(Exercise exercise) async {
this.exercise = exercise;
saved.add(exercise);
}
}
final class _FakeWorkoutTemplateRepository
implements WorkoutTemplateRepository {
@override

View File

@ -36,6 +36,7 @@ void main() {
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
);
await exerciseRepository.save(exercise);

View File

@ -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(

View File

@ -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,
),
);