feat(watch): clôture lot #91 - fréquence cardiaque live, notifications de séance et finitions montre

Consolide le lot applicatif watch companion validé :
- télémétrie fréquence cardiaque live remontée montre -> téléphone
  (collecteur watch, adapter Wear Data Layer, persistance Drift,
  propagation aux écrans historique/programme/profil/exécution)
- notifications de séance en arrière-plan côté téléphone (service
  foreground de statut + passerelle applicative)
- finitions montre : chrono d'étape, score d'étape, retrait du bouton
  "lancer une séance", thème, icônes et polices watch_app

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 05:56:12 +02:00
parent c65a5a76a9
commit 65d43b9768
80 changed files with 12292 additions and 892 deletions

View File

@ -10,6 +10,7 @@ part 'app_database.g.dart';
ActiveExerciseStepProgressStates,
ActiveExerciseStepResults,
ActiveRestStates,
ActiveManualScoreStates,
ActiveScoreStopwatchStates,
ActiveSetTimerStates,
ActiveSetResults,
@ -48,7 +49,7 @@ final class AppDatabase extends _$AppDatabase {
}
@override
int get schemaVersion => 19;
int get schemaVersion => 22;
@override
MigrationStrategy get migration {
@ -119,6 +120,15 @@ final class AppDatabase extends _$AppDatabase {
if (from < 19) {
await _migrateToSchema19();
}
if (from < 20) {
await _migrateToSchema20(migrator);
}
if (from < 21) {
await _migrateToSchema21();
}
if (from < 22) {
await _migrateToSchema22();
}
await _createIndexes();
},
beforeOpen: (details) async {
@ -199,6 +209,10 @@ final class AppDatabase extends _$AppDatabase {
'CREATE INDEX IF NOT EXISTS idx_active_set_results_session_id '
'ON active_set_results (active_workout_session_id)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_active_manual_score_states_session_id '
'ON active_manual_score_states (active_workout_session_id)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_active_score_stopwatch_states_session_id '
'ON active_score_stopwatch_states (active_workout_session_id)',
@ -288,6 +302,7 @@ final class AppDatabase extends _$AppDatabase {
const _syncableTableNames = [
'active_exercise_step_progress_states',
'active_exercise_step_results',
'active_manual_score_states',
'active_rest_states',
'active_score_stopwatch_states',
'active_set_timer_states',
@ -770,6 +785,37 @@ CREATE TABLE IF NOT EXISTS active_set_timer_states (
);
}
Future<void> _migrateToSchema20(Migrator migrator) async {
await migrator.createTable(activeManualScoreStates);
}
Future<void> _migrateToSchema21() async {
await _addColumnIfMissing(
tableName: 'workout_history',
columnName: 'average_heart_rate_bpm',
definition:
'average_heart_rate_bpm REAL CHECK '
'(average_heart_rate_bpm IS NULL OR average_heart_rate_bpm > 0)',
);
await _addColumnIfMissing(
tableName: 'workout_history',
columnName: 'max_heart_rate_bpm',
definition:
'max_heart_rate_bpm INTEGER CHECK '
'(max_heart_rate_bpm IS NULL OR max_heart_rate_bpm > 0)',
);
}
Future<void> _migrateToSchema22() async {
await _addColumnIfMissing(
tableName: 'exercise_steps',
columnName: 'linked_to_series_score',
definition:
'linked_to_series_score INTEGER NOT NULL DEFAULT 0 '
'CHECK (linked_to_series_score IN (0, 1))',
);
}
Future<void> _backfillWorkoutHistorySetSourceExerciseIds() async {
await customStatement(r'''
UPDATE workout_history_set_results AS result

File diff suppressed because it is too large Load Diff

View File

@ -1192,6 +1192,19 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
);
}
@override
Future<void> saveManualScoreState(domain.ActiveManualScoreState state) async {
await _upsertWithChangeLog(
database: database,
tableName: 'active_manual_score_states',
entityType: 'ActiveManualScoreState',
metadata: state.metadata,
write: () => database
.into(database.activeManualScoreStates)
.insertOnConflictUpdate(_activeManualScoreStateCompanion(state)),
);
}
@override
Future<void> saveExerciseStepProgressState(
domain.ActiveExerciseStepProgressState state,
@ -1260,6 +1273,42 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
);
}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
final row =
await (database.select(database.activeManualScoreStates)..where(
(table) =>
table.activeWorkoutSessionId.equals(sessionId) &
table.programIndex.equals(programIndex) &
table.exerciseIndex.equals(exerciseIndex) &
table.setIndex.equals(setIndex) &
table.deletedAt.isNull(),
))
.getSingleOrNull();
if (row == null) {
return;
}
final revision = row.localRevision + 1;
await (database.delete(
database.activeManualScoreStates,
)..where((table) => table.id.equals(row.id))).go();
await _writeChangeLog(
database: database,
entityType: 'ActiveManualScoreState',
entityId: row.id,
operation: 'delete',
localRevision: revision,
originDeviceId: row.originDeviceId,
createdAt: deletedAt,
);
}
@override
Future<domain.ActiveScoreStopwatchState?> findScoreStopwatchState({
required String sessionId,
@ -1280,6 +1329,26 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
return row == null ? null : _activeScoreStopwatchStateFromRow(row);
}
@override
Future<domain.ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final row =
await (database.select(database.activeManualScoreStates)..where(
(table) =>
table.activeWorkoutSessionId.equals(sessionId) &
table.programIndex.equals(programIndex) &
table.exerciseIndex.equals(exerciseIndex) &
table.setIndex.equals(setIndex) &
table.deletedAt.isNull(),
))
.getSingleOrNull();
return row == null ? null : _activeManualScoreStateFromRow(row);
}
@override
Future<domain.ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -1432,6 +1501,26 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
.get();
return rows.map(_activeScoreStopwatchStateFromRow).toList();
}
@override
Future<List<domain.ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
final rows =
await (database.select(database.activeManualScoreStates)
..where(
(table) =>
table.activeWorkoutSessionId.equals(sessionId) &
table.deletedAt.isNull(),
)
..orderBy([
(table) => OrderingTerm.asc(table.programIndex),
(table) => OrderingTerm.asc(table.exerciseIndex),
(table) => OrderingTerm.asc(table.setIndex),
]))
.get();
return rows.map(_activeManualScoreStateFromRow).toList();
}
}
final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
@ -1522,6 +1611,51 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
});
}
@override
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {
if (averageHeartRateBpm <= 0 || maxHeartRateBpm <= 0) {
return;
}
final row =
await (database.select(database.workoutHistories)..where(
(table) =>
table.id.equals(historyId) &
table.deletedAt.isNull() &
table.averageHeartRateBpm.isNull() &
table.maxHeartRateBpm.isNull(),
))
.getSingleOrNull();
if (row == null) {
return;
}
final revision = row.localRevision + 1;
await (database.update(
database.workoutHistories,
)..where((table) => table.id.equals(historyId))).write(
db.WorkoutHistoriesCompanion(
updatedAt: Value(patchedAt.toUtc()),
syncState: const Value('dirty'),
localRevision: Value(revision),
averageHeartRateBpm: Value(averageHeartRateBpm),
maxHeartRateBpm: Value(maxHeartRateBpm),
),
);
await _writeChangeLog(
database: database,
entityType: 'WorkoutHistory',
entityId: historyId,
operation: 'update',
localRevision: revision,
originDeviceId: row.originDeviceId,
createdAt: patchedAt,
);
}
@override
Future<void> saveSetResult(domain.WorkoutHistorySetResult result) async {
await _upsertWithChangeLog(
@ -2777,6 +2911,7 @@ Future<void> _replaceExerciseSteps(
scoreUnit: Value(step.scoreUnit),
defaultTargetScore: Value(step.defaultTargetScore),
defaultTargetScoreTimeMs: Value(step.defaultTargetScoreTimeMs),
linkedToSeriesScore: Value(step.linkedToSeriesScore),
),
),
);
@ -3252,6 +3387,7 @@ domain.ExerciseStep _exerciseStepFromRow(db.ExerciseStep row) {
scoreUnit: row.scoreUnit,
defaultTargetScore: row.defaultTargetScore,
defaultTargetScoreTimeMs: row.defaultTargetScoreTimeMs,
linkedToSeriesScore: row.linkedToSeriesScore,
);
}
@ -4013,6 +4149,45 @@ db.ActiveScoreStopwatchStatesCompanion _activeScoreStopwatchStateCompanion(
);
}
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(
db.ActiveManualScoreState row,
) {
return domain.ActiveManualScoreState(
metadata: _metadataFromRow(row),
activeWorkoutSessionId: row.activeWorkoutSessionId,
programIndex: row.programIndex,
exerciseIndex: row.exerciseIndex,
setIndex: row.setIndex,
value: row.value,
updatedAt: _utc(row.scoreUpdatedAt),
);
}
domain.ActiveScoreStopwatchState _activeScoreStopwatchStateFromRow(
db.ActiveScoreStopwatchState row,
) {
@ -4188,6 +4363,8 @@ db.WorkoutHistoriesCompanion _workoutHistoryCompanion(
totalActiveMs: Value(history.totalActiveMs),
completed: Value(history.completed),
historySnapshotJson: Value(history.historySnapshotJson),
averageHeartRateBpm: Value(history.averageHeartRateBpm),
maxHeartRateBpm: Value(history.maxHeartRateBpm),
);
}
@ -4303,6 +4480,8 @@ domain.WorkoutHistory _workoutHistoryFromRow(
totalActiveMs: row.totalActiveMs,
completed: row.completed,
historySnapshotJson: row.historySnapshotJson,
averageHeartRateBpm: row.averageHeartRateBpm,
maxHeartRateBpm: row.maxHeartRateBpm,
results: results,
stepResults: stepResults,
);
@ -4560,6 +4739,8 @@ Map<String, Object?> _workoutHistoryPayload(domain.WorkoutHistory history) => {
'totalActiveMs': history.totalActiveMs,
'completed': history.completed,
'historySnapshotJson': history.historySnapshotJson,
'averageHeartRateBpm': history.averageHeartRateBpm,
'maxHeartRateBpm': history.maxHeartRateBpm,
};
Map<String, Object?> _localWorkoutHistoryPayload(
@ -4714,6 +4895,8 @@ domain.WorkoutHistory _workoutHistoryFromLocalBackupPayload(
completed: payload['completed'] as bool? ?? false,
historySnapshotJson:
payload['historySnapshotJson'] as String? ?? '{"programs":[]}',
averageHeartRateBpm: (payload['averageHeartRateBpm'] as num?)?.toDouble(),
maxHeartRateBpm: payload['maxHeartRateBpm'] as int?,
results: _workoutHistorySetResultsFromPayload(payload['results'], metadata),
stepResults: _workoutHistoryStepResultsFromPayload(
payload['stepResults'],
@ -5142,6 +5325,7 @@ List<domain.ExerciseStep> _stepsFromPayload(Object? value) {
json,
'defaultTargetScoreTimeMs',
),
linkedToSeriesScore: json['linkedToSeriesScore'] == true,
);
})
.toList(growable: false);
@ -5487,6 +5671,7 @@ List<domain.ExerciseStep> _decodeExerciseStepsSnapshot(String? encoded) {
json,
'defaultTargetScoreTimeMs',
),
linkedToSeriesScore: json['linkedToSeriesScore'] == true,
);
})
.toList(growable: false);

View File

@ -137,7 +137,7 @@ class PendingShareActions extends Table {
@override
List<String> get customConstraints => [
"CHECK (action_type IN ('send', 'accept', 'decline', 'revoke'))",
"CHECK (resource_type IS NULL OR resource_type IN "
'CHECK (resource_type IS NULL OR resource_type IN '
"('program', 'workoutTemplate'))",
"CHECK (status IN ('pending', 'succeeded', 'failed'))",
];
@ -250,6 +250,8 @@ class ExerciseSteps extends SyncableTable {
TextColumn get scoreUnit => text().nullable()();
RealColumn get defaultTargetScore => real().nullable()();
IntColumn get defaultTargetScoreTimeMs => integer().nullable()();
BoolColumn get linkedToSeriesScore =>
boolean().withDefault(const Constant(false))();
@override
List<String> get customConstraints => [
@ -276,6 +278,8 @@ class ExerciseSteps extends SyncableTable {
'AND default_target_score IS NULL))',
'CHECK (default_target_score IS NULL OR '
'default_target_score_time_ms IS NULL)',
'CHECK (NOT linked_to_series_score OR '
"(has_score AND score_input_mode = 'manual'))",
];
}
@ -548,6 +552,29 @@ class ActiveScoreStopwatchStates extends SyncableTable {
];
}
class ActiveManualScoreStates extends SyncableTable {
@override
String get tableName => 'active_manual_score_states';
TextColumn get activeWorkoutSessionId =>
text().references(ActiveWorkoutSessions, #id)();
IntColumn get programIndex => integer()();
IntColumn get exerciseIndex => integer()();
IntColumn get setIndex => integer()();
RealColumn get value => real()();
DateTimeColumn get scoreUpdatedAt => dateTime()();
@override
List<String> get customConstraints => [
'UNIQUE (active_workout_session_id, program_index, exercise_index, '
'set_index)',
'CHECK (program_index >= 0)',
'CHECK (exercise_index >= 0)',
'CHECK (set_index >= 0)',
'CHECK (value >= 0)',
];
}
class ActiveSetTimerStates extends SyncableTable {
@override
String get tableName => 'active_set_timer_states';
@ -723,9 +750,15 @@ class WorkoutHistories extends SyncableTable {
IntColumn get totalActiveMs => integer()();
BoolColumn get completed => boolean()();
TextColumn get historySnapshotJson => text().withLength(min: 1)();
RealColumn get averageHeartRateBpm => real().nullable()();
IntColumn get maxHeartRateBpm => integer().nullable()();
@override
List<String> get customConstraints => ['CHECK (total_active_ms >= 0)'];
List<String> get customConstraints => [
'CHECK (total_active_ms >= 0)',
'CHECK (average_heart_rate_bpm IS NULL OR average_heart_rate_bpm > 0)',
'CHECK (max_heart_rate_bpm IS NULL OR max_heart_rate_bpm > 0)',
];
}
class WorkoutHistorySetResults extends SyncableTable {