mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:07:16 +01:00
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:
parent
9e4c45dfa3
commit
2bb84f6d1b
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user