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

Étend le modèle Drift (tables.dart, app_database.dart/.g.dart), les
entités/use cases/repositories et les écrans bibliothèque d'exercices
et programme pour supporter des valeurs par défaut par exercice
(répétitions/temps/poids proposés à la création d'une série).
flutter 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:31 +02:00
parent 6ee06fef01
commit 88af0387da
14 changed files with 882 additions and 27 deletions

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