feat(server): implemente synchronisation et serveur avec fixtures (#187)

- Implémente la couche de synchronisation avec le serveur
- Ajoute les fixtures versionnées pour les tests
- Met à jour Drift database et repositories pour le support sync
- Améliore les tests de synchronisation
- Corrige et améliore le watch companion pour la collecte de métriques

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 23:07:31 +02:00
parent d1edb2397e
commit fe3608cc4a
31 changed files with 5056 additions and 394 deletions

View File

@ -9,6 +9,7 @@ part 'app_database.g.dart';
tables: [
ActiveExerciseStepProgressStates,
ActiveExerciseStepResults,
ActiveWorkoutTelemetryWindowStates,
ActiveRestStates,
ActiveManualScoreStates,
ActiveScoreStopwatchStates,
@ -51,7 +52,7 @@ final class AppDatabase extends _$AppDatabase {
}
@override
int get schemaVersion => 25;
int get schemaVersion => 26;
@override
MigrationStrategy get migration {
@ -141,6 +142,9 @@ final class AppDatabase extends _$AppDatabase {
if (from < 25) {
await _migrateToSchema25();
}
if (from < 26) {
await _migrateToSchema26(migrator);
}
await _createIndexes();
},
beforeOpen: (details) async {
@ -269,6 +273,11 @@ final class AppDatabase extends _$AppDatabase {
'CREATE INDEX IF NOT EXISTS idx_workout_telemetry_samples_session '
'ON workout_telemetry_samples (session_id, captured_at)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS '
'idx_active_workout_telemetry_window_states_session '
'ON active_workout_telemetry_window_states (session_id)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_workout_telemetry_aggregates_session '
'ON workout_telemetry_aggregates (session_id, scope)',
@ -472,6 +481,10 @@ extension on AppDatabase {
await migrator.createTable(pendingShareActions);
}
Future<void> _migrateToSchema26(Migrator migrator) async {
await migrator.createTable(activeWorkoutTelemetryWindowStates);
}
Future<void> _migrateToSchema13() async {
await customStatement('PRAGMA foreign_keys = OFF');
await customStatement('''

File diff suppressed because it is too large Load Diff

View File

@ -671,6 +671,7 @@ final class DriftLocalSyncChangeRepository
.into(database.workoutHistoryStepResults)
.insertOnConflictUpdate(_workoutHistoryStepResultCompanion(result));
}
await _replaceRemoteWorkoutTelemetry(history);
final activeResultIds = history.results
.map((result) => result.metadata.id)
@ -693,6 +694,44 @@ final class DriftLocalSyncChangeRepository
});
}
Future<void> _replaceRemoteWorkoutTelemetry(
domain.WorkoutHistory history,
) async {
final samples = _workoutTelemetrySamplesFromHistoryPayload(history);
if (samples.isEmpty) {
return;
}
final sessionIds = samples.map((sample) => sample.sessionId).toSet();
for (final sessionId in sessionIds) {
await (database.delete(
database.workoutTelemetrySamples,
)..where((table) => table.sessionId.equals(sessionId))).go();
await (database.delete(
database.workoutTelemetryAggregates,
)..where((table) => table.sessionId.equals(sessionId))).go();
}
await database.batch((batch) {
batch.insertAll(
database.workoutTelemetrySamples,
samples.map(_workoutTelemetrySampleCompanion).toList(),
);
});
for (final sessionId in sessionIds) {
final sessionSamples = samples
.where((sample) => sample.sessionId == sessionId)
.toList(growable: false);
final aggregates = _workoutTelemetryAggregatesFromSamples(sessionSamples);
if (aggregates.isNotEmpty) {
await database.batch((batch) {
batch.insertAll(
database.workoutTelemetryAggregates,
aggregates.map(_workoutTelemetryAggregateCompanion).toList(),
);
});
}
}
}
Future<void> _softDeleteRemoteWorkoutHistoryChildren({
required String tableName,
required String historyId,
@ -2150,6 +2189,34 @@ final class DriftWorkoutTelemetryRepository
return true;
}
@override
Future<domain.ActiveWorkoutTelemetryWindowState?> findWindowState(
String sessionId,
) async {
final row = await (database.select(
database.activeWorkoutTelemetryWindowStates,
)..where((table) => table.sessionId.equals(sessionId))).getSingleOrNull();
return row == null ? null : _activeWorkoutTelemetryWindowStateFromRow(row);
}
@override
Future<void> saveWindowState(
domain.ActiveWorkoutTelemetryWindowState state,
) async {
await database
.into(database.activeWorkoutTelemetryWindowStates)
.insertOnConflictUpdate(
_activeWorkoutTelemetryWindowStateCompanion(state),
);
}
@override
Future<void> deleteWindowState(String sessionId) async {
await (database.delete(
database.activeWorkoutTelemetryWindowStates,
)..where((table) => table.sessionId.equals(sessionId))).go();
}
@override
Future<List<domain.WorkoutTelemetrySample>> listSamples(
String sessionId,
@ -5323,6 +5390,28 @@ db.WorkoutTelemetrySamplesCompanion _workoutTelemetrySampleCompanion(
);
}
db.ActiveWorkoutTelemetryWindowStatesCompanion
_activeWorkoutTelemetryWindowStateCompanion(
domain.ActiveWorkoutTelemetryWindowState state,
) {
return db.ActiveWorkoutTelemetryWindowStatesCompanion.insert(
sessionId: state.sessionId,
windowStartedActiveMs: state.windowStartedActiveMs,
latestCapturedAt: state.latestCapturedAt.toUtc(),
programIndex: Value(state.programIndex),
exerciseIndex: Value(state.exerciseIndex),
setIndex: Value(state.setIndex),
passageIndex: Value(state.passageIndex),
stepIndex: Value(state.stepIndex),
programSnapshotId: Value(state.programSnapshotId),
exerciseSnapshotId: Value(state.exerciseSnapshotId),
stepSnapshotId: Value(state.stepSnapshotId),
heartRateBpm: Value(state.heartRateBpm),
distanceMeters: Value(state.distanceMeters),
caloriesKcal: Value(state.caloriesKcal),
);
}
db.WorkoutTelemetryAggregatesCompanion _workoutTelemetryAggregateCompanion(
domain.WorkoutTelemetryAggregate aggregate,
) {
@ -5486,6 +5575,28 @@ domain.WorkoutTelemetrySample _workoutTelemetrySampleFromRow(
);
}
domain.ActiveWorkoutTelemetryWindowState
_activeWorkoutTelemetryWindowStateFromRow(
db.ActiveWorkoutTelemetryWindowState row,
) {
return domain.ActiveWorkoutTelemetryWindowState(
sessionId: row.sessionId,
windowStartedActiveMs: row.windowStartedActiveMs,
latestCapturedAt: _utc(row.latestCapturedAt),
programIndex: row.programIndex,
exerciseIndex: row.exerciseIndex,
setIndex: row.setIndex,
passageIndex: row.passageIndex,
stepIndex: row.stepIndex,
programSnapshotId: row.programSnapshotId,
exerciseSnapshotId: row.exerciseSnapshotId,
stepSnapshotId: row.stepSnapshotId,
heartRateBpm: row.heartRateBpm,
distanceMeters: row.distanceMeters,
caloriesKcal: row.caloriesKcal,
);
}
bool _telemetrySampleMatchesScope(
domain.WorkoutTelemetrySample sample, {
required domain.WorkoutTelemetryAggregateScope scope,
@ -5806,6 +5917,9 @@ Map<String, Object?> _localWorkoutHistoryPayload(
'stepResults': history.stepResults
.map(_workoutHistoryStepResultPayload)
.toList(),
'telemetrySamples': _workoutTelemetrySamplesFromHistoryPayload(
history,
).map(_workoutTelemetrySamplePayload).toList(),
};
Map<String, Object?> _workoutHistorySetResultPayload(
@ -5875,6 +5989,248 @@ Map<String, Object?> _workoutHistoryStepResultPayload(
'sourceExerciseIdSnapshot': result.sourceExerciseIdSnapshot,
};
Map<String, Object?> _workoutTelemetrySamplePayload(
domain.WorkoutTelemetrySample sample,
) => {
'id': sample.id,
'sessionId': sample.sessionId,
'capturedAt': sample.capturedAt.toUtc().toIso8601String(),
'programIndex': sample.programIndex,
'exerciseIndex': sample.exerciseIndex,
'setIndex': sample.setIndex,
'passageIndex': sample.passageIndex,
'stepIndex': sample.stepIndex,
'programSnapshotId': sample.programSnapshotId,
'exerciseSnapshotId': sample.exerciseSnapshotId,
'stepSnapshotId': sample.stepSnapshotId,
'heartRateBpm': sample.heartRateBpm,
'distanceMeters': sample.distanceMeters,
'caloriesKcal': sample.caloriesKcal,
};
List<domain.WorkoutTelemetrySample> _workoutTelemetrySamplesFromHistoryPayload(
domain.WorkoutHistory history,
) {
final decoded = jsonDecode(history.historySnapshotJson);
final snapshot = decoded is Map
? Map<String, Object?>.from(decoded)
: const <String, Object?>{};
final rawSamples = snapshot['telemetrySamples'];
if (rawSamples is! List) {
return const [];
}
return _workoutTelemetrySamplesFromPayload(rawSamples);
}
List<domain.WorkoutTelemetrySample> _workoutTelemetrySamplesFromPayload(
Object? value,
) {
if (value is! List) {
return const [];
}
final output = <domain.WorkoutTelemetrySample>[];
for (final rawSample in value) {
if (rawSample is! Map) {
continue;
}
final map = Map<String, Object?>.from(rawSample);
final id = map['id'] as String?;
final sessionId = map['sessionId'] as String?;
final capturedAt = _dateTimeFromPayload(map['capturedAt']);
if (id == null || sessionId == null || capturedAt == null) {
continue;
}
output.add(
domain.WorkoutTelemetrySample(
id: id,
sessionId: sessionId,
capturedAt: capturedAt,
programIndex: map['programIndex'] as int?,
exerciseIndex: map['exerciseIndex'] as int?,
setIndex: map['setIndex'] as int?,
passageIndex: map['passageIndex'] as int?,
stepIndex: map['stepIndex'] as int?,
programSnapshotId: map['programSnapshotId'] as String?,
exerciseSnapshotId: map['exerciseSnapshotId'] as String?,
stepSnapshotId: map['stepSnapshotId'] as String?,
heartRateBpm: map['heartRateBpm'] as int?,
distanceMeters: (map['distanceMeters'] as num?)?.toDouble(),
caloriesKcal: (map['caloriesKcal'] as num?)?.toDouble(),
),
);
}
return output;
}
List<domain.WorkoutTelemetryAggregate> _workoutTelemetryAggregatesFromSamples(
List<domain.WorkoutTelemetrySample> samples,
) {
final builders = <_TelemetryAggregateKey, _TelemetryAggregateBuilder>{};
for (final sample in samples) {
for (final key in _telemetryAggregateKeys(sample)) {
builders
.putIfAbsent(key, () => _TelemetryAggregateBuilder(key))
.add(sample);
}
}
return [for (final builder in builders.values) builder.build()];
}
List<_TelemetryAggregateKey> _telemetryAggregateKeys(
domain.WorkoutTelemetrySample sample,
) {
final keys = [
_TelemetryAggregateKey(
sessionId: sample.sessionId,
scope: domain.WorkoutTelemetryAggregateScope.session,
),
];
if (sample.programIndex != null && sample.exerciseIndex != null) {
keys.add(
_TelemetryAggregateKey(
sessionId: sample.sessionId,
scope: domain.WorkoutTelemetryAggregateScope.exercise,
programIndex: sample.programIndex,
exerciseIndex: sample.exerciseIndex,
),
);
}
if (sample.programIndex != null &&
sample.exerciseIndex != null &&
sample.setIndex != null) {
keys.add(
_TelemetryAggregateKey(
sessionId: sample.sessionId,
scope: domain.WorkoutTelemetryAggregateScope.set,
programIndex: sample.programIndex,
exerciseIndex: sample.exerciseIndex,
setIndex: sample.setIndex,
),
);
}
if (sample.programIndex != null &&
sample.exerciseIndex != null &&
sample.setIndex != null &&
sample.stepIndex != null) {
keys.add(
_TelemetryAggregateKey(
sessionId: sample.sessionId,
scope: domain.WorkoutTelemetryAggregateScope.step,
programIndex: sample.programIndex,
exerciseIndex: sample.exerciseIndex,
setIndex: sample.setIndex,
passageIndex: sample.passageIndex,
stepIndex: sample.stepIndex,
),
);
}
return keys;
}
final class _TelemetryAggregateKey {
const _TelemetryAggregateKey({
required this.sessionId,
required this.scope,
this.programIndex,
this.exerciseIndex,
this.setIndex,
this.passageIndex,
this.stepIndex,
});
final String sessionId;
final domain.WorkoutTelemetryAggregateScope scope;
final int? programIndex;
final int? exerciseIndex;
final int? setIndex;
final int? passageIndex;
final int? stepIndex;
@override
bool operator ==(Object other) {
return identical(this, other) ||
other is _TelemetryAggregateKey &&
sessionId == other.sessionId &&
scope == other.scope &&
programIndex == other.programIndex &&
exerciseIndex == other.exerciseIndex &&
setIndex == other.setIndex &&
passageIndex == other.passageIndex &&
stepIndex == other.stepIndex;
}
@override
int get hashCode => Object.hash(
sessionId,
scope,
programIndex,
exerciseIndex,
setIndex,
passageIndex,
stepIndex,
);
}
final class _TelemetryAggregateBuilder {
_TelemetryAggregateBuilder(this.key);
final _TelemetryAggregateKey key;
var sampleCount = 0;
var heartRateCount = 0;
var heartRateSum = 0.0;
int? minHeartRateBpm;
int? maxHeartRateBpm;
double? maxDistanceMeters;
double? maxCaloriesKcal;
void add(domain.WorkoutTelemetrySample sample) {
sampleCount += 1;
final heartRate = sample.heartRateBpm;
if (heartRate != null) {
heartRateCount += 1;
heartRateSum += heartRate;
minHeartRateBpm = minHeartRateBpm == null
? heartRate
: (heartRate < minHeartRateBpm! ? heartRate : minHeartRateBpm);
maxHeartRateBpm = maxHeartRateBpm == null
? heartRate
: (heartRate > maxHeartRateBpm! ? heartRate : maxHeartRateBpm);
}
final distance = sample.distanceMeters;
if (distance != null) {
maxDistanceMeters = maxDistanceMeters == null
? distance
: (distance > maxDistanceMeters! ? distance : maxDistanceMeters);
}
final calories = sample.caloriesKcal;
if (calories != null) {
maxCaloriesKcal = maxCaloriesKcal == null
? calories
: (calories > maxCaloriesKcal! ? calories : maxCaloriesKcal);
}
}
domain.WorkoutTelemetryAggregate build() {
return domain.WorkoutTelemetryAggregate(
sessionId: key.sessionId,
scope: key.scope,
programIndex: key.programIndex,
exerciseIndex: key.exerciseIndex,
setIndex: key.setIndex,
passageIndex: key.passageIndex,
stepIndex: key.stepIndex,
sampleCount: sampleCount,
minHeartRateBpm: minHeartRateBpm,
averageHeartRateBpm: heartRateCount == 0
? null
: heartRateSum / heartRateCount,
maxHeartRateBpm: maxHeartRateBpm,
totalDistanceMeters: maxDistanceMeters,
totalCaloriesKcal: maxCaloriesKcal,
);
}
}
LocalBackupResource _backupResource(
domain.EntityMetadata metadata,
Map<String, Object?> payload,
@ -5937,6 +6293,7 @@ domain.WorkoutHistory _workoutHistoryFromLocalBackupPayload(
) {
final payload = item.payload;
final metadata = _metadataFromPayload(item);
final historySnapshotJson = _historySnapshotJsonWithTelemetryPayload(payload);
return domain.WorkoutHistory(
metadata: metadata,
sourceWorkoutTemplateId: payload['sourceWorkoutTemplateId'] as String?,
@ -5948,8 +6305,7 @@ domain.WorkoutHistory _workoutHistoryFromLocalBackupPayload(
endedAt: _dateTimeFromPayload(payload['endedAt']) ?? item.clientUpdatedAt,
totalActiveMs: payload['totalActiveMs'] as int? ?? 0,
completed: payload['completed'] as bool? ?? false,
historySnapshotJson:
payload['historySnapshotJson'] as String? ?? '{"programs":[]}',
historySnapshotJson: historySnapshotJson,
minHeartRateBpm: payload['minHeartRateBpm'] as int?,
averageHeartRateBpm: (payload['averageHeartRateBpm'] as num?)?.toDouble(),
maxHeartRateBpm: payload['maxHeartRateBpm'] as int?,
@ -5963,6 +6319,20 @@ domain.WorkoutHistory _workoutHistoryFromLocalBackupPayload(
);
}
String _historySnapshotJsonWithTelemetryPayload(Map<String, Object?> payload) {
final rawSnapshot = payload['historySnapshotJson'] as String?;
final rawSamples = payload['telemetrySamples'];
if (rawSamples is! List) {
return rawSnapshot ?? '{"programs":[]}';
}
final decoded = rawSnapshot == null ? null : jsonDecode(rawSnapshot);
final snapshot = decoded is Map
? Map<String, Object?>.from(decoded)
: <String, Object?>{'programs': const []};
snapshot['telemetrySamples'] = rawSamples;
return jsonEncode(snapshot);
}
domain.Exercise _exerciseFromPayload(RemoteSyncedItem item) {
final payload = item.payload;
return domain.Exercise(

View File

@ -804,6 +804,48 @@ class WorkoutTelemetrySamples extends Table {
];
}
class ActiveWorkoutTelemetryWindowStates extends Table {
@override
String get tableName => 'active_workout_telemetry_window_states';
TextColumn get sessionId => text().references(
ActiveWorkoutSessions,
#id,
onDelete: KeyAction.cascade,
)();
IntColumn get windowStartedActiveMs => integer()();
DateTimeColumn get latestCapturedAt => dateTime()();
IntColumn get programIndex => integer().nullable()();
IntColumn get exerciseIndex => integer().nullable()();
IntColumn get setIndex => integer().nullable()();
IntColumn get passageIndex => integer().nullable()();
IntColumn get stepIndex => integer().nullable()();
TextColumn get programSnapshotId => text().nullable()();
TextColumn get exerciseSnapshotId => text().nullable()();
TextColumn get stepSnapshotId => text().nullable()();
IntColumn get heartRateBpm => integer().nullable()();
RealColumn get distanceMeters => real().nullable()();
RealColumn get caloriesKcal => real().nullable()();
@override
Set<Column> get primaryKey => {sessionId};
@override
List<String> get customConstraints => [
'CHECK (window_started_active_ms >= 0)',
'CHECK (program_index IS NULL OR program_index >= 0)',
'CHECK (exercise_index IS NULL OR exercise_index >= 0)',
'CHECK (set_index IS NULL OR set_index >= 0)',
'CHECK (passage_index IS NULL OR passage_index >= 0)',
'CHECK (step_index IS NULL OR step_index >= 0)',
'CHECK (heart_rate_bpm IS NULL OR heart_rate_bpm > 0)',
'CHECK (distance_meters IS NULL OR distance_meters >= 0)',
'CHECK (calories_kcal IS NULL OR calories_kcal >= 0)',
'CHECK (heart_rate_bpm IS NOT NULL OR distance_meters IS NOT NULL OR '
'calories_kcal IS NOT NULL)',
];
}
class WorkoutTelemetryAggregates extends Table {
@override
String get tableName => 'workout_telemetry_aggregates';