Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1408,28 +1408,76 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
Future<void> saveScoreStopwatchState(
|
Future<void> saveScoreStopwatchState(
|
||||||
domain.ActiveScoreStopwatchState state,
|
domain.ActiveScoreStopwatchState state,
|
||||||
) async {
|
) async {
|
||||||
await _upsertWithChangeLog(
|
await database.transaction(() async {
|
||||||
database: database,
|
final existing = await _findActiveScoreStopwatchStateRowByPosition(
|
||||||
tableName: 'active_score_stopwatch_states',
|
database,
|
||||||
entityType: 'ActiveScoreStopwatchState',
|
state.activeWorkoutSessionId,
|
||||||
metadata: state.metadata,
|
state.programIndex,
|
||||||
write: () => database
|
state.exerciseIndex,
|
||||||
.into(database.activeScoreStopwatchStates)
|
state.setIndex,
|
||||||
.insertOnConflictUpdate(_activeScoreStopwatchStateCompanion(state)),
|
);
|
||||||
);
|
final effectiveState = existing == null
|
||||||
|
? state
|
||||||
|
: state.copyWith(
|
||||||
|
metadata: _metadataWithStoredIdentity(
|
||||||
|
state.metadata,
|
||||||
|
_metadataFromRow(existing),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final operation = existing == null
|
||||||
|
? 'insert'
|
||||||
|
: _operationForExistingMutation(
|
||||||
|
existingDeletedAt: existing.deletedAt,
|
||||||
|
deletedAt: effectiveState.metadata.deletedAt,
|
||||||
|
);
|
||||||
|
await _upsertActiveScoreStopwatchState(database, effectiveState);
|
||||||
|
await _writeChangeLog(
|
||||||
|
database: database,
|
||||||
|
entityType: 'ActiveScoreStopwatchState',
|
||||||
|
entityId: effectiveState.metadata.id,
|
||||||
|
operation: operation,
|
||||||
|
localRevision: effectiveState.metadata.localRevision,
|
||||||
|
originDeviceId: effectiveState.metadata.originDeviceId,
|
||||||
|
createdAt: effectiveState.metadata.updatedAt,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> saveManualScoreState(domain.ActiveManualScoreState state) async {
|
Future<void> saveManualScoreState(domain.ActiveManualScoreState state) async {
|
||||||
await _upsertWithChangeLog(
|
await database.transaction(() async {
|
||||||
database: database,
|
final existing = await _findActiveManualScoreStateRowByPosition(
|
||||||
tableName: 'active_manual_score_states',
|
database,
|
||||||
entityType: 'ActiveManualScoreState',
|
state.activeWorkoutSessionId,
|
||||||
metadata: state.metadata,
|
state.programIndex,
|
||||||
write: () => database
|
state.exerciseIndex,
|
||||||
.into(database.activeManualScoreStates)
|
state.setIndex,
|
||||||
.insertOnConflictUpdate(_activeManualScoreStateCompanion(state)),
|
);
|
||||||
);
|
final effectiveState = existing == null
|
||||||
|
? state
|
||||||
|
: state.copyWith(
|
||||||
|
metadata: _metadataWithStoredIdentity(
|
||||||
|
state.metadata,
|
||||||
|
_metadataFromRow(existing),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final operation = existing == null
|
||||||
|
? 'insert'
|
||||||
|
: _operationForExistingMutation(
|
||||||
|
existingDeletedAt: existing.deletedAt,
|
||||||
|
deletedAt: effectiveState.metadata.deletedAt,
|
||||||
|
);
|
||||||
|
await _upsertActiveManualScoreState(database, effectiveState);
|
||||||
|
await _writeChangeLog(
|
||||||
|
database: database,
|
||||||
|
entityType: 'ActiveManualScoreState',
|
||||||
|
entityId: effectiveState.metadata.id,
|
||||||
|
operation: operation,
|
||||||
|
localRevision: effectiveState.metadata.localRevision,
|
||||||
|
originDeviceId: effectiveState.metadata.originDeviceId,
|
||||||
|
createdAt: effectiveState.metadata.updatedAt,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -1486,14 +1534,21 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final revision = row.localRevision + 1;
|
final revision = row.localRevision + 1;
|
||||||
await (database.delete(
|
await (database.update(
|
||||||
database.activeScoreStopwatchStates,
|
database.activeScoreStopwatchStates,
|
||||||
)..where((table) => table.id.equals(row.id))).go();
|
)..where((table) => table.id.equals(row.id))).write(
|
||||||
|
db.ActiveScoreStopwatchStatesCompanion(
|
||||||
|
deletedAt: Value(deletedAt.toUtc()),
|
||||||
|
updatedAt: Value(deletedAt.toUtc()),
|
||||||
|
localRevision: Value(revision),
|
||||||
|
syncState: const Value('deleted'),
|
||||||
|
),
|
||||||
|
);
|
||||||
await _writeChangeLog(
|
await _writeChangeLog(
|
||||||
database: database,
|
database: database,
|
||||||
entityType: 'ActiveScoreStopwatchState',
|
entityType: 'ActiveScoreStopwatchState',
|
||||||
entityId: row.id,
|
entityId: row.id,
|
||||||
operation: 'delete',
|
operation: 'softDelete',
|
||||||
localRevision: revision,
|
localRevision: revision,
|
||||||
originDeviceId: row.originDeviceId,
|
originDeviceId: row.originDeviceId,
|
||||||
createdAt: deletedAt,
|
createdAt: deletedAt,
|
||||||
@ -1522,14 +1577,21 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final revision = row.localRevision + 1;
|
final revision = row.localRevision + 1;
|
||||||
await (database.delete(
|
await (database.update(
|
||||||
database.activeManualScoreStates,
|
database.activeManualScoreStates,
|
||||||
)..where((table) => table.id.equals(row.id))).go();
|
)..where((table) => table.id.equals(row.id))).write(
|
||||||
|
db.ActiveManualScoreStatesCompanion(
|
||||||
|
deletedAt: Value(deletedAt.toUtc()),
|
||||||
|
updatedAt: Value(deletedAt.toUtc()),
|
||||||
|
localRevision: Value(revision),
|
||||||
|
syncState: const Value('deleted'),
|
||||||
|
),
|
||||||
|
);
|
||||||
await _writeChangeLog(
|
await _writeChangeLog(
|
||||||
database: database,
|
database: database,
|
||||||
entityType: 'ActiveManualScoreState',
|
entityType: 'ActiveManualScoreState',
|
||||||
entityId: row.id,
|
entityId: row.id,
|
||||||
operation: 'delete',
|
operation: 'softDelete',
|
||||||
localRevision: revision,
|
localRevision: revision,
|
||||||
originDeviceId: row.originDeviceId,
|
originDeviceId: row.originDeviceId,
|
||||||
createdAt: deletedAt,
|
createdAt: deletedAt,
|
||||||
@ -4277,6 +4339,184 @@ Future<void> _updateActiveRestPauseFields(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<db.ActiveScoreStopwatchState?>
|
||||||
|
_findActiveScoreStopwatchStateRowByPosition(
|
||||||
|
db.AppDatabase database,
|
||||||
|
String sessionId,
|
||||||
|
int programIndex,
|
||||||
|
int exerciseIndex,
|
||||||
|
int setIndex,
|
||||||
|
) {
|
||||||
|
return (database.select(database.activeScoreStopwatchStates)..where(
|
||||||
|
(table) =>
|
||||||
|
table.activeWorkoutSessionId.equals(sessionId) &
|
||||||
|
table.programIndex.equals(programIndex) &
|
||||||
|
table.exerciseIndex.equals(exerciseIndex) &
|
||||||
|
table.setIndex.equals(setIndex),
|
||||||
|
))
|
||||||
|
.getSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<db.ActiveManualScoreState?> _findActiveManualScoreStateRowByPosition(
|
||||||
|
db.AppDatabase database,
|
||||||
|
String sessionId,
|
||||||
|
int programIndex,
|
||||||
|
int exerciseIndex,
|
||||||
|
int setIndex,
|
||||||
|
) {
|
||||||
|
return (database.select(database.activeManualScoreStates)..where(
|
||||||
|
(table) =>
|
||||||
|
table.activeWorkoutSessionId.equals(sessionId) &
|
||||||
|
table.programIndex.equals(programIndex) &
|
||||||
|
table.exerciseIndex.equals(exerciseIndex) &
|
||||||
|
table.setIndex.equals(setIndex),
|
||||||
|
))
|
||||||
|
.getSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
domain.EntityMetadata _metadataWithStoredIdentity(
|
||||||
|
domain.EntityMetadata metadata,
|
||||||
|
domain.EntityMetadata stored,
|
||||||
|
) {
|
||||||
|
return domain.EntityMetadata(
|
||||||
|
id: stored.id,
|
||||||
|
createdAt: stored.createdAt,
|
||||||
|
updatedAt: metadata.updatedAt,
|
||||||
|
deletedAt: metadata.deletedAt,
|
||||||
|
schemaVersion: metadata.schemaVersion,
|
||||||
|
syncState: metadata.syncState,
|
||||||
|
localRevision: metadata.localRevision,
|
||||||
|
originDeviceId: metadata.originDeviceId,
|
||||||
|
futureOwnerProfileId: metadata.futureOwnerProfileId,
|
||||||
|
lastSyncedAt: metadata.lastSyncedAt,
|
||||||
|
remoteRevision: metadata.remoteRevision,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _operationForExistingMutation({
|
||||||
|
required DateTime? existingDeletedAt,
|
||||||
|
required DateTime? deletedAt,
|
||||||
|
}) {
|
||||||
|
if (deletedAt != null) {
|
||||||
|
return 'softDelete';
|
||||||
|
}
|
||||||
|
if (existingDeletedAt != null) {
|
||||||
|
return 'restore';
|
||||||
|
}
|
||||||
|
return 'update';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _upsertActiveScoreStopwatchState(
|
||||||
|
db.AppDatabase database,
|
||||||
|
domain.ActiveScoreStopwatchState state,
|
||||||
|
) async {
|
||||||
|
final metadata = state.metadata;
|
||||||
|
await database.customInsert(
|
||||||
|
'INSERT INTO active_score_stopwatch_states ('
|
||||||
|
'id, created_at, updated_at, deleted_at, schema_version, sync_state, '
|
||||||
|
'local_revision, origin_device_id, future_owner_profile_id, '
|
||||||
|
'last_synced_at, remote_revision, active_workout_session_id, '
|
||||||
|
'program_index, exercise_index, set_index, status, started_at, '
|
||||||
|
'accumulated_ms, stopped_at'
|
||||||
|
') VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) '
|
||||||
|
'ON CONFLICT(active_workout_session_id, program_index, exercise_index, '
|
||||||
|
'set_index) DO UPDATE SET '
|
||||||
|
'id = active_score_stopwatch_states.id, '
|
||||||
|
'created_at = active_score_stopwatch_states.created_at, '
|
||||||
|
'updated_at = excluded.updated_at, '
|
||||||
|
'deleted_at = excluded.deleted_at, '
|
||||||
|
'schema_version = excluded.schema_version, '
|
||||||
|
'sync_state = excluded.sync_state, '
|
||||||
|
'local_revision = excluded.local_revision, '
|
||||||
|
'origin_device_id = excluded.origin_device_id, '
|
||||||
|
'future_owner_profile_id = excluded.future_owner_profile_id, '
|
||||||
|
'last_synced_at = excluded.last_synced_at, '
|
||||||
|
'remote_revision = excluded.remote_revision, '
|
||||||
|
'active_workout_session_id = excluded.active_workout_session_id, '
|
||||||
|
'program_index = excluded.program_index, '
|
||||||
|
'exercise_index = excluded.exercise_index, '
|
||||||
|
'set_index = excluded.set_index, '
|
||||||
|
'status = excluded.status, '
|
||||||
|
'started_at = excluded.started_at, '
|
||||||
|
'accumulated_ms = excluded.accumulated_ms, '
|
||||||
|
'stopped_at = excluded.stopped_at',
|
||||||
|
variables: [
|
||||||
|
Variable<String>(metadata.id),
|
||||||
|
Variable<DateTime>(metadata.createdAt.toUtc()),
|
||||||
|
Variable<DateTime>(metadata.updatedAt.toUtc()),
|
||||||
|
Variable<DateTime>(_utcOrNull(metadata.deletedAt)),
|
||||||
|
Variable<int>(metadata.schemaVersion),
|
||||||
|
Variable<String>(_syncStateToDb(metadata.syncState)),
|
||||||
|
Variable<int>(metadata.localRevision),
|
||||||
|
Variable<String>(metadata.originDeviceId),
|
||||||
|
Variable<String>(metadata.futureOwnerProfileId),
|
||||||
|
Variable<DateTime>(_utcOrNull(metadata.lastSyncedAt)),
|
||||||
|
Variable<String>(metadata.remoteRevision),
|
||||||
|
Variable<String>(state.activeWorkoutSessionId),
|
||||||
|
Variable<int>(state.programIndex),
|
||||||
|
Variable<int>(state.exerciseIndex),
|
||||||
|
Variable<int>(state.setIndex),
|
||||||
|
Variable<String>(_scoreStopwatchStatusToDb(state.status)),
|
||||||
|
Variable<DateTime>(state.startedAt.toUtc()),
|
||||||
|
Variable<int>(state.accumulatedMs),
|
||||||
|
Variable<DateTime>(_utcOrNull(state.stoppedAt)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _upsertActiveManualScoreState(
|
||||||
|
db.AppDatabase database,
|
||||||
|
domain.ActiveManualScoreState state,
|
||||||
|
) async {
|
||||||
|
final metadata = state.metadata;
|
||||||
|
await database.customInsert(
|
||||||
|
'INSERT INTO active_manual_score_states ('
|
||||||
|
'id, created_at, updated_at, deleted_at, schema_version, sync_state, '
|
||||||
|
'local_revision, origin_device_id, future_owner_profile_id, '
|
||||||
|
'last_synced_at, remote_revision, active_workout_session_id, '
|
||||||
|
'program_index, exercise_index, set_index, value, score_updated_at'
|
||||||
|
') VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) '
|
||||||
|
'ON CONFLICT(active_workout_session_id, program_index, exercise_index, '
|
||||||
|
'set_index) DO UPDATE SET '
|
||||||
|
'id = active_manual_score_states.id, '
|
||||||
|
'created_at = active_manual_score_states.created_at, '
|
||||||
|
'updated_at = excluded.updated_at, '
|
||||||
|
'deleted_at = excluded.deleted_at, '
|
||||||
|
'schema_version = excluded.schema_version, '
|
||||||
|
'sync_state = excluded.sync_state, '
|
||||||
|
'local_revision = excluded.local_revision, '
|
||||||
|
'origin_device_id = excluded.origin_device_id, '
|
||||||
|
'future_owner_profile_id = excluded.future_owner_profile_id, '
|
||||||
|
'last_synced_at = excluded.last_synced_at, '
|
||||||
|
'remote_revision = excluded.remote_revision, '
|
||||||
|
'active_workout_session_id = excluded.active_workout_session_id, '
|
||||||
|
'program_index = excluded.program_index, '
|
||||||
|
'exercise_index = excluded.exercise_index, '
|
||||||
|
'set_index = excluded.set_index, '
|
||||||
|
'value = excluded.value, '
|
||||||
|
'score_updated_at = excluded.score_updated_at',
|
||||||
|
variables: [
|
||||||
|
Variable<String>(metadata.id),
|
||||||
|
Variable<DateTime>(metadata.createdAt.toUtc()),
|
||||||
|
Variable<DateTime>(metadata.updatedAt.toUtc()),
|
||||||
|
Variable<DateTime>(_utcOrNull(metadata.deletedAt)),
|
||||||
|
Variable<int>(metadata.schemaVersion),
|
||||||
|
Variable<String>(_syncStateToDb(metadata.syncState)),
|
||||||
|
Variable<int>(metadata.localRevision),
|
||||||
|
Variable<String>(metadata.originDeviceId),
|
||||||
|
Variable<String>(metadata.futureOwnerProfileId),
|
||||||
|
Variable<DateTime>(_utcOrNull(metadata.lastSyncedAt)),
|
||||||
|
Variable<String>(metadata.remoteRevision),
|
||||||
|
Variable<String>(state.activeWorkoutSessionId),
|
||||||
|
Variable<int>(state.programIndex),
|
||||||
|
Variable<int>(state.exerciseIndex),
|
||||||
|
Variable<int>(state.setIndex),
|
||||||
|
Variable<double>(state.value),
|
||||||
|
Variable<DateTime>(state.updatedAt.toUtc()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _upsertActiveSetTimerState(
|
Future<void> _upsertActiveSetTimerState(
|
||||||
db.AppDatabase database,
|
db.AppDatabase database,
|
||||||
domain.ActiveSetTimerState state,
|
domain.ActiveSetTimerState state,
|
||||||
@ -4459,58 +4699,6 @@ String? _activeMeasuresPredicate(ActivePerformanceMeasures measures) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
db.ActiveScoreStopwatchStatesCompanion _activeScoreStopwatchStateCompanion(
|
|
||||||
domain.ActiveScoreStopwatchState state,
|
|
||||||
) {
|
|
||||||
final values = _metadataValues(state.metadata);
|
|
||||||
return db.ActiveScoreStopwatchStatesCompanion(
|
|
||||||
id: values[0] as Value<String>,
|
|
||||||
createdAt: values[1] as Value<DateTime>,
|
|
||||||
updatedAt: values[2] as Value<DateTime>,
|
|
||||||
deletedAt: values[3] as Value<DateTime?>,
|
|
||||||
schemaVersion: values[4] as Value<int>,
|
|
||||||
syncState: values[5] as Value<String>,
|
|
||||||
localRevision: values[6] as Value<int>,
|
|
||||||
originDeviceId: values[7] as Value<String>,
|
|
||||||
futureOwnerProfileId: values[8] as Value<String?>,
|
|
||||||
lastSyncedAt: values[9] as Value<DateTime?>,
|
|
||||||
remoteRevision: values[10] as Value<String?>,
|
|
||||||
activeWorkoutSessionId: Value(state.activeWorkoutSessionId),
|
|
||||||
programIndex: Value(state.programIndex),
|
|
||||||
exerciseIndex: Value(state.exerciseIndex),
|
|
||||||
setIndex: Value(state.setIndex),
|
|
||||||
status: Value(_scoreStopwatchStatusToDb(state.status)),
|
|
||||||
startedAt: Value(state.startedAt.toUtc()),
|
|
||||||
accumulatedMs: Value(state.accumulatedMs),
|
|
||||||
stoppedAt: Value(_utcOrNull(state.stoppedAt)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
db.ActiveManualScoreStatesCompanion _activeManualScoreStateCompanion(
|
|
||||||
domain.ActiveManualScoreState state,
|
|
||||||
) {
|
|
||||||
final values = _metadataValues(state.metadata);
|
|
||||||
return db.ActiveManualScoreStatesCompanion(
|
|
||||||
id: values[0] as Value<String>,
|
|
||||||
createdAt: values[1] as Value<DateTime>,
|
|
||||||
updatedAt: values[2] as Value<DateTime>,
|
|
||||||
deletedAt: values[3] as Value<DateTime?>,
|
|
||||||
schemaVersion: values[4] as Value<int>,
|
|
||||||
syncState: values[5] as Value<String>,
|
|
||||||
localRevision: values[6] as Value<int>,
|
|
||||||
originDeviceId: values[7] as Value<String>,
|
|
||||||
futureOwnerProfileId: values[8] as Value<String?>,
|
|
||||||
lastSyncedAt: values[9] as Value<DateTime?>,
|
|
||||||
remoteRevision: values[10] as Value<String?>,
|
|
||||||
activeWorkoutSessionId: Value(state.activeWorkoutSessionId),
|
|
||||||
programIndex: Value(state.programIndex),
|
|
||||||
exerciseIndex: Value(state.exerciseIndex),
|
|
||||||
setIndex: Value(state.setIndex),
|
|
||||||
value: Value(state.value),
|
|
||||||
scoreUpdatedAt: Value(state.updatedAt.toUtc()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
domain.ActiveManualScoreState _activeManualScoreStateFromRow(
|
domain.ActiveManualScoreState _activeManualScoreStateFromRow(
|
||||||
db.ActiveManualScoreState row,
|
db.ActiveManualScoreState row,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import 'exercise_step_audio.dart';
|
|||||||
import 'history_screen.dart';
|
import 'history_screen.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
||||||
enum WorkoutExecutionMode { active, rest, paused, finished }
|
enum WorkoutExecutionMode { active, rest, paused, finished, resumingSavedExit }
|
||||||
|
|
||||||
typedef VideoMediaBuilder =
|
typedef VideoMediaBuilder =
|
||||||
Widget Function(BuildContext context, MediaAsset asset);
|
Widget Function(BuildContext context, MediaAsset asset);
|
||||||
@ -117,9 +117,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
|||||||
(widget.stepUseCases == null
|
(widget.stepUseCases == null
|
||||||
? const NoOpExerciseStepAudioCuePlayer()
|
? const NoOpExerciseStepAudioCuePlayer()
|
||||||
: AudioplayersExerciseStepAudioCuePlayer());
|
: AudioplayersExerciseStepAudioCuePlayer());
|
||||||
_mode = _session.status == ActiveWorkoutStatus.running
|
_mode = _modeForSessionStatus(_session.status);
|
||||||
? WorkoutExecutionMode.active
|
|
||||||
: WorkoutExecutionMode.paused;
|
|
||||||
_reps = _initialRepsFor(_exercise);
|
_reps = _initialRepsFor(_exercise);
|
||||||
_performanceReference = _loadPerformanceReference();
|
_performanceReference = _loadPerformanceReference();
|
||||||
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
@ -136,11 +134,16 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
|||||||
}
|
}
|
||||||
setState(() => _sensorState = state);
|
setState(() => _sensorState = state);
|
||||||
});
|
});
|
||||||
unawaited(_loadSetTimer());
|
final resumingSavedExit = _session.status == ActiveWorkoutStatus.savedExit;
|
||||||
unawaited(_loadScoreStopwatch());
|
if (resumingSavedExit) {
|
||||||
unawaited(_syncManualScoreInput(force: true));
|
unawaited(_resumeSavedExitSession());
|
||||||
unawaited(_loadStepProgress());
|
} else {
|
||||||
unawaited(_restoreActiveRest());
|
unawaited(_loadSetTimer());
|
||||||
|
unawaited(_loadScoreStopwatch());
|
||||||
|
unawaited(_syncManualScoreInput(force: true));
|
||||||
|
unawaited(_loadStepProgress());
|
||||||
|
unawaited(_restoreActiveRest());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -225,7 +228,8 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
|||||||
onPressed: _openWorkoutPlan,
|
onPressed: _openWorkoutPlan,
|
||||||
icon: const Icon(Icons.list_alt),
|
icon: const Icon(Icons.list_alt),
|
||||||
),
|
),
|
||||||
TextButton(onPressed: _pause, child: const Text('Pause')),
|
if (_canPauseFromNavigation)
|
||||||
|
TextButton(onPressed: _pause, child: const Text('Pause')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
bottomNavigationBar: _mode == WorkoutExecutionMode.active
|
bottomNavigationBar: _mode == WorkoutExecutionMode.active
|
||||||
@ -243,6 +247,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
|||||||
WorkoutExecutionMode.rest => _buildRest(),
|
WorkoutExecutionMode.rest => _buildRest(),
|
||||||
WorkoutExecutionMode.paused => _buildPaused(),
|
WorkoutExecutionMode.paused => _buildPaused(),
|
||||||
WorkoutExecutionMode.finished => _buildFinished(),
|
WorkoutExecutionMode.finished => _buildFinished(),
|
||||||
|
WorkoutExecutionMode.resumingSavedExit => _buildResumingSavedExit(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -510,6 +515,22 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildResumingSavedExit() {
|
||||||
|
return const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text('Reprise de la séance...'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildFinished() {
|
Widget _buildFinished() {
|
||||||
final elapsed = Duration(
|
final elapsed = Duration(
|
||||||
milliseconds: widget.activeUseCases.elapsedActiveMilliseconds(_session),
|
milliseconds: widget.activeUseCases.elapsedActiveMilliseconds(_session),
|
||||||
@ -1221,7 +1242,9 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _syncExternalSessionChanges() async {
|
Future<void> _syncExternalSessionChanges() async {
|
||||||
if (_externalSyncInFlight || !mounted) {
|
if (_externalSyncInFlight ||
|
||||||
|
!mounted ||
|
||||||
|
_mode == WorkoutExecutionMode.resumingSavedExit) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_externalSyncInFlight = true;
|
_externalSyncInFlight = true;
|
||||||
@ -1250,16 +1273,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_mode = switch (session.status) {
|
_mode = _modeForSessionStatus(session.status);
|
||||||
ActiveWorkoutStatus.running =>
|
|
||||||
_activeRestStateId == null
|
|
||||||
? WorkoutExecutionMode.active
|
|
||||||
: WorkoutExecutionMode.rest,
|
|
||||||
ActiveWorkoutStatus.paused => WorkoutExecutionMode.paused,
|
|
||||||
ActiveWorkoutStatus.completed ||
|
|
||||||
ActiveWorkoutStatus.abandoned ||
|
|
||||||
ActiveWorkoutStatus.savedExit => WorkoutExecutionMode.finished,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
_refreshScoreStopwatchTicker();
|
_refreshScoreStopwatchTicker();
|
||||||
_refreshStepTicker();
|
_refreshStepTicker();
|
||||||
@ -1400,6 +1414,19 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
|||||||
_mode == WorkoutExecutionMode.rest;
|
_mode == WorkoutExecutionMode.rest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
WorkoutExecutionMode _modeForSessionStatus(ActiveWorkoutStatus status) {
|
||||||
|
return switch (status) {
|
||||||
|
ActiveWorkoutStatus.running =>
|
||||||
|
_activeRestStateId == null
|
||||||
|
? WorkoutExecutionMode.active
|
||||||
|
: WorkoutExecutionMode.rest,
|
||||||
|
ActiveWorkoutStatus.paused => WorkoutExecutionMode.paused,
|
||||||
|
ActiveWorkoutStatus.savedExit => WorkoutExecutionMode.resumingSavedExit,
|
||||||
|
ActiveWorkoutStatus.completed ||
|
||||||
|
ActiveWorkoutStatus.abandoned => WorkoutExecutionMode.finished,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
void _handleSystemBack(bool didPop, Object? result) {
|
void _handleSystemBack(bool didPop, Object? result) {
|
||||||
if (didPop || !_canPauseFromNavigation) return;
|
if (didPop || !_canPauseFromNavigation) return;
|
||||||
unawaited(_pause());
|
unawaited(_pause());
|
||||||
@ -1443,6 +1470,13 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
|||||||
_refreshStepTicker();
|
_refreshStepTicker();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _resumeSavedExitSession() async {
|
||||||
|
if (!mounted || _mode != WorkoutExecutionMode.resumingSavedExit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _resume();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _quitAndSave() async {
|
Future<void> _quitAndSave() async {
|
||||||
await widget.activeUseCases.quitAndSave(_session.metadata.id);
|
await widget.activeUseCases.quitAndSave(_session.metadata.id);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@ -2777,6 +2811,8 @@ final class _CurrentStepPane extends StatelessWidget {
|
|||||||
step.type == ExerciseStepType.time &&
|
step.type == ExerciseStepType.time &&
|
||||||
step.hasScore &&
|
step.hasScore &&
|
||||||
step.scoreInputMode == ScoreInputMode.manual;
|
step.scoreInputMode == ScoreInputMode.manual;
|
||||||
|
final useCompactRepsActions =
|
||||||
|
step.type == ExerciseStepType.reps && constraints.maxHeight < 180;
|
||||||
if (hasRepsWithStopwatchScore || hasTimedWithManualScore) {
|
if (hasRepsWithStopwatchScore || hasTimedWithManualScore) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@ -2900,7 +2936,8 @@ final class _CurrentStepPane extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
if (step.type == ExerciseStepType.reps) ...[
|
if (step.type == ExerciseStepType.reps &&
|
||||||
|
!useCompactRepsActions) ...[
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: onCompleteStep,
|
onPressed: onCompleteStep,
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
@ -2922,6 +2959,19 @@ final class _CurrentStepPane extends StatelessWidget {
|
|||||||
child: const Text('Passer l’étape'),
|
child: const Text('Passer l’étape'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (useCompactRepsActions) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: onCompleteStep,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(44),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.check),
|
||||||
|
label: const Text('Étape suivante'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
PopupMenuButton<_StepSkipAction>(
|
PopupMenuButton<_StepSkipAction>(
|
||||||
tooltip: 'Plus d’actions',
|
tooltip: 'Plus d’actions',
|
||||||
|
|||||||
@ -2067,6 +2067,188 @@ CREATE TABLE pending_share_actions (
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'active score states are soft deleted with softDelete change log entries',
|
||||||
|
() async {
|
||||||
|
final now = DateTime.utc(2026, 7, 17, 12);
|
||||||
|
await activeRepository.save(
|
||||||
|
ActiveWorkoutSession(
|
||||||
|
metadata: _metadata('session-score-delete', now),
|
||||||
|
status: ActiveWorkoutStatus.running,
|
||||||
|
startedAt: now,
|
||||||
|
lastPersistedAt: now,
|
||||||
|
elapsedActiveMs: 0,
|
||||||
|
currentProgramIndex: 0,
|
||||||
|
currentExerciseIndex: 0,
|
||||||
|
currentSetIndex: 0,
|
||||||
|
resolvedTemplateSnapshotJson: _resolvedSnapshot(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await activeRepository.saveScoreStopwatchState(
|
||||||
|
ActiveScoreStopwatchState(
|
||||||
|
metadata: _metadata('score-stopwatch-delete', now),
|
||||||
|
activeWorkoutSessionId: 'session-score-delete',
|
||||||
|
programIndex: 0,
|
||||||
|
exerciseIndex: 0,
|
||||||
|
setIndex: 0,
|
||||||
|
status: ActiveScoreStopwatchStatus.stopped,
|
||||||
|
startedAt: now,
|
||||||
|
accumulatedMs: 12000,
|
||||||
|
stoppedAt: now.add(const Duration(seconds: 12)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await activeRepository.saveManualScoreState(
|
||||||
|
ActiveManualScoreState(
|
||||||
|
metadata: _metadata('manual-score-delete', now),
|
||||||
|
activeWorkoutSessionId: 'session-score-delete',
|
||||||
|
programIndex: 0,
|
||||||
|
exerciseIndex: 0,
|
||||||
|
setIndex: 0,
|
||||||
|
value: 3,
|
||||||
|
updatedAt: now,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final deletedAt = now.add(const Duration(minutes: 1));
|
||||||
|
await activeRepository.deleteScoreStopwatchState(
|
||||||
|
sessionId: 'session-score-delete',
|
||||||
|
programIndex: 0,
|
||||||
|
exerciseIndex: 0,
|
||||||
|
setIndex: 0,
|
||||||
|
deletedAt: deletedAt,
|
||||||
|
);
|
||||||
|
await activeRepository.deleteManualScoreState(
|
||||||
|
sessionId: 'session-score-delete',
|
||||||
|
programIndex: 0,
|
||||||
|
exerciseIndex: 0,
|
||||||
|
setIndex: 0,
|
||||||
|
deletedAt: deletedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
final stopwatchRow =
|
||||||
|
await (database.select(database.activeScoreStopwatchStates)
|
||||||
|
..where((table) => table.id.equals('score-stopwatch-delete')))
|
||||||
|
.getSingle();
|
||||||
|
final manualRow = await (database.select(
|
||||||
|
database.activeManualScoreStates,
|
||||||
|
)..where((table) => table.id.equals('manual-score-delete'))).getSingle();
|
||||||
|
final changes =
|
||||||
|
await (database.select(database.changeLogEntries)..where(
|
||||||
|
(table) =>
|
||||||
|
table.entityId.isIn([
|
||||||
|
'score-stopwatch-delete',
|
||||||
|
'manual-score-delete',
|
||||||
|
]) &
|
||||||
|
table.localRevision.equals(1),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
expect(stopwatchRow.deletedAt?.toUtc(), deletedAt);
|
||||||
|
expect(stopwatchRow.syncState, 'deleted');
|
||||||
|
expect(manualRow.deletedAt?.toUtc(), deletedAt);
|
||||||
|
expect(manualRow.syncState, 'deleted');
|
||||||
|
expect(changes.map((change) => change.operation), [
|
||||||
|
'softDelete',
|
||||||
|
'softDelete',
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'active score states keep stored identity when upserted by position',
|
||||||
|
() async {
|
||||||
|
final now = DateTime.utc(2026, 7, 17, 12);
|
||||||
|
await activeRepository.save(
|
||||||
|
ActiveWorkoutSession(
|
||||||
|
metadata: _metadata('session-score-upsert', now),
|
||||||
|
status: ActiveWorkoutStatus.running,
|
||||||
|
startedAt: now,
|
||||||
|
lastPersistedAt: now,
|
||||||
|
elapsedActiveMs: 0,
|
||||||
|
currentProgramIndex: 0,
|
||||||
|
currentExerciseIndex: 0,
|
||||||
|
currentSetIndex: 0,
|
||||||
|
resolvedTemplateSnapshotJson: _resolvedSnapshot(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await activeRepository.saveScoreStopwatchState(
|
||||||
|
ActiveScoreStopwatchState(
|
||||||
|
metadata: _metadata('score-stopwatch-original', now),
|
||||||
|
activeWorkoutSessionId: 'session-score-upsert',
|
||||||
|
programIndex: 0,
|
||||||
|
exerciseIndex: 0,
|
||||||
|
setIndex: 0,
|
||||||
|
status: ActiveScoreStopwatchStatus.running,
|
||||||
|
startedAt: now,
|
||||||
|
accumulatedMs: 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await activeRepository.saveManualScoreState(
|
||||||
|
ActiveManualScoreState(
|
||||||
|
metadata: _metadata('manual-score-original', now),
|
||||||
|
activeWorkoutSessionId: 'session-score-upsert',
|
||||||
|
programIndex: 0,
|
||||||
|
exerciseIndex: 0,
|
||||||
|
setIndex: 0,
|
||||||
|
value: 1,
|
||||||
|
updatedAt: now,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final updatedAt = now.add(const Duration(minutes: 1));
|
||||||
|
await activeRepository.saveScoreStopwatchState(
|
||||||
|
ActiveScoreStopwatchState(
|
||||||
|
metadata: _metadata('score-stopwatch-new-id', updatedAt, 1),
|
||||||
|
activeWorkoutSessionId: 'session-score-upsert',
|
||||||
|
programIndex: 0,
|
||||||
|
exerciseIndex: 0,
|
||||||
|
setIndex: 0,
|
||||||
|
status: ActiveScoreStopwatchStatus.paused,
|
||||||
|
startedAt: updatedAt,
|
||||||
|
accumulatedMs: 30000,
|
||||||
|
stoppedAt: updatedAt,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await activeRepository.saveManualScoreState(
|
||||||
|
ActiveManualScoreState(
|
||||||
|
metadata: _metadata('manual-score-new-id', updatedAt, 1),
|
||||||
|
activeWorkoutSessionId: 'session-score-upsert',
|
||||||
|
programIndex: 0,
|
||||||
|
exerciseIndex: 0,
|
||||||
|
setIndex: 0,
|
||||||
|
value: 4,
|
||||||
|
updatedAt: updatedAt,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final stopwatchRow =
|
||||||
|
await (database.select(database.activeScoreStopwatchStates)
|
||||||
|
..where((table) => table.id.equals('score-stopwatch-original')))
|
||||||
|
.getSingle();
|
||||||
|
final manualRow =
|
||||||
|
await (database.select(database.activeManualScoreStates)
|
||||||
|
..where((table) => table.id.equals('manual-score-original')))
|
||||||
|
.getSingle();
|
||||||
|
final changes =
|
||||||
|
await (database.select(database.changeLogEntries)..where(
|
||||||
|
(table) =>
|
||||||
|
table.entityId.isIn([
|
||||||
|
'score-stopwatch-original',
|
||||||
|
'manual-score-original',
|
||||||
|
]) &
|
||||||
|
table.operation.equals('update'),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
expect(stopwatchRow.createdAt.toUtc(), now);
|
||||||
|
expect(stopwatchRow.status, 'paused');
|
||||||
|
expect(stopwatchRow.accumulatedMs, 30000);
|
||||||
|
expect(manualRow.createdAt.toUtc(), now);
|
||||||
|
expect(manualRow.value, 4);
|
||||||
|
expect(changes.map((change) => change.localRevision), [1, 1]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'closing a session stores autonomous history rows with set snapshots',
|
'closing a session stores autonomous history rows with set snapshots',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
@ -88,6 +89,60 @@ void main() {
|
|||||||
expect(repository.session?.status, ActiveWorkoutStatus.abandoned);
|
expect(repository.session?.status, ActiveWorkoutStatus.abandoned);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'une séance sauvegardée est reprise sans afficher un faux état terminé',
|
||||||
|
(tester) async {
|
||||||
|
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12, 0, 15));
|
||||||
|
final resumeGate = Completer<void>();
|
||||||
|
final repository = _FakeActiveSessionRepository()
|
||||||
|
..findByIdBlocker = resumeGate.future;
|
||||||
|
final session = ActiveWorkoutSession(
|
||||||
|
metadata: _metadata('session-1'),
|
||||||
|
sourceWorkoutTemplateId: 'template-1',
|
||||||
|
status: ActiveWorkoutStatus.savedExit,
|
||||||
|
startedAt: DateTime.utc(2026, 7, 17, 11, 59),
|
||||||
|
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
|
||||||
|
elapsedActiveMs: 30000,
|
||||||
|
currentProgramIndex: 0,
|
||||||
|
currentExerciseIndex: 0,
|
||||||
|
currentSetIndex: 0,
|
||||||
|
resolvedTemplateSnapshotJson: _sessionSnapshot(),
|
||||||
|
);
|
||||||
|
repository.session = session;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: WorkoutExecutionScreen(
|
||||||
|
initialSession: session,
|
||||||
|
activeUseCases: _activeUseCases(repository, clock),
|
||||||
|
closeUseCase: _closeUseCase(repository, clock),
|
||||||
|
historyUseCases: _historyUseCases(clock),
|
||||||
|
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Reprise de la séance...'), findsOneWidget);
|
||||||
|
expect(find.text('Pause'), findsNothing);
|
||||||
|
expect(find.text('Séance terminée'), findsNothing);
|
||||||
|
|
||||||
|
await tester.pump(const Duration(seconds: 2));
|
||||||
|
|
||||||
|
expect(find.text('Reprise de la séance...'), findsOneWidget);
|
||||||
|
expect(find.text('Séance terminée'), findsNothing);
|
||||||
|
expect(repository.session?.status, ActiveWorkoutStatus.savedExit);
|
||||||
|
|
||||||
|
resumeGate.complete();
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(repository.session?.status, ActiveWorkoutStatus.running);
|
||||||
|
expect(find.text('Squat'), findsOneWidget);
|
||||||
|
expect(find.text('Séance terminée'), findsNothing);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets('les répétitions sont initialisées avec la cible', (
|
testWidgets('les répétitions sont initialisées avec la cible', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@ -2678,6 +2733,7 @@ final class _FakeExercisePerformanceReferenceRepository
|
|||||||
|
|
||||||
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||||
ActiveWorkoutSession? session;
|
ActiveWorkoutSession? session;
|
||||||
|
Future<void>? findByIdBlocker;
|
||||||
final results = <ActiveSetResult>[];
|
final results = <ActiveSetResult>[];
|
||||||
final restStates = <ActiveRestState>[];
|
final restStates = <ActiveRestState>[];
|
||||||
final scoreStopwatchStates = <ActiveScoreStopwatchState>[];
|
final scoreStopwatchStates = <ActiveScoreStopwatchState>[];
|
||||||
@ -2688,6 +2744,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ActiveWorkoutSession?> findById(String id) async {
|
Future<ActiveWorkoutSession?> findById(String id) async {
|
||||||
|
await findByIdBlocker;
|
||||||
return session?.metadata.id == id ? session : null;
|
return session?.metadata.id == id ? session : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user