screen-locker/stronglift_replacement/workout_app/lib/services/sync_settings.dart
Krzysztof kuhy Rudnicki 399f9815c4
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Add phone-workout GitHub sync via shared crdt_sync (Workstream C)
Sync the workout log across devices using the shared crdt-sync /
crdt_sync_dart CRDT transport (same scheme as diet_guard).

- Python: new _workout_sync.py + _temperature_status_mixin.py; wire sync
  into screen_lock.py / status_view.py; add the crdt-sync git dependency
  and test_sync_fixtures / test_workout_sync suites.
- Dart (workout_app): new workout_sync_service.dart, github_device_auth.dart,
  sync_settings.dart with tests + fakes; settings/workout screens updated.
- docs: workstream-d-shared-crdt-transport.md.

Verification: pytest 747 passed at 100% coverage; flutter analyze clean;
flutter test passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM
2026-07-11 00:06:59 +02:00

90 lines
3.5 KiB
Dart

/// Locally-stored GitHub sync token, trimmed from diet-guard's
/// `sync_settings.dart`: this app only ever talks to one hardcoded repo, so
/// there's no owner/repo to persist -- just the pasted PAT (kept as a
/// fallback; see [defaultClientId] for the primary "Connect GitHub" path).
///
/// Unlike diet-guard/todo, the token is also mirrored to external storage
/// via [BackupService] so it survives an app uninstall -- see that class's
/// library doc for the security trade-off this represents.
library;
import 'package:flutter/services.dart' show PlatformException;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:workout_app/services/backup_service.dart';
/// The repo owner/org, matching the PC's `SYNC_REPO_OWNER` constant.
const syncRepoOwner = 'kuhyx';
/// The repo name, matching the PC's `SYNC_REPO_NAME` constant.
const syncRepoName = 'syncs';
/// The GitHub token is kept in the OS keystore (Android Keystore) via
/// [FlutterSecureStorage] -- it never touches `SharedPreferences`, so there's
/// no plaintext-migration path to carry (unlike diet-guard/todo, which
/// predate this app and had an existing plaintext copy to migrate away from).
class SyncSettings {
/// Creates a [SyncSettings] from its token.
const SyncSettings({required this.token});
/// A GitHub PAT with contents read/write on `$syncRepoOwner/$syncRepoName`.
final String token;
/// True when a token has been pasted and sync can be attempted.
bool get isConfigured => token.isNotEmpty;
/// This app's own GitHub OAuth App (device-flow enabled) client id, baked
/// in so "Connect GitHub" works with zero setup. Registered 2026-07-06 at
/// github.com/settings/developers, device flow enabled -- distinct from
/// the sibling diet-guard/todo apps' OAuth Apps, which belong to different
/// products and can't be shared across them.
static const defaultClientId = 'Ov23liJNyu82dkIUiXSZ';
static const _secureToken = 'sync.token';
/// Default options keep us off the deprecated `encryptedSharedPreferences`
/// path on Android.
static const _secure = FlutterSecureStorage();
/// Loads the token from the keystore, falling back to the external-storage
/// backup (and re-seeding the keystore from it) if the keystore is empty --
/// which is exactly what happens right after a reinstall, since the
/// keystore entry does not survive uninstall but the backup file does.
static Future<SyncSettings> load() async {
String? token;
try {
token = await _secure.read(key: _secureToken);
} on PlatformException {
token = null;
}
if (token == null || token.isEmpty) {
final backedUp = await BackupService.instance.readSyncToken();
if (backedUp != null && backedUp.isNotEmpty) {
token = backedUp;
try {
await _secure.write(key: _secureToken, value: backedUp);
} on PlatformException {
// Keystore unavailable; still return the recovered token below.
}
}
}
return SyncSettings(token: token ?? '');
}
/// Persists [token] to the keystore (deleting the entry when empty) and
/// mirrors it to external storage so it survives an app uninstall.
/// Returns false if the platform secret service is unavailable.
Future<bool> save() async {
await BackupService.instance.exportSyncToken(token);
try {
if (token.isEmpty) {
await _secure.delete(key: _secureToken);
} else {
await _secure.write(key: _secureToken, value: token);
}
return true;
} on PlatformException {
return false;
}
}
}