mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 18:07:22 +01:00
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:
parent
c3a5d1470c
commit
9e71954348
@ -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';
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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
|
||||
@ -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;
|
||||
}
|
||||
@ -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],
|
||||
|
||||
@ -50,6 +50,7 @@ extension StorageServiceDeload on StorageService {
|
||||
'reps': t.reps,
|
||||
'success_streak': 0,
|
||||
'fail_streak': 0,
|
||||
'updated_at': _nowMs(),
|
||||
},
|
||||
where: 'name = ?',
|
||||
whereArgs: [name],
|
||||
|
||||
@ -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],
|
||||
);
|
||||
|
||||
@ -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;
|
||||
@ -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('|'),
|
||||
};
|
||||
@ -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 {
|
||||
|
||||
@ -61,7 +61,7 @@ extension StorageServiceSessions on StorageService {
|
||||
}
|
||||
await _db.update(
|
||||
'exercise_state',
|
||||
update,
|
||||
{...update, 'updated_at': _nowMs()},
|
||||
where: 'name = ?',
|
||||
whereArgs: [entry.key],
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_linux
|
||||
file_selector_linux
|
||||
flutter_secure_storage_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'),
|
||||
);
|
||||
@ -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>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user