diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen.dart index c18ec9f..400b074 100644 --- a/stronglift_replacement/workout_app/lib/screens/settings_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen.dart @@ -24,6 +24,7 @@ import 'package:workout_app/services/pc_pairing.dart'; import 'package:workout_app/services/progression_sync_service.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/ui/theme.dart'; +import 'package:workout_app/widgets/data_transfer_buttons.dart'; import 'package:workout_app/widgets/exercise_settings_sheet.dart'; part 'settings_screen_actions.dart'; diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen_sections.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen_sections.dart index 077e628..068a38c 100644 --- a/stronglift_replacement/workout_app/lib/screens/settings_screen_sections.dart +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen_sections.dart @@ -145,6 +145,11 @@ class _OfflineBackupSection extends StatelessWidget { label: const Text('Grant storage permission'), ), ), + DataTransferButtons( + onImported: () => unawaited( + context.findAncestorStateOfType<_SettingsScreenState>()!._load(), + ), + ), ], ); } @@ -171,10 +176,7 @@ class _SettingsAppBar extends StatelessWidget implements PreferredSizeWidget { final colorScheme = Theme.of(context).colorScheme; return AppBar( backgroundColor: colorScheme.surfaceContainerHigh, - title: Text( - 'Settings', - style: TextStyle(color: colorScheme.onSurface), - ), + title: Text('Settings', style: TextStyle(color: colorScheme.onSurface)), iconTheme: IconThemeData(color: colorScheme.onSurface), actions: [ TextButton( diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen_widget.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen_widget.dart index 6034163..710e01f 100644 --- a/stronglift_replacement/workout_app/lib/screens/settings_screen_widget.dart +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen_widget.dart @@ -8,16 +8,8 @@ part of 'settings_screen.dart'; /// Screen for per-exercise settings and manual weight/rep overrides. /// -/// No [BackupSlot] is wired into the shared Sync settings screen: unlike the -/// notes app and home_inventory, workout_app has no user-facing export/import -/// action to hand it. `BackupService.export`/`readBackup` are called only from -/// [StorageService]'s automatic paths (on every weight/threshold write, and -/// once at startup via `restoreFromBackupIfNeeded`) -- there is nothing for a -/// manual "Export"/"Import" button to trigger that isn't already automatic, -/// and a blind manual import would risk clobbering current progression with -/// whatever is on external storage. The storage-permission affordance itself -/// is a device permission, not a sync action, so it stays here rather than -/// moving to either sync screen. +/// The user-owned export/import lives in OFFLINE BACKUP +/// ([DataTransferButtons]); its import only merges, never clobbers. class SettingsScreen extends StatefulWidget { /// Creates a [SettingsScreen]. const SettingsScreen({ diff --git a/stronglift_replacement/workout_app/lib/services/data_transfer.dart b/stronglift_replacement/workout_app/lib/services/data_transfer.dart new file mode 100644 index 0000000..10324cb --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/data_transfer.dart @@ -0,0 +1,37 @@ +import 'dart:convert'; + +import 'package:data_transfer_flutter/data_transfer_flutter.dart'; +import 'package:workout_app/services/storage_service.dart'; + +String _exportFileName(DateTime now) => + 'workout_app-export-${now.toIso8601String().substring(0, 10)}.json'; + +/// Parses [source] and merges it; throws [FormatException] on a bad file. +Future importExportText(String source) async { + final decoded = jsonDecode(source); + if (decoded is! Map) { + throw const FormatException('export is not a JSON object'); + } + return await StorageService.instance.importDataReport(decoded); +} + +// coverage:ignore-start + +/// Saves an export via the system Save-as picker; false when cancelled. +Future exportUserData() async { + final json = const JsonEncoder.withIndent( + ' ', + ).convert(await StorageService.instance.exportData()); + return await exportJsonText( + fileName: _exportFileName(DateTime.now()), + text: json, + ); +} + +/// Picks an export and merges it; null when cancelled. +Future importUserData() async { + final text = await pickJsonText(); + if (text == null) return null; + return await importExportText(text); +} +// coverage:ignore-end diff --git a/stronglift_replacement/workout_app/lib/services/manual_workout_port.dart b/stronglift_replacement/workout_app/lib/services/manual_workout_port.dart new file mode 100644 index 0000000..21932da --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/manual_workout_port.dart @@ -0,0 +1,43 @@ +/// The remote manual-workout log, as the data export/import sees it. +library; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:workout_app/services/workout_sync_service.dart'; + +/// Where manual workouts live: only in the remote crdt_sync log. +abstract interface class ManualWorkoutPort { + /// All manual records (tombstones included), or null when the remote is + /// unreachable or this device is not signed in. + Future read(); + + /// Merges [records] into the remote log; false when that did not happen. + Future push(Log records); +} + +/// The real port: the app's existing crdt_sync/Firebase client. +class SyncManualWorkoutPort implements ManualWorkoutPort { + /// Creates the port over [service] (a default service when omitted). + SyncManualWorkoutPort([WorkoutSyncService? service]) + : _service = service ?? WorkoutSyncService(); + + final WorkoutSyncService _service; + + @override + Future read() => _service.readManualLog(); + + @override + Future push(Log records) async => + (await _service.pushManualLog(records)).pushed; +} + +/// A port that is always offline; the default under test. +class OfflineManualWorkoutPort implements ManualWorkoutPort { + /// Creates the offline port. + const OfflineManualWorkoutPort(); + + @override + Future read() async => null; + + @override + Future push(Log records) async => false; +} diff --git a/stronglift_replacement/workout_app/lib/services/storage_service.dart b/stronglift_replacement/workout_app/lib/services/storage_service.dart index 0aa76e4..3327dce 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:developer' show log; import 'dart:io' show Platform; +import 'package:crdt_sync/crdt_sync.dart' show Log, Record, mergeLogs; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -15,6 +16,7 @@ import 'package:workout_app/models/manual_deload.dart'; import 'package:workout_app/models/progression.dart'; import 'package:workout_app/models/workout_plan.dart'; import 'package:workout_app/services/backup_service.dart'; +import 'package:workout_app/services/manual_workout_port.dart'; import 'package:workout_app/services/progression_sync_service.dart'; // Re-exported: ExerciseState was defined here until it moved to models/, and @@ -26,6 +28,8 @@ export 'package:workout_app/models/progression.dart'; part 'storage_service_backup.dart'; part 'storage_service_deload.dart'; part 'storage_service_exercises.dart'; +part 'storage_service_export.dart'; +part 'storage_service_import.dart'; part 'storage_service_pc_link.dart'; part 'storage_service_sandbox.dart'; part 'storage_service_schema.dart'; @@ -64,8 +68,15 @@ class StorageService { _instance = null; _testDbPath = dbPath; remoteActiveSessionReader = null; + manualWorkouts = const OfflineManualWorkoutPort(); } + /// The remote manual-workout log the data export/import reads and writes. + /// + /// Defaults to the real crdt_sync client; [resetForTesting] swaps in an + /// offline stub because the real one needs the OS keystore and a network. + static ManualWorkoutPort manualWorkouts = SyncManualWorkoutPort(); + /// Reads the shared in-progress session from Firebase. Injected because the /// real implementation reaches the OS keystore through a platform channel, /// which `flutter test` has no binding for — and the resulting failure is an @@ -83,7 +94,7 @@ class StorageService { // coverage:ignore-end _db = await openDatabase( dbPath, - version: 7, + version: 8, onCreate: _createSchema, onUpgrade: _migrateSchema, // In tests resetForTesting() reopens a ':memory:' DB per test; sqflite's @@ -108,6 +119,7 @@ class StorageService { await _db.insert('settings', { 'key': key, 'value': value, + 'updated_at': _nowMs(), }, conflictAlgorithm: ConflictAlgorithm.replace); } @@ -205,6 +217,7 @@ class StorageService { 'fail_threshold': 2, 'success_streak': 0, 'fail_streak': 0, + 'updated_at': _nowMs(), }, where: 'name = ?', whereArgs: [name], diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_deload.dart b/stronglift_replacement/workout_app/lib/services/storage_service_deload.dart index 4dc1d95..c6c33f3 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_deload.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_deload.dart @@ -50,6 +50,7 @@ extension StorageServiceDeload on StorageService { 'reps': t.reps, 'success_streak': 0, 'fail_streak': 0, + 'updated_at': _nowMs(), }, where: 'name = ?', whereArgs: [name], diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_exercises.dart b/stronglift_replacement/workout_app/lib/services/storage_service_exercises.dart index a3d81bb..8a8384d 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_exercises.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_exercises.dart @@ -57,7 +57,11 @@ extension StorageServiceExercises on StorageService { }) async { await _db.update( 'exercise_state', - {'success_threshold': successThreshold, 'fail_threshold': failThreshold}, + { + 'success_threshold': successThreshold, + 'fail_threshold': failThreshold, + 'updated_at': _nowMs(), + }, where: 'name = ?', whereArgs: [name], ); @@ -82,6 +86,7 @@ extension StorageServiceExercises on StorageService { 'has_warmup': state.hasWarmup ? 1 : 0, 'paused_until': _pauseColumn(state), ..._restColumnValues(state), + 'updated_at': _nowMs(), }, where: 'name = ?', whereArgs: [state.name], @@ -109,7 +114,12 @@ extension StorageServiceExercises on StorageService { Future setExerciseReps(String name, int reps) async { await _db.update( 'exercise_state', - {'reps': reps, 'success_streak': 0, 'fail_streak': 0}, + { + 'reps': reps, + 'success_streak': 0, + 'fail_streak': 0, + 'updated_at': _nowMs(), + }, where: 'name = ?', whereArgs: [name], ); @@ -143,6 +153,7 @@ extension StorageServiceExercises on StorageService { 'has_warmup': state.hasWarmup ? 1 : 0, 'paused_until': _pauseColumn(state), ..._restColumnValues(state), + 'updated_at': _nowMs(), }, conflictAlgorithm: ConflictAlgorithm.replace); } @@ -150,7 +161,12 @@ extension StorageServiceExercises on StorageService { Future setExerciseWeight(String name, double weight) async { await _db.update( 'exercise_state', - {'weight': weight, 'success_streak': 0, 'fail_streak': 0}, + { + 'weight': weight, + 'success_streak': 0, + 'fail_streak': 0, + 'updated_at': _nowMs(), + }, where: 'name = ?', whereArgs: [name], ); diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_export.dart b/stronglift_replacement/workout_app/lib/services/storage_service_export.dart new file mode 100644 index 0000000..eb5c93d --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/storage_service_export.dart @@ -0,0 +1,74 @@ +// User-owned export. Skips active_session and keystore secrets: the file +// travels through a share sheet. Same keys as the automatic backup.json. +// The merge-import half lives in storage_service_import.dart. +part of 'storage_service.dart'; + +/// Marks a document as a workout_app export. +const kExportFormat = 'workout_app-export'; + +/// The format written now. Version 2 adds `updated_at` to `exercise_state` and +/// `settings` rows plus `manual_workouts`; version 1 files still import. +const kExportVersion = 2; + +const _exportTables = [ + 'workout_history', + 'progression_events', + 'exercise_state', + 'settings', +]; + +/// Export to one plain JSON document. +extension StorageServiceExport on StorageService { + /// Every user-data table, minus local AUTOINCREMENT ids. + /// + /// `manual_workouts` is the remote crdt_sync manual log in wire format + /// (`Record.toJson`, tombstones included). When the remote cannot be read + /// (offline, not signed in) the export still succeeds: `manual_workouts` is + /// null and `manual_workouts_skipped` is true, so a file never claims "no + /// manual workouts" when it merely could not look. + Future> exportData() async { + List> withoutId(List> rows) => [ + for (final row in rows) Map.of(row)..remove('id'), + ]; + final settings = await _db.query('settings', orderBy: 'key'); + final manual = await _readManualLog(); + return { + 'format': kExportFormat, + 'version': kExportVersion, + 'exported_at': DateTime.now().toIso8601String(), + 'exercise_state': await _db.query('exercise_state', orderBy: 'name'), + 'workout_history': withoutId( + await _db.query('workout_history', orderBy: 'date, id'), + ), + 'progression_events': withoutId( + await _db.query('progression_events', orderBy: 'at, id'), + ), + 'settings': [ + for (final row in settings) + if (!_isPerInstallSetting(row['key']! as String)) row, + ], + 'manual_workouts': manual == null + ? null + : [ + for (final id in manual.keys.toList()..sort()) + manual[id]!.toJson(), + ], + 'manual_workouts_skipped': manual == null, + }; + } + + Future _readManualLog() async { + try { + return await StorageService.manualWorkouts.read(); + } on Object catch (error) { + debugPrint('WorkoutApp: manual workouts NOT exported: $error'); + return null; + } + } +} + +// Per-install sync bookkeeping: importing it would block a new install's +// Firebase pull and hide its out-of-date card. +bool _isPerInstallSetting(String key) => + key == StorageServiceBackup._progressionSyncedKey || + key == StorageServiceBackup._lastSyncedAtKey; diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_import.dart b/stronglift_replacement/workout_app/lib/services/storage_service_import.dart new file mode 100644 index 0000000..51943ad --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/storage_service_import.dart @@ -0,0 +1,189 @@ +// Merge-import of a user-owned export; never deletes. See +// storage_service_export.dart for the format. +part of 'storage_service.dart'; + +/// What an import did; [StorageServiceImport.importData] returns the sum. +typedef ImportReport = ({int rows, int manualRecords, bool manualSkipped}); + +/// Merge-import from one plain JSON document. +extension StorageServiceImport on StorageService { + /// Merges [data] in and returns the rows and manual records written. + Future importData(Map data) async { + final report = await importDataReport(data); + return report.rows + report.manualRecords; + } + + /// Merges [data] in and says what happened to each part. + /// + /// Logs are matched on content and only appended. `exercise_state` and + /// `settings` rows (version 2 files) carry `updated_at`: the newer stamp + /// wins per row, a tie keeps local. A version 1 file has no stamps, so it + /// keeps the old rule: it wins only on a [looksFreshlyInstalled] store. + /// `manual_workouts` merge through `mergeLogs` into the remote log; when + /// the remote is unreachable they are skipped (the rest still merges) and + /// [ImportReport.manualSkipped] says so. Idempotent. + Future importDataReport(Map data) async { + if (!_exportTables.any((t) => data[t] is List)) { + if (data['manual_workouts'] is! List) { + throw const FormatException('not a workout_app export'); + } + } + final version = data['version']; + final stamped = version is int && version >= 2; + final fileWins = !stamped && await looksFreshlyInstalled(); + var rows = 0; + await _db.transaction((txn) async { + for (final table in _exportTables) { + rows += await _mergeTable(txn, table, data[table], fileWins, stamped); + } + }); + if (rows > 0) unawaited(_backupNow()); + final manual = await _importManual(data['manual_workouts']); + return ( + rows: rows, + manualRecords: manual.written, + manualSkipped: manual.skipped, + ); + } + + Future<({int written, bool skipped})> _importManual(Object? raw) async { + if (raw is! List) { + if (raw != null) _warnSkipped('manual_workouts', 1); + return (written: 0, skipped: false); + } + final incoming = {}; + var malformed = 0; + for (final entry in raw) { + try { + final record = Record.fromJson(entry as Map); + incoming[record.id] = incoming.containsKey(record.id) + ? mergeLogs( + {record.id: incoming[record.id]!}, + {record.id: record}, + )[record.id]! + : record; + } on Object { + malformed++; + } + } + if (malformed > 0) _warnSkipped('manual_workouts', malformed); + if (incoming.isEmpty) return (written: 0, skipped: false); + try { + final port = StorageService.manualWorkouts; + final current = await port.read(); + if (current == null) return (written: 0, skipped: true); + final changed = { + for (final e in incoming.entries) + if (_addsTo(current[e.key], e.value)) e.key: e.value, + }; + if (changed.isEmpty) return (written: 0, skipped: false); + if (!await port.push(changed)) return (written: 0, skipped: true); + return (written: changed.length, skipped: false); + } on Object catch (error) { + debugPrint('WorkoutApp: manual workouts NOT imported: $error'); + return (written: 0, skipped: true); + } + } + + Future _mergeTable( + Transaction txn, + String table, + Object? raw, + bool fileWins, + bool stamped, + ) async { + if (raw is! List) { + if (raw != null) _warnSkipped(table, 1); + return 0; + } + final columns = { + for (final c in await txn.rawQuery('PRAGMA table_info($table)')) + c['name']! as String, + }..remove('id'); + final keyOf = _identity(table); + final local = {for (final row in await txn.query(table)) keyOf(row): row}; + final appendOnly = !columns.contains('name') && !columns.contains('key'); + var written = 0; + var malformed = raw.length - raw.whereType>().length; + for (final entry in raw.whereType>()) { + final row = { + for (final e in entry.entries) + if (columns.contains(e.key)) e.key: e.value, + }; + final key = keyOf(row); + if (key.isEmpty) malformed++; + if (key.isEmpty || (table == 'settings' && _isPerInstallSetting(key))) { + continue; + } + final existing = local[key]; + if (existing != null) { + final replace = + !appendOnly && + (stamped + ? _stamp(row) > _stamp(existing) + : fileWins && !row.keys.every((c) => existing[c] == row[c])); + if (!replace) continue; + } + await txn.insert( + table, + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + local[key] = row; + written++; + } + if (malformed > 0) _warnSkipped(table, malformed); + return written; + } +} + +/// Whether merging [incoming] would change [current]: a new record, a field +/// with a strictly greater HLC, a field [current] lacks, or a new tombstone. +/// +/// Decided on HLCs, not `Record ==`: that compares payload maps by identity, +/// so a copy that went through JSON would always look changed and every +/// repeat import would push again. +bool _addsTo(Record? current, Record incoming) { + if (current == null) return true; + if (incoming.deleted && !current.deleted) return true; + for (final f in incoming.fields.entries) { + final have = current.fields[f.key]; + if (have == null || f.value.$2 > have.$2) return true; + } + return false; +} + +// A row without a usable stamp is "never edited": 0 loses to any stamp. +int _stamp(Map row) { + final v = row['updated_at']; + return v is num ? v.toInt() : 0; +} + +void _warnSkipped(String table, int count) => debugPrint( + 'WorkoutApp: import SKIPPED $count malformed $table entr(y/ies) -- ' + 'no identity or not an object; the rest of the file was merged.', +); + +const _eventColumns = [ + 'at', + 'exercise', + 'kind', + 'source', + 'from_weight', + 'from_reps', + 'to_weight', + 'to_reps', +]; + +String Function(Map) _identity(String table) => + switch (table) { + 'exercise_state' => (r) => r['name'] as String? ?? '', + 'settings' => (r) => r['key'] as String? ?? '', + 'workout_history' => (r) { + final key = StorageServiceSessions._sessionKey( + StorageServiceSessions._decodeJson(r['json']), + ); + return key.isNotEmpty ? key : '${r['date']}|${r['json']}'; + }, + _ => (r) => [for (final c in _eventColumns) r[c]].join('|'), + }; diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_schema.dart b/stronglift_replacement/workout_app/lib/services/storage_service_schema.dart index 502a584..4c0a23b 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_schema.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_schema.dart @@ -29,6 +29,14 @@ const _progressionEventsTable = ''' ) '''; +/// The v8 last-write stamp (ms since epoch, UTC), shared by create and +/// migrate. 0 = never edited by the user (seeded, or older than v8): any +/// stamped import row beats it. +const _updatedAtColumn = 'updated_at INTEGER NOT NULL DEFAULT 0'; + +/// Milliseconds since the epoch, UTC: the value every user write stamps. +int _nowMs() => DateTime.now().toUtc().millisecondsSinceEpoch; + /// Schema creation, migration, and seeding. extension StorageServiceSchema on StorageService { Future _createSchema(Database db, int version) async { @@ -47,7 +55,8 @@ extension StorageServiceSchema on StorageService { reps_low INTEGER NOT NULL DEFAULT $kDefaultRepsLow, has_warmup INTEGER, paused_until TEXT, - $_restColumns + $_restColumns, + $_updatedAtColumn ) '''); await db.execute(''' @@ -63,7 +72,8 @@ extension StorageServiceSchema on StorageService { await db.execute(''' CREATE TABLE settings ( key TEXT PRIMARY KEY, - value TEXT NOT NULL + value TEXT NOT NULL, + $_updatedAtColumn ) '''); await db.execute(''' @@ -129,6 +139,13 @@ extension StorageServiceSchema on StorageService { // A new table, nothing to backfill: deloads before v7 left no trace. await db.execute(_progressionEventsTable); } + if (oldVersion < 8) { + // NOT NULL DEFAULT 0 backfills every existing row with 0: their real + // edit time is unknown, so an import with a stamp may overwrite them. + for (final table in const ['exercise_state', 'settings']) { + await db.execute('ALTER TABLE $table ADD COLUMN $_updatedAtColumn'); + } + } } Future _seedDefaultsIfNeeded() async { diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_sessions.dart b/stronglift_replacement/workout_app/lib/services/storage_service_sessions.dart index 0b8cca9..456db79 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_sessions.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_sessions.dart @@ -61,7 +61,7 @@ extension StorageServiceSessions on StorageService { } await _db.update( 'exercise_state', - update, + {...update, 'updated_at': _nowMs()}, where: 'name = ?', whereArgs: [entry.key], ); diff --git a/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart b/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart index a25ca6d..b669d6e 100644 --- a/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart +++ b/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart @@ -15,6 +15,7 @@ import 'package:workout_app/services/sync_state_factory.dart'; import 'package:workout_app/services/workout_sync_session.dart'; part 'workout_sync_service_read.dart'; +part 'workout_sync_service_transfer.dart'; const _pathPrefix = 'screen-locker-sync/devices'; const _logFilename = 'log.json'; @@ -81,8 +82,7 @@ class WorkoutSyncService { http.Client? httpClient, this.firebaseFactory, this.stateStore, - // Dart forbids private named params, so this can't be an initializing - // formal; assign it explicitly (mirrors crdt_sync_dart's GitHubClient). + // Dart forbids private named params; assign explicitly (as GitHubClient). // ignore: prefer_initializing_formals }) : _httpClient = httpClient; diff --git a/stronglift_replacement/workout_app/lib/services/workout_sync_service_transfer.dart b/stronglift_replacement/workout_app/lib/services/workout_sync_service_transfer.dart new file mode 100644 index 0000000..414df17 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/workout_sync_service_transfer.dart @@ -0,0 +1,82 @@ +// Manual-workout records for the user-owned export/import. +// +// An `extension` in a `part` -- see storage_service_backup.dart for why. +part of 'workout_sync_service.dart'; + +/// Reads and writes the manual-workout slice of the shared crdt_sync log. +extension WorkoutSyncServiceTransfer on WorkoutSyncService { + /// Every manual-workout record on every device log, tombstones included, + /// merged with [mergeLogs]; null when the remote cannot be read. + /// + /// Unlike [readMergedManualPayloads] this keeps the records (HLCs and + /// deletions) so they can be carried across and merged back losslessly, and + /// it tells "empty" (`{}`) from "unreachable" (null) -- an export must not + /// claim there are no manual workouts when it merely could not look. + Future readManualLog() async { + final settings = await SyncSettings.load(); + if (!settings.isConfigured && !await _hasFirebaseAccount()) { + debugPrint('WorkoutSyncService: manual log NOT read -- sync not set up.'); + return null; + } + final github = GitHubClient( + owner: owner, + repo: repo, + token: settings.token, + httpClient: _httpClient, + ); + final firebase = await _openFirebase(); + final client = firebase == null + ? github + : MirrorStore(primary: firebase, mirror: github); + try { + var merged = {}; + for (final device in await client.listDirectory(_pathPrefix)) { + final text = await client.getFileText( + '$_pathPrefix/$device/$_logFilename', + ); + if (text != null) merged = mergeLogs(merged, _decode(text)); + } + return { + for (final e in merged.entries) + if (_isManual(e.value)) e.key: e.value, + }; + } on Object catch (error, stackTrace) { + log( + 'WorkoutSyncService.readManualLog failed', + level: 1000, + error: error, + stackTrace: stackTrace, + ); + return null; + } finally { + github.close(); + firebase?.close(); + } + } + + /// Merges [records] into this device's log and syncs, in one round trip. + /// + /// Same never-throw, always-report contract as [pushManual]. + Future pushManualLog(Log records) async { + final settings = await SyncSettings.load(); + if (!settings.isConfigured && !await _hasFirebaseAccount()) { + const reason = 'sync not configured (no Firebase account, no token)'; + log('WorkoutSyncService.pushManualLog skipped: $reason', level: 900); + return const PushResult(pushed: false, reason: reason); + } + return await _session(settings.token).run( + operation: 'pushManualLog', + successReason: 'pushed', + failurePrefix: 'push failed', + additions: records, + ); + } + + /// A manual workout, or the tombstone of one: tombstones carry no payload, + /// so the id (`manual:T`) is what identifies them. + static bool _isManual(Record r) { + if (r.id.startsWith('manual:')) return true; + final payload = r.fields['payload']?.$1; + return payload is Map && payload['kind'] == kManualWorkoutSyncKind; + } +} diff --git a/stronglift_replacement/workout_app/lib/services/workout_sync_session.dart b/stronglift_replacement/workout_app/lib/services/workout_sync_session.dart index 2c25260..36261c0 100644 --- a/stronglift_replacement/workout_app/lib/services/workout_sync_session.dart +++ b/stronglift_replacement/workout_app/lib/services/workout_sync_session.dart @@ -70,6 +70,7 @@ class WorkoutSyncSession { /// Runs one tick, merging [addition] into this device's log if given. /// + /// [additions] merges many records in one round trip (the data import). /// Pass null for [addition] to round-trip the existing log unchanged — that /// is how a connectivity check gets a real answer without inventing a /// record to push. @@ -82,6 +83,7 @@ class WorkoutSyncSession { required String successReason, required String failurePrefix, Record? addition, + Log? additions, }) async { final github = GitHubClient( owner: owner, @@ -99,9 +101,13 @@ class WorkoutSyncSession { final existingLog = existingText == null ? {} : decode(existingText); - final localLog = addition == null + final incoming = { + ...?additions, + ?addition?.id: ?addition, + }; + final localLog = incoming.isEmpty ? existingLog - : mergeLogs(existingLog, {addition.id: addition}); + : mergeLogs(existingLog, incoming); await syncLog( client: client, deviceId: currentSyncDeviceId, diff --git a/stronglift_replacement/workout_app/lib/widgets/data_transfer_buttons.dart b/stronglift_replacement/workout_app/lib/widgets/data_transfer_buttons.dart new file mode 100644 index 0000000..f7fedce --- /dev/null +++ b/stronglift_replacement/workout_app/lib/widgets/data_transfer_buttons.dart @@ -0,0 +1,109 @@ +/// Icon-only export/import controls; the outcome shows as an icon, not text. +library; + +import 'dart:developer' show log; + +import 'package:flutter/material.dart'; +import 'package:workout_app/services/data_transfer.dart'; +import 'package:workout_app/services/storage_service.dart'; + +/// How the last export or import ended. +enum _Outcome { done, skipped, failed } + +/// Export and import buttons plus a result icon. +class DataTransferButtons extends StatefulWidget { + /// Creates the buttons; the seams default to the real platform flows. + const DataTransferButtons({ + super.key, + this.onImported, + this.exporter = exportUserData, + this.importer = importUserData, + }); + + /// Called after an import wrote rows, so the caller can reload. + final VoidCallback? onImported; + + /// Writes the export; false when the user cancelled. + final Future Function() exporter; + + /// Merges a picked export; null when cancelled, else what it did. + final Future Function() importer; + + @override + State createState() => _DataTransferButtonsState(); +} + +class _DataTransferButtonsState extends State { + bool _busy = false; + _Outcome? _outcome; + + Future _run(String what, Future<_Outcome?> Function() action) async { + setState(() { + _busy = true; + _outcome = null; + }); + _Outcome? outcome; + try { + outcome = await action(); + } on Object catch (error, stackTrace) { + log( + 'Data $what failed', + level: 1000, + error: error, + stackTrace: stackTrace, + ); + debugPrint('WorkoutApp: data $what FAILED: $error'); + outcome = _Outcome.failed; + } + if (!mounted) return; + setState(() { + _busy = false; + _outcome = outcome; + }); + } + + Future<_Outcome?> _export() async => + await widget.exporter() ? _Outcome.done : null; + + Future<_Outcome?> _import() async { + final report = await widget.importer(); + if (report == null) return null; + if (report.rows + report.manualRecords > 0) widget.onImported?.call(); + return report.manualSkipped ? _Outcome.skipped : _Outcome.done; + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Row( + children: [ + IconButton( + onPressed: _busy ? null : () => _run('export', _export), + icon: const Icon(Icons.save_alt, semanticLabel: 'Export data'), + ), + IconButton( + onPressed: _busy ? null : () => _run('import', _import), + icon: const Icon(Icons.file_open, semanticLabel: 'Import data'), + ), + if (_outcome == _Outcome.done) + Icon( + Icons.check_circle, + color: colorScheme.primary, + semanticLabel: 'Done', + ) + else if (_outcome == _Outcome.skipped) + Icon( + Icons.warning_amber, + color: colorScheme.tertiary, + semanticLabel: 'Manual workouts skipped', + ) + else if (_outcome == _Outcome.failed) + Icon( + Icons.error_outline, + color: colorScheme.error, + semanticLabel: 'Failed', + ), + ], + ); + } +} diff --git a/stronglift_replacement/workout_app/linux/flutter/generated_plugin_registrant.cc b/stronglift_replacement/workout_app/linux/flutter/generated_plugin_registrant.cc index ef9ba8a..2cffa44 100644 --- a/stronglift_replacement/workout_app/linux/flutter/generated_plugin_registrant.cc +++ b/stronglift_replacement/workout_app/linux/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include @@ -14,6 +15,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); diff --git a/stronglift_replacement/workout_app/linux/flutter/generated_plugins.cmake b/stronglift_replacement/workout_app/linux/flutter/generated_plugins.cmake index 69de96a..a9cc2e1 100644 --- a/stronglift_replacement/workout_app/linux/flutter/generated_plugins.cmake +++ b/stronglift_replacement/workout_app/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_linux + file_selector_linux flutter_secure_storage_linux url_launcher_linux ) diff --git a/stronglift_replacement/workout_app/pubspec.lock b/stronglift_replacement/workout_app/pubspec.lock index 58de221..b709a4a 100644 --- a/stronglift_replacement/workout_app/pubspec.lock +++ b/stronglift_replacement/workout_app/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + android_file_picker: + dependency: transitive + description: + name: android_file_picker + sha256: "03d3dd3cb578ab26b1e528aeea27bf6b976942f5a24e3da82d270369b8e161a8" + url: "https://pub.dev" + source: hosted + version: "2.1.2" archive: dependency: transitive description: @@ -146,6 +154,14 @@ packages: url: "https://github.com/kuhyx/utils" source: git version: "0.11.3" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" crypto: dependency: "direct main" description: @@ -154,14 +170,23 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.8" + data_transfer_flutter: + dependency: "direct main" + description: + path: data_transfer_flutter + ref: "data_transfer_flutter-v0.2.0" + resolved-ref: bbb7f0f0c0cd891518418472955c8a2ac6cbca4d + url: "https://github.com/kuhyx/utils" + source: git + version: "0.2.0" dbus: dependency: transitive description: name: dbus - sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 + sha256: f7db8504e715b835f5ebe5fe74604ccd961d504c73389a5d8e81bafc2e2f3612 url: "https://pub.dev" source: hosted - version: "0.7.15" + version: "0.8.0" device_info_plus: dependency: transitive description: @@ -210,6 +235,110 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: transitive + description: + name: file_picker + sha256: "02f2945a7f29535c735f6b283c3c5553810566d3052d730429aec78fe9d06592" + url: "https://pub.dev" + source: hosted + version: "13.3.1" + file_picker_darwin: + dependency: transitive + description: + name: file_picker_darwin + sha256: b845af092ca7a3cff0e768029f835157d371a8341750f4158a7ad358143b0e54 + url: "https://pub.dev" + source: hosted + version: "2.4.1" + file_picker_linux: + dependency: transitive + description: + name: file_picker_linux + sha256: c6562fc7d426db2fb85ea66fe2627953d2709932df5584ceaacf0aa5e39cf4da + url: "https://pub.dev" + source: hosted + version: "2.0.2" + file_picker_platform_interface: + dependency: transitive + description: + name: file_picker_platform_interface + sha256: "336e7168306834a2b68e6168ed768e00d3fabeac23154d5793e277ca9f97cf20" + url: "https://pub.dev" + source: hosted + version: "4.2.0" + file_picker_web: + dependency: transitive + description: + name: file_picker_web + sha256: a2288008280aabe371aff029466b3405e60838e0a1d714767488c59b926c8b8c + url: "https://pub.dev" + source: hosted + version: "4.2.0" + file_selector: + dependency: transitive + description: + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: d670cd0ce77a2e785b18d8b4d0a8d6a222d6a813ec9b7ddf2790a1b4fb6fa92c + url: "https://pub.dev" + source: hosted + version: "0.5.2+11" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: "5b42ec0ef7626124b4624ea244341e9df780406793c204e89fa68a2e71af115c" + url: "https://pub.dev" + source: hosted + version: "0.5.3+7" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab + url: "https://pub.dev" + source: hosted + version: "0.9.4+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: d785431f1910aac8a50e5c492cae912320158030a88703c67b9ef7f1f96fd7ba + url: "https://pub.dev" + source: hosted + version: "0.9.5+2" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec + url: "https://pub.dev" + source: hosted + version: "0.9.3+6" fixnum: dependency: transitive description: @@ -529,6 +658,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.3" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" native_toolchain_c: dependency: transitive description: @@ -705,6 +842,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + share_plus: + dependency: transitive + description: + name: share_plus + sha256: db40767f130189d53f3ae8a6393c217ff4a492da1baff718a9873679c0c00224 + url: "https://pub.dev" + source: hosted + version: "13.3.1" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41" + url: "https://pub.dev" + source: hosted + version: "7.2.0" shared_preferences: dependency: "direct main" description: @@ -1039,6 +1192,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + windows_file_picker: + dependency: transitive + description: + name: windows_file_picker + sha256: "18452aa293de3b6961c6d05b0e71b4be7bb90c67635abdd3aa5c4e75dd875072" + url: "https://pub.dev" + source: hosted + version: "2.0.1" xdg_directories: dependency: transitive description: @@ -1065,4 +1226,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.13.0 <4.0.0" - flutter: ">=3.44.0" + flutter: ">=3.47.0" diff --git a/stronglift_replacement/workout_app/pubspec.yaml b/stronglift_replacement/workout_app/pubspec.yaml index 8d5aa65..059299a 100644 --- a/stronglift_replacement/workout_app/pubspec.yaml +++ b/stronglift_replacement/workout_app/pubspec.yaml @@ -16,6 +16,12 @@ dependencies: path: crdt_sync_dart # HMAC-SHA256 for the signed phone->PC workout poke (pc_poke_wire.dart). crypto: 3.0.8 + # Export/import file plumbing (Save-as picker, open picker), shared. + data_transfer_flutter: + git: + url: https://github.com/kuhyx/utils + ref: data_transfer_flutter-v0.2.0 + path: data_transfer_flutter flutter: sdk: flutter # Keeps the rest-period deadline ticking with the screen off, and owns the diff --git a/stronglift_replacement/workout_app/test/services/storage_service_export_manual_test.dart b/stronglift_replacement/workout_app/test/services/storage_service_export_manual_test.dart new file mode 100644 index 0000000..97b26e9 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/storage_service_export_manual_test.dart @@ -0,0 +1,189 @@ +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/services/manual_workout_port.dart'; +import 'package:workout_app/services/storage_service.dart'; + +import '_sync_test_fixtures.dart' show manualRecord; + +StorageService get _svc => StorageService.instance; + +/// An in-memory stand-in for the remote crdt_sync log. +class _FakePort implements ManualWorkoutPort { + _FakePort(this.log, {this.throwOnRead = false}); + + /// Null = offline / not signed in. + Log? log; + final bool throwOnRead; + int pushes = 0; + + @override + Future read() async { + if (throwOnRead) throw StateError('network down'); + return log; + } + + @override + Future push(Log records) async { + if (log == null) return false; + pushes++; + log = mergeLogs(log!, records); + return true; + } +} + +/// Records compare payload maps by identity, so compare their wire form. +String _wire(Log? log) => jsonEncode({ + for (final id in (log ?? {}).keys.toList()..sort()) id: log![id]!.toJson(), +}); + +Future _freshStore(ManualWorkoutPort port) async { + StorageService.resetForTesting(); + await StorageService.init(); + StorageService.manualWorkouts = port; +} + +Future> _roundTripped() async => + jsonDecode(jsonEncode(await _svc.exportData())) as Map; + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + final live = manualRecord('manual:2026-07-13T10:00', 'NEW', 2000); + final tomb = buildTombstone('manual:2026-07-01T09:00'); + + test('export carries the remote manual log in crdt wire format', () async { + await _freshStore(_FakePort({live.id: live, tomb.id: tomb})); + final data = await _roundTripped(); + + expect(data['manual_workouts_skipped'], false); + final wire = (data['manual_workouts'] as List).cast>(); + expect([for (final w in wire) w['id']], [tomb.id, live.id]); + expect(wire.last, live.toJson()); + expect(Record.fromJson(wire.first).deleted, true); + }); + + test('offline export succeeds with null and a skipped flag', () async { + await _freshStore(_FakePort(null)); + final data = await _roundTripped(); + + expect(data['manual_workouts'], isNull); + expect(data['manual_workouts_skipped'], true); + expect(data['exercise_state'], isNotEmpty); + }); + + test('a port that throws is the same as offline', () async { + await _freshStore(_FakePort({}, throwOnRead: true)); + final data = await _roundTripped(); + + expect(data['manual_workouts'], isNull); + expect(data['manual_workouts_skipped'], true); + }); + + test('manual workouts round-trip through export and import', () async { + await _freshStore(_FakePort({live.id: live, tomb.id: tomb})); + final file = await _roundTripped(); + + final target = _FakePort({}); + await _freshStore(target); + final written = await _svc.importData(file); + + expect(_wire(target.log), _wire({live.id: live, tomb.id: tomb})); + expect(target.pushes, 1); + expect(written, greaterThanOrEqualTo(2)); + }); + + test('importing twice is the same as importing once', () async { + await _freshStore(_FakePort({live.id: live, tomb.id: tomb})); + final file = await _roundTripped(); + + final target = _FakePort({}); + await _freshStore(target); + await _svc.importData(file); + final once = Map.of(target.log!); + // A fresh decode: payload maps no longer share identity with the first. + final again = await _svc.importData( + jsonDecode(jsonEncode(file)) as Map, + ); + + expect(again, 0); + expect(target.pushes, 1); + expect(_wire(target.log), _wire(once)); + }); + + test('an older remote copy never beats a newer local one', () async { + final newer = manualRecord(live.id, 'NEWER', 9000); + final target = _FakePort({newer.id: newer}); + await _freshStore(target); + await _svc.importData({ + 'format': kExportFormat, + 'version': 2, + 'manual_workouts': [live.toJson()], + }); + expect(_wire(target.log), _wire({newer.id: newer})); + }); + + test( + 'offline import still merges the database and reports skipped', + () async { + await _freshStore(_FakePort({live.id: live})); + final file = await _roundTripped(); + (file['settings'] as List).add({ + 'key': 'last_workout_type', + 'value': 'B', + 'updated_at': 5, + }); + + await _freshStore(_FakePort(null)); + final report = await _svc.importDataReport(file); + + expect(report.manualSkipped, true); + expect(report.manualRecords, 0); + expect(report.rows, greaterThan(0)); + expect(await _svc.getNextWorkoutType(), 'A'); + }, + ); + + test('a null manual_workouts (v2 offline file) imports cleanly', () async { + final target = _FakePort({}); + await _freshStore(target); + final report = await _svc.importDataReport({ + 'format': kExportFormat, + 'version': 2, + 'manual_workouts': null, + 'manual_workouts_skipped': true, + 'settings': [ + {'key': 'last_workout_type', 'value': 'B', 'updated_at': 5}, + ], + }); + expect(report.manualSkipped, false); + expect(target.pushes, 0); + }); + + test('malformed manual entries are skipped, the rest merged', () async { + final target = _FakePort({}); + await _freshStore(target); + await _svc.importData({ + 'format': kExportFormat, + 'version': 2, + 'manual_workouts': [ + live.toJson(), + 'junk', + {'id': 3}, + ], + }); + expect(_wire(target.log), _wire({live.id: live})); + }); +} + +Record buildTombstone(String id) => Record( + id: id, + fields: const {}, + deleted: true, + deletedHlc: const Hlc(wallTimeMs: 3000, counter: 0, nodeId: 'phone'), +); diff --git a/stronglift_replacement/workout_app/test/services/storage_service_export_test.dart b/stronglift_replacement/workout_app/test/services/storage_service_export_test.dart new file mode 100644 index 0000000..936ffa0 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/storage_service_export_test.dart @@ -0,0 +1,117 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/models/workout_plan.dart'; +import 'package:workout_app/services/storage_service.dart'; + +StorageService get _svc => StorageService.instance; + +Future _freshStore() async { + StorageService.resetForTesting(); + await StorageService.init(); +} + +Future _session(String date, String start, String type) => + _svc.saveSession( + date: date, + workoutType: type, + durationSeconds: 1800, + succeeded: true, + json: jsonEncode({ + 'date': date, + 'start_time': start, + 'workout_type': type, + 'exercises': [], + }), + ); + +/// The comparable part of an export: everything but the export timestamp. +Future> _snapshot() async => + (await _svc.exportData())..remove('exported_at'); + +/// A store with history, an edited target, a deload event and settings. +Future _seedRichStore() async { + final name = workoutA.first.name; + await _svc.setExerciseWeight(name, 72.5); + await _svc.manualDeload(workoutB.last.name, source: DeloadSource.settings); + await _session('2026-10-01', '2026-10-01T07:00:00.000', 'A'); + await _session('2026-10-03', '2026-10-03T07:05:00.000', 'B'); + await _svc.setLastWorkoutType('B'); + await _svc.markProgressionSynced(); +} + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(_freshStore); + + test('export -> import into an empty store reproduces it', () async { + await _seedRichStore(); + final original = await _snapshot(); + final file = jsonDecode(jsonEncode(await _svc.exportData())); + + await _freshStore(); + await _svc.importData(file as Map); + + expect(await _snapshot(), original); + expect(original['workout_history'] as List, hasLength(2)); + expect(original['progression_events'] as List, hasLength(1)); + }); + + test('per-install sync markers never leave the device', () async { + await _seedRichStore(); + final keys = [ + for (final row in (await _svc.exportData())['settings'] as List) + (row as Map)['key'], + ]; + expect(keys, contains('last_workout_type')); + expect(keys, isNot(contains('progression_synced_at'))); + }); + + test('importing the same file twice is the same as once', () async { + await _seedRichStore(); + final file = await _svc.exportData(); + + await _freshStore(); + await _svc.importData(file); + final once = await _snapshot(); + final written = await _svc.importData(file); + + expect(written, 0); + expect(await _snapshot(), once); + }); + + test('import into a non-empty store keeps every existing record', () async { + await _seedRichStore(); + final file = await _svc.exportData(); + + await _freshStore(); + final name = workoutA.first.name; + await _svc.setExerciseWeight(name, 40); + await _session('2026-10-20', '2026-10-20T18:00:00.000', 'A'); + await _svc.setLastWorkoutType('A'); + final written = await _svc.importData(file); + + final history = await _svc.getWorkoutHistory(); + expect(history.map((r) => r['date']), [ + '2026-10-20', + '2026-10-03', + '2026-10-01', + ]); + expect(written, greaterThan(0)); + // Local progression and settings stay: this store is not a fresh install. + expect((await _svc.getExerciseState(name))!.weight, 40); + expect(await _svc.getNextWorkoutType(), 'B'); + }); + + test('rejects a document that is not a workout_app export', () async { + expect( + () => _svc.importData({'days': []}), + throwsA(isA()), + ); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/storage_service_import_merge_test.dart b/stronglift_replacement/workout_app/test/services/storage_service_import_merge_test.dart new file mode 100644 index 0000000..3005eeb --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/storage_service_import_merge_test.dart @@ -0,0 +1,152 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/models/workout_plan.dart'; +import 'package:workout_app/services/storage_service.dart'; + +StorageService get _svc => StorageService.instance; + +final String _name = workoutA.first.name; +const _key = 'last_workout_type'; + +Future _freshStore() async { + StorageService.resetForTesting(); + await StorageService.init(); +} + +Future> _row(String table, String id) async { + final rows = ((await _svc.exportData())[table] as List) + .cast>(); + return rows.firstWhere((r) => (r['name'] ?? r['key']) == id); +} + +/// A version-2 file carrying one exercise row and one setting row. +Map _file({ + required double weight, + required String type, + required int stamp, + required Map exerciseBase, +}) => { + 'format': kExportFormat, + 'version': 2, + 'exercise_state': [ + {...exerciseBase, 'weight': weight, 'updated_at': stamp}, + ], + 'settings': [ + {'key': _key, 'value': type, 'updated_at': stamp}, + ], +}; + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(_freshStore); + + Future> localEdited() async { + await _svc.setExerciseWeight(_name, 40); + await _svc.setLastWorkoutType('A'); + return await _row('exercise_state', _name); + } + + test('export is version 2 and rows carry updated_at', () async { + final data = await _svc.exportData(); + expect(data['version'], 2); + expect( + (await _row('exercise_state', _name)).containsKey('updated_at'), + true, + ); + }); + + test('a newer file row replaces the local row (both tables)', () async { + final base = await localEdited(); + final future = (base['updated_at']! as int) + 100000; + + final written = await _svc.importData( + _file(weight: 99, type: 'B', stamp: future, exerciseBase: base), + ); + + expect(written, 2); + expect((await _svc.getExerciseState(_name))!.weight, 99); + expect(await _svc.getNextWorkoutType(), 'A'); + expect((await _row('exercise_state', _name))['updated_at'], future); + expect((await _row('settings', _key))['updated_at'], future); + }); + + test('an older file row loses to the local row (both tables)', () async { + final base = await localEdited(); + + final written = await _svc.importData( + _file(weight: 99, type: 'B', stamp: 1, exerciseBase: base), + ); + + expect(written, 0); + expect((await _svc.getExerciseState(_name))!.weight, 40); + expect(await _svc.getNextWorkoutType(), 'B'); + }); + + test('a tie keeps the local row', () async { + final base = await localEdited(); + final tie = base['updated_at']! as int; + + final written = await _svc.importData( + _file(weight: 99, type: 'B', stamp: tie, exerciseBase: base), + ); + + expect(written, 0); + expect((await _svc.getExerciseState(_name))!.weight, 40); + }); + + test( + 'a seeded default (updated_at 0) loses to any stamped file row', + () async { + final base = await _row('exercise_state', _name); + await _svc.importData( + _file(weight: 77, type: 'B', stamp: 5, exerciseBase: base), + ); + expect((await _svc.getExerciseState(_name))!.weight, 77); + }, + ); + + group('a version 1 file (no updated_at) keeps the old rule', () { + Map v1(Map base) => { + 'format': kExportFormat, + 'version': 1, + 'exercise_state': [ + {...base, 'weight': 88.0}..remove('updated_at'), + ], + 'settings': [ + {'key': _key, 'value': 'B'}, + ], + }; + + test('applies on a freshly installed store', () async { + final base = await _row('exercise_state', _name); + final written = await _svc.importData(v1(base)); + expect(written, greaterThan(0)); + expect((await _svc.getExerciseState(_name))!.weight, 88); + expect(await _svc.getNextWorkoutType(), 'A'); + }); + + test('never clobbers a store that has local data', () async { + final base = await localEdited(); + await _svc.importData(v1(base)); + expect((await _svc.getExerciseState(_name))!.weight, 40); + expect(await _svc.getNextWorkoutType(), 'B'); + }); + }); + + test('importing a v2 file twice is the same as once', () async { + final base = await localEdited(); + final future = (base['updated_at']! as int) + 100000; + final file = _file( + weight: 99, + type: 'B', + stamp: future, + exerciseBase: base, + ); + await _svc.importData(file); + expect(await _svc.importData(file), 0); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/storage_service_updated_at_test.dart b/stronglift_replacement/workout_app/test/services/storage_service_updated_at_test.dart new file mode 100644 index 0000000..87aeb98 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/storage_service_updated_at_test.dart @@ -0,0 +1,177 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/models/workout_plan.dart'; +import 'package:workout_app/services/storage_service.dart'; + +StorageService get _svc => StorageService.instance; + +Future>> _rows(String table) async => + ((await _svc.exportData())[table] as List).cast>(); + +Future _stamp(String table, String idColumn, String id) async { + final row = (await _rows(table)).firstWhere((r) => r[idColumn] == id); + return row['updated_at']! as int; +} + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + test('v7 -> v8 backfills updated_at with 0 on existing rows', () async { + final dir = await Directory.systemTemp.createTemp('mw_v8'); + addTearDown(() => dir.delete(recursive: true)); + final path = p.join(dir.path, 'v7.db'); + final old = await databaseFactory.openDatabase( + path, + options: OpenDatabaseOptions( + version: 7, + onCreate: (db, _) 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, ' + "progression_mode TEXT NOT NULL DEFAULT 'weight', " + 'reps_high INTEGER NOT NULL DEFAULT 12, ' + 'reps_low INTEGER NOT NULL DEFAULT 8, has_warmup INTEGER, ' + 'paused_until TEXT, ' + 'rest_success_secs INTEGER NOT NULL DEFAULT 120, ' + 'rest_fail_secs INTEGER NOT NULL DEFAULT 180, ' + 'rest_warmup_secs INTEGER NOT NULL DEFAULT 60)', + ); + 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, ' + 'json TEXT NOT NULL)', + ); + 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 progression_events (id INTEGER PRIMARY KEY ' + 'AUTOINCREMENT, at TEXT NOT NULL, exercise TEXT NOT NULL, ' + 'kind TEXT NOT NULL, source TEXT NOT NULL, ' + 'from_weight REAL NOT NULL, from_reps INTEGER NOT NULL, ' + 'to_weight REAL NOT NULL, to_reps INTEGER NOT NULL)', + ); + await db.insert('exercise_state', { + 'name': workoutA.first.name, + 'weight': 61.5, + 'reps': 5, + 'max_weight': 100.0, + }); + await db.insert('settings', { + 'key': 'last_workout_type', + 'value': 'B', + }); + }, + ), + ); + await old.close(); + + StorageService.resetForTesting(dbPath: path); + await StorageService.init(); + + final name = workoutA.first.name; + expect(await _stamp('exercise_state', 'name', name), 0); + expect(await _stamp('settings', 'key', 'last_workout_type'), 0); + // Data survived, seeded rows are 0 too (a default is not an edit). + expect((await _svc.getExerciseState(name))!.weight, 61.5); + for (final row in await _rows('exercise_state')) { + expect(row['updated_at'], 0); + } + }); + + group('every write path stamps updated_at with now', () { + late String name; + late int before; + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + name = workoutA.first.name; + before = DateTime.now().toUtc().millisecondsSinceEpoch; + }); + + Future expectStamped(Future Function() write) async { + expect(await _stamp('exercise_state', 'name', name), 0); + await write(); + expect( + await _stamp('exercise_state', 'name', name), + greaterThanOrEqualTo(before), + ); + } + + test( + 'setExerciseWeight', + () => expectStamped(() => _svc.setExerciseWeight(name, 50)), + ); + test( + 'setExerciseReps', + () => expectStamped(() => _svc.setExerciseReps(name, 6)), + ); + test( + 'setExerciseThresholds', + () => expectStamped( + () => _svc.setExerciseThresholds( + name, + successThreshold: 4, + failThreshold: 3, + ), + ), + ); + test( + 'setExerciseSettings', + () => expectStamped(() async { + final s = (await _svc.getExerciseState(name))!; + await _svc.setExerciseSettings(s.copyWith(hasWarmup: false)); + }), + ); + test( + 'replaceExerciseState', + () => expectStamped(() async { + final s = (await _svc.getExerciseState(name))!; + await _svc.replaceExerciseState(s.copyWith(weight: 33)); + }), + ); + test( + 'resetExerciseToDefaults', + () => expectStamped(() => _svc.resetExerciseToDefaults(name)), + ); + test( + 'manualDeload', + () => expectStamped(() async { + await _svc.manualDeload(name, source: DeloadSource.settings); + }), + ); + test( + 'applyProgression', + () => expectStamped( + () => _svc.applyProgression( + succeededExercises: {name: true}, + lastWorkoutDate: DateTime.now(), + ), + ), + ); + test('settings writes', () async { + await _svc.setLastWorkoutType('A'); + expect( + await _stamp('settings', 'key', 'last_workout_type'), + greaterThanOrEqualTo(before), + ); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/workout_sync_manual_transfer_test.dart b/stronglift_replacement/workout_app/test/services/workout_sync_manual_transfer_test.dart new file mode 100644 index 0000000..700b255 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/workout_sync_manual_transfer_test.dart @@ -0,0 +1,94 @@ +// The real crdt_sync read/push behind the export's manual_workouts. +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/services/manual_workout_port.dart'; + +import '../fake_secure_storage.dart'; +import '_sync_test_fixtures.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final manual = manualRecord('manual:2026-07-13T10:00', 'NEW', 2000); + const session = Record( + id: '2026-07-05T09:00:00', + fields: { + 'payload': ( + {'workout_type': 'A'}, + Hlc(wallTimeMs: 1000, counter: 0, nodeId: 'pc'), + ), + }, + ); + const tomb = Record( + id: 'manual:2026-07-01T09:00', + fields: {}, + deleted: true, + deletedHlc: Hlc(wallTimeMs: 3000, counter: 0, nodeId: 'pc'), + ); + + test('read is null when sync is not configured', () async { + installFakeSecureStorage(); + final (:httpClient, putCalls: _) = mockGitHub(); + final port = SyncManualWorkoutPort(syncService(httpClient: httpClient)); + expect(await port.read(), isNull); + }); + + test('read is null when the backend errors', () async { + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + final (:httpClient, putCalls: _) = mockGitHub( + contentResponses: {'screen-locker-sync/devices': syncResponse(500)}, + ); + final port = SyncManualWorkoutPort(syncService(httpClient: httpClient)); + expect(await port.read(), isNull); + }); + + test('read returns manual records and tombstones, not sessions', () async { + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + final phone = jsonEncode({ + manual.id: manual.toJson(), + session.id: session.toJson(), + }); + final pc = jsonEncode({tomb.id: tomb.toJson()}); + final (:httpClient, putCalls: _) = mockGitHub( + contentResponses: { + 'screen-locker-sync/devices': syncResponse(200, [ + {'name': 'phone'}, + {'name': 'pc'}, + ]), + 'screen-locker-sync/devices/phone/log.json': fileContaining(phone), + 'screen-locker-sync/devices/pc/log.json': fileContaining(pc), + }, + ); + final port = SyncManualWorkoutPort(syncService(httpClient: httpClient)); + + final log = await port.read(); + + expect(log!.keys.toSet(), {manual.id, tomb.id}); + expect(log[tomb.id]!.deleted, true); + }); + + test('push merges the records into this device log', () async { + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + final (:httpClient, :putCalls) = mockGitHub( + contentResponses: {'screen-locker-sync/devices': syncResponse(200, [])}, + ); + final port = SyncManualWorkoutPort(syncService(httpClient: httpClient)); + + expect(await port.push({manual.id: manual}), true); + + final body = utf8.decode( + base64.decode(logPut(putCalls).body['content'] as String), + ); + expect((jsonDecode(body) as Map).keys, contains(manual.id)); + }); + + test('push reports failure instead of throwing when unconfigured', () async { + installFakeSecureStorage(); + final (:httpClient, :putCalls) = mockGitHub(); + final port = SyncManualWorkoutPort(syncService(httpClient: httpClient)); + expect(await port.push({manual.id: manual}), false); + expect(putCalls, isEmpty); + }); +} diff --git a/stronglift_replacement/workout_app/test/widgets/data_transfer_buttons_test.dart b/stronglift_replacement/workout_app/test/widgets/data_transfer_buttons_test.dart new file mode 100644 index 0000000..5bac90b --- /dev/null +++ b/stronglift_replacement/workout_app/test/widgets/data_transfer_buttons_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/ui/theme.dart'; +import 'package:workout_app/widgets/data_transfer_buttons.dart'; + +Widget _wrap(Widget child) => MaterialApp( + theme: buildAppTheme(), + home: Scaffold(body: child), +); + +Future _tapImport(WidgetTester tester, ImportReport? report) async { + var reloaded = 0; + await tester.pumpWidget( + _wrap( + DataTransferButtons( + exporter: () async => true, + importer: () async => report, + onImported: () => reloaded++, + ), + ), + ); + await tester.tap(find.bySemanticsLabel('Import data')); + await tester.pumpAndSettle(); + addTearDown(() => expect(reloaded, report == null ? 0 : 1)); +} + +void main() { + testWidgets('a full import shows the check', (tester) async { + await _tapImport(tester, (rows: 2, manualRecords: 1, manualSkipped: false)); + + expect(find.byIcon(Icons.check_circle), findsOneWidget); + expect(find.byIcon(Icons.warning_amber), findsNothing); + }); + + testWidgets('skipped manual workouts show a warning, not the check', ( + tester, + ) async { + await _tapImport(tester, (rows: 2, manualRecords: 0, manualSkipped: true)); + + expect(find.byIcon(Icons.warning_amber), findsOneWidget); + expect(find.byIcon(Icons.check_circle), findsNothing); + expect(find.bySemanticsLabel('Manual workouts skipped'), findsOneWidget); + }); + + testWidgets('a cancelled picker shows no outcome icon', (tester) async { + await _tapImport(tester, null); + + expect(find.byIcon(Icons.check_circle), findsNothing); + expect(find.byIcon(Icons.warning_amber), findsNothing); + expect(find.byIcon(Icons.error_outline), findsNothing); + }); + + testWidgets('a failing export shows the error icon', (tester) async { + await tester.pumpWidget( + _wrap( + DataTransferButtons( + exporter: () async => throw StateError('disk'), + importer: () async => null, + ), + ), + ); + await tester.tap(find.bySemanticsLabel('Export data')); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.error_outline), findsOneWidget); + }); + + testWidgets('export is a save icon, not a share icon', (tester) async { + await tester.pumpWidget( + _wrap( + DataTransferButtons( + exporter: () async => true, + importer: () async => null, + ), + ), + ); + + expect(find.byIcon(Icons.save_alt), findsOneWidget); + expect(find.byIcon(Icons.ios_share), findsNothing); + expect(find.bySemanticsLabel('Export data'), findsOneWidget); + }); +}