From 2bb84f6d1b03a5d43b68fc8bb2720c07d6d36a6f Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 21:44:43 +0200 Subject: [PATCH] Split progression_sync_service.dart (433) into four progression_sync_service.dart client, record IO, JSON mapping progression_sync_service_push.dart push local progression progression_sync_service_pull.dart pull remote progression progression_sync_service_session.dart the shared active session Extensions are safe here: no test fake subclasses ProgressionSyncService, so static dispatch cannot swallow an override the way it did for WorkoutSyncService. Static members need qualifying from inside an extension (ProgressionSyncService._stateToJson, .pathForExercise). 402 Dart tests; dart analyze clean; coverage gate passes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y --- .../services/progression_sync_service.dart | 254 +----------------- .../progression_sync_service_pull.dart | 82 ++++++ .../progression_sync_service_push.dart | 96 +++++++ .../progression_sync_service_session.dart | 98 +++++++ 4 files changed, 280 insertions(+), 250 deletions(-) create mode 100644 stronglift_replacement/workout_app/lib/services/progression_sync_service_pull.dart create mode 100644 stronglift_replacement/workout_app/lib/services/progression_sync_service_push.dart create mode 100644 stronglift_replacement/workout_app/lib/services/progression_sync_service_session.dart diff --git a/stronglift_replacement/workout_app/lib/services/progression_sync_service.dart b/stronglift_replacement/workout_app/lib/services/progression_sync_service.dart index aa90d23..d12bb1f 100644 --- a/stronglift_replacement/workout_app/lib/services/progression_sync_service.dart +++ b/stronglift_replacement/workout_app/lib/services/progression_sync_service.dart @@ -34,6 +34,10 @@ import 'package:workout_app/services/firebase_backend.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/services/sync_device_id.dart'; +part 'progression_sync_service_pull.dart'; +part 'progression_sync_service_push.dart'; +part 'progression_sync_service_session.dart'; + /// Root for everything this module owns. Deliberately NOT under `devices/`. const kProgressionPrefix = 'screen-locker-sync/exercise_state'; @@ -180,254 +184,4 @@ class ProgressionSyncService { message: 'progression: $id', ); } - - /// Pushes local progression to Firebase, one record per exercise. - /// - /// Each record carries an HLC derived from the remote one it replaces - /// (`previous:`), so the stamps form a causal chain per exercise rather than - /// depending on device clocks agreeing. - /// - /// Refuses to push at all from a freshly-installed database that still has - /// remote records to lose. Without that, the seed-then-push sequence a - /// reinstall performs would overwrite real progression with factory defaults - /// and leave Firebase as the only — wrong — copy. The test is - /// [StorageService.looksFreshlyInstalled], not a per-exercise value - /// comparison: a deliberate [StorageService.resetExerciseToDefaults] can - /// produce a row identical to a seeded one, and that reset must still sync. - Future pushProgression() async { - final client = await _openFirebase(); - if (client == null) { - const reason = - 'progression NOT pushed: no Firebase account on this device — ' - 'connect one in Settings, or progression lives only on this phone'; - log( - 'ProgressionSyncService.pushProgression skipped: $reason', - level: 900, - ); - return const ProgressionSyncResult(changed: false, reason: reason); - } - - try { - final states = await StorageService.instance.getAllExerciseStates(); - - // Decided ONCE, before any write. Evaluating this per exercise would - // still write every record Firebase happens not to hold yet before - // hitting the first one it does — a fresh install would leak factory - // defaults for exactly the exercises with no remote copy to protect them. - // - // Gated on hasSyncedProgression, NOT looksFreshlyInstalled: the only - // production caller finishes a workout (writing history and - // last_workout_type) before pushing, so a freshness test would already - // be false here and could never fire. - if (!await StorageService.instance.hasSyncedProgression() && - await _remoteHasAnyProgression(client, states)) { - const reason = - 'progression NOT pushed: this install has never pulled from ' - 'Firebase, but Firebase already holds progression. Refusing to ' - 'overwrite it with local state that may be factory defaults — ' - 'restart the app (or reconnect sync) so the real state is pulled ' - 'down first.'; - log('ProgressionSyncService: $reason', level: 1000); - return const ProgressionSyncResult(changed: false, reason: reason); - } - - var written = 0; - for (final state in states) { - final path = pathForExercise(state.name); - final remote = await _readRecord(client, path); - - await _writeRecord( - client, - path, - 'exercise_state:${state.name}', - _stateToJson(state), - // Chain from the record being replaced so the stamp is causal, not - // merely wall-clock: an offline device cannot mint a "newer" tick - // just by having a fast clock. - Hlc.newTick(currentSyncDeviceId, previous: remote?.$2), - ); - written++; - } - return ProgressionSyncResult( - changed: written > 0, - count: written, - reason: 'pushed $written exercise(s) to Firebase', - ); - } on Object catch (error, stackTrace) { - // Deliberately broad and swallowed: a sync failure must never cost the - // user their workout. Never silent, though — this logs at error level - // and the reason travels back to the caller. - final reason = 'progression push failed: $error'; - log( - 'ProgressionSyncService.pushProgression failed', - level: 1000, - error: error, - stackTrace: stackTrace, - ); - return ProgressionSyncResult(changed: false, reason: reason); - } finally { - client.close(); - } - } - - /// Pulls remote progression into the local DB. - /// - /// Applies only to a freshly-installed database - /// ([StorageService.looksFreshlyInstalled]) — so a pull can restore a wiped - /// install but can never clobber progression, or a deliberate reset, made on - /// this phone. That keeps the operation safe to run unconditionally at app - /// start. - Future pullProgression() async { - final client = await _openFirebase(); - if (client == null) { - const reason = - 'progression NOT pulled: no Firebase account on this device — ' - 'connect one in Settings to restore progression without storage ' - 'permission'; - log( - 'ProgressionSyncService.pullProgression skipped: $reason', - level: 900, - ); - return const ProgressionSyncResult(changed: false, reason: reason); - } - - try { - // Once this install has reconciled with Firebase, local state wins. - // Checked instead of looksFreshlyInstalled because a hand edit - // (setExerciseWeight, resetExerciseToDefaults) writes neither history - // nor last_workout_type: the DB still "looks fresh" afterwards, so a - // freshness test would silently revert the user's own change on the very - // next launch. - if (await StorageService.instance.hasSyncedProgression()) { - const reason = - 'progression NOT pulled: this install has already reconciled with ' - 'Firebase, so local state wins and must not be overwritten'; - log('ProgressionSyncService: $reason', level: 800); - return const ProgressionSyncResult(changed: false, reason: reason); - } - - var applied = 0; - for (final name in _defaults.keys) { - final remote = await _readRecord(client, pathForExercise(name)); - if (remote == null) continue; - await StorageService.instance.replaceExerciseState( - _stateFromJson(remote.$1), - ); - applied++; - } - // Marked even when nothing came down: reaching the backend and finding - // it empty IS a successful reconcile, and it is what unblocks this - // device's first push. Only a thrown error leaves the flag unset. - await StorageService.instance.markProgressionSynced(); - final reason = applied == 0 - ? 'no progression restored: Firebase holds no exercise records yet' - : 'restored $applied exercise(s) from Firebase'; - log('ProgressionSyncService.pullProgression: $reason', level: 800); - return ProgressionSyncResult( - changed: applied > 0, - count: applied, - reason: reason, - ); - } on Object catch (error, stackTrace) { - final reason = 'progression pull failed: $error'; - log( - 'ProgressionSyncService.pullProgression failed', - level: 1000, - error: error, - stackTrace: stackTrace, - ); - return ProgressionSyncResult(changed: false, reason: reason); - } finally { - client.close(); - } - } - - /// Publishes the in-progress session, or clears it when [data] is null. - /// - /// Called on set completion rather than per rep. `saveActiveSession` fires on - /// every tap, and a Firebase write per tap is exactly the traffic the sync - /// revision cache exists to avoid. - Future pushActiveSession( - Map? data, - ) async { - final client = await _openFirebase(); - if (client == null) { - const reason = 'active session NOT pushed: no Firebase account'; - log( - 'ProgressionSyncService.pushActiveSession skipped: $reason', - level: 900, - ); - return const ProgressionSyncResult(changed: false, reason: reason); - } - - try { - if (data == null) { - // An empty object, not a delete: the readers treat absent and empty - // alike, and a write keeps the HLC ordering intact for the next push. - await _writeRecord( - client, - kActiveSessionPath, - 'active_session', - const {}, - Hlc.newTick(currentSyncDeviceId), - ); - return const ProgressionSyncResult( - changed: true, - reason: 'cleared the shared active session', - ); - } - await _writeRecord( - client, - kActiveSessionPath, - 'active_session', - data, - Hlc.newTick(currentSyncDeviceId), - ); - return const ProgressionSyncResult( - changed: true, - reason: 'published the active session', - ); - } on Object catch (error, stackTrace) { - final reason = 'active session push failed: $error'; - log( - 'ProgressionSyncService.pushActiveSession failed', - level: 1000, - error: error, - stackTrace: stackTrace, - ); - return ProgressionSyncResult(changed: false, reason: reason); - } finally { - client.close(); - } - } - - /// Returns the shared in-progress session, or null when there is none. - /// - /// An empty payload means "explicitly cleared" and reads back as null, so a - /// finished workout is never resurrected. - Future?> readActiveSession() async { - final client = await _openFirebase(); - if (client == null) { - log( - 'ProgressionSyncService.readActiveSession skipped: no Firebase account', - level: 900, - ); - return null; - } - try { - final remote = await _readRecord(client, kActiveSessionPath); - if (remote == null || remote.$1.isEmpty) return null; - return remote.$1; - } on Object catch (error, stackTrace) { - log( - 'ProgressionSyncService.readActiveSession failed', - level: 1000, - error: error, - stackTrace: stackTrace, - ); - return null; - } finally { - client.close(); - } - } } diff --git a/stronglift_replacement/workout_app/lib/services/progression_sync_service_pull.dart b/stronglift_replacement/workout_app/lib/services/progression_sync_service_pull.dart new file mode 100644 index 0000000..3a18527 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/progression_sync_service_pull.dart @@ -0,0 +1,82 @@ +// Pulling remote progression back into local state. +// +// See progression_sync_service_session.dart for why this is a `part`. +part of 'progression_sync_service.dart'; + +/// Pulls remote progression into the local database. +extension ProgressionSyncServicePull on ProgressionSyncService { + /// Pulls remote progression into the local DB. + /// + /// Applies only to a freshly-installed database + /// ([StorageService.looksFreshlyInstalled]) — so a pull can restore a wiped + /// install but can never clobber progression, or a deliberate reset, made on + /// this phone. That keeps the operation safe to run unconditionally at app + /// start. + Future pullProgression() async { + final client = await _openFirebase(); + if (client == null) { + const reason = + 'progression NOT pulled: no Firebase account on this device — ' + 'connect one in Settings to restore progression without storage ' + 'permission'; + log( + 'ProgressionSyncService.pullProgression skipped: $reason', + level: 900, + ); + return const ProgressionSyncResult(changed: false, reason: reason); + } + + try { + // Once this install has reconciled with Firebase, local state wins. + // Checked instead of looksFreshlyInstalled because a hand edit + // (setExerciseWeight, resetExerciseToDefaults) writes neither history + // nor last_workout_type: the DB still "looks fresh" afterwards, so a + // freshness test would silently revert the user's own change on the very + // next launch. + if (await StorageService.instance.hasSyncedProgression()) { + const reason = + 'progression NOT pulled: this install has already reconciled with ' + 'Firebase, so local state wins and must not be overwritten'; + log('ProgressionSyncService: $reason', level: 800); + return const ProgressionSyncResult(changed: false, reason: reason); + } + + var applied = 0; + for (final name in ProgressionSyncService._defaults.keys) { + final remote = await _readRecord( + client, + ProgressionSyncService.pathForExercise(name), + ); + if (remote == null) continue; + await StorageService.instance.replaceExerciseState( + ProgressionSyncService._stateFromJson(remote.$1), + ); + applied++; + } + // Marked even when nothing came down: reaching the backend and finding + // it empty IS a successful reconcile, and it is what unblocks this + // device's first push. Only a thrown error leaves the flag unset. + await StorageService.instance.markProgressionSynced(); + final reason = applied == 0 + ? 'no progression restored: Firebase holds no exercise records yet' + : 'restored $applied exercise(s) from Firebase'; + log('ProgressionSyncService.pullProgression: $reason', level: 800); + return ProgressionSyncResult( + changed: applied > 0, + count: applied, + reason: reason, + ); + } on Object catch (error, stackTrace) { + final reason = 'progression pull failed: $error'; + log( + 'ProgressionSyncService.pullProgression failed', + level: 1000, + error: error, + stackTrace: stackTrace, + ); + return ProgressionSyncResult(changed: false, reason: reason); + } finally { + client.close(); + } + } +} diff --git a/stronglift_replacement/workout_app/lib/services/progression_sync_service_push.dart b/stronglift_replacement/workout_app/lib/services/progression_sync_service_push.dart new file mode 100644 index 0000000..9c9eeaf --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/progression_sync_service_push.dart @@ -0,0 +1,96 @@ +// Pushing local progression up to Firebase. +// +// See progression_sync_service_session.dart for why this is a `part`. +part of 'progression_sync_service.dart'; + +/// Pushes local progression state to the remote backend. +extension ProgressionSyncServicePush on ProgressionSyncService { + /// Pushes local progression to Firebase, one record per exercise. + /// + /// Each record carries an HLC derived from the remote one it replaces + /// (`previous:`), so the stamps form a causal chain per exercise rather than + /// depending on device clocks agreeing. + /// + /// Refuses to push at all from a freshly-installed database that still has + /// remote records to lose. Without that, the seed-then-push sequence a + /// reinstall performs would overwrite real progression with factory defaults + /// and leave Firebase as the only — wrong — copy. The test is + /// [StorageService.looksFreshlyInstalled], not a per-exercise value + /// comparison: a deliberate [StorageService.resetExerciseToDefaults] can + /// produce a row identical to a seeded one, and that reset must still sync. + Future pushProgression() async { + final client = await _openFirebase(); + if (client == null) { + const reason = + 'progression NOT pushed: no Firebase account on this device — ' + 'connect one in Settings, or progression lives only on this phone'; + log( + 'ProgressionSyncService.pushProgression skipped: $reason', + level: 900, + ); + return const ProgressionSyncResult(changed: false, reason: reason); + } + + try { + final states = await StorageService.instance.getAllExerciseStates(); + + // Decided ONCE, before any write. Evaluating this per exercise would + // still write every record Firebase happens not to hold yet before + // hitting the first one it does — a fresh install would leak factory + // defaults for exactly the exercises with no remote copy to protect them. + // + // Gated on hasSyncedProgression, NOT looksFreshlyInstalled: the only + // production caller finishes a workout (writing history and + // last_workout_type) before pushing, so a freshness test would already + // be false here and could never fire. + if (!await StorageService.instance.hasSyncedProgression() && + await _remoteHasAnyProgression(client, states)) { + const reason = + 'progression NOT pushed: this install has never pulled from ' + 'Firebase, but Firebase already holds progression. Refusing to ' + 'overwrite it with local state that may be factory defaults — ' + 'restart the app (or reconnect sync) so the real state is pulled ' + 'down first.'; + log('ProgressionSyncService: $reason', level: 1000); + return const ProgressionSyncResult(changed: false, reason: reason); + } + + var written = 0; + for (final state in states) { + final path = ProgressionSyncService.pathForExercise(state.name); + final remote = await _readRecord(client, path); + + await _writeRecord( + client, + path, + 'exercise_state:${state.name}', + ProgressionSyncService._stateToJson(state), + // Chain from the record being replaced so the stamp is causal, not + // merely wall-clock: an offline device cannot mint a "newer" tick + // just by having a fast clock. + Hlc.newTick(currentSyncDeviceId, previous: remote?.$2), + ); + written++; + } + return ProgressionSyncResult( + changed: written > 0, + count: written, + reason: 'pushed $written exercise(s) to Firebase', + ); + } on Object catch (error, stackTrace) { + // Deliberately broad and swallowed: a sync failure must never cost the + // user their workout. Never silent, though — this logs at error level + // and the reason travels back to the caller. + final reason = 'progression push failed: $error'; + log( + 'ProgressionSyncService.pushProgression failed', + level: 1000, + error: error, + stackTrace: stackTrace, + ); + return ProgressionSyncResult(changed: false, reason: reason); + } finally { + client.close(); + } + } +} diff --git a/stronglift_replacement/workout_app/lib/services/progression_sync_service_session.dart b/stronglift_replacement/workout_app/lib/services/progression_sync_service_session.dart new file mode 100644 index 0000000..802e62a --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/progression_sync_service_session.dart @@ -0,0 +1,98 @@ +// The shared in-progress session, pushed to and read from Firebase. +// +// An `extension` in a `part`. Safe here because no test fake subclasses +// ProgressionSyncService -- extension methods dispatch statically, so an +// override would silently not take effect. +part of 'progression_sync_service.dart'; + +/// Pushing and reading the shared active session. +extension ProgressionSyncServiceSession on ProgressionSyncService { + /// Publishes the in-progress session, or clears it when [data] is null. + /// + /// Called on set completion rather than per rep. `saveActiveSession` fires on + /// every tap, and a Firebase write per tap is exactly the traffic the sync + /// revision cache exists to avoid. + Future pushActiveSession( + Map? data, + ) async { + final client = await _openFirebase(); + if (client == null) { + const reason = 'active session NOT pushed: no Firebase account'; + log( + 'ProgressionSyncService.pushActiveSession skipped: $reason', + level: 900, + ); + return const ProgressionSyncResult(changed: false, reason: reason); + } + + try { + if (data == null) { + // An empty object, not a delete: the readers treat absent and empty + // alike, and a write keeps the HLC ordering intact for the next push. + await _writeRecord( + client, + kActiveSessionPath, + 'active_session', + const {}, + Hlc.newTick(currentSyncDeviceId), + ); + return const ProgressionSyncResult( + changed: true, + reason: 'cleared the shared active session', + ); + } + await _writeRecord( + client, + kActiveSessionPath, + 'active_session', + data, + Hlc.newTick(currentSyncDeviceId), + ); + return const ProgressionSyncResult( + changed: true, + reason: 'published the active session', + ); + } on Object catch (error, stackTrace) { + final reason = 'active session push failed: $error'; + log( + 'ProgressionSyncService.pushActiveSession failed', + level: 1000, + error: error, + stackTrace: stackTrace, + ); + return ProgressionSyncResult(changed: false, reason: reason); + } finally { + client.close(); + } + } + + /// Returns the shared in-progress session, or null when there is none. + /// + /// An empty payload means "explicitly cleared" and reads back as null, so a + /// finished workout is never resurrected. + Future?> readActiveSession() async { + final client = await _openFirebase(); + if (client == null) { + log( + 'ProgressionSyncService.readActiveSession skipped: no Firebase account', + level: 900, + ); + return null; + } + try { + final remote = await _readRecord(client, kActiveSessionPath); + if (remote == null || remote.$1.isEmpty) return null; + return remote.$1; + } on Object catch (error, stackTrace) { + log( + 'ProgressionSyncService.readActiveSession failed', + level: 1000, + error: error, + stackTrace: stackTrace, + ); + return null; + } finally { + client.close(); + } + } +}