diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 9ff71b2..8393b2e 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -717,11 +717,117 @@ final class ActiveWorkoutSessionUseCases { actualScore: actualScore, scoreLabelSnapshot: scoreLabelSnapshot, scoreUnitSnapshot: scoreUnitSnapshot, + status: SetResultStatus.completed, ); await sessionRepository.saveSetResult(result); return result; } + Future upsertSetResultAtPosition({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required SetResultStatus status, + int? actualTimeMs, + int? actualReps, + double? actualScore, + String? scoreLabelSnapshot, + String? scoreUnitSnapshot, + String? note, + }) async { + final session = await _requiredSession(sessionId); + _ensureEditablePastPosition( + session: session, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + final snapshot = _findSetSnapshot( + resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + if (snapshot == null) { + throw const DomainException( + 'Set position not found in session snapshot.', + ); + } + final existingResults = await sessionRepository.listSetResults(sessionId); + ActiveSetResult? existing; + for (final result in existingResults) { + if (result.programIndex == programIndex && + result.exerciseIndex == exerciseIndex && + result.setIndex == setIndex) { + existing = result; + break; + } + } + final now = clock.now(); + final isSkipped = status == SetResultStatus.skipped; + final upserted = ActiveSetResult( + metadata: existing == null + ? _newMetadata(ids, originDeviceId, now) + : existing.metadata.touch(now), + activeWorkoutSessionId: sessionId, + programSnapshotId: snapshot.programSnapshotId, + exerciseSnapshotId: snapshot.exerciseSnapshotId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + startedAt: existing?.startedAt, + completedAt: isSkipped ? null : now, + actualTimeMs: isSkipped ? null : actualTimeMs, + actualReps: isSkipped ? null : actualReps, + actualScore: isSkipped ? null : actualScore, + scoreLabelSnapshot: isSkipped + ? null + : scoreLabelSnapshot ?? snapshot.scoreLabelSnapshot, + scoreUnitSnapshot: isSkipped + ? null + : scoreUnitSnapshot ?? snapshot.scoreUnitSnapshot, + note: note, + status: status, + ); + await sessionRepository.saveSetResult(upserted); + return upserted; + } + + Future> listSetResults(String sessionId) async { + final session = await _requiredSession(sessionId); + final snapshots = _listSetSnapshots(session.resolvedTemplateSnapshotJson); + final results = await sessionRepository.listSetResults(sessionId); + final resultsByPosition = { + for (final result in results) + _positionKey( + result.programIndex, + result.exerciseIndex, + result.setIndex, + ): result, + }; + return snapshots.map((snapshot) { + final result = + resultsByPosition[_positionKey( + snapshot.programIndex, + snapshot.exerciseIndex, + snapshot.setIndex, + )]; + return SetResultPositionState( + programIndex: snapshot.programIndex, + exerciseIndex: snapshot.exerciseIndex, + setIndex: snapshot.setIndex, + status: result == null + ? SetPositionStatus.pending + : switch (result.status) { + SetResultStatus.completed => SetPositionStatus.completed, + SetResultStatus.skipped => SetPositionStatus.skipped, + }, + result: result, + ); + }).toList(); + } + Future updateProgress({ required String sessionId, required int programIndex, @@ -921,6 +1027,7 @@ final class CloseWorkoutSessionUseCase { 'actualScore': result.actualScore, 'scoreLabelSnapshot': result.scoreLabelSnapshot, 'scoreUnitSnapshot': result.scoreUnitSnapshot, + 'status': result.status.name, }, ) .toList(), @@ -945,6 +1052,24 @@ final class WorkoutHistoryUseCases { Future delete(String id) => repository.delete(id, clock.now()); } +enum SetPositionStatus { pending, completed, skipped } + +final class SetResultPositionState { + const SetResultPositionState({ + required this.programIndex, + required this.exerciseIndex, + required this.setIndex, + required this.status, + this.result, + }); + + final int programIndex; + final int exerciseIndex; + final int setIndex; + final SetPositionStatus status; + final ActiveSetResult? result; +} + void _validateOverrideTargets( String programSnapshotJson, WorkoutTemplateExerciseOverrideConfig input, @@ -973,6 +1098,132 @@ void _validateOverrideTargets( } } +void _ensureEditablePastPosition({ + required ActiveWorkoutSession session, + required int programIndex, + required int exerciseIndex, + required int setIndex, +}) { + final comparison = _comparePositions( + programIndex, + exerciseIndex, + setIndex, + session.currentProgramIndex, + session.currentExerciseIndex, + session.currentSetIndex, + ); + if (comparison >= 0) { + throw const DomainException( + 'Only past set results can be edited outside the normal execution flow.', + ); + } +} + +int _comparePositions( + int leftProgram, + int leftExercise, + int leftSet, + int rightProgram, + int rightExercise, + int rightSet, +) { + final programComparison = leftProgram.compareTo(rightProgram); + if (programComparison != 0) { + return programComparison; + } + final exerciseComparison = leftExercise.compareTo(rightExercise); + if (exerciseComparison != 0) { + return exerciseComparison; + } + return leftSet.compareTo(rightSet); +} + +String _positionKey(int programIndex, int exerciseIndex, int setIndex) { + return '$programIndex:$exerciseIndex:$setIndex'; +} + +_SetPositionSnapshot? _findSetSnapshot({ + required String resolvedTemplateSnapshotJson, + required int programIndex, + required int exerciseIndex, + required int setIndex, +}) { + for (final snapshot in _listSetSnapshots(resolvedTemplateSnapshotJson)) { + if (snapshot.programIndex == programIndex && + snapshot.exerciseIndex == exerciseIndex && + snapshot.setIndex == setIndex) { + return snapshot; + } + } + return null; +} + +List<_SetPositionSnapshot> _listSetSnapshots( + String resolvedTemplateSnapshotJson, +) { + final decoded = + jsonDecode(resolvedTemplateSnapshotJson) as Map; + final programs = (decoded['programs'] as List? ?? const []); + final snapshots = <_SetPositionSnapshot>[]; + for (var programIndex = 0; programIndex < programs.length; programIndex++) { + final program = programs[programIndex] as Map; + final programSnapshotId = + program['id'] as String? ?? 'program-$programIndex'; + final programSnapshotJson = program['programSnapshotJson'] as String?; + if (programSnapshotJson == null) { + continue; + } + final programSnapshot = + jsonDecode(programSnapshotJson) as Map; + final exercises = + (programSnapshot['exercises'] as List? ?? const []); + for ( + var exerciseIndex = 0; + exerciseIndex < exercises.length; + exerciseIndex++ + ) { + final exercise = exercises[exerciseIndex] as Map; + final exerciseSnapshotId = + exercise['id'] as String? ?? 'exercise-$exerciseIndex'; + final setsCount = exercise['setsCount'] as int? ?? 0; + for (var setIndex = 0; setIndex < setsCount; setIndex++) { + snapshots.add( + _SetPositionSnapshot( + programSnapshotId: programSnapshotId, + exerciseSnapshotId: exerciseSnapshotId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?, + scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?, + ), + ); + } + } + } + return snapshots; +} + +final class _SetPositionSnapshot { + const _SetPositionSnapshot({ + required this.programSnapshotId, + required this.exerciseSnapshotId, + required this.programIndex, + required this.exerciseIndex, + required this.setIndex, + this.scoreLabelSnapshot, + this.scoreUnitSnapshot, + }); + + final String programSnapshotId; + final String exerciseSnapshotId; + final int programIndex; + final int exerciseIndex; + final int setIndex; + final String? scoreLabelSnapshot; + final String? scoreUnitSnapshot; +} + List _historyResultsFromActiveResults({ required String historyId, required List results, @@ -1012,6 +1263,7 @@ List _historyResultsFromActiveResults({ result.scoreUnitSnapshot ?? snapshot?.scoreUnitSnapshot, startedAt: result.startedAt, completedAt: result.completedAt, + status: result.status, ); }).toList(); } diff --git a/lib/domain/entities.dart b/lib/domain/entities.dart index 4b23e76..fce8387 100644 --- a/lib/domain/entities.dart +++ b/lib/domain/entities.dart @@ -8,6 +8,8 @@ enum WorkoutMeasure { time, reps, score } enum ActiveWorkoutStatus { running, paused, savedExit, completed, abandoned } +enum SetResultStatus { completed, skipped } + final class DomainException implements Exception { const DomainException(this.message); @@ -572,7 +574,7 @@ final class ActiveWorkoutSession { } final class ActiveSetResult { - const ActiveSetResult({ + ActiveSetResult({ required this.metadata, required this.activeWorkoutSessionId, required this.programSnapshotId, @@ -588,7 +590,13 @@ final class ActiveSetResult { this.scoreLabelSnapshot, this.scoreUnitSnapshot, this.note, - }); + this.status = SetResultStatus.completed, + }) { + if (status == SetResultStatus.skipped && + (actualTimeMs != null || actualReps != null || actualScore != null)) { + throw const DomainException('Skipped set results cannot have values.'); + } + } final EntityMetadata metadata; final String activeWorkoutSessionId; @@ -605,6 +613,7 @@ final class ActiveSetResult { final String? scoreLabelSnapshot; final String? scoreUnitSnapshot; final String? note; + final SetResultStatus status; } final class ActiveRestState { @@ -682,7 +691,7 @@ final class WorkoutHistory { } final class WorkoutHistorySetResult { - const WorkoutHistorySetResult({ + WorkoutHistorySetResult({ required this.metadata, required this.workoutHistoryId, required this.programSnapshotId, @@ -705,7 +714,15 @@ final class WorkoutHistorySetResult { this.scoreUnitSnapshot, this.startedAt, this.completedAt, - }); + this.status = SetResultStatus.completed, + }) { + if (status == SetResultStatus.skipped && + (actualTimeMs != null || actualReps != null || actualScore != null)) { + throw const DomainException( + 'Skipped history results cannot have values.', + ); + } + } final EntityMetadata metadata; final String workoutHistoryId; @@ -729,6 +746,7 @@ final class WorkoutHistorySetResult { final String? scoreUnitSnapshot; final DateTime? startedAt; final DateTime? completedAt; + final SetResultStatus status; } String _nonBlank(String? value, String label) { diff --git a/lib/infrastructure/local/app_database.dart b/lib/infrastructure/local/app_database.dart index 2750930..8330e5d 100644 --- a/lib/infrastructure/local/app_database.dart +++ b/lib/infrastructure/local/app_database.dart @@ -35,7 +35,7 @@ final class AppDatabase extends _$AppDatabase { } @override - int get schemaVersion => 1; + int get schemaVersion => 2; @override MigrationStrategy get migration { @@ -44,6 +44,19 @@ final class AppDatabase extends _$AppDatabase { await migrator.createAll(); await _createIndexes(); }, + onUpgrade: (migrator, from, to) async { + if (from < 2) { + await customStatement( + 'ALTER TABLE active_set_results ADD COLUMN status TEXT NOT NULL ' + "DEFAULT 'completed' CHECK (status IN ('completed', 'skipped'))", + ); + await customStatement( + 'ALTER TABLE workout_history_set_results ADD COLUMN status TEXT ' + "NOT NULL DEFAULT 'completed' CHECK (status IN ('completed', " + "'skipped'))", + ); + } + }, beforeOpen: (details) async { await customStatement('PRAGMA foreign_keys = ON'); }, diff --git a/lib/infrastructure/local/app_database.g.dart b/lib/infrastructure/local/app_database.g.dart index 21d43f7..3a1c0d5 100644 --- a/lib/infrastructure/local/app_database.g.dart +++ b/lib/infrastructure/local/app_database.g.dart @@ -3578,6 +3578,16 @@ class $ActiveSetResultsTable extends ActiveSetResults type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _statusMeta = const VerificationMeta('status'); + @override + late final GeneratedColumn status = GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('completed'), + ); @override List get $columns => [ id, @@ -3605,6 +3615,7 @@ class $ActiveSetResultsTable extends ActiveSetResults scoreLabelSnapshot, scoreUnitSnapshot, note, + status, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -3837,6 +3848,12 @@ class $ActiveSetResultsTable extends ActiveSetResults note.isAcceptableOrUnknown(data['note']!, _noteMeta), ); } + if (data.containsKey('status')) { + context.handle( + _statusMeta, + status.isAcceptableOrUnknown(data['status']!, _statusMeta), + ); + } return context; } @@ -3946,6 +3963,10 @@ class $ActiveSetResultsTable extends ActiveSetResults DriftSqlType.string, data['${effectivePrefix}note'], ), + status: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status'], + )!, ); } @@ -3981,6 +4002,7 @@ class ActiveSetResult extends DataClass implements Insertable { final String? scoreLabelSnapshot; final String? scoreUnitSnapshot; final String? note; + final String status; const ActiveSetResult({ required this.id, required this.createdAt, @@ -4007,6 +4029,7 @@ class ActiveSetResult extends DataClass implements Insertable { this.scoreLabelSnapshot, this.scoreUnitSnapshot, this.note, + required this.status, }); @override Map toColumns(bool nullToAbsent) { @@ -4060,6 +4083,7 @@ class ActiveSetResult extends DataClass implements Insertable { if (!nullToAbsent || note != null) { map['note'] = Variable(note); } + map['status'] = Variable(status); return map; } @@ -4112,6 +4136,7 @@ class ActiveSetResult extends DataClass implements Insertable { ? const Value.absent() : Value(scoreUnitSnapshot), note: note == null && nullToAbsent ? const Value.absent() : Value(note), + status: Value(status), ); } @@ -4156,6 +4181,7 @@ class ActiveSetResult extends DataClass implements Insertable { json['scoreUnitSnapshot'], ), note: serializer.fromJson(json['note']), + status: serializer.fromJson(json['status']), ); } @override @@ -4189,6 +4215,7 @@ class ActiveSetResult extends DataClass implements Insertable { 'scoreLabelSnapshot': serializer.toJson(scoreLabelSnapshot), 'scoreUnitSnapshot': serializer.toJson(scoreUnitSnapshot), 'note': serializer.toJson(note), + 'status': serializer.toJson(status), }; } @@ -4218,6 +4245,7 @@ class ActiveSetResult extends DataClass implements Insertable { Value scoreLabelSnapshot = const Value.absent(), Value scoreUnitSnapshot = const Value.absent(), Value note = const Value.absent(), + String? status, }) => ActiveSetResult( id: id ?? this.id, createdAt: createdAt ?? this.createdAt, @@ -4253,6 +4281,7 @@ class ActiveSetResult extends DataClass implements Insertable { ? scoreUnitSnapshot.value : this.scoreUnitSnapshot, note: note.present ? note.value : this.note, + status: status ?? this.status, ); ActiveSetResult copyWithCompanion(ActiveSetResultsCompanion data) { return ActiveSetResult( @@ -4315,6 +4344,7 @@ class ActiveSetResult extends DataClass implements Insertable { ? data.scoreUnitSnapshot.value : this.scoreUnitSnapshot, note: data.note.present ? data.note.value : this.note, + status: data.status.present ? data.status.value : this.status, ); } @@ -4345,7 +4375,8 @@ class ActiveSetResult extends DataClass implements Insertable { ..write('actualScore: $actualScore, ') ..write('scoreLabelSnapshot: $scoreLabelSnapshot, ') ..write('scoreUnitSnapshot: $scoreUnitSnapshot, ') - ..write('note: $note') + ..write('note: $note, ') + ..write('status: $status') ..write(')')) .toString(); } @@ -4377,6 +4408,7 @@ class ActiveSetResult extends DataClass implements Insertable { scoreLabelSnapshot, scoreUnitSnapshot, note, + status, ]); @override bool operator ==(Object other) => @@ -4406,7 +4438,8 @@ class ActiveSetResult extends DataClass implements Insertable { other.actualScore == this.actualScore && other.scoreLabelSnapshot == this.scoreLabelSnapshot && other.scoreUnitSnapshot == this.scoreUnitSnapshot && - other.note == this.note); + other.note == this.note && + other.status == this.status); } class ActiveSetResultsCompanion extends UpdateCompanion { @@ -4435,6 +4468,7 @@ class ActiveSetResultsCompanion extends UpdateCompanion { final Value scoreLabelSnapshot; final Value scoreUnitSnapshot; final Value note; + final Value status; final Value rowid; const ActiveSetResultsCompanion({ this.id = const Value.absent(), @@ -4462,6 +4496,7 @@ class ActiveSetResultsCompanion extends UpdateCompanion { this.scoreLabelSnapshot = const Value.absent(), this.scoreUnitSnapshot = const Value.absent(), this.note = const Value.absent(), + this.status = const Value.absent(), this.rowid = const Value.absent(), }); ActiveSetResultsCompanion.insert({ @@ -4490,6 +4525,7 @@ class ActiveSetResultsCompanion extends UpdateCompanion { this.scoreLabelSnapshot = const Value.absent(), this.scoreUnitSnapshot = const Value.absent(), this.note = const Value.absent(), + this.status = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), createdAt = Value(createdAt), @@ -4529,6 +4565,7 @@ class ActiveSetResultsCompanion extends UpdateCompanion { Expression? scoreLabelSnapshot, Expression? scoreUnitSnapshot, Expression? note, + Expression? status, Expression? rowid, }) { return RawValuesInsertable({ @@ -4561,6 +4598,7 @@ class ActiveSetResultsCompanion extends UpdateCompanion { 'score_label_snapshot': scoreLabelSnapshot, if (scoreUnitSnapshot != null) 'score_unit_snapshot': scoreUnitSnapshot, if (note != null) 'note': note, + if (status != null) 'status': status, if (rowid != null) 'rowid': rowid, }); } @@ -4591,6 +4629,7 @@ class ActiveSetResultsCompanion extends UpdateCompanion { Value? scoreLabelSnapshot, Value? scoreUnitSnapshot, Value? note, + Value? status, Value? rowid, }) { return ActiveSetResultsCompanion( @@ -4620,6 +4659,7 @@ class ActiveSetResultsCompanion extends UpdateCompanion { scoreLabelSnapshot: scoreLabelSnapshot ?? this.scoreLabelSnapshot, scoreUnitSnapshot: scoreUnitSnapshot ?? this.scoreUnitSnapshot, note: note ?? this.note, + status: status ?? this.status, rowid: rowid ?? this.rowid, ); } @@ -4706,6 +4746,9 @@ class ActiveSetResultsCompanion extends UpdateCompanion { if (note.present) { map['note'] = Variable(note.value); } + if (status.present) { + map['status'] = Variable(status.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -4740,6 +4783,7 @@ class ActiveSetResultsCompanion extends UpdateCompanion { ..write('scoreLabelSnapshot: $scoreLabelSnapshot, ') ..write('scoreUnitSnapshot: $scoreUnitSnapshot, ') ..write('note: $note, ') + ..write('status: $status, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -12033,6 +12077,16 @@ class $WorkoutHistorySetResultsTable extends WorkoutHistorySetResults type: DriftSqlType.dateTime, requiredDuringInsert: false, ); + static const VerificationMeta _statusMeta = const VerificationMeta('status'); + @override + late final GeneratedColumn status = GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('completed'), + ); @override List get $columns => [ id, @@ -12067,6 +12121,7 @@ class $WorkoutHistorySetResultsTable extends WorkoutHistorySetResults scoreUnitSnapshot, startedAt, completedAt, + status, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -12375,6 +12430,12 @@ class $WorkoutHistorySetResultsTable extends WorkoutHistorySetResults ), ); } + if (data.containsKey('status')) { + context.handle( + _statusMeta, + status.isAcceptableOrUnknown(data['status']!, _statusMeta), + ); + } return context; } @@ -12515,6 +12576,10 @@ class $WorkoutHistorySetResultsTable extends WorkoutHistorySetResults DriftSqlType.dateTime, data['${effectivePrefix}completed_at'], ), + status: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status'], + )!, ); } @@ -12558,6 +12623,7 @@ class WorkoutHistorySetResult extends DataClass final String? scoreUnitSnapshot; final DateTime? startedAt; final DateTime? completedAt; + final String status; const WorkoutHistorySetResult({ required this.id, required this.createdAt, @@ -12591,6 +12657,7 @@ class WorkoutHistorySetResult extends DataClass this.scoreUnitSnapshot, this.startedAt, this.completedAt, + required this.status, }); @override Map toColumns(bool nullToAbsent) { @@ -12657,6 +12724,7 @@ class WorkoutHistorySetResult extends DataClass if (!nullToAbsent || completedAt != null) { map['completed_at'] = Variable(completedAt); } + map['status'] = Variable(status); return map; } @@ -12723,6 +12791,7 @@ class WorkoutHistorySetResult extends DataClass completedAt: completedAt == null && nullToAbsent ? const Value.absent() : Value(completedAt), + status: Value(status), ); } @@ -12786,6 +12855,7 @@ class WorkoutHistorySetResult extends DataClass ), startedAt: serializer.fromJson(json['startedAt']), completedAt: serializer.fromJson(json['completedAt']), + status: serializer.fromJson(json['status']), ); } @override @@ -12826,6 +12896,7 @@ class WorkoutHistorySetResult extends DataClass 'scoreUnitSnapshot': serializer.toJson(scoreUnitSnapshot), 'startedAt': serializer.toJson(startedAt), 'completedAt': serializer.toJson(completedAt), + 'status': serializer.toJson(status), }; } @@ -12862,6 +12933,7 @@ class WorkoutHistorySetResult extends DataClass Value scoreUnitSnapshot = const Value.absent(), Value startedAt = const Value.absent(), Value completedAt = const Value.absent(), + String? status, }) => WorkoutHistorySetResult( id: id ?? this.id, createdAt: createdAt ?? this.createdAt, @@ -12909,6 +12981,7 @@ class WorkoutHistorySetResult extends DataClass : this.scoreUnitSnapshot, startedAt: startedAt.present ? startedAt.value : this.startedAt, completedAt: completedAt.present ? completedAt.value : this.completedAt, + status: status ?? this.status, ); WorkoutHistorySetResult copyWithCompanion( WorkoutHistorySetResultsCompanion data, @@ -12996,6 +13069,7 @@ class WorkoutHistorySetResult extends DataClass completedAt: data.completedAt.present ? data.completedAt.value : this.completedAt, + status: data.status.present ? data.status.value : this.status, ); } @@ -13033,7 +13107,8 @@ class WorkoutHistorySetResult extends DataClass ..write('scoreLabelSnapshot: $scoreLabelSnapshot, ') ..write('scoreUnitSnapshot: $scoreUnitSnapshot, ') ..write('startedAt: $startedAt, ') - ..write('completedAt: $completedAt') + ..write('completedAt: $completedAt, ') + ..write('status: $status') ..write(')')) .toString(); } @@ -13072,6 +13147,7 @@ class WorkoutHistorySetResult extends DataClass scoreUnitSnapshot, startedAt, completedAt, + status, ]); @override bool operator ==(Object other) => @@ -13108,7 +13184,8 @@ class WorkoutHistorySetResult extends DataClass other.scoreLabelSnapshot == this.scoreLabelSnapshot && other.scoreUnitSnapshot == this.scoreUnitSnapshot && other.startedAt == this.startedAt && - other.completedAt == this.completedAt); + other.completedAt == this.completedAt && + other.status == this.status); } class WorkoutHistorySetResultsCompanion @@ -13145,6 +13222,7 @@ class WorkoutHistorySetResultsCompanion final Value scoreUnitSnapshot; final Value startedAt; final Value completedAt; + final Value status; final Value rowid; const WorkoutHistorySetResultsCompanion({ this.id = const Value.absent(), @@ -13179,6 +13257,7 @@ class WorkoutHistorySetResultsCompanion this.scoreUnitSnapshot = const Value.absent(), this.startedAt = const Value.absent(), this.completedAt = const Value.absent(), + this.status = const Value.absent(), this.rowid = const Value.absent(), }); WorkoutHistorySetResultsCompanion.insert({ @@ -13214,6 +13293,7 @@ class WorkoutHistorySetResultsCompanion this.scoreUnitSnapshot = const Value.absent(), this.startedAt = const Value.absent(), this.completedAt = const Value.absent(), + this.status = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), createdAt = Value(createdAt), @@ -13265,6 +13345,7 @@ class WorkoutHistorySetResultsCompanion Expression? scoreUnitSnapshot, Expression? startedAt, Expression? completedAt, + Expression? status, Expression? rowid, }) { return RawValuesInsertable({ @@ -13311,6 +13392,7 @@ class WorkoutHistorySetResultsCompanion if (scoreUnitSnapshot != null) 'score_unit_snapshot': scoreUnitSnapshot, if (startedAt != null) 'started_at': startedAt, if (completedAt != null) 'completed_at': completedAt, + if (status != null) 'status': status, if (rowid != null) 'rowid': rowid, }); } @@ -13348,6 +13430,7 @@ class WorkoutHistorySetResultsCompanion Value? scoreUnitSnapshot, Value? startedAt, Value? completedAt, + Value? status, Value? rowid, }) { return WorkoutHistorySetResultsCompanion( @@ -13384,6 +13467,7 @@ class WorkoutHistorySetResultsCompanion scoreUnitSnapshot: scoreUnitSnapshot ?? this.scoreUnitSnapshot, startedAt: startedAt ?? this.startedAt, completedAt: completedAt ?? this.completedAt, + status: status ?? this.status, rowid: rowid ?? this.rowid, ); } @@ -13499,6 +13583,9 @@ class WorkoutHistorySetResultsCompanion if (completedAt.present) { map['completed_at'] = Variable(completedAt.value); } + if (status.present) { + map['status'] = Variable(status.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -13540,6 +13627,7 @@ class WorkoutHistorySetResultsCompanion ..write('scoreUnitSnapshot: $scoreUnitSnapshot, ') ..write('startedAt: $startedAt, ') ..write('completedAt: $completedAt, ') + ..write('status: $status, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -18114,6 +18202,7 @@ typedef $$ActiveSetResultsTableCreateCompanionBuilder = Value scoreLabelSnapshot, Value scoreUnitSnapshot, Value note, + Value status, Value rowid, }); typedef $$ActiveSetResultsTableUpdateCompanionBuilder = @@ -18143,6 +18232,7 @@ typedef $$ActiveSetResultsTableUpdateCompanionBuilder = Value scoreLabelSnapshot, Value scoreUnitSnapshot, Value note, + Value status, Value rowid, }); @@ -18307,6 +18397,11 @@ class $$ActiveSetResultsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnFilters(column), + ); + $$ActiveWorkoutSessionsTableFilterComposer get activeWorkoutSessionId { final $$ActiveWorkoutSessionsTableFilterComposer composer = $composerBuilder( @@ -18461,6 +18556,11 @@ class $$ActiveSetResultsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnOrderings(column), + ); + $$ActiveWorkoutSessionsTableOrderingComposer get activeWorkoutSessionId { final $$ActiveWorkoutSessionsTableOrderingComposer composer = $composerBuilder( @@ -18599,6 +18699,9 @@ class $$ActiveSetResultsTableAnnotationComposer GeneratedColumn get note => $composableBuilder(column: $table.note, builder: (column) => column); + GeneratedColumn get status => + $composableBuilder(column: $table.status, builder: (column) => column); + $$ActiveWorkoutSessionsTableAnnotationComposer get activeWorkoutSessionId { final $$ActiveWorkoutSessionsTableAnnotationComposer composer = $composerBuilder( @@ -18679,6 +18782,7 @@ class $$ActiveSetResultsTableTableManager Value scoreLabelSnapshot = const Value.absent(), Value scoreUnitSnapshot = const Value.absent(), Value note = const Value.absent(), + Value status = const Value.absent(), Value rowid = const Value.absent(), }) => ActiveSetResultsCompanion( id: id, @@ -18706,6 +18810,7 @@ class $$ActiveSetResultsTableTableManager scoreLabelSnapshot: scoreLabelSnapshot, scoreUnitSnapshot: scoreUnitSnapshot, note: note, + status: status, rowid: rowid, ), createCompanionCallback: @@ -18735,6 +18840,7 @@ class $$ActiveSetResultsTableTableManager Value scoreLabelSnapshot = const Value.absent(), Value scoreUnitSnapshot = const Value.absent(), Value note = const Value.absent(), + Value status = const Value.absent(), Value rowid = const Value.absent(), }) => ActiveSetResultsCompanion.insert( id: id, @@ -18762,6 +18868,7 @@ class $$ActiveSetResultsTableTableManager scoreLabelSnapshot: scoreLabelSnapshot, scoreUnitSnapshot: scoreUnitSnapshot, note: note, + status: status, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -23550,6 +23657,7 @@ typedef $$WorkoutHistorySetResultsTableCreateCompanionBuilder = Value scoreUnitSnapshot, Value startedAt, Value completedAt, + Value status, Value rowid, }); typedef $$WorkoutHistorySetResultsTableUpdateCompanionBuilder = @@ -23586,6 +23694,7 @@ typedef $$WorkoutHistorySetResultsTableUpdateCompanionBuilder = Value scoreUnitSnapshot, Value startedAt, Value completedAt, + Value status, Value rowid, }); @@ -23786,6 +23895,11 @@ class $$WorkoutHistorySetResultsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnFilters(column), + ); + $$WorkoutHistoriesTableFilterComposer get workoutHistoryId { final $$WorkoutHistoriesTableFilterComposer composer = $composerBuilder( composer: this, @@ -23974,6 +24088,11 @@ class $$WorkoutHistorySetResultsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnOrderings(column), + ); + $$WorkoutHistoriesTableOrderingComposer get workoutHistoryId { final $$WorkoutHistoriesTableOrderingComposer composer = $composerBuilder( composer: this, @@ -24148,6 +24267,9 @@ class $$WorkoutHistorySetResultsTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get status => + $composableBuilder(column: $table.status, builder: (column) => column); + $$WorkoutHistoriesTableAnnotationComposer get workoutHistoryId { final $$WorkoutHistoriesTableAnnotationComposer composer = $composerBuilder( composer: this, @@ -24243,6 +24365,7 @@ class $$WorkoutHistorySetResultsTableTableManager Value scoreUnitSnapshot = const Value.absent(), Value startedAt = const Value.absent(), Value completedAt = const Value.absent(), + Value status = const Value.absent(), Value rowid = const Value.absent(), }) => WorkoutHistorySetResultsCompanion( id: id, @@ -24277,6 +24400,7 @@ class $$WorkoutHistorySetResultsTableTableManager scoreUnitSnapshot: scoreUnitSnapshot, startedAt: startedAt, completedAt: completedAt, + status: status, rowid: rowid, ), createCompanionCallback: @@ -24313,6 +24437,7 @@ class $$WorkoutHistorySetResultsTableTableManager Value scoreUnitSnapshot = const Value.absent(), Value startedAt = const Value.absent(), Value completedAt = const Value.absent(), + Value status = const Value.absent(), Value rowid = const Value.absent(), }) => WorkoutHistorySetResultsCompanion.insert( id: id, @@ -24347,6 +24472,7 @@ class $$WorkoutHistorySetResultsTableTableManager scoreUnitSnapshot: scoreUnitSnapshot, startedAt: startedAt, completedAt: completedAt, + status: status, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 9978bda..6931ec2 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -929,6 +929,7 @@ db.ActiveSetResultsCompanion _activeSetResultCompanion( scoreLabelSnapshot: Value(result.scoreLabelSnapshot), scoreUnitSnapshot: Value(result.scoreUnitSnapshot), note: Value(result.note), + status: Value(_setResultStatusToDb(result.status)), ); } @@ -949,6 +950,7 @@ domain.ActiveSetResult _activeSetResultFromRow(db.ActiveSetResult row) { scoreLabelSnapshot: row.scoreLabelSnapshot, scoreUnitSnapshot: row.scoreUnitSnapshot, note: row.note, + status: _setResultStatusFromDb(row.status), ); } @@ -1059,6 +1061,7 @@ db.WorkoutHistorySetResultsCompanion _workoutHistorySetResultCompanion( scoreUnitSnapshot: Value(result.scoreUnitSnapshot), startedAt: Value(result.startedAt), completedAt: Value(result.completedAt), + status: Value(_setResultStatusToDb(result.status)), ); } @@ -1106,6 +1109,7 @@ domain.WorkoutHistorySetResult _workoutHistorySetResultFromRow( scoreUnitSnapshot: row.scoreUnitSnapshot, startedAt: row.startedAt, completedAt: row.completedAt, + status: _setResultStatusFromDb(row.status), ); } @@ -1143,6 +1147,17 @@ domain.MediaKind _mediaKindFromDb(String value) => switch (value) { _ => throw domain.DomainException('Unknown media kind: $value'), }; +String _setResultStatusToDb(domain.SetResultStatus status) => switch (status) { + domain.SetResultStatus.completed => 'completed', + domain.SetResultStatus.skipped => 'skipped', +}; + +domain.SetResultStatus _setResultStatusFromDb(String value) => switch (value) { + 'completed' => domain.SetResultStatus.completed, + 'skipped' => domain.SetResultStatus.skipped, + _ => throw domain.DomainException('Unknown set result status: $value'), +}; + domain.ActiveWorkoutStatus _activeStatusFromDb(String value) => switch (value) { 'running' => domain.ActiveWorkoutStatus.running, 'paused' => domain.ActiveWorkoutStatus.paused, diff --git a/lib/infrastructure/local/tables.dart b/lib/infrastructure/local/tables.dart index 7c8b78e..a7ae9d8 100644 --- a/lib/infrastructure/local/tables.dart +++ b/lib/infrastructure/local/tables.dart @@ -244,6 +244,7 @@ class ActiveSetResults extends SyncableTable { TextColumn get scoreLabelSnapshot => text().nullable()(); TextColumn get scoreUnitSnapshot => text().nullable()(); TextColumn get note => text().nullable()(); + TextColumn get status => text().withDefault(const Constant('completed'))(); @override List get customConstraints => [ @@ -255,6 +256,9 @@ class ActiveSetResults extends SyncableTable { 'CHECK (actual_time_ms IS NULL OR actual_time_ms >= 0)', 'CHECK (actual_reps IS NULL OR actual_reps >= 0)', 'CHECK (actual_score IS NULL OR actual_score >= 0)', + "CHECK (status IN ('completed', 'skipped'))", + 'CHECK (status != \'skipped\' OR (actual_time_ms IS NULL ' + 'AND actual_reps IS NULL AND actual_score IS NULL))', 'CHECK (actual_score IS NULL OR (score_label_snapshot IS NOT NULL ' 'AND length(trim(score_label_snapshot)) > 0 ' 'AND score_unit_snapshot IS NOT NULL ' @@ -332,6 +336,7 @@ class WorkoutHistorySetResults extends SyncableTable { TextColumn get scoreUnitSnapshot => text().nullable()(); DateTimeColumn get startedAt => dateTime().nullable()(); DateTimeColumn get completedAt => dateTime().nullable()(); + TextColumn get status => text().withDefault(const Constant('completed'))(); @override List get customConstraints => [ @@ -349,6 +354,9 @@ class WorkoutHistorySetResults extends SyncableTable { 'CHECK (actual_time_ms IS NULL OR actual_time_ms >= 0)', 'CHECK (actual_reps IS NULL OR actual_reps >= 0)', 'CHECK (actual_score IS NULL OR actual_score >= 0)', + "CHECK (status IN ('completed', 'skipped'))", + 'CHECK (status != \'skipped\' OR (actual_time_ms IS NULL ' + 'AND actual_reps IS NULL AND actual_score IS NULL))', 'CHECK (actual_score IS NULL OR (score_label_snapshot IS NOT NULL ' 'AND length(trim(score_label_snapshot)) > 0 ' 'AND score_unit_snapshot IS NOT NULL ' diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index a808b73..fee0301 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -209,6 +209,123 @@ void main() { expect(active?.metadata.id, 'latest-active'); }); + + test('upsertSetResultAtPosition does not move session cursor', () async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final session = _sessionWithSnapshot( + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 1, + ); + final repository = _FakeActiveSessionRepository()..session = session; + final useCase = _activeUseCase(repository, clock); + + await useCase.upsertSetResultAtPosition( + sessionId: session.metadata.id, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + status: SetResultStatus.completed, + actualReps: 12, + ); + + expect(repository.session?.currentProgramIndex, 0); + expect(repository.session?.currentExerciseIndex, 0); + expect(repository.session?.currentSetIndex, 1); + expect(repository.results.single.actualReps, 12); + }); + + test( + 'upsertSetResultAtPosition preserves existing id and createdAt', + () async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final session = _sessionWithSnapshot( + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 1, + ); + final existingCreatedAt = DateTime.utc(2026, 7, 17, 10); + final existing = ActiveSetResult( + metadata: EntityMetadata( + id: 'result-1', + createdAt: existingCreatedAt, + updatedAt: existingCreatedAt, + originDeviceId: 'device-1', + ), + activeWorkoutSessionId: session.metadata.id, + programSnapshotId: 'program-snapshot-1', + exerciseSnapshotId: 'exercise-snapshot-1', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + actualReps: 8, + ); + final repository = _FakeActiveSessionRepository() + ..session = session + ..results.add(existing); + final useCase = _activeUseCase(repository, clock); + + final updated = await useCase.upsertSetResultAtPosition( + sessionId: session.metadata.id, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + status: SetResultStatus.completed, + actualReps: 12, + ); + + expect(updated.metadata.id, 'result-1'); + expect(updated.metadata.createdAt, existingCreatedAt); + expect(updated.actualReps, 12); + }, + ); + + test( + 'listSetResults returns pending, skipped and completed statuses', + () async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final session = _sessionWithSnapshot( + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 2, + setsCount: 3, + ); + final repository = _FakeActiveSessionRepository() + ..session = session + ..results.addAll([ + ActiveSetResult( + metadata: _metadata('completed'), + activeWorkoutSessionId: session.metadata.id, + programSnapshotId: 'program-snapshot-1', + exerciseSnapshotId: 'exercise-snapshot-1', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + actualReps: 10, + status: SetResultStatus.completed, + ), + ActiveSetResult( + metadata: _metadata('skipped'), + activeWorkoutSessionId: session.metadata.id, + programSnapshotId: 'program-snapshot-1', + exerciseSnapshotId: 'exercise-snapshot-1', + programIndex: 0, + exerciseIndex: 0, + setIndex: 1, + status: SetResultStatus.skipped, + ), + ]); + final useCase = _activeUseCase(repository, clock); + + final states = await useCase.listSetResults(session.metadata.id); + + expect(states.map((state) => state.status), [ + SetPositionStatus.completed, + SetPositionStatus.skipped, + SetPositionStatus.pending, + ]); + }, + ); } EntityMetadata _metadata(String id) { @@ -294,13 +411,17 @@ ActiveWorkoutSessionUseCases _activeUseCase( } final class _FakeActiveSessionRepository implements ActiveSessionRepository { + ActiveWorkoutSession? session; + final results = []; final restStates = {}; @override - Future findById(String id) async => null; + Future findById(String id) async { + return session?.metadata.id == id ? session : null; + } @override - Future findOpen() async => null; + Future findOpen() async => session; @override Future findRestStateById(String id) async => restStates[id]; @@ -313,11 +434,16 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { } @override - Future> listSetResults(String sessionId) async => - const []; + Future> listSetResults(String sessionId) async { + return results + .where((result) => result.activeWorkoutSessionId == sessionId) + .toList(); + } @override - Future save(ActiveWorkoutSession session) async {} + Future save(ActiveWorkoutSession session) async { + this.session = session; + } @override Future saveRestState(ActiveRestState restState) async { @@ -325,5 +451,53 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { } @override - Future saveSetResult(ActiveSetResult result) async {} + Future saveSetResult(ActiveSetResult result) async { + results.removeWhere( + (existing) => + existing.activeWorkoutSessionId == result.activeWorkoutSessionId && + existing.programIndex == result.programIndex && + existing.exerciseIndex == result.exerciseIndex && + existing.setIndex == result.setIndex, + ); + results.add(result); + } +} + +ActiveWorkoutSession _sessionWithSnapshot({ + required int currentProgramIndex, + required int currentExerciseIndex, + required int currentSetIndex, + int setsCount = 2, +}) { + return ActiveWorkoutSession( + metadata: _metadata('session-1'), + status: ActiveWorkoutStatus.running, + startedAt: DateTime.utc(2026, 7, 17, 12), + lastPersistedAt: DateTime.utc(2026, 7, 17, 12), + elapsedActiveMs: 0, + currentProgramIndex: currentProgramIndex, + currentExerciseIndex: currentExerciseIndex, + currentSetIndex: currentSetIndex, + resolvedTemplateSnapshotJson: jsonEncode({ + 'programs': [ + { + 'id': 'program-snapshot-1', + 'programNameSnapshot': 'Programme', + 'programSnapshotJson': jsonEncode({ + 'exercises': [ + { + 'id': 'exercise-snapshot-1', + 'exerciseNameSnapshot': 'Squat', + 'setsCount': setsCount, + 'timeEnabled': false, + 'repsEnabled': true, + 'scoreEnabled': false, + 'targetReps': 10, + }, + ], + }), + }, + ], + }), + ); }