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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 21:44:43 +02:00
parent 9e4c45dfa3
commit 2bb84f6d1b
4 changed files with 280 additions and 250 deletions

View File

@ -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<ProgressionSyncResult> 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<ProgressionSyncResult> 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<ProgressionSyncResult> pushActiveSession(
Map<String, dynamic>? 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<Map<String, dynamic>?> 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();
}
}
}

View File

@ -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<ProgressionSyncResult> 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();
}
}
}

View File

@ -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<ProgressionSyncResult> 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();
}
}
}

View File

@ -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<ProgressionSyncResult> pushActiveSession(
Map<String, dynamic>? 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<Map<String, dynamic>?> 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();
}
}
}