feat(core): consolide modele donnees stats par scope, types metier d'exercice et correctif contrainte active_exercise_step_progress_states

Couche domaine/application/infra locale partagee par #179 (stockage stats par etape/serie/exercice/seance), #182 (correctif contrainte UNIQUE active_exercise_step_progress_states) et #183 (modele/persistance types metier d'exercice).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 14:55:52 +02:00
parent 7b5801f959
commit fdaa518fa9
7 changed files with 1036 additions and 80 deletions

View File

@ -112,7 +112,8 @@ final class DriftExerciseRepository implements ExerciseRepository {
) async {
final row = await database
.customSelect(
'SELECT category, is_example FROM exercises WHERE id = ? LIMIT 1',
'SELECT category, is_example, business_types_json '
'FROM exercises WHERE id = ? LIMIT 1',
variables: [Variable<String>(exerciseId)],
)
.getSingleOrNull();
@ -123,6 +124,9 @@ final class DriftExerciseRepository implements ExerciseRepository {
category: _exerciseCategoryFromDb(
row.readNullable<String>('category') ?? 'uncategorized',
),
businessTypes: _decodeBusinessExerciseTypesJson(
row.readNullable<String>('business_types_json'),
),
isExample: (row.readNullable<int>('is_example') ?? 0) == 1,
);
}
@ -1484,17 +1488,39 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
Future<void> saveExerciseStepProgressState(
domain.ActiveExerciseStepProgressState state,
) async {
await _upsertWithChangeLog(
database: database,
tableName: 'active_exercise_step_progress_states',
entityType: 'ActiveExerciseStepProgressState',
metadata: state.metadata,
write: () => database
.into(database.activeExerciseStepProgressStates)
.insertOnConflictUpdate(
_activeExerciseStepProgressStateCompanion(state),
),
);
await database.transaction(() async {
final existing = await _findActiveExerciseStepProgressStateRowByPosition(
database,
state.activeWorkoutSessionId,
state.programIndex,
state.exerciseIndex,
state.setIndex,
);
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 _upsertActiveExerciseStepProgressState(database, effectiveState);
await _writeChangeLog(
database: database,
entityType: 'ActiveExerciseStepProgressState',
entityId: effectiveState.metadata.id,
operation: operation,
localRevision: effectiveState.metadata.localRevision,
originDeviceId: effectiveState.metadata.originDeviceId,
createdAt: effectiveState.metadata.updatedAt,
);
});
}
@override
@ -2049,16 +2075,13 @@ final class DriftWorkoutTelemetryRepository
final existing = await (database.select(
database.workoutTelemetrySamples,
)..where((table) => table.id.equals(sample.id))).getSingleOrNull();
if (existing != null) {
if (existing != null && !sample.capturedAt.isAfter(existing.capturedAt)) {
return false;
}
final inserted = await database
await database
.into(database.workoutTelemetrySamples)
.insert(
_workoutTelemetrySampleCompanion(sample),
mode: InsertMode.insertOrIgnore,
);
return inserted > 0;
.insertOnConflictUpdate(_workoutTelemetrySampleCompanion(sample));
return true;
}
@override
@ -2073,6 +2096,37 @@ final class DriftWorkoutTelemetryRepository
return rows.map(_workoutTelemetrySampleFromRow).toList();
}
@override
Future<List<domain.WorkoutTelemetrySample>> listSamplesForScope({
required String sessionId,
required domain.WorkoutTelemetryAggregateScope scope,
int? programIndex,
int? exerciseIndex,
int? setIndex,
int? passageIndex,
int? stepIndex,
}) async {
final rows =
await (database.select(database.workoutTelemetrySamples)
..where((table) => table.sessionId.equals(sessionId))
..orderBy([(table) => OrderingTerm.asc(table.capturedAt)]))
.get();
return rows
.map(_workoutTelemetrySampleFromRow)
.where(
(sample) => _telemetrySampleMatchesScope(
sample,
scope: scope,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
passageIndex: passageIndex,
stepIndex: stepIndex,
),
)
.toList(growable: false);
}
@override
Future<void> replaceAggregatesForSession({
required String sessionId,
@ -3382,10 +3436,12 @@ String _exerciseImageId(String exerciseId, String mediaId) {
final class _ExerciseStarterMetadata {
const _ExerciseStarterMetadata({
this.category = domain.ExerciseCategory.uncategorized,
this.businessTypes = const [],
this.isExample = false,
});
final domain.ExerciseCategory category;
final List<domain.BusinessExerciseType> businessTypes;
final bool isExample;
}
@ -3394,10 +3450,12 @@ Future<void> _writeExerciseStarterMetadata(
domain.Exercise exercise,
) async {
await database.customStatement(
'UPDATE exercises SET category = ?, is_example = ? WHERE id = ?',
'UPDATE exercises SET category = ?, is_example = ?, '
'business_types_json = ? WHERE id = ?',
[
_exerciseCategoryToDb(exercise.category),
exercise.isExample ? 1 : 0,
_encodeBusinessExerciseTypes(exercise.businessTypes),
exercise.metadata.id,
],
);
@ -3764,6 +3822,7 @@ domain.Exercise _exerciseFromRow(
defaultTargetScoreTimeMs: row.defaultTargetScoreTimeMs,
autoStartNextTimedStep: row.autoStartNextTimedStep,
category: starterMetadata.category,
businessTypes: starterMetadata.businessTypes,
isExample: starterMetadata.isExample,
tags: _decodeTags(row.tagsJson),
steps: steps,
@ -4374,6 +4433,24 @@ Future<db.ActiveManualScoreState?> _findActiveManualScoreStateRowByPosition(
.getSingleOrNull();
}
Future<db.ActiveExerciseStepProgressState?>
_findActiveExerciseStepProgressStateRowByPosition(
db.AppDatabase database,
String sessionId,
int programIndex,
int exerciseIndex,
int setIndex,
) {
return (database.select(database.activeExerciseStepProgressStates)..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,
@ -4517,6 +4594,71 @@ Future<void> _upsertActiveManualScoreState(
);
}
Future<void> _upsertActiveExerciseStepProgressState(
db.AppDatabase database,
domain.ActiveExerciseStepProgressState state,
) async {
final metadata = state.metadata;
await database.customInsert(
'INSERT INTO active_exercise_step_progress_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, current_passage_index, '
'current_step_index, current_step_snapshot_id, status, started_at, '
'accumulated_ms, last_transition_at'
') VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) '
'ON CONFLICT(active_workout_session_id, program_index, exercise_index, '
'set_index) DO UPDATE SET '
'id = active_exercise_step_progress_states.id, '
'created_at = active_exercise_step_progress_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, '
'current_passage_index = excluded.current_passage_index, '
'current_step_index = excluded.current_step_index, '
'current_step_snapshot_id = excluded.current_step_snapshot_id, '
'status = excluded.status, '
'started_at = excluded.started_at, '
'accumulated_ms = excluded.accumulated_ms, '
'last_transition_at = excluded.last_transition_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<int>(state.currentPassageIndex),
Variable<int>(state.currentStepIndex),
Variable<String>(state.currentStepSnapshotId),
Variable<String>(_exerciseStepProgressStatusToDb(state.status)),
Variable<DateTime>(_utcOrNull(state.startedAt)),
Variable<int>(state.accumulatedMs),
Variable<DateTime>(state.lastTransitionAt.toUtc()),
],
);
}
Future<void> _upsertActiveSetTimerState(
db.AppDatabase database,
domain.ActiveSetTimerState state,
@ -4729,37 +4871,6 @@ domain.ActiveScoreStopwatchState _activeScoreStopwatchStateFromRow(
);
}
db.ActiveExerciseStepProgressStatesCompanion
_activeExerciseStepProgressStateCompanion(
domain.ActiveExerciseStepProgressState state,
) {
final values = _metadataValues(state.metadata);
return db.ActiveExerciseStepProgressStatesCompanion(
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),
currentPassageIndex: Value(state.currentPassageIndex),
currentStepIndex: Value(state.currentStepIndex),
currentStepSnapshotId: Value(state.currentStepSnapshotId),
status: Value(_exerciseStepProgressStatusToDb(state.status)),
startedAt: Value<DateTime?>(_utcOrNull(state.startedAt)),
accumulatedMs: Value(state.accumulatedMs),
lastTransitionAt: Value(state.lastTransitionAt.toUtc()),
);
}
domain.ActiveExerciseStepProgressState _activeExerciseStepProgressStateFromRow(
db.ActiveExerciseStepProgressState row,
) {
@ -5080,6 +5191,33 @@ domain.WorkoutTelemetrySample _workoutTelemetrySampleFromRow(
);
}
bool _telemetrySampleMatchesScope(
domain.WorkoutTelemetrySample sample, {
required domain.WorkoutTelemetryAggregateScope scope,
int? programIndex,
int? exerciseIndex,
int? setIndex,
int? passageIndex,
int? stepIndex,
}) {
return switch (scope) {
domain.WorkoutTelemetryAggregateScope.session => true,
domain.WorkoutTelemetryAggregateScope.exercise =>
sample.programIndex == programIndex &&
sample.exerciseIndex == exerciseIndex,
domain.WorkoutTelemetryAggregateScope.set =>
sample.programIndex == programIndex &&
sample.exerciseIndex == exerciseIndex &&
sample.setIndex == setIndex,
domain.WorkoutTelemetryAggregateScope.step =>
sample.programIndex == programIndex &&
sample.exerciseIndex == exerciseIndex &&
sample.setIndex == setIndex &&
(passageIndex == null || sample.passageIndex == passageIndex) &&
sample.stepIndex == stepIndex,
};
}
domain.WorkoutTelemetryAggregate _workoutTelemetryAggregateFromRow(
db.WorkoutTelemetryAggregate row,
) {
@ -5270,6 +5408,12 @@ Map<String, Object?> _exercisePayload(domain.Exercise exercise) => {
'defaultTargetScoreTimeMs': exercise.defaultTargetScoreTimeMs,
'autoStartNextTimedStep': exercise.autoStartNextTimedStep,
'category': exercise.category.name,
'businessTypes': exercise.businessTypes
.map(_businessExerciseTypeToDb)
.toList(),
'effectiveBusinessTypes': exercise.effectiveBusinessTypes
.map(_businessExerciseTypeToDb)
.toList(),
'isExample': exercise.isExample,
'tags': exercise.tags,
'steps': exercise.steps.map((step) => step.toSnapshotJson()).toList(),
@ -5549,6 +5693,7 @@ domain.Exercise _exerciseFromPayload(RemoteSyncedItem item) {
category: _exerciseCategoryFromDb(
payload['category'] as String? ?? 'uncategorized',
),
businessTypes: _businessExerciseTypesFromPayload(payload['businessTypes']),
isExample: payload['isExample'] as bool? ?? false,
tags: _stringListFromPayload(payload['tags']),
steps: _stepsFromPayload(payload['steps']),
@ -5920,6 +6065,40 @@ List<String> _decodeTags(String? encoded) {
}
}
String _encodeBusinessExerciseTypes(List<domain.BusinessExerciseType> types) {
return jsonEncode(types.map(_businessExerciseTypeToDb).toList());
}
List<domain.BusinessExerciseType> _decodeBusinessExerciseTypesJson(
String? encoded,
) {
if (encoded == null || encoded.trim().isEmpty) {
return const [];
}
try {
final decoded = jsonDecode(encoded);
return _businessExerciseTypesFromPayload(decoded);
} on Object {
return const [];
}
}
List<domain.BusinessExerciseType> _businessExerciseTypesFromPayload(
Object? value,
) {
if (value is! List) {
return const [];
}
final output = <domain.BusinessExerciseType>[];
for (final raw in value.whereType<String>()) {
final type = _businessExerciseTypeFromDbOrNull(raw);
if (type != null && !output.contains(type)) {
output.add(type);
}
}
return List.unmodifiable(output);
}
List<domain.ExerciseStep> _stepsFromPayload(Object? value) {
if (value is! List) {
return const [];
@ -6158,6 +6337,33 @@ domain.ExerciseCategory _exerciseCategoryFromDb(String value) =>
_ => throw domain.DomainException('Unknown exercise category: $value'),
};
String _businessExerciseTypeToDb(domain.BusinessExerciseType type) =>
switch (type) {
domain.BusinessExerciseType.shoot => 'shoot',
domain.BusinessExerciseType.freeThrows => 'freeThrows',
domain.BusinessExerciseType.dribble => 'dribble',
domain.BusinessExerciseType.finishing => 'finishing',
domain.BusinessExerciseType.conditioning => 'conditioning',
domain.BusinessExerciseType.defense => 'defense',
domain.BusinessExerciseType.mobility => 'mobility',
domain.BusinessExerciseType.highIntensity => 'highIntensity',
domain.BusinessExerciseType.recovery => 'recovery',
};
domain.BusinessExerciseType? _businessExerciseTypeFromDbOrNull(String value) =>
switch (value) {
'shoot' => domain.BusinessExerciseType.shoot,
'freeThrows' => domain.BusinessExerciseType.freeThrows,
'dribble' => domain.BusinessExerciseType.dribble,
'finishing' => domain.BusinessExerciseType.finishing,
'conditioning' => domain.BusinessExerciseType.conditioning,
'defense' => domain.BusinessExerciseType.defense,
'mobility' => domain.BusinessExerciseType.mobility,
'highIntensity' => domain.BusinessExerciseType.highIntensity,
'recovery' => domain.BusinessExerciseType.recovery,
_ => null,
};
String _setResultStatusToDb(domain.SetResultStatus status) => switch (status) {
domain.SetResultStatus.completed => 'completed',
domain.SetResultStatus.skipped => 'skipped',