mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
Split storage_service.dart (781) into part extensions
storage_service.dart the class: open, settings, active session
storage_service_schema.dart create / migrate / seed
storage_service_exercises.dart read+write a single exercise's row
storage_service_sessions.dart apply progression, record sessions
storage_service_backup.dart backup, restore, sync markers
Dart cannot continue a class body across files, so unlike history_screen these
are `extension`s in `part` files. Library privacy still applies inside a part,
so they reach `_db` and `_getSetting` unchanged and every caller still writes
`StorageService.instance.foo()` -- no public API change, nothing for
public_member_api_docs to demand.
Doing this exposed a bug in scripts/check_silent_failures_dart.py: its bare
`on Foo {` pattern (the catch clause form) also matched
`extension StorageServiceBackup on StorageService {`, so it reported the
declarations as swallowed exceptions. It now requires the try block's closing
`}` first. Verified both directions on a fixture: a real silent
`} on FormatException {` is still reported, the extension is not.
402 Dart tests still pass; flutter coverage still 100%; dart analyze clean.
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
38e8364306
commit
d418994f6f
@ -51,8 +51,12 @@ _LOUD_CALLS = (
|
||||
"ScaffoldMessenger",
|
||||
)
|
||||
|
||||
# `} on Foo catch (e) {`, `} catch (e) {`, `on Foo {`.
|
||||
_CATCH_RE = re.compile(r"\bcatch\s*\(|\bon\s+\w+\s*\{")
|
||||
# `} on Foo catch (e) {`, `} catch (e) {`, `} on Foo {`.
|
||||
#
|
||||
# The bare `on Foo {` form requires the preceding `}` of the try block: without
|
||||
# it this also matched `extension StorageServiceBackup on StorageService {`,
|
||||
# reporting an extension declaration as a swallowed exception.
|
||||
_CATCH_RE = re.compile(r"\bcatch\s*\(|\}\s*on\s+\w+\s*\{")
|
||||
|
||||
# `.catchError((_) {})` and friends -- an empty body is always silent.
|
||||
_EMPTY_CATCH_ERROR_RE = re.compile(r"\.catchError\(\s*\([^)]*\)\s*(?:=>\s*)?\{\s*\}")
|
||||
|
||||
@ -11,6 +11,11 @@ import 'package:workout_app/models/workout_plan.dart';
|
||||
import 'package:workout_app/services/backup_service.dart';
|
||||
import 'package:workout_app/services/progression_sync_service.dart';
|
||||
|
||||
part 'storage_service_backup.dart';
|
||||
part 'storage_service_exercises.dart';
|
||||
part 'storage_service_schema.dart';
|
||||
part 'storage_service_sessions.dart';
|
||||
|
||||
/// Per-exercise progression state stored in SQLite.
|
||||
class ExerciseState {
|
||||
/// Creates an [ExerciseState] with all required progression fields.
|
||||
@ -114,94 +119,6 @@ class StorageService {
|
||||
await _seedDefaultsIfNeeded();
|
||||
}
|
||||
|
||||
Future<void> _createSchema(Database db, int version) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE exercise_state (
|
||||
name TEXT PRIMARY KEY,
|
||||
weight REAL NOT NULL,
|
||||
reps INTEGER NOT NULL,
|
||||
success_streak INTEGER NOT NULL DEFAULT 0,
|
||||
fail_streak INTEGER NOT NULL DEFAULT 0,
|
||||
max_weight REAL NOT NULL,
|
||||
success_threshold INTEGER NOT NULL DEFAULT 3,
|
||||
fail_threshold INTEGER NOT NULL DEFAULT 2
|
||||
)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE workout_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
workout_type TEXT NOT NULL,
|
||||
duration_seconds INTEGER NOT NULL,
|
||||
succeeded INTEGER NOT NULL,
|
||||
json TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE active_session (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
json TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
Future<void> _migrateSchema(
|
||||
Database db,
|
||||
int oldVersion,
|
||||
int newVersion,
|
||||
) async {
|
||||
if (oldVersion < 2) {
|
||||
await db.execute(
|
||||
'ALTER TABLE exercise_state '
|
||||
'ADD COLUMN success_threshold INTEGER NOT NULL DEFAULT 3',
|
||||
);
|
||||
await db.execute(
|
||||
'ALTER TABLE exercise_state '
|
||||
'ADD COLUMN fail_threshold INTEGER NOT NULL DEFAULT 2',
|
||||
);
|
||||
}
|
||||
if (oldVersion < 3) {
|
||||
await db.execute(
|
||||
'CREATE TABLE IF NOT EXISTS settings '
|
||||
'(key TEXT PRIMARY KEY, value TEXT NOT NULL)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE TABLE IF NOT EXISTS active_session '
|
||||
'(id INTEGER PRIMARY KEY CHECK (id = 1), json TEXT NOT NULL)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _seedDefaultsIfNeeded() async {
|
||||
for (final ex in [...workoutA, ...workoutB]) {
|
||||
final rows = await _db.query(
|
||||
'exercise_state',
|
||||
where: 'name = ?',
|
||||
whereArgs: [ex.name],
|
||||
);
|
||||
if (rows.isEmpty) {
|
||||
await _db.insert('exercise_state', {
|
||||
'name': ex.name,
|
||||
'weight': ex.weight,
|
||||
'reps': ex.reps,
|
||||
'success_streak': 0,
|
||||
'fail_streak': 0,
|
||||
'max_weight': ex.maxWeight,
|
||||
'success_threshold': 3,
|
||||
'fail_threshold': 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────────────
|
||||
|
||||
Future<String?> _getSetting(String key) async {
|
||||
final rows = await _db.query(
|
||||
'settings',
|
||||
@ -284,303 +201,6 @@ class StorageService {
|
||||
await BackupService.instance.exportActiveSession(null);
|
||||
}
|
||||
|
||||
// ── Exercise state ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns the progression state for [name], or null if not found.
|
||||
Future<ExerciseState?> getExerciseState(String name) async {
|
||||
final rows = await _db.query(
|
||||
'exercise_state',
|
||||
where: 'name = ?',
|
||||
whereArgs: [name],
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
final r = rows.first;
|
||||
return ExerciseState(
|
||||
name: r['name']! as String,
|
||||
weight: r['weight']! as double,
|
||||
reps: r['reps']! as int,
|
||||
successStreak: r['success_streak']! as int,
|
||||
failStreak: r['fail_streak']! as int,
|
||||
maxWeight: r['max_weight']! as double,
|
||||
successThreshold: r['success_threshold'] as int? ?? 3,
|
||||
failThreshold: r['fail_threshold'] as int? ?? 2,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns progression states for every exercise across both plans.
|
||||
Future<List<ExerciseState>> getAllExerciseStates() async {
|
||||
final allNames = [...workoutA, ...workoutB].map((e) => e.name).toSet();
|
||||
final states = <ExerciseState>[];
|
||||
for (final name in allNames) {
|
||||
final s = await getExerciseState(name);
|
||||
if (s != null) states.add(s);
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
/// Updates the streak thresholds for exercise [name].
|
||||
Future<void> setExerciseThresholds(
|
||||
String name, {
|
||||
required int successThreshold,
|
||||
required int failThreshold,
|
||||
}) async {
|
||||
await _db.update(
|
||||
'exercise_state',
|
||||
{
|
||||
'success_threshold': successThreshold,
|
||||
'fail_threshold': failThreshold,
|
||||
},
|
||||
where: 'name = ?',
|
||||
whereArgs: [name],
|
||||
);
|
||||
}
|
||||
|
||||
/// Sets the target reps for [name], resetting streaks.
|
||||
///
|
||||
/// Progression can only ever raise reps (by one, and only once an exercise
|
||||
/// is at its max weight), so without this there is no way to lower a rep
|
||||
/// target that a defaults re-seed set wrong -- the 2026-08-05 wipe dropped
|
||||
/// Situp from 31 to the default 30 with no way back short of editing the DB.
|
||||
Future<void> setExerciseReps(String name, int reps) async {
|
||||
await _db.update(
|
||||
'exercise_state',
|
||||
{'reps': reps, 'success_streak': 0, 'fail_streak': 0},
|
||||
where: 'name = ?',
|
||||
whereArgs: [name],
|
||||
);
|
||||
unawaited(_backupNow());
|
||||
}
|
||||
|
||||
/// Overwrites [state] wholesale, inserting it when the row is absent.
|
||||
///
|
||||
/// Used by the Firebase progression restore, which carries a complete record
|
||||
/// (weight, reps, both streaks, max weight, both thresholds) and must land it
|
||||
/// atomically — a field-by-field update would leave a half-restored row if it
|
||||
/// failed partway. Unlike [setExerciseWeight] this deliberately does NOT
|
||||
/// reset streaks: the streaks are part of what is being restored.
|
||||
///
|
||||
/// Does not trigger a backup write. The caller is restoring INTO a
|
||||
/// freshly-seeded database, and `_backupNow()` mid-restore is what previously
|
||||
/// let default rows reach the only off-device copy.
|
||||
Future<void> replaceExerciseState(ExerciseState state) async {
|
||||
await _db.insert('exercise_state', {
|
||||
'name': state.name,
|
||||
'weight': state.weight,
|
||||
'reps': state.reps,
|
||||
'success_streak': state.successStreak,
|
||||
'fail_streak': state.failStreak,
|
||||
'max_weight': state.maxWeight,
|
||||
'success_threshold': state.successThreshold,
|
||||
'fail_threshold': state.failThreshold,
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
|
||||
/// Sets the working weight for [name], resetting streaks.
|
||||
Future<void> setExerciseWeight(String name, double weight) async {
|
||||
await _db.update(
|
||||
'exercise_state',
|
||||
{'weight': weight, 'success_streak': 0, 'fail_streak': 0},
|
||||
where: 'name = ?',
|
||||
whereArgs: [name],
|
||||
);
|
||||
unawaited(_backupNow());
|
||||
}
|
||||
|
||||
/// Returns exercises for [workoutType] with weights/reps from stored state.
|
||||
Future<List<Exercise>> getCurrentExercises(String workoutType) async {
|
||||
final template = workoutType == 'A' ? workoutA : workoutB;
|
||||
final result = <Exercise>[];
|
||||
for (final ex in template) {
|
||||
final state = await getExerciseState(ex.name);
|
||||
if (state == null) {
|
||||
// Defensive: _seedDefaultsIfNeeded guarantees every template exercise
|
||||
// has a state row, so this branch is unreachable in practice.
|
||||
// coverage:ignore-start
|
||||
result.add(ex);
|
||||
// coverage:ignore-end
|
||||
} else {
|
||||
result.add(ex.copyWith(weight: state.weight, reps: state.reps));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Applies progressive overload or regression based on [succeededExercises].
|
||||
Future<void> applyProgression({
|
||||
required Map<String, bool> succeededExercises,
|
||||
required DateTime lastWorkoutDate,
|
||||
}) async {
|
||||
final daysSince = DateTime.now().difference(lastWorkoutDate).inDays;
|
||||
final hadBreak = daysSince > 7;
|
||||
|
||||
for (final entry in succeededExercises.entries) {
|
||||
final state = await getExerciseState(entry.key);
|
||||
if (state == null) continue;
|
||||
|
||||
if (hadBreak) {
|
||||
final newWeight = (state.weight - kWeightIncrement).clamp(
|
||||
0.0,
|
||||
state.maxWeight,
|
||||
);
|
||||
await _db.update(
|
||||
'exercise_state',
|
||||
{'weight': newWeight, 'success_streak': 0, 'fail_streak': 0},
|
||||
where: 'name = ?',
|
||||
whereArgs: [entry.key],
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.value) {
|
||||
final newStreak = state.successStreak + 1;
|
||||
final shouldProgress = newStreak >= state.successThreshold;
|
||||
var newWeight = state.weight;
|
||||
var newReps = state.reps;
|
||||
|
||||
if (shouldProgress) {
|
||||
if (state.weight >= state.maxWeight) {
|
||||
newReps = state.reps + 1;
|
||||
} else {
|
||||
newWeight = (state.weight + kWeightIncrement).clamp(
|
||||
0.0,
|
||||
state.maxWeight,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await _db.update(
|
||||
'exercise_state',
|
||||
{
|
||||
'weight': newWeight,
|
||||
'reps': newReps,
|
||||
'success_streak': shouldProgress ? 0 : newStreak,
|
||||
'fail_streak': 0,
|
||||
},
|
||||
where: 'name = ?',
|
||||
whereArgs: [entry.key],
|
||||
);
|
||||
} else {
|
||||
final newStreak = state.failStreak + 1;
|
||||
final shouldRegress = newStreak >= state.failThreshold;
|
||||
final newWeight = shouldRegress
|
||||
? (state.weight - kWeightIncrement).clamp(0.0, state.maxWeight)
|
||||
: state.weight;
|
||||
|
||||
await _db.update(
|
||||
'exercise_state',
|
||||
{
|
||||
'weight': newWeight,
|
||||
'fail_streak': shouldRegress ? 0 : newStreak,
|
||||
'success_streak': 0,
|
||||
},
|
||||
where: 'name = ?',
|
||||
whereArgs: [entry.key],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists a completed session to the workout history table.
|
||||
Future<void> saveSession({
|
||||
required String date,
|
||||
required String workoutType,
|
||||
required int durationSeconds,
|
||||
required bool succeeded,
|
||||
required String json,
|
||||
}) async {
|
||||
await _db.insert('workout_history', {
|
||||
'date': date,
|
||||
'workout_type': workoutType,
|
||||
'duration_seconds': durationSeconds,
|
||||
'succeeded': succeeded ? 1 : 0,
|
||||
'json': json,
|
||||
});
|
||||
unawaited(_backupNow());
|
||||
}
|
||||
|
||||
/// Returns the date of the most recent completed session, or null.
|
||||
Future<DateTime?> getLastWorkoutDate() async {
|
||||
final rows = await _db.rawQuery(
|
||||
'SELECT date FROM workout_history ORDER BY date DESC LIMIT 1',
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return DateTime.tryParse(rows.first['date']! as String);
|
||||
}
|
||||
|
||||
/// Returns up to [limit] rows from workout history, newest first.
|
||||
/// Inserts synced sessions this device has no local record of.
|
||||
///
|
||||
/// These are the user's OWN completed workouts, already in Firebase/GitHub;
|
||||
/// a reinstall wipes the local `workout_history` table while the remote copy
|
||||
/// survives, so the app ends up showing far less history than actually
|
||||
/// happened. This puts the real records back rather than inventing anything.
|
||||
///
|
||||
/// Idempotent: a session is matched on (date, start_time) taken from the
|
||||
/// payload, so repeated syncs never duplicate a row. Sessions the device
|
||||
/// already has always win -- the local row is the one the progression
|
||||
/// engine acted on.
|
||||
///
|
||||
/// Returns the number of sessions restored.
|
||||
Future<int> restoreSyncedSessions(
|
||||
List<Map<String, dynamic>> payloads,
|
||||
) async {
|
||||
if (payloads.isEmpty) return 0;
|
||||
final existing = await _db.query('workout_history', columns: ['json']);
|
||||
final seen = <String>{
|
||||
for (final row in existing) _sessionKey(_decodeJson(row['json'])),
|
||||
};
|
||||
|
||||
var restored = 0;
|
||||
for (final payload in payloads) {
|
||||
// Only real sessions: a payload with no exercises is a PC-side
|
||||
// runnerup/manual record, which belongs in the synced list, not here.
|
||||
if (payload['exercises'] is! List) continue;
|
||||
final key = _sessionKey(payload);
|
||||
if (key.isEmpty || seen.contains(key)) continue;
|
||||
seen.add(key);
|
||||
await _db.insert('workout_history', {
|
||||
'date': payload['date'] as String? ?? '',
|
||||
'workout_type': payload['workout_type'] as String? ?? '',
|
||||
'duration_seconds': (payload['duration_seconds'] as num?)?.toInt() ?? 0,
|
||||
'succeeded': (payload['succeeded'] as bool? ?? false) ? 1 : 0,
|
||||
'json': jsonEncode(payload),
|
||||
});
|
||||
restored++;
|
||||
}
|
||||
if (restored > 0) {
|
||||
debugPrint('WorkoutApp: restored $restored synced session(s) locally.');
|
||||
unawaited(_backupNow());
|
||||
}
|
||||
return restored;
|
||||
}
|
||||
|
||||
/// Identity of a session: its date plus start time, both from the payload.
|
||||
///
|
||||
/// `start_time` alone would be enough, but pairing it with the date keeps
|
||||
/// the key readable in logs and survives a payload missing either field.
|
||||
static String _sessionKey(Map<String, dynamic>? payload) {
|
||||
if (payload == null) return '';
|
||||
final start = payload['start_time'] as String? ?? '';
|
||||
final date = payload['date'] as String? ?? '';
|
||||
return start.isEmpty && date.isEmpty ? '' : '$date|$start';
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _decodeJson(Object? raw) {
|
||||
if (raw is! String || raw.isEmpty) return null;
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
return decoded is Map<String, dynamic> ? decoded : null;
|
||||
} on FormatException catch (error) {
|
||||
// A corrupt local row must not block restoring the rest; it simply
|
||||
// cannot be matched against, so it is treated as "not seen".
|
||||
debugPrint(
|
||||
'WorkoutApp: a stored session row is not valid JSON ($error) — '
|
||||
'treating it as unseen, so a remote copy may be re-imported.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns up to [limit] past sessions, newest first.
|
||||
Future<List<Map<String, dynamic>>> getWorkoutHistory({
|
||||
int limit = 60,
|
||||
@ -622,160 +242,4 @@ class StorageService {
|
||||
}
|
||||
|
||||
// ── Backup / restore ───────────────────────────────────────────────────────
|
||||
|
||||
/// Exports all persistent data to external storage as a JSON snapshot.
|
||||
///
|
||||
/// Refuses to overwrite a richer existing backup with a freshly-seeded,
|
||||
/// history-less DB. That combination is never a real state worth saving: it
|
||||
/// only happens when a restore failed (permission denied, unreadable file)
|
||||
/// and the DB is sitting at [workoutA]/[workoutB] defaults. The old code
|
||||
/// exported anyway on the first write after such a seed, replacing the only
|
||||
/// off-device copy of months of progression with default rows — the step
|
||||
/// that turned the 2026-08-05 reinstall from recoverable into permanent.
|
||||
Future<void> _backupNow() async {
|
||||
final exerciseRows = await _db.query('exercise_state');
|
||||
final historyRows = await _db.query('workout_history');
|
||||
final settingsRows = await _db.query('settings');
|
||||
|
||||
if (historyRows.isEmpty) {
|
||||
final existing = await BackupService.instance.readBackup();
|
||||
final existingHistory =
|
||||
(existing?['workout_history'] as List?) ?? const [];
|
||||
if (existingHistory.isNotEmpty) {
|
||||
debugPrint(
|
||||
'WorkoutApp: REFUSING to overwrite the backup — this DB has no '
|
||||
'workout history but the backup holds ${existingHistory.length} '
|
||||
'session(s). A restore almost certainly failed; keeping the backup '
|
||||
'so the data stays recoverable.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await BackupService.instance.export({
|
||||
'exercise_state': exerciseRows,
|
||||
'workout_history': historyRows,
|
||||
'settings': settingsRows,
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether this database has never recorded a workout on this install.
|
||||
///
|
||||
/// "Empty" means no workout history and no [last_workout_type] setting —
|
||||
/// the state a fresh install or a `pm clear` leaves behind, and the only
|
||||
/// state in which overwriting local rows wholesale is safe.
|
||||
///
|
||||
/// Shared with the Firebase progression restore so both restore paths agree
|
||||
/// on what "fresh install" means. Deliberately NOT a per-exercise value
|
||||
/// comparison: [resetExerciseToDefaults] leaves `reps` and `max_weight`
|
||||
/// alone, so resetting an exercise that already sits at its default reps
|
||||
/// produces a row byte-identical to a seeded one. Treating that as "fresh"
|
||||
/// would let a pull silently revert a reset the user just performed.
|
||||
Future<bool> looksFreshlyInstalled() async {
|
||||
final hasHistory =
|
||||
(await _db.rawQuery('SELECT COUNT(*) AS c FROM workout_history'))
|
||||
.first['c'] as int? ??
|
||||
0;
|
||||
if (hasHistory > 0) return false;
|
||||
return await _getSetting('last_workout_type') == null;
|
||||
}
|
||||
|
||||
/// Whether this install has reconciled its progression with Firebase.
|
||||
///
|
||||
/// This — NOT [looksFreshlyInstalled] — is what gates pushing progression
|
||||
/// up. The freshness test is unusable there because the only production
|
||||
/// caller (`_finishWorkout`) writes a `workout_history` row and
|
||||
/// `last_workout_type` *before* it pushes, so the DB has stopped looking
|
||||
/// fresh by the time the guard runs: the guard could never fire, and a
|
||||
/// reinstall that connected Firebase late would overwrite every real remote
|
||||
/// record with factory defaults on its first finished workout.
|
||||
///
|
||||
/// It is also wrong for hand edits: `setExerciseWeight` and
|
||||
/// `resetExerciseToDefaults` write neither history nor `last_workout_type`,
|
||||
/// so a DB still "looks fresh" after them and the next pull would revert the
|
||||
/// user's own change.
|
||||
Future<bool> hasSyncedProgression() async =>
|
||||
await _getSetting(_progressionSyncedKey) != null;
|
||||
|
||||
/// Records that remote progression has been reconciled, so later pushes are
|
||||
/// allowed and later pulls stop overwriting local state.
|
||||
Future<void> markProgressionSynced() =>
|
||||
_setSetting(_progressionSyncedKey, DateTime.now().toIso8601String());
|
||||
|
||||
static const _progressionSyncedKey = 'progression_synced_at';
|
||||
|
||||
/// When a workout sync last SUCCEEDED, or null if one never has.
|
||||
///
|
||||
/// Drives the home screen's "Out of date" card. Nothing persisted a
|
||||
/// last-sync time before — which is why the app could sit disconnected for
|
||||
/// days with nothing on screen saying so.
|
||||
Future<DateTime?> getLastSyncedAt() async {
|
||||
final raw = await _getSetting(_lastSyncedAtKey);
|
||||
if (raw == null) return null;
|
||||
final parsed = DateTime.tryParse(raw);
|
||||
if (parsed == null) {
|
||||
// Never silently treat a corrupt timestamp as "never synced": that
|
||||
// would show the nag card forever with no clue why.
|
||||
debugPrint(
|
||||
'WorkoutApp: last_synced_at is not a valid timestamp ($raw) — '
|
||||
'treating this device as never synced until the next success.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/// Records that a sync just succeeded.
|
||||
Future<void> markSyncedNow([DateTime? at]) => _setSetting(
|
||||
_lastSyncedAtKey,
|
||||
(at ?? DateTime.now()).toIso8601String(),
|
||||
);
|
||||
|
||||
static const _lastSyncedAtKey = 'last_synced_at';
|
||||
|
||||
/// Restores from backup if the local DB is empty (fresh install).
|
||||
///
|
||||
/// "Empty" means no workout history and no [last_workout_type] setting.
|
||||
Future<void> restoreFromBackupIfNeeded() async {
|
||||
if (!await looksFreshlyInstalled()) return; // DB has real data
|
||||
|
||||
final backup = await BackupService.instance.readBackup();
|
||||
if (backup == null) {
|
||||
// readBackup() has already said which case this is. Say what it MEANS:
|
||||
// the DB is sitting at seeded defaults and that is now the live state.
|
||||
debugPrint(
|
||||
'WorkoutApp: no backup restored — the database is at factory defaults. '
|
||||
'If this is a reinstall, progression state (weights, reps, streaks) '
|
||||
'has been lost and must be re-entered.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await _db.transaction((txn) async {
|
||||
for (final row in (backup['exercise_state'] as List? ?? [])
|
||||
.cast<Map<String, dynamic>>()) {
|
||||
await txn.insert(
|
||||
'exercise_state',
|
||||
row,
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
for (final row in (backup['workout_history'] as List? ?? [])
|
||||
.cast<Map<String, dynamic>>()) {
|
||||
await txn.insert(
|
||||
'workout_history',
|
||||
row,
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
for (final row in (backup['settings'] as List? ?? [])
|
||||
.cast<Map<String, dynamic>>()) {
|
||||
await txn.insert(
|
||||
'settings',
|
||||
row,
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,166 @@
|
||||
// Backup, restore, and the sync marker settings.
|
||||
//
|
||||
// An `extension` in a `part` rather than a separate library: Dart cannot
|
||||
// continue a class body across files, but library-private members (`_db`,
|
||||
// `_getSetting`) are still reachable from a part, so these methods keep
|
||||
// working unchanged and callers still say `StorageService.instance.foo()`.
|
||||
part of 'storage_service.dart';
|
||||
|
||||
/// Backup/restore and the last-synced markers.
|
||||
extension StorageServiceBackup on StorageService {
|
||||
/// Exports all persistent data to external storage as a JSON snapshot.
|
||||
///
|
||||
/// Refuses to overwrite a richer existing backup with a freshly-seeded,
|
||||
/// history-less DB. That combination is never a real state worth saving: it
|
||||
/// only happens when a restore failed (permission denied, unreadable file)
|
||||
/// and the DB is sitting at [workoutA]/[workoutB] defaults. The old code
|
||||
/// exported anyway on the first write after such a seed, replacing the only
|
||||
/// off-device copy of months of progression with default rows — the step
|
||||
/// that turned the 2026-08-05 reinstall from recoverable into permanent.
|
||||
Future<void> _backupNow() async {
|
||||
final exerciseRows = await _db.query('exercise_state');
|
||||
final historyRows = await _db.query('workout_history');
|
||||
final settingsRows = await _db.query('settings');
|
||||
|
||||
if (historyRows.isEmpty) {
|
||||
final existing = await BackupService.instance.readBackup();
|
||||
final existingHistory =
|
||||
(existing?['workout_history'] as List?) ?? const [];
|
||||
if (existingHistory.isNotEmpty) {
|
||||
debugPrint(
|
||||
'WorkoutApp: REFUSING to overwrite the backup — this DB has no '
|
||||
'workout history but the backup holds ${existingHistory.length} '
|
||||
'session(s). A restore almost certainly failed; keeping the backup '
|
||||
'so the data stays recoverable.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await BackupService.instance.export({
|
||||
'exercise_state': exerciseRows,
|
||||
'workout_history': historyRows,
|
||||
'settings': settingsRows,
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether this database has never recorded a workout on this install.
|
||||
///
|
||||
/// "Empty" means no workout history and no [last_workout_type] setting —
|
||||
/// the state a fresh install or a `pm clear` leaves behind, and the only
|
||||
/// state in which overwriting local rows wholesale is safe.
|
||||
///
|
||||
/// Shared with the Firebase progression restore so both restore paths agree
|
||||
/// on what "fresh install" means. Deliberately NOT a per-exercise value
|
||||
/// comparison: [resetExerciseToDefaults] leaves `reps` and `max_weight`
|
||||
/// alone, so resetting an exercise that already sits at its default reps
|
||||
/// produces a row byte-identical to a seeded one. Treating that as "fresh"
|
||||
/// would let a pull silently revert a reset the user just performed.
|
||||
Future<bool> looksFreshlyInstalled() async {
|
||||
final hasHistory =
|
||||
(await _db.rawQuery('SELECT COUNT(*) AS c FROM workout_history'))
|
||||
.first['c'] as int? ??
|
||||
0;
|
||||
if (hasHistory > 0) return false;
|
||||
return await _getSetting('last_workout_type') == null;
|
||||
}
|
||||
|
||||
/// Whether this install has reconciled its progression with Firebase.
|
||||
///
|
||||
/// This — NOT [looksFreshlyInstalled] — is what gates pushing progression
|
||||
/// up. The freshness test is unusable there because the only production
|
||||
/// caller (`_finishWorkout`) writes a `workout_history` row and
|
||||
/// `last_workout_type` *before* it pushes, so the DB has stopped looking
|
||||
/// fresh by the time the guard runs: the guard could never fire, and a
|
||||
/// reinstall that connected Firebase late would overwrite every real remote
|
||||
/// record with factory defaults on its first finished workout.
|
||||
///
|
||||
/// It is also wrong for hand edits: `setExerciseWeight` and
|
||||
/// `resetExerciseToDefaults` write neither history nor `last_workout_type`,
|
||||
/// so a DB still "looks fresh" after them and the next pull would revert the
|
||||
/// user's own change.
|
||||
Future<bool> hasSyncedProgression() async =>
|
||||
await _getSetting(_progressionSyncedKey) != null;
|
||||
|
||||
/// Records that remote progression has been reconciled, so later pushes are
|
||||
/// allowed and later pulls stop overwriting local state.
|
||||
Future<void> markProgressionSynced() =>
|
||||
_setSetting(_progressionSyncedKey, DateTime.now().toIso8601String());
|
||||
|
||||
static const _progressionSyncedKey = 'progression_synced_at';
|
||||
|
||||
/// When a workout sync last SUCCEEDED, or null if one never has.
|
||||
///
|
||||
/// Drives the home screen's "Out of date" card. Nothing persisted a
|
||||
/// last-sync time before — which is why the app could sit disconnected for
|
||||
/// days with nothing on screen saying so.
|
||||
Future<DateTime?> getLastSyncedAt() async {
|
||||
final raw = await _getSetting(_lastSyncedAtKey);
|
||||
if (raw == null) return null;
|
||||
final parsed = DateTime.tryParse(raw);
|
||||
if (parsed == null) {
|
||||
// Never silently treat a corrupt timestamp as "never synced": that
|
||||
// would show the nag card forever with no clue why.
|
||||
debugPrint(
|
||||
'WorkoutApp: last_synced_at is not a valid timestamp ($raw) — '
|
||||
'treating this device as never synced until the next success.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/// Records that a sync just succeeded.
|
||||
Future<void> markSyncedNow([DateTime? at]) => _setSetting(
|
||||
_lastSyncedAtKey,
|
||||
(at ?? DateTime.now()).toIso8601String(),
|
||||
);
|
||||
|
||||
static const _lastSyncedAtKey = 'last_synced_at';
|
||||
|
||||
/// Restores from backup if the local DB is empty (fresh install).
|
||||
///
|
||||
/// "Empty" means no workout history and no [last_workout_type] setting.
|
||||
Future<void> restoreFromBackupIfNeeded() async {
|
||||
if (!await looksFreshlyInstalled()) return; // DB has real data
|
||||
|
||||
final backup = await BackupService.instance.readBackup();
|
||||
if (backup == null) {
|
||||
// readBackup() has already said which case this is. Say what it MEANS:
|
||||
// the DB is sitting at seeded defaults and that is now the live state.
|
||||
debugPrint(
|
||||
'WorkoutApp: no backup restored — the database is at factory defaults. '
|
||||
'If this is a reinstall, progression state (weights, reps, streaks) '
|
||||
'has been lost and must be re-entered.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await _db.transaction((txn) async {
|
||||
for (final row in (backup['exercise_state'] as List? ?? [])
|
||||
.cast<Map<String, dynamic>>()) {
|
||||
await txn.insert(
|
||||
'exercise_state',
|
||||
row,
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
for (final row in (backup['workout_history'] as List? ?? [])
|
||||
.cast<Map<String, dynamic>>()) {
|
||||
await txn.insert(
|
||||
'workout_history',
|
||||
row,
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
for (final row in (backup['settings'] as List? ?? [])
|
||||
.cast<Map<String, dynamic>>()) {
|
||||
await txn.insert(
|
||||
'settings',
|
||||
row,
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
// Per-exercise progression state.
|
||||
//
|
||||
// The largest cohesive group: reading and writing exercise_state rows.
|
||||
// See storage_service_backup.dart for why these are `part` extensions.
|
||||
part of 'storage_service.dart';
|
||||
|
||||
/// Per-exercise progression state.
|
||||
extension StorageServiceExercises on StorageService {
|
||||
// ── Exercise state ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns the progression state for [name], or null if not found.
|
||||
Future<ExerciseState?> getExerciseState(String name) async {
|
||||
final rows = await _db.query(
|
||||
'exercise_state',
|
||||
where: 'name = ?',
|
||||
whereArgs: [name],
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
final r = rows.first;
|
||||
return ExerciseState(
|
||||
name: r['name']! as String,
|
||||
weight: r['weight']! as double,
|
||||
reps: r['reps']! as int,
|
||||
successStreak: r['success_streak']! as int,
|
||||
failStreak: r['fail_streak']! as int,
|
||||
maxWeight: r['max_weight']! as double,
|
||||
successThreshold: r['success_threshold'] as int? ?? 3,
|
||||
failThreshold: r['fail_threshold'] as int? ?? 2,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns progression states for every exercise across both plans.
|
||||
Future<List<ExerciseState>> getAllExerciseStates() async {
|
||||
final allNames = [...workoutA, ...workoutB].map((e) => e.name).toSet();
|
||||
final states = <ExerciseState>[];
|
||||
for (final name in allNames) {
|
||||
final s = await getExerciseState(name);
|
||||
if (s != null) states.add(s);
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
/// Updates the streak thresholds for exercise [name].
|
||||
Future<void> setExerciseThresholds(
|
||||
String name, {
|
||||
required int successThreshold,
|
||||
required int failThreshold,
|
||||
}) async {
|
||||
await _db.update(
|
||||
'exercise_state',
|
||||
{
|
||||
'success_threshold': successThreshold,
|
||||
'fail_threshold': failThreshold,
|
||||
},
|
||||
where: 'name = ?',
|
||||
whereArgs: [name],
|
||||
);
|
||||
}
|
||||
|
||||
/// Sets the target reps for [name], resetting streaks.
|
||||
///
|
||||
/// Progression can only ever raise reps (by one, and only once an exercise
|
||||
/// is at its max weight), so without this there is no way to lower a rep
|
||||
/// target that a defaults re-seed set wrong -- the 2026-08-05 wipe dropped
|
||||
/// Situp from 31 to the default 30 with no way back short of editing the DB.
|
||||
Future<void> setExerciseReps(String name, int reps) async {
|
||||
await _db.update(
|
||||
'exercise_state',
|
||||
{'reps': reps, 'success_streak': 0, 'fail_streak': 0},
|
||||
where: 'name = ?',
|
||||
whereArgs: [name],
|
||||
);
|
||||
unawaited(_backupNow());
|
||||
}
|
||||
|
||||
/// Overwrites [state] wholesale, inserting it when the row is absent.
|
||||
///
|
||||
/// Used by the Firebase progression restore, which carries a complete record
|
||||
/// (weight, reps, both streaks, max weight, both thresholds) and must land it
|
||||
/// atomically — a field-by-field update would leave a half-restored row if it
|
||||
/// failed partway. Unlike [setExerciseWeight] this deliberately does NOT
|
||||
/// reset streaks: the streaks are part of what is being restored.
|
||||
///
|
||||
/// Does not trigger a backup write. The caller is restoring INTO a
|
||||
/// freshly-seeded database, and `_backupNow()` mid-restore is what previously
|
||||
/// let default rows reach the only off-device copy.
|
||||
Future<void> replaceExerciseState(ExerciseState state) async {
|
||||
await _db.insert('exercise_state', {
|
||||
'name': state.name,
|
||||
'weight': state.weight,
|
||||
'reps': state.reps,
|
||||
'success_streak': state.successStreak,
|
||||
'fail_streak': state.failStreak,
|
||||
'max_weight': state.maxWeight,
|
||||
'success_threshold': state.successThreshold,
|
||||
'fail_threshold': state.failThreshold,
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
|
||||
/// Sets the working weight for [name], resetting streaks.
|
||||
Future<void> setExerciseWeight(String name, double weight) async {
|
||||
await _db.update(
|
||||
'exercise_state',
|
||||
{'weight': weight, 'success_streak': 0, 'fail_streak': 0},
|
||||
where: 'name = ?',
|
||||
whereArgs: [name],
|
||||
);
|
||||
unawaited(_backupNow());
|
||||
}
|
||||
|
||||
/// Returns exercises for [workoutType] with weights/reps from stored state.
|
||||
Future<List<Exercise>> getCurrentExercises(String workoutType) async {
|
||||
final template = workoutType == 'A' ? workoutA : workoutB;
|
||||
final result = <Exercise>[];
|
||||
for (final ex in template) {
|
||||
final state = await getExerciseState(ex.name);
|
||||
if (state == null) {
|
||||
// Defensive: _seedDefaultsIfNeeded guarantees every template exercise
|
||||
// has a state row, so this branch is unreachable in practice.
|
||||
// coverage:ignore-start
|
||||
result.add(ex);
|
||||
// coverage:ignore-end
|
||||
} else {
|
||||
result.add(ex.copyWith(weight: state.weight, reps: state.reps));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
// Schema creation, migration, and seeding.
|
||||
//
|
||||
// Runs once at open; nothing else touches these tables' shape.
|
||||
// See storage_service_backup.dart for why these are `part` extensions.
|
||||
part of 'storage_service.dart';
|
||||
|
||||
/// Schema creation, migration, and seeding.
|
||||
extension StorageServiceSchema on StorageService {
|
||||
|
||||
Future<void> _createSchema(Database db, int version) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE exercise_state (
|
||||
name TEXT PRIMARY KEY,
|
||||
weight REAL NOT NULL,
|
||||
reps INTEGER NOT NULL,
|
||||
success_streak INTEGER NOT NULL DEFAULT 0,
|
||||
fail_streak INTEGER NOT NULL DEFAULT 0,
|
||||
max_weight REAL NOT NULL,
|
||||
success_threshold INTEGER NOT NULL DEFAULT 3,
|
||||
fail_threshold INTEGER NOT NULL DEFAULT 2
|
||||
)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE workout_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
workout_type TEXT NOT NULL,
|
||||
duration_seconds INTEGER NOT NULL,
|
||||
succeeded INTEGER NOT NULL,
|
||||
json TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE active_session (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
json TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
Future<void> _migrateSchema(
|
||||
Database db,
|
||||
int oldVersion,
|
||||
int newVersion,
|
||||
) async {
|
||||
if (oldVersion < 2) {
|
||||
await db.execute(
|
||||
'ALTER TABLE exercise_state '
|
||||
'ADD COLUMN success_threshold INTEGER NOT NULL DEFAULT 3',
|
||||
);
|
||||
await db.execute(
|
||||
'ALTER TABLE exercise_state '
|
||||
'ADD COLUMN fail_threshold INTEGER NOT NULL DEFAULT 2',
|
||||
);
|
||||
}
|
||||
if (oldVersion < 3) {
|
||||
await db.execute(
|
||||
'CREATE TABLE IF NOT EXISTS settings '
|
||||
'(key TEXT PRIMARY KEY, value TEXT NOT NULL)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE TABLE IF NOT EXISTS active_session '
|
||||
'(id INTEGER PRIMARY KEY CHECK (id = 1), json TEXT NOT NULL)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _seedDefaultsIfNeeded() async {
|
||||
for (final ex in [...workoutA, ...workoutB]) {
|
||||
final rows = await _db.query(
|
||||
'exercise_state',
|
||||
where: 'name = ?',
|
||||
whereArgs: [ex.name],
|
||||
);
|
||||
if (rows.isEmpty) {
|
||||
await _db.insert('exercise_state', {
|
||||
'name': ex.name,
|
||||
'weight': ex.weight,
|
||||
'reps': ex.reps,
|
||||
'success_streak': 0,
|
||||
'fail_streak': 0,
|
||||
'max_weight': ex.maxWeight,
|
||||
'success_threshold': 3,
|
||||
'fail_threshold': 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────────────
|
||||
|
||||
}
|
||||
@ -0,0 +1,186 @@
|
||||
// Progression application and session recording.
|
||||
//
|
||||
// Split from storage_service_exercises.dart: that file reads and writes a
|
||||
// single exercise's row, this one advances progression and records whole
|
||||
// sessions. See storage_service_backup.dart for why these are `part`
|
||||
// extensions.
|
||||
part of 'storage_service.dart';
|
||||
|
||||
/// Progression application and session recording.
|
||||
extension StorageServiceSessions on StorageService {
|
||||
/// Applies progressive overload or regression based on [succeededExercises].
|
||||
Future<void> applyProgression({
|
||||
required Map<String, bool> succeededExercises,
|
||||
required DateTime lastWorkoutDate,
|
||||
}) async {
|
||||
final daysSince = DateTime.now().difference(lastWorkoutDate).inDays;
|
||||
final hadBreak = daysSince > 7;
|
||||
|
||||
for (final entry in succeededExercises.entries) {
|
||||
final state = await getExerciseState(entry.key);
|
||||
if (state == null) continue;
|
||||
|
||||
if (hadBreak) {
|
||||
final newWeight = (state.weight - kWeightIncrement).clamp(
|
||||
0.0,
|
||||
state.maxWeight,
|
||||
);
|
||||
await _db.update(
|
||||
'exercise_state',
|
||||
{'weight': newWeight, 'success_streak': 0, 'fail_streak': 0},
|
||||
where: 'name = ?',
|
||||
whereArgs: [entry.key],
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.value) {
|
||||
final newStreak = state.successStreak + 1;
|
||||
final shouldProgress = newStreak >= state.successThreshold;
|
||||
var newWeight = state.weight;
|
||||
var newReps = state.reps;
|
||||
|
||||
if (shouldProgress) {
|
||||
if (state.weight >= state.maxWeight) {
|
||||
newReps = state.reps + 1;
|
||||
} else {
|
||||
newWeight = (state.weight + kWeightIncrement).clamp(
|
||||
0.0,
|
||||
state.maxWeight,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await _db.update(
|
||||
'exercise_state',
|
||||
{
|
||||
'weight': newWeight,
|
||||
'reps': newReps,
|
||||
'success_streak': shouldProgress ? 0 : newStreak,
|
||||
'fail_streak': 0,
|
||||
},
|
||||
where: 'name = ?',
|
||||
whereArgs: [entry.key],
|
||||
);
|
||||
} else {
|
||||
final newStreak = state.failStreak + 1;
|
||||
final shouldRegress = newStreak >= state.failThreshold;
|
||||
final newWeight = shouldRegress
|
||||
? (state.weight - kWeightIncrement).clamp(0.0, state.maxWeight)
|
||||
: state.weight;
|
||||
|
||||
await _db.update(
|
||||
'exercise_state',
|
||||
{
|
||||
'weight': newWeight,
|
||||
'fail_streak': shouldRegress ? 0 : newStreak,
|
||||
'success_streak': 0,
|
||||
},
|
||||
where: 'name = ?',
|
||||
whereArgs: [entry.key],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists a completed session to the workout history table.
|
||||
Future<void> saveSession({
|
||||
required String date,
|
||||
required String workoutType,
|
||||
required int durationSeconds,
|
||||
required bool succeeded,
|
||||
required String json,
|
||||
}) async {
|
||||
await _db.insert('workout_history', {
|
||||
'date': date,
|
||||
'workout_type': workoutType,
|
||||
'duration_seconds': durationSeconds,
|
||||
'succeeded': succeeded ? 1 : 0,
|
||||
'json': json,
|
||||
});
|
||||
unawaited(_backupNow());
|
||||
}
|
||||
|
||||
/// Returns the date of the most recent completed session, or null.
|
||||
Future<DateTime?> getLastWorkoutDate() async {
|
||||
final rows = await _db.rawQuery(
|
||||
'SELECT date FROM workout_history ORDER BY date DESC LIMIT 1',
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return DateTime.tryParse(rows.first['date']! as String);
|
||||
}
|
||||
|
||||
/// Returns up to [limit] rows from workout history, newest first.
|
||||
/// Inserts synced sessions this device has no local record of.
|
||||
///
|
||||
/// These are the user's OWN completed workouts, already in Firebase/GitHub;
|
||||
/// a reinstall wipes the local `workout_history` table while the remote copy
|
||||
/// survives, so the app ends up showing far less history than actually
|
||||
/// happened. This puts the real records back rather than inventing anything.
|
||||
///
|
||||
/// Idempotent: a session is matched on (date, start_time) taken from the
|
||||
/// payload, so repeated syncs never duplicate a row. Sessions the device
|
||||
/// already has always win -- the local row is the one the progression
|
||||
/// engine acted on.
|
||||
///
|
||||
/// Returns the number of sessions restored.
|
||||
Future<int> restoreSyncedSessions(
|
||||
List<Map<String, dynamic>> payloads,
|
||||
) async {
|
||||
if (payloads.isEmpty) return 0;
|
||||
final existing = await _db.query('workout_history', columns: ['json']);
|
||||
final seen = <String>{
|
||||
for (final row in existing) _sessionKey(_decodeJson(row['json'])),
|
||||
};
|
||||
|
||||
var restored = 0;
|
||||
for (final payload in payloads) {
|
||||
// Only real sessions: a payload with no exercises is a PC-side
|
||||
// runnerup/manual record, which belongs in the synced list, not here.
|
||||
if (payload['exercises'] is! List) continue;
|
||||
final key = _sessionKey(payload);
|
||||
if (key.isEmpty || seen.contains(key)) continue;
|
||||
seen.add(key);
|
||||
await _db.insert('workout_history', {
|
||||
'date': payload['date'] as String? ?? '',
|
||||
'workout_type': payload['workout_type'] as String? ?? '',
|
||||
'duration_seconds': (payload['duration_seconds'] as num?)?.toInt() ?? 0,
|
||||
'succeeded': (payload['succeeded'] as bool? ?? false) ? 1 : 0,
|
||||
'json': jsonEncode(payload),
|
||||
});
|
||||
restored++;
|
||||
}
|
||||
if (restored > 0) {
|
||||
debugPrint('WorkoutApp: restored $restored synced session(s) locally.');
|
||||
unawaited(_backupNow());
|
||||
}
|
||||
return restored;
|
||||
}
|
||||
|
||||
/// Identity of a session: its date plus start time, both from the payload.
|
||||
///
|
||||
/// `start_time` alone would be enough, but pairing it with the date keeps
|
||||
/// the key readable in logs and survives a payload missing either field.
|
||||
static String _sessionKey(Map<String, dynamic>? payload) {
|
||||
if (payload == null) return '';
|
||||
final start = payload['start_time'] as String? ?? '';
|
||||
final date = payload['date'] as String? ?? '';
|
||||
return start.isEmpty && date.isEmpty ? '' : '$date|$start';
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _decodeJson(Object? raw) {
|
||||
if (raw is! String || raw.isEmpty) return null;
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
return decoded is Map<String, dynamic> ? decoded : null;
|
||||
} on FormatException catch (error) {
|
||||
// A corrupt local row must not block restoring the rest; it simply
|
||||
// cannot be matched against, so it is treated as "not seen".
|
||||
debugPrint(
|
||||
'WorkoutApp: a stored session row is not valid JSON ($error) — '
|
||||
'treating it as unseen, so a remote copy may be re-imported.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user