diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index 0b38037..248c4c6 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -25,12 +25,19 @@ final class AppBootstrap implements AppDependencies { }); final AppDatabase database; + @override final ExerciseUseCases exerciseUseCases; + @override final MediaUseCases mediaUseCases; + @override final ProgramUseCases programUseCases; + @override final WorkoutTemplateUseCases workoutTemplateUseCases; + @override final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases; + @override final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase; + @override final WorkoutHistoryUseCases workoutHistoryUseCases; final SyncGateway syncGateway; diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 5456401..38d4881 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -3,6 +3,8 @@ import 'dart:convert'; import '../domain/domain.dart'; import 'ports.dart'; +const Object _useCaseUnchanged = Object(); + final class ExerciseUseCases { const ExerciseUseCases({ required this.repository, @@ -27,7 +29,21 @@ final class ExerciseUseCases { ScoreInputMode scoreInputMode = ScoreInputMode.manual, String? scoreLabel, String? scoreUnit, + int? defaultTargetTimeSeconds, + int? defaultTargetReps, + double? defaultTargetScore, + int? defaultTargetScoreTimeMs, }) async { + _validateExerciseDefaultTargets( + hasTimeMeasure: hasTimeMeasure, + hasRepsMeasure: hasRepsMeasure, + hasScoreMeasure: hasScoreMeasure, + scoreInputMode: scoreInputMode, + defaultTargetTimeSeconds: defaultTargetTimeSeconds, + defaultTargetReps: defaultTargetReps, + defaultTargetScore: defaultTargetScore, + defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, + ); final now = clock.now(); final exercise = Exercise( metadata: _newMetadata(ids, originDeviceId, now), @@ -41,6 +57,10 @@ final class ExerciseUseCases { scoreInputMode: scoreInputMode, scoreLabel: scoreLabel, scoreUnit: scoreUnit, + defaultTargetTimeSeconds: defaultTargetTimeSeconds, + defaultTargetReps: defaultTargetReps, + defaultTargetScore: defaultTargetScore, + defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, ); await repository.save(exercise); return exercise; @@ -66,11 +86,39 @@ final class ExerciseUseCases { ScoreInputMode scoreInputMode = ScoreInputMode.manual, String? scoreLabel, String? scoreUnit, + Object? defaultTargetTimeSeconds = _useCaseUnchanged, + Object? defaultTargetReps = _useCaseUnchanged, + Object? defaultTargetScore = _useCaseUnchanged, + Object? defaultTargetScoreTimeMs = _useCaseUnchanged, }) async { final exercise = await repository.findById(id); if (exercise == null) { throw const DomainException('Exercise not found.'); } + final resolvedDefaultTargetTimeSeconds = + defaultTargetTimeSeconds == _useCaseUnchanged + ? exercise.defaultTargetTimeSeconds + : defaultTargetTimeSeconds as int?; + final resolvedDefaultTargetReps = defaultTargetReps == _useCaseUnchanged + ? exercise.defaultTargetReps + : defaultTargetReps as int?; + final resolvedDefaultTargetScore = defaultTargetScore == _useCaseUnchanged + ? exercise.defaultTargetScore + : (defaultTargetScore as num?)?.toDouble(); + final resolvedDefaultTargetScoreTimeMs = + defaultTargetScoreTimeMs == _useCaseUnchanged + ? exercise.defaultTargetScoreTimeMs + : defaultTargetScoreTimeMs as int?; + _validateExerciseDefaultTargets( + hasTimeMeasure: hasTimeMeasure, + hasRepsMeasure: hasRepsMeasure, + hasScoreMeasure: hasScoreMeasure, + scoreInputMode: scoreInputMode, + defaultTargetTimeSeconds: resolvedDefaultTargetTimeSeconds, + defaultTargetReps: resolvedDefaultTargetReps, + defaultTargetScore: resolvedDefaultTargetScore, + defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs, + ); final updated = exercise.copyWith( metadata: exercise.metadata.touch(clock.now()), name: name, @@ -83,6 +131,10 @@ final class ExerciseUseCases { scoreInputMode: scoreInputMode, scoreLabel: scoreLabel, scoreUnit: scoreUnit, + defaultTargetTimeSeconds: resolvedDefaultTargetTimeSeconds, + defaultTargetReps: resolvedDefaultTargetReps, + defaultTargetScore: resolvedDefaultTargetScore, + defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs, ); await repository.save(updated); return updated; @@ -1271,6 +1323,49 @@ final class SetResultPositionState { final ActiveSetResult? result; } +void _validateExerciseDefaultTargets({ + required bool hasTimeMeasure, + required bool hasRepsMeasure, + required bool hasScoreMeasure, + required ScoreInputMode scoreInputMode, + required int? defaultTargetTimeSeconds, + required int? defaultTargetReps, + required double? defaultTargetScore, + required int? defaultTargetScoreTimeMs, +}) { + if (hasTimeMeasure && + (defaultTargetTimeSeconds == null || defaultTargetTimeSeconds <= 0)) { + throw const DomainException( + 'Default target time seconds must be positive.', + ); + } + if (hasRepsMeasure && (defaultTargetReps == null || defaultTargetReps <= 0)) { + throw const DomainException('Default target reps must be positive.'); + } + if (hasScoreMeasure && + scoreInputMode == ScoreInputMode.manual && + (defaultTargetScore == null || defaultTargetScore <= 0)) { + throw const DomainException('Default target score must be positive.'); + } + if (defaultTargetScore != null && + (!hasScoreMeasure || scoreInputMode != ScoreInputMode.manual)) { + throw const DomainException( + 'Default target score requires manual score mode.', + ); + } + if (defaultTargetScoreTimeMs != null && defaultTargetScoreTimeMs <= 0) { + throw const DomainException( + 'Default target score time ms must be positive.', + ); + } + if (defaultTargetScoreTimeMs != null && + (!hasScoreMeasure || scoreInputMode != ScoreInputMode.stopwatch)) { + throw const DomainException( + 'Default target score time ms requires stopwatch score mode.', + ); + } +} + void _validateOverrideTargets( String programSnapshotJson, WorkoutTemplateExerciseOverrideConfig input, diff --git a/lib/domain/entities.dart b/lib/domain/entities.dart index fadfe16..f2031f1 100644 --- a/lib/domain/entities.dart +++ b/lib/domain/entities.dart @@ -132,6 +132,10 @@ final class Exercise { this.scoreInputMode = ScoreInputMode.manual, this.scoreLabel, this.scoreUnit, + this.defaultTargetTimeSeconds, + this.defaultTargetReps, + this.defaultTargetScore, + this.defaultTargetScoreTimeMs, this.archivedAt, }) : name = _nonBlank(name, 'Exercise name') { _requireAtLeastOneMeasure( @@ -148,6 +152,16 @@ final class Exercise { _nonBlank(scoreLabel, 'Score label'); _nonBlank(scoreUnit, 'Score unit'); } + _requireNullablePositive( + defaultTargetTimeSeconds, + 'Default target time seconds', + ); + _requireNullablePositive(defaultTargetReps, 'Default target reps'); + _requireNullablePositiveDouble(defaultTargetScore, 'Default target score'); + _requireNullablePositive( + defaultTargetScoreTimeMs, + 'Default target score time ms', + ); } final EntityMetadata metadata; @@ -161,6 +175,10 @@ final class Exercise { final ScoreInputMode scoreInputMode; final String? scoreLabel; final String? scoreUnit; + final int? defaultTargetTimeSeconds; + final int? defaultTargetReps; + final double? defaultTargetScore; + final int? defaultTargetScoreTimeMs; final DateTime? archivedAt; Set get availableMeasures => { @@ -185,6 +203,10 @@ final class Exercise { ScoreInputMode? scoreInputMode, Object? scoreLabel = _unchanged, Object? scoreUnit = _unchanged, + Object? defaultTargetTimeSeconds = _unchanged, + Object? defaultTargetReps = _unchanged, + Object? defaultTargetScore = _unchanged, + Object? defaultTargetScoreTimeMs = _unchanged, Object? archivedAt = _unchanged, }) { return Exercise( @@ -209,6 +231,18 @@ final class Exercise { scoreUnit: scoreUnit == _unchanged ? this.scoreUnit : scoreUnit as String?, + defaultTargetTimeSeconds: defaultTargetTimeSeconds == _unchanged + ? this.defaultTargetTimeSeconds + : defaultTargetTimeSeconds as int?, + defaultTargetReps: defaultTargetReps == _unchanged + ? this.defaultTargetReps + : defaultTargetReps as int?, + defaultTargetScore: defaultTargetScore == _unchanged + ? this.defaultTargetScore + : defaultTargetScore as double?, + defaultTargetScoreTimeMs: defaultTargetScoreTimeMs == _unchanged + ? this.defaultTargetScoreTimeMs + : defaultTargetScoreTimeMs as int?, archivedAt: archivedAt == _unchanged ? this.archivedAt : archivedAt as DateTime?, @@ -371,10 +405,10 @@ final class ProgramExercise { timeEnabled: enabledMeasures.contains(WorkoutMeasure.time), repsEnabled: enabledMeasures.contains(WorkoutMeasure.reps), scoreEnabled: enabledMeasures.contains(WorkoutMeasure.score), - targetTimeSeconds: targetTimeSeconds, - targetReps: targetReps, - targetScore: targetScore, - targetScoreTimeMs: targetScoreTimeMs, + targetTimeSeconds: targetTimeSeconds ?? exercise.defaultTargetTimeSeconds, + targetReps: targetReps ?? exercise.defaultTargetReps, + targetScore: targetScore ?? exercise.defaultTargetScore, + targetScoreTimeMs: targetScoreTimeMs ?? exercise.defaultTargetScoreTimeMs, restSecondsOverride: restSecondsOverride, ); } @@ -927,6 +961,12 @@ void _requireNullableNonNegativeDouble(double? value, String label) { } } +void _requireNullablePositiveDouble(double? value, String label) { + if (value != null && value <= 0) { + throw DomainException('$label must be positive.'); + } +} + void _requireScoreTargetShape({ required bool scoreEnabled, required ScoreInputMode scoreInputMode, diff --git a/lib/infrastructure/local/app_database.dart b/lib/infrastructure/local/app_database.dart index 43cfd1c..e6a9b42 100644 --- a/lib/infrastructure/local/app_database.dart +++ b/lib/infrastructure/local/app_database.dart @@ -36,7 +36,7 @@ final class AppDatabase extends _$AppDatabase { } @override - int get schemaVersion => 3; + int get schemaVersion => 4; @override MigrationStrategy get migration { @@ -60,6 +60,9 @@ final class AppDatabase extends _$AppDatabase { if (from < 3) { await _migrateToSchema3(migrator); } + if (from < 4) { + await _migrateToSchema4(); + } await _createIndexes(); }, beforeOpen: (details) async { @@ -202,4 +205,25 @@ extension on AppDatabase { ); await migrator.createTable(activeScoreStopwatchStates); } + + Future _migrateToSchema4() async { + await customStatement( + 'ALTER TABLE exercises ADD COLUMN default_target_time_seconds INTEGER ' + 'CHECK (default_target_time_seconds IS NULL OR ' + 'default_target_time_seconds > 0)', + ); + await customStatement( + 'ALTER TABLE exercises ADD COLUMN default_target_reps INTEGER ' + 'CHECK (default_target_reps IS NULL OR default_target_reps > 0)', + ); + await customStatement( + 'ALTER TABLE exercises ADD COLUMN default_target_score REAL ' + 'CHECK (default_target_score IS NULL OR default_target_score > 0)', + ); + await customStatement( + 'ALTER TABLE exercises ADD COLUMN default_target_score_time_ms INTEGER ' + 'CHECK (default_target_score_time_ms IS NULL OR ' + 'default_target_score_time_ms > 0)', + ); + } } diff --git a/lib/infrastructure/local/app_database.g.dart b/lib/infrastructure/local/app_database.g.dart index 6b01c63..8c32fb3 100644 --- a/lib/infrastructure/local/app_database.g.dart +++ b/lib/infrastructure/local/app_database.g.dart @@ -8063,6 +8063,50 @@ class $ExercisesTable extends Exercises type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _defaultTargetTimeSecondsMeta = + const VerificationMeta('defaultTargetTimeSeconds'); + @override + late final GeneratedColumn defaultTargetTimeSeconds = + GeneratedColumn( + 'default_target_time_seconds', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _defaultTargetRepsMeta = const VerificationMeta( + 'defaultTargetReps', + ); + @override + late final GeneratedColumn defaultTargetReps = GeneratedColumn( + 'default_target_reps', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _defaultTargetScoreMeta = + const VerificationMeta('defaultTargetScore'); + @override + late final GeneratedColumn defaultTargetScore = + GeneratedColumn( + 'default_target_score', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + static const VerificationMeta _defaultTargetScoreTimeMsMeta = + const VerificationMeta('defaultTargetScoreTimeMs'); + @override + late final GeneratedColumn defaultTargetScoreTimeMs = + GeneratedColumn( + 'default_target_score_time_ms', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); static const VerificationMeta _archivedAtMeta = const VerificationMeta( 'archivedAt', ); @@ -8097,6 +8141,10 @@ class $ExercisesTable extends Exercises scoreInputMode, scoreLabel, scoreUnit, + defaultTargetTimeSeconds, + defaultTargetReps, + defaultTargetScore, + defaultTargetScoreTimeMs, archivedAt, ]; @override @@ -8293,6 +8341,42 @@ class $ExercisesTable extends Exercises scoreUnit.isAcceptableOrUnknown(data['score_unit']!, _scoreUnitMeta), ); } + if (data.containsKey('default_target_time_seconds')) { + context.handle( + _defaultTargetTimeSecondsMeta, + defaultTargetTimeSeconds.isAcceptableOrUnknown( + data['default_target_time_seconds']!, + _defaultTargetTimeSecondsMeta, + ), + ); + } + if (data.containsKey('default_target_reps')) { + context.handle( + _defaultTargetRepsMeta, + defaultTargetReps.isAcceptableOrUnknown( + data['default_target_reps']!, + _defaultTargetRepsMeta, + ), + ); + } + if (data.containsKey('default_target_score')) { + context.handle( + _defaultTargetScoreMeta, + defaultTargetScore.isAcceptableOrUnknown( + data['default_target_score']!, + _defaultTargetScoreMeta, + ), + ); + } + if (data.containsKey('default_target_score_time_ms')) { + context.handle( + _defaultTargetScoreTimeMsMeta, + defaultTargetScoreTimeMs.isAcceptableOrUnknown( + data['default_target_score_time_ms']!, + _defaultTargetScoreTimeMsMeta, + ), + ); + } if (data.containsKey('archived_at')) { context.handle( _archivedAtMeta, @@ -8392,6 +8476,22 @@ class $ExercisesTable extends Exercises DriftSqlType.string, data['${effectivePrefix}score_unit'], ), + defaultTargetTimeSeconds: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}default_target_time_seconds'], + ), + defaultTargetReps: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}default_target_reps'], + ), + defaultTargetScore: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}default_target_score'], + ), + defaultTargetScoreTimeMs: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}default_target_score_time_ms'], + ), archivedAt: attachedDatabase.typeMapping.read( DriftSqlType.dateTime, data['${effectivePrefix}archived_at'], @@ -8427,6 +8527,10 @@ class Exercise extends DataClass implements Insertable { final String scoreInputMode; final String? scoreLabel; final String? scoreUnit; + final int? defaultTargetTimeSeconds; + final int? defaultTargetReps; + final double? defaultTargetScore; + final int? defaultTargetScoreTimeMs; final DateTime? archivedAt; const Exercise({ required this.id, @@ -8450,6 +8554,10 @@ class Exercise extends DataClass implements Insertable { required this.scoreInputMode, this.scoreLabel, this.scoreUnit, + this.defaultTargetTimeSeconds, + this.defaultTargetReps, + this.defaultTargetScore, + this.defaultTargetScoreTimeMs, this.archivedAt, }); @override @@ -8494,6 +8602,22 @@ class Exercise extends DataClass implements Insertable { if (!nullToAbsent || scoreUnit != null) { map['score_unit'] = Variable(scoreUnit); } + if (!nullToAbsent || defaultTargetTimeSeconds != null) { + map['default_target_time_seconds'] = Variable( + defaultTargetTimeSeconds, + ); + } + if (!nullToAbsent || defaultTargetReps != null) { + map['default_target_reps'] = Variable(defaultTargetReps); + } + if (!nullToAbsent || defaultTargetScore != null) { + map['default_target_score'] = Variable(defaultTargetScore); + } + if (!nullToAbsent || defaultTargetScoreTimeMs != null) { + map['default_target_score_time_ms'] = Variable( + defaultTargetScoreTimeMs, + ); + } if (!nullToAbsent || archivedAt != null) { map['archived_at'] = Variable(archivedAt); } @@ -8541,6 +8665,18 @@ class Exercise extends DataClass implements Insertable { scoreUnit: scoreUnit == null && nullToAbsent ? const Value.absent() : Value(scoreUnit), + defaultTargetTimeSeconds: defaultTargetTimeSeconds == null && nullToAbsent + ? const Value.absent() + : Value(defaultTargetTimeSeconds), + defaultTargetReps: defaultTargetReps == null && nullToAbsent + ? const Value.absent() + : Value(defaultTargetReps), + defaultTargetScore: defaultTargetScore == null && nullToAbsent + ? const Value.absent() + : Value(defaultTargetScore), + defaultTargetScoreTimeMs: defaultTargetScoreTimeMs == null && nullToAbsent + ? const Value.absent() + : Value(defaultTargetScoreTimeMs), archivedAt: archivedAt == null && nullToAbsent ? const Value.absent() : Value(archivedAt), @@ -8576,6 +8712,16 @@ class Exercise extends DataClass implements Insertable { scoreInputMode: serializer.fromJson(json['scoreInputMode']), scoreLabel: serializer.fromJson(json['scoreLabel']), scoreUnit: serializer.fromJson(json['scoreUnit']), + defaultTargetTimeSeconds: serializer.fromJson( + json['defaultTargetTimeSeconds'], + ), + defaultTargetReps: serializer.fromJson(json['defaultTargetReps']), + defaultTargetScore: serializer.fromJson( + json['defaultTargetScore'], + ), + defaultTargetScoreTimeMs: serializer.fromJson( + json['defaultTargetScoreTimeMs'], + ), archivedAt: serializer.fromJson(json['archivedAt']), ); } @@ -8604,6 +8750,14 @@ class Exercise extends DataClass implements Insertable { 'scoreInputMode': serializer.toJson(scoreInputMode), 'scoreLabel': serializer.toJson(scoreLabel), 'scoreUnit': serializer.toJson(scoreUnit), + 'defaultTargetTimeSeconds': serializer.toJson( + defaultTargetTimeSeconds, + ), + 'defaultTargetReps': serializer.toJson(defaultTargetReps), + 'defaultTargetScore': serializer.toJson(defaultTargetScore), + 'defaultTargetScoreTimeMs': serializer.toJson( + defaultTargetScoreTimeMs, + ), 'archivedAt': serializer.toJson(archivedAt), }; } @@ -8630,6 +8784,10 @@ class Exercise extends DataClass implements Insertable { String? scoreInputMode, Value scoreLabel = const Value.absent(), Value scoreUnit = const Value.absent(), + Value defaultTargetTimeSeconds = const Value.absent(), + Value defaultTargetReps = const Value.absent(), + Value defaultTargetScore = const Value.absent(), + Value defaultTargetScoreTimeMs = const Value.absent(), Value archivedAt = const Value.absent(), }) => Exercise( id: id ?? this.id, @@ -8657,6 +8815,18 @@ class Exercise extends DataClass implements Insertable { scoreInputMode: scoreInputMode ?? this.scoreInputMode, scoreLabel: scoreLabel.present ? scoreLabel.value : this.scoreLabel, scoreUnit: scoreUnit.present ? scoreUnit.value : this.scoreUnit, + defaultTargetTimeSeconds: defaultTargetTimeSeconds.present + ? defaultTargetTimeSeconds.value + : this.defaultTargetTimeSeconds, + defaultTargetReps: defaultTargetReps.present + ? defaultTargetReps.value + : this.defaultTargetReps, + defaultTargetScore: defaultTargetScore.present + ? defaultTargetScore.value + : this.defaultTargetScore, + defaultTargetScoreTimeMs: defaultTargetScoreTimeMs.present + ? defaultTargetScoreTimeMs.value + : this.defaultTargetScoreTimeMs, archivedAt: archivedAt.present ? archivedAt.value : this.archivedAt, ); Exercise copyWithCompanion(ExercisesCompanion data) { @@ -8710,6 +8880,18 @@ class Exercise extends DataClass implements Insertable { ? data.scoreLabel.value : this.scoreLabel, scoreUnit: data.scoreUnit.present ? data.scoreUnit.value : this.scoreUnit, + defaultTargetTimeSeconds: data.defaultTargetTimeSeconds.present + ? data.defaultTargetTimeSeconds.value + : this.defaultTargetTimeSeconds, + defaultTargetReps: data.defaultTargetReps.present + ? data.defaultTargetReps.value + : this.defaultTargetReps, + defaultTargetScore: data.defaultTargetScore.present + ? data.defaultTargetScore.value + : this.defaultTargetScore, + defaultTargetScoreTimeMs: data.defaultTargetScoreTimeMs.present + ? data.defaultTargetScoreTimeMs.value + : this.defaultTargetScoreTimeMs, archivedAt: data.archivedAt.present ? data.archivedAt.value : this.archivedAt, @@ -8740,6 +8922,10 @@ class Exercise extends DataClass implements Insertable { ..write('scoreInputMode: $scoreInputMode, ') ..write('scoreLabel: $scoreLabel, ') ..write('scoreUnit: $scoreUnit, ') + ..write('defaultTargetTimeSeconds: $defaultTargetTimeSeconds, ') + ..write('defaultTargetReps: $defaultTargetReps, ') + ..write('defaultTargetScore: $defaultTargetScore, ') + ..write('defaultTargetScoreTimeMs: $defaultTargetScoreTimeMs, ') ..write('archivedAt: $archivedAt') ..write(')')) .toString(); @@ -8768,6 +8954,10 @@ class Exercise extends DataClass implements Insertable { scoreInputMode, scoreLabel, scoreUnit, + defaultTargetTimeSeconds, + defaultTargetReps, + defaultTargetScore, + defaultTargetScoreTimeMs, archivedAt, ]); @override @@ -8795,6 +8985,10 @@ class Exercise extends DataClass implements Insertable { other.scoreInputMode == this.scoreInputMode && other.scoreLabel == this.scoreLabel && other.scoreUnit == this.scoreUnit && + other.defaultTargetTimeSeconds == this.defaultTargetTimeSeconds && + other.defaultTargetReps == this.defaultTargetReps && + other.defaultTargetScore == this.defaultTargetScore && + other.defaultTargetScoreTimeMs == this.defaultTargetScoreTimeMs && other.archivedAt == this.archivedAt); } @@ -8820,6 +9014,10 @@ class ExercisesCompanion extends UpdateCompanion { final Value scoreInputMode; final Value scoreLabel; final Value scoreUnit; + final Value defaultTargetTimeSeconds; + final Value defaultTargetReps; + final Value defaultTargetScore; + final Value defaultTargetScoreTimeMs; final Value archivedAt; final Value rowid; const ExercisesCompanion({ @@ -8844,6 +9042,10 @@ class ExercisesCompanion extends UpdateCompanion { this.scoreInputMode = const Value.absent(), this.scoreLabel = const Value.absent(), this.scoreUnit = const Value.absent(), + this.defaultTargetTimeSeconds = const Value.absent(), + this.defaultTargetReps = const Value.absent(), + this.defaultTargetScore = const Value.absent(), + this.defaultTargetScoreTimeMs = const Value.absent(), this.archivedAt = const Value.absent(), this.rowid = const Value.absent(), }); @@ -8869,6 +9071,10 @@ class ExercisesCompanion extends UpdateCompanion { this.scoreInputMode = const Value.absent(), this.scoreLabel = const Value.absent(), this.scoreUnit = const Value.absent(), + this.defaultTargetTimeSeconds = const Value.absent(), + this.defaultTargetReps = const Value.absent(), + this.defaultTargetScore = const Value.absent(), + this.defaultTargetScoreTimeMs = const Value.absent(), this.archivedAt = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), @@ -8903,6 +9109,10 @@ class ExercisesCompanion extends UpdateCompanion { Expression? scoreInputMode, Expression? scoreLabel, Expression? scoreUnit, + Expression? defaultTargetTimeSeconds, + Expression? defaultTargetReps, + Expression? defaultTargetScore, + Expression? defaultTargetScoreTimeMs, Expression? archivedAt, Expression? rowid, }) { @@ -8929,6 +9139,13 @@ class ExercisesCompanion extends UpdateCompanion { if (scoreInputMode != null) 'score_input_mode': scoreInputMode, if (scoreLabel != null) 'score_label': scoreLabel, if (scoreUnit != null) 'score_unit': scoreUnit, + if (defaultTargetTimeSeconds != null) + 'default_target_time_seconds': defaultTargetTimeSeconds, + if (defaultTargetReps != null) 'default_target_reps': defaultTargetReps, + if (defaultTargetScore != null) + 'default_target_score': defaultTargetScore, + if (defaultTargetScoreTimeMs != null) + 'default_target_score_time_ms': defaultTargetScoreTimeMs, if (archivedAt != null) 'archived_at': archivedAt, if (rowid != null) 'rowid': rowid, }); @@ -8956,6 +9173,10 @@ class ExercisesCompanion extends UpdateCompanion { Value? scoreInputMode, Value? scoreLabel, Value? scoreUnit, + Value? defaultTargetTimeSeconds, + Value? defaultTargetReps, + Value? defaultTargetScore, + Value? defaultTargetScoreTimeMs, Value? archivedAt, Value? rowid, }) { @@ -8981,6 +9202,12 @@ class ExercisesCompanion extends UpdateCompanion { scoreInputMode: scoreInputMode ?? this.scoreInputMode, scoreLabel: scoreLabel ?? this.scoreLabel, scoreUnit: scoreUnit ?? this.scoreUnit, + defaultTargetTimeSeconds: + defaultTargetTimeSeconds ?? this.defaultTargetTimeSeconds, + defaultTargetReps: defaultTargetReps ?? this.defaultTargetReps, + defaultTargetScore: defaultTargetScore ?? this.defaultTargetScore, + defaultTargetScoreTimeMs: + defaultTargetScoreTimeMs ?? this.defaultTargetScoreTimeMs, archivedAt: archivedAt ?? this.archivedAt, rowid: rowid ?? this.rowid, ); @@ -9054,6 +9281,22 @@ class ExercisesCompanion extends UpdateCompanion { if (scoreUnit.present) { map['score_unit'] = Variable(scoreUnit.value); } + if (defaultTargetTimeSeconds.present) { + map['default_target_time_seconds'] = Variable( + defaultTargetTimeSeconds.value, + ); + } + if (defaultTargetReps.present) { + map['default_target_reps'] = Variable(defaultTargetReps.value); + } + if (defaultTargetScore.present) { + map['default_target_score'] = Variable(defaultTargetScore.value); + } + if (defaultTargetScoreTimeMs.present) { + map['default_target_score_time_ms'] = Variable( + defaultTargetScoreTimeMs.value, + ); + } if (archivedAt.present) { map['archived_at'] = Variable(archivedAt.value); } @@ -9087,6 +9330,10 @@ class ExercisesCompanion extends UpdateCompanion { ..write('scoreInputMode: $scoreInputMode, ') ..write('scoreLabel: $scoreLabel, ') ..write('scoreUnit: $scoreUnit, ') + ..write('defaultTargetTimeSeconds: $defaultTargetTimeSeconds, ') + ..write('defaultTargetReps: $defaultTargetReps, ') + ..write('defaultTargetScore: $defaultTargetScore, ') + ..write('defaultTargetScoreTimeMs: $defaultTargetScoreTimeMs, ') ..write('archivedAt: $archivedAt, ') ..write('rowid: $rowid') ..write(')')) @@ -22641,6 +22888,10 @@ typedef $$ExercisesTableCreateCompanionBuilder = Value scoreInputMode, Value scoreLabel, Value scoreUnit, + Value defaultTargetTimeSeconds, + Value defaultTargetReps, + Value defaultTargetScore, + Value defaultTargetScoreTimeMs, Value archivedAt, Value rowid, }); @@ -22667,6 +22918,10 @@ typedef $$ExercisesTableUpdateCompanionBuilder = Value scoreInputMode, Value scoreLabel, Value scoreUnit, + Value defaultTargetTimeSeconds, + Value defaultTargetReps, + Value defaultTargetScore, + Value defaultTargetScoreTimeMs, Value archivedAt, Value rowid, }); @@ -22834,6 +23089,26 @@ class $$ExercisesTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get defaultTargetTimeSeconds => $composableBuilder( + column: $table.defaultTargetTimeSeconds, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get defaultTargetReps => $composableBuilder( + column: $table.defaultTargetReps, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get defaultTargetScore => $composableBuilder( + column: $table.defaultTargetScore, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get defaultTargetScoreTimeMs => $composableBuilder( + column: $table.defaultTargetScoreTimeMs, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get archivedAt => $composableBuilder( column: $table.archivedAt, builder: (column) => ColumnFilters(column), @@ -23015,6 +23290,26 @@ class $$ExercisesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get defaultTargetTimeSeconds => $composableBuilder( + column: $table.defaultTargetTimeSeconds, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get defaultTargetReps => $composableBuilder( + column: $table.defaultTargetReps, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get defaultTargetScore => $composableBuilder( + column: $table.defaultTargetScore, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get defaultTargetScoreTimeMs => $composableBuilder( + column: $table.defaultTargetScoreTimeMs, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get archivedAt => $composableBuilder( column: $table.archivedAt, builder: (column) => ColumnOrderings(column), @@ -23157,6 +23452,26 @@ class $$ExercisesTableAnnotationComposer GeneratedColumn get scoreUnit => $composableBuilder(column: $table.scoreUnit, builder: (column) => column); + GeneratedColumn get defaultTargetTimeSeconds => $composableBuilder( + column: $table.defaultTargetTimeSeconds, + builder: (column) => column, + ); + + GeneratedColumn get defaultTargetReps => $composableBuilder( + column: $table.defaultTargetReps, + builder: (column) => column, + ); + + GeneratedColumn get defaultTargetScore => $composableBuilder( + column: $table.defaultTargetScore, + builder: (column) => column, + ); + + GeneratedColumn get defaultTargetScoreTimeMs => $composableBuilder( + column: $table.defaultTargetScoreTimeMs, + builder: (column) => column, + ); + GeneratedColumn get archivedAt => $composableBuilder( column: $table.archivedAt, builder: (column) => column, @@ -23287,6 +23602,10 @@ class $$ExercisesTableTableManager Value scoreInputMode = const Value.absent(), Value scoreLabel = const Value.absent(), Value scoreUnit = const Value.absent(), + Value defaultTargetTimeSeconds = const Value.absent(), + Value defaultTargetReps = const Value.absent(), + Value defaultTargetScore = const Value.absent(), + Value defaultTargetScoreTimeMs = const Value.absent(), Value archivedAt = const Value.absent(), Value rowid = const Value.absent(), }) => ExercisesCompanion( @@ -23311,6 +23630,10 @@ class $$ExercisesTableTableManager scoreInputMode: scoreInputMode, scoreLabel: scoreLabel, scoreUnit: scoreUnit, + defaultTargetTimeSeconds: defaultTargetTimeSeconds, + defaultTargetReps: defaultTargetReps, + defaultTargetScore: defaultTargetScore, + defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, archivedAt: archivedAt, rowid: rowid, ), @@ -23337,6 +23660,10 @@ class $$ExercisesTableTableManager Value scoreInputMode = const Value.absent(), Value scoreLabel = const Value.absent(), Value scoreUnit = const Value.absent(), + Value defaultTargetTimeSeconds = const Value.absent(), + Value defaultTargetReps = const Value.absent(), + Value defaultTargetScore = const Value.absent(), + Value defaultTargetScoreTimeMs = const Value.absent(), Value archivedAt = const Value.absent(), Value rowid = const Value.absent(), }) => ExercisesCompanion.insert( @@ -23361,6 +23688,10 @@ class $$ExercisesTableTableManager scoreInputMode: scoreInputMode, scoreLabel: scoreLabel, scoreUnit: scoreUnit, + defaultTargetTimeSeconds: defaultTargetTimeSeconds, + defaultTargetReps: defaultTargetReps, + defaultTargetScore: defaultTargetScore, + defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, archivedAt: archivedAt, rowid: rowid, ), diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 8d5444a..2137681 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -966,6 +966,10 @@ db.ExercisesCompanion _exerciseCompanion(domain.Exercise exercise) { scoreInputMode: Value(_scoreInputModeToDb(exercise.scoreInputMode)), scoreLabel: Value(exercise.scoreLabel), scoreUnit: Value(exercise.scoreUnit), + defaultTargetTimeSeconds: Value(exercise.defaultTargetTimeSeconds), + defaultTargetReps: Value(exercise.defaultTargetReps), + defaultTargetScore: Value(exercise.defaultTargetScore), + defaultTargetScoreTimeMs: Value(exercise.defaultTargetScoreTimeMs), archivedAt: Value(_utcOrNull(exercise.archivedAt)), ); } @@ -983,6 +987,10 @@ domain.Exercise _exerciseFromRow(db.Exercise row) { scoreInputMode: _scoreInputModeFromDb(row.scoreInputMode), scoreLabel: row.scoreLabel, scoreUnit: row.scoreUnit, + defaultTargetTimeSeconds: row.defaultTargetTimeSeconds, + defaultTargetReps: row.defaultTargetReps, + defaultTargetScore: row.defaultTargetScore, + defaultTargetScoreTimeMs: row.defaultTargetScoreTimeMs, archivedAt: _utcOrNull(row.archivedAt), ); } diff --git a/lib/infrastructure/local/tables.dart b/lib/infrastructure/local/tables.dart index 399ecfe..b8e95ef 100644 --- a/lib/infrastructure/local/tables.dart +++ b/lib/infrastructure/local/tables.dart @@ -69,6 +69,10 @@ class Exercises extends SyncableTable { text().withDefault(const Constant('manual'))(); TextColumn get scoreLabel => text().nullable()(); TextColumn get scoreUnit => text().nullable()(); + IntColumn get defaultTargetTimeSeconds => integer().nullable()(); + IntColumn get defaultTargetReps => integer().nullable()(); + RealColumn get defaultTargetScore => real().nullable()(); + IntColumn get defaultTargetScoreTimeMs => integer().nullable()(); DateTimeColumn get archivedAt => dateTime().nullable()(); @override @@ -79,6 +83,12 @@ class Exercises extends SyncableTable { "CHECK (score_input_mode != 'manual' OR NOT has_score_measure OR " '(score_label IS NOT NULL AND length(trim(score_label)) > 0 ' 'AND score_unit IS NOT NULL AND length(trim(score_unit)) > 0))', + 'CHECK (default_target_time_seconds IS NULL OR ' + 'default_target_time_seconds > 0)', + 'CHECK (default_target_reps IS NULL OR default_target_reps > 0)', + 'CHECK (default_target_score IS NULL OR default_target_score > 0)', + 'CHECK (default_target_score_time_ms IS NULL OR ' + 'default_target_score_time_ms > 0)', ]; } diff --git a/lib/presentation/exercise_library_screen.dart b/lib/presentation/exercise_library_screen.dart index 1085b84..d6e8375 100644 --- a/lib/presentation/exercise_library_screen.dart +++ b/lib/presentation/exercise_library_screen.dart @@ -287,6 +287,10 @@ final class _ExerciseFormScreenState extends State { late final TextEditingController _descriptionController; late final TextEditingController _scoreLabelController; late final TextEditingController _scoreUnitController; + late final TextEditingController _defaultTimeController; + late final TextEditingController _defaultRepsController; + late final TextEditingController _defaultScoreController; + late final TextEditingController _defaultScoreTimeController; String? _imageMediaId; String? _videoMediaId; String? _selectedImageName; @@ -311,6 +315,20 @@ final class _ExerciseFormScreenState extends State { _descriptionController = TextEditingController(text: exercise?.description); _scoreLabelController = TextEditingController(text: exercise?.scoreLabel); _scoreUnitController = TextEditingController(text: exercise?.scoreUnit); + _defaultTimeController = TextEditingController( + text: _optionalIntText(exercise?.defaultTargetTimeSeconds), + ); + _defaultRepsController = TextEditingController( + text: _optionalIntText(exercise?.defaultTargetReps), + ); + _defaultScoreController = TextEditingController( + text: _optionalDoubleText(exercise?.defaultTargetScore), + ); + _defaultScoreTimeController = TextEditingController( + text: _optionalDoubleText( + _millisecondsToSeconds(exercise?.defaultTargetScoreTimeMs), + ), + ); _imageMediaId = exercise?.imageMediaId; _videoMediaId = exercise?.videoMediaId; _hasTime = exercise?.hasTimeMeasure ?? true; @@ -325,6 +343,10 @@ final class _ExerciseFormScreenState extends State { _descriptionController.dispose(); _scoreLabelController.dispose(); _scoreUnitController.dispose(); + _defaultTimeController.dispose(); + _defaultRepsController.dispose(); + _defaultScoreController.dispose(); + _defaultScoreTimeController.dispose(); super.dispose(); } @@ -389,12 +411,44 @@ final class _ExerciseFormScreenState extends State { value: _hasTime, onChanged: (value) => _setMeasure(() => _hasTime = value), ), + if (_hasTime) ...[ + const SizedBox(height: 8), + TextFormField( + controller: _defaultTimeController, + decoration: const InputDecoration( + labelText: 'Temps par défaut (s)', + ), + keyboardType: TextInputType.number, + validator: (value) => _hasTime + ? _positiveIntValidator( + value, + 'Saisis un temps supérieur à 0.', + ) + : null, + ), + ], _MeasureSwitch( title: 'Répétitions', subtitle: 'Compter le nombre de mouvements réalisés.', value: _hasReps, onChanged: (value) => _setMeasure(() => _hasReps = value), ), + if (_hasReps) ...[ + const SizedBox(height: 8), + TextFormField( + controller: _defaultRepsController, + decoration: const InputDecoration( + labelText: 'Répétitions par défaut', + ), + keyboardType: TextInputType.number, + validator: (value) => _hasReps + ? _positiveIntValidator( + value, + 'Saisis un nombre de répétitions supérieur à 0.', + ) + : null, + ), + ], _MeasureSwitch( title: 'Score', subtitle: 'Saisir une valeur libre avec son unité.', @@ -467,6 +521,49 @@ final class _ExerciseFormScreenState extends State { : null; }, ), + const SizedBox(height: 12), + TextFormField( + controller: _defaultScoreController, + decoration: const InputDecoration( + labelText: 'Score par défaut', + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + validator: (value) { + if (!_hasScore || + _scoreInputMode != ScoreInputMode.manual) { + return null; + } + return _positiveDoubleValidator( + value, + 'Saisis un score supérieur à 0.', + ); + }, + ), + ] else ...[ + const SizedBox(height: 12), + TextFormField( + controller: _defaultScoreTimeController, + decoration: const InputDecoration( + labelText: 'Objectif de chrono par défaut (optionnel)', + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + validator: (value) { + if (!_hasScore || + _scoreInputMode != ScoreInputMode.stopwatch || + value == null || + value.trim().isEmpty) { + return null; + } + return _positiveDoubleValidator( + value, + 'Saisis un objectif supérieur à 0.', + ); + }, + ), ], ], if (_warning != null) ...[ @@ -586,6 +683,20 @@ final class _ExerciseFormScreenState extends State { final scoreUnit = _hasScore && scoreInputMode == ScoreInputMode.manual ? _scoreUnitController.text.trim() : null; + final defaultTargetTimeSeconds = _hasTime + ? int.parse(_defaultTimeController.text.trim()) + : null; + final defaultTargetReps = _hasReps + ? int.parse(_defaultRepsController.text.trim()) + : null; + final defaultTargetScore = + _hasScore && scoreInputMode == ScoreInputMode.manual + ? double.parse(_defaultScoreController.text.trim()) + : null; + final defaultTargetScoreTimeMs = + _hasScore && scoreInputMode == ScoreInputMode.stopwatch + ? _optionalSecondsToMilliseconds(_defaultScoreTimeController.text) + : null; try { if (exercise == null) { await widget.exerciseUseCases.create( @@ -599,6 +710,10 @@ final class _ExerciseFormScreenState extends State { scoreInputMode: scoreInputMode, scoreLabel: scoreLabel, scoreUnit: scoreUnit, + defaultTargetTimeSeconds: defaultTargetTimeSeconds, + defaultTargetReps: defaultTargetReps, + defaultTargetScore: defaultTargetScore, + defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, ); } else { await widget.exerciseUseCases.update( @@ -613,6 +728,10 @@ final class _ExerciseFormScreenState extends State { scoreInputMode: scoreInputMode, scoreLabel: scoreLabel, scoreUnit: scoreUnit, + defaultTargetTimeSeconds: defaultTargetTimeSeconds, + defaultTargetReps: defaultTargetReps, + defaultTargetScore: defaultTargetScore, + defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, ); } if (mounted) { @@ -642,6 +761,48 @@ final class _ExerciseFormScreenState extends State { return text.isEmpty ? null : text; } + String? _positiveIntValidator(String? value, String message) { + final number = int.tryParse(value?.trim() ?? ''); + if (number == null || number <= 0) { + return message; + } + return null; + } + + String? _positiveDoubleValidator(String? value, String message) { + final number = double.tryParse(value?.trim() ?? ''); + if (number == null || number <= 0) { + return message; + } + return null; + } + + String _optionalIntText(int? value) { + return value == null ? '' : '$value'; + } + + String _optionalDoubleText(double? value) { + if (value == null) { + return ''; + } + if (value == value.roundToDouble()) { + return '${value.round()}'; + } + return '$value'; + } + + double? _millisecondsToSeconds(int? milliseconds) { + return milliseconds == null ? null : milliseconds / 1000; + } + + int? _optionalSecondsToMilliseconds(String value) { + final text = value.trim(); + if (text.isEmpty) { + return null; + } + return (double.parse(text) * 1000).round(); + } + String _fileNameFromPath(String path) { final parts = path.split(RegExp(r'[/\\]')); return parts.isEmpty ? path : parts.last; diff --git a/lib/presentation/program_screen.dart b/lib/presentation/program_screen.dart index 84d4deb..f29537f 100644 --- a/lib/presentation/program_screen.dart +++ b/lib/presentation/program_screen.dart @@ -223,7 +223,7 @@ final class _ProgramFormScreenState extends State { buildDefaultDragHandles: false, padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), itemCount: _exercises.length, - onReorder: _reorderExercise, + onReorderItem: _reorderExercise, itemBuilder: (context, index) { final exercise = _exercises[index]; return _ProgramExerciseCard( @@ -517,7 +517,6 @@ final class _ProgramExerciseCustomizationScreen extends StatefulWidget { const _ProgramExerciseCustomizationScreen({ required _ProgramExerciseDraft draft, required this.defaultRestSeconds, - super.key, }) : _initialDraft = draft; final _ProgramExerciseDraft _initialDraft; @@ -853,6 +852,14 @@ final class _ProgramExerciseDraft { scoreUnitSnapshot: exercise.scoreUnit, setsCount: 3, enabledMeasures: exercise.availableMeasures, + targetTimeSeconds: exercise.defaultTargetTimeSeconds, + targetReps: exercise.defaultTargetReps, + targetScore: exercise.scoreInputMode == ScoreInputMode.manual + ? exercise.defaultTargetScore + : null, + targetScoreTimeMs: exercise.scoreInputMode == ScoreInputMode.stopwatch + ? exercise.defaultTargetScoreTimeMs + : null, restSecondsOverride: restSecondsOverride, ); } diff --git a/test/application/media_use_cases_test.dart b/test/application/media_use_cases_test.dart index f66caf7..aadf0b2 100644 --- a/test/application/media_use_cases_test.dart +++ b/test/application/media_use_cases_test.dart @@ -38,6 +38,7 @@ void main() { hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, + defaultTargetReps: 10, ); final mediaRepository = _FakeMediaAssetRepository() ..asset = MediaAsset( diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index 186a46f..1659246 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -18,6 +18,144 @@ void main() { ); }); + test( + 'Exercise use case rejects active measures without default targets', + () async { + final useCase = _exerciseUseCase(_FakeExerciseRepository()); + + await expectLater( + useCase.create( + name: 'Squat', + hasTimeMeasure: true, + hasRepsMeasure: false, + hasScoreMeasure: false, + ), + throwsA(isA()), + ); + await expectLater( + useCase.create( + name: 'Pompes', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + ), + throwsA(isA()), + ); + await expectLater( + useCase.create( + name: 'Charge', + hasTimeMeasure: false, + hasRepsMeasure: false, + hasScoreMeasure: true, + scoreLabel: 'Charge', + scoreUnit: 'kg', + ), + throwsA(isA()), + ); + }, + ); + + test('Exercise use case rejects non-positive default targets', () async { + final useCase = _exerciseUseCase(_FakeExerciseRepository()); + + await expectLater( + useCase.create( + name: 'Squat', + hasTimeMeasure: true, + hasRepsMeasure: false, + hasScoreMeasure: false, + defaultTargetTimeSeconds: 0, + ), + throwsA(isA()), + ); + await expectLater( + useCase.create( + name: 'Pompes', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + defaultTargetReps: -1, + ), + throwsA(isA()), + ); + await expectLater( + useCase.create( + name: 'Charge', + hasTimeMeasure: false, + hasRepsMeasure: false, + hasScoreMeasure: true, + scoreLabel: 'Charge', + scoreUnit: 'kg', + defaultTargetScore: 0, + ), + throwsA(isA()), + ); + }); + + test( + 'Exercise use case accepts stopwatch score without default target', + () async { + final repository = _FakeExerciseRepository(); + final useCase = _exerciseUseCase(repository); + + final exercise = await useCase.create( + name: 'Sprint', + hasTimeMeasure: false, + hasRepsMeasure: false, + hasScoreMeasure: true, + scoreInputMode: ScoreInputMode.stopwatch, + scoreLabel: 'Temps réalisé', + ); + + expect(exercise.defaultTargetScoreTimeMs, isNull); + expect(repository.saved.single.metadata.id, exercise.metadata.id); + }, + ); + + test( + 'Exercise use case rejects non-positive stopwatch score default target', + () async { + final useCase = _exerciseUseCase(_FakeExerciseRepository()); + + await expectLater( + useCase.create( + name: 'Sprint', + hasTimeMeasure: false, + hasRepsMeasure: false, + hasScoreMeasure: true, + scoreInputMode: ScoreInputMode.stopwatch, + scoreLabel: 'Temps réalisé', + defaultTargetScoreTimeMs: 0, + ), + throwsA(isA()), + ); + }, + ); + + test('Exercise update validates default targets too', () async { + final repository = _FakeExerciseRepository() + ..exercise = Exercise( + metadata: _metadata('exercise-1'), + name: 'Squat', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + defaultTargetReps: 10, + ); + final useCase = _exerciseUseCase(repository); + + await expectLater( + useCase.update( + id: 'exercise-1', + name: 'Squat', + hasTimeMeasure: true, + hasRepsMeasure: false, + hasScoreMeasure: false, + ), + throwsA(isA()), + ); + }); + test('Workout template use case rejects active measure overrides', () async { final useCase = WorkoutTemplateUseCases( templateRepository: _FakeWorkoutTemplateRepository(), @@ -474,6 +612,38 @@ final class _FakeIds implements IdGenerator { } } +ExerciseUseCases _exerciseUseCase(_FakeExerciseRepository repository) { + return ExerciseUseCases( + repository: repository, + clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), + ids: _FakeIds(), + originDeviceId: 'device-1', + ); +} + +final class _FakeExerciseRepository implements ExerciseRepository { + Exercise? exercise; + final saved = []; + + @override + Future findById(String id) async { + return exercise?.metadata.id == id ? exercise : null; + } + + @override + Future isReferencedByProgram(String id) async => false; + + @override + Future> listActive() async => + exercise == null ? const [] : [exercise!]; + + @override + Future save(Exercise exercise) async { + this.exercise = exercise; + saved.add(exercise); + } +} + final class _FakeWorkoutTemplateRepository implements WorkoutTemplateRepository { @override diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart index 663da31..0d4d836 100644 --- a/test/infrastructure/drift_repositories_test.dart +++ b/test/infrastructure/drift_repositories_test.dart @@ -36,6 +36,7 @@ void main() { hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, + defaultTargetReps: 10, ); await exerciseRepository.save(exercise); diff --git a/test/presentation/exercise_library_screen_test.dart b/test/presentation/exercise_library_screen_test.dart index e12a6ea..c57f5e7 100644 --- a/test/presentation/exercise_library_screen_test.dart +++ b/test/presentation/exercise_library_screen_test.dart @@ -51,6 +51,9 @@ void main() { hasScoreMeasure: true, scoreLabel: 'Charge', scoreUnit: 'kg', + defaultTargetTimeSeconds: 45, + defaultTargetReps: 10, + defaultTargetScore: 80, ); await tester.pumpWidget( @@ -71,6 +74,9 @@ void main() { ) async { final exerciseRepository = _FakeExerciseRepository(); + await tester.binding.setSurfaceSize(const Size(400, 2400)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( MaterialApp( home: ExerciseFormScreen( @@ -81,43 +87,28 @@ void main() { ); await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Run'); - await tester.dragUntilVisible( - find.text('Score'), - find.byType(ListView), - const Offset(0, -200), + await tester.enterText( + find.widgetWithText(TextFormField, 'Temps par défaut (s)'), + '30', ); await tester.tap(find.widgetWithText(SwitchListTile, 'Score')); await tester.pump(); - await tester.dragUntilVisible( - find.text('Unité'), - find.byType(ListView), - const Offset(0, -200), - ); expect(find.widgetWithText(TextFormField, 'Unité'), findsOneWidget); - await tester.dragUntilVisible( - find.text('Chrono intégré'), - find.byType(ListView), - const Offset(0, 200), - ); await tester.tap(find.text('Chrono intégré')); await tester.pump(); - await tester.dragUntilVisible( - find.text('Temps réalisé'), - find.byType(ListView), - const Offset(0, -200), - ); expect(find.text('Temps réalisé'), findsOneWidget); expect(find.widgetWithText(TextFormField, 'Unité'), findsNothing); - await tester.ensureVisible(find.text('Enregistrer')); await tester.tap(find.text('Enregistrer')); await tester.pumpAndSettle(); final saved = exerciseRepository.saved.single; expect(saved.scoreInputMode, ScoreInputMode.stopwatch); + expect(saved.defaultTargetTimeSeconds, 30); + expect(saved.defaultTargetScoreTimeMs, isNull); await tester.pumpWidget( MaterialApp( diff --git a/test/presentation/program_screen_test.dart b/test/presentation/program_screen_test.dart index 7d7129e..2f371a1 100644 --- a/test/presentation/program_screen_test.dart +++ b/test/presentation/program_screen_test.dart @@ -17,6 +17,8 @@ void main() { hasTimeMeasure: true, hasRepsMeasure: true, hasScoreMeasure: false, + defaultTargetTimeSeconds: 45, + defaultTargetReps: 10, ), ); @@ -55,6 +57,8 @@ void main() { expect(savedExercise.timeEnabled, isTrue); expect(savedExercise.repsEnabled, isTrue); expect(savedExercise.scoreEnabled, isFalse); + expect(savedExercise.targetTimeSeconds, 45); + expect(savedExercise.targetReps, 10); expect(savedExercise.restSecondsOverride, 60); }, ); @@ -70,6 +74,7 @@ void main() { hasTimeMeasure: false, hasRepsMeasure: true, hasScoreMeasure: false, + defaultTargetReps: 12, ), ); @@ -114,6 +119,7 @@ void main() { hasTimeMeasure: true, hasRepsMeasure: false, hasScoreMeasure: false, + defaultTargetTimeSeconds: 45, ), ); @@ -171,6 +177,9 @@ void main() { hasScoreMeasure: true, scoreInputMode: ScoreInputMode.stopwatch, scoreLabel: 'Temps réalisé', + defaultTargetTimeSeconds: 30, + defaultTargetReps: 5, + defaultTargetScoreTimeMs: 12000, ), );