feat: JSON export/import on device

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXTUM9dpnCS2wtFkx4
This commit is contained in:
Krzysztof Rudnicki 2026-11-01 19:55:30 +01:00
parent c3a5d1470c
commit 9e71954348
26 changed files with 1594 additions and 28 deletions

View File

@ -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';

View File

@ -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(

View File

@ -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({

View File

@ -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<ImportReport> importExportText(String source) async {
final decoded = jsonDecode(source);
if (decoded is! Map<String, dynamic>) {
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<bool> 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<ImportReport?> importUserData() async {
final text = await pickJsonText();
if (text == null) return null;
return await importExportText(text);
}
// coverage:ignore-end

View File

@ -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<Log?> read();
/// Merges [records] into the remote log; false when that did not happen.
Future<bool> 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<Log?> read() => _service.readManualLog();
@override
Future<bool> 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<Log?> read() async => null;
@override
Future<bool> push(Log records) async => false;
}

View File

@ -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],

View File

@ -50,6 +50,7 @@ extension StorageServiceDeload on StorageService {
'reps': t.reps,
'success_streak': 0,
'fail_streak': 0,
'updated_at': _nowMs(),
},
where: 'name = ?',
whereArgs: [name],

View File

@ -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<void> 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<void> 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],
);

View File

@ -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<Map<String, dynamic>> exportData() async {
List<Map<String, Object?>> withoutId(List<Map<String, Object?>> 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<Log?> _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;

View File

@ -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<int> importData(Map<String, dynamic> 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<ImportReport> importDataReport(Map<String, dynamic> 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 = <String, Record>{};
var malformed = 0;
for (final entry in raw) {
try {
final record = Record.fromJson(entry as Map<String, dynamic>);
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 = <String, Record>{
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<int> _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<Map<String, dynamic>>().length;
for (final entry in raw.whereType<Map<String, dynamic>>()) {
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<String, Object?> 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<String, Object?>) _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('|'),
};

View File

@ -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<void> _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<void> _seedDefaultsIfNeeded() async {

View File

@ -61,7 +61,7 @@ extension StorageServiceSessions on StorageService {
}
await _db.update(
'exercise_state',
update,
{...update, 'updated_at': _nowMs()},
where: 'name = ?',
whereArgs: [entry.key],
);

View File

@ -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;

View File

@ -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<Log?> 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 = <String, Record>{};
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<PushResult> 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:<date>T<HH:MM>`) 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;
}
}

View File

@ -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
? <String, Record>{}
: decode(existingText);
final localLog = addition == null
final incoming = <String, Record>{
...?additions,
?addition?.id: ?addition,
};
final localLog = incoming.isEmpty
? existingLog
: mergeLogs(existingLog, {addition.id: addition});
: mergeLogs(existingLog, incoming);
await syncLog(
client: client,
deviceId: currentSyncDeviceId,

View File

@ -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<bool> Function() exporter;
/// Merges a picked export; null when cancelled, else what it did.
final Future<ImportReport?> Function() importer;
@override
State<DataTransferButtons> createState() => _DataTransferButtonsState();
}
class _DataTransferButtonsState extends State<DataTransferButtons> {
bool _busy = false;
_Outcome? _outcome;
Future<void> _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',
),
],
);
}
}

View File

@ -7,6 +7,7 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_linux/audioplayers_linux_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
@ -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);

View File

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
file_selector_linux
flutter_secure_storage_linux
url_launcher_linux
)

View File

@ -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"

View File

@ -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

View File

@ -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<Log?> read() async {
if (throwOnRead) throw StateError('network down');
return log;
}
@override
Future<bool> 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<void> _freshStore(ManualWorkoutPort port) async {
StorageService.resetForTesting();
await StorageService.init();
StorageService.manualWorkouts = port;
}
Future<Map<String, dynamic>> _roundTripped() async =>
jsonDecode(jsonEncode(await _svc.exportData())) as Map<String, dynamic>;
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<Map<String, dynamic>>();
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<String, dynamic>,
);
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'),
);

View File

@ -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<void> _freshStore() async {
StorageService.resetForTesting();
await StorageService.init();
}
Future<void> _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': <Object>[],
}),
);
/// The comparable part of an export: everything but the export timestamp.
Future<Map<String, dynamic>> _snapshot() async =>
(await _svc.exportData())..remove('exported_at');
/// A store with history, an edited target, a deload event and settings.
Future<void> _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<String, dynamic>);
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': <Object>[]}),
throwsA(isA<FormatException>()),
);
});
}

View File

@ -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<void> _freshStore() async {
StorageService.resetForTesting();
await StorageService.init();
}
Future<Map<String, dynamic>> _row(String table, String id) async {
final rows = ((await _svc.exportData())[table] as List)
.cast<Map<String, dynamic>>();
return rows.firstWhere((r) => (r['name'] ?? r['key']) == id);
}
/// A version-2 file carrying one exercise row and one setting row.
Map<String, dynamic> _file({
required double weight,
required String type,
required int stamp,
required Map<String, dynamic> 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<Map<String, dynamic>> 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<String, dynamic> v1(Map<String, dynamic> 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);
});
}

View File

@ -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<List<Map<String, dynamic>>> _rows(String table) async =>
((await _svc.exportData())[table] as List).cast<Map<String, dynamic>>();
Future<int> _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<void> expectStamped(Future<void> 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),
);
});
});
}

View File

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

View File

@ -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<void> _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);
});
}