diff --git a/app/.metadata b/app/.metadata index fdcdbd8..2ffa74b 100644 --- a/app/.metadata +++ b/app/.metadata @@ -15,7 +15,7 @@ migration: - platform: root create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 - - platform: android + - platform: linux create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 diff --git a/app/lib/screens/log_meal_screen.dart b/app/lib/screens/log_meal_screen.dart index 6486740..b7be7ae 100644 --- a/app/lib/screens/log_meal_screen.dart +++ b/app/lib/screens/log_meal_screen.dart @@ -9,26 +9,36 @@ import 'package:diet_guard_app/models/nutrition.dart'; import 'package:diet_guard_app/models/slot.dart'; import 'package:diet_guard_app/screens/history_screen.dart'; import 'package:diet_guard_app/screens/meal_builder_screen.dart'; +import 'package:diet_guard_app/screens/settings_screen.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/github_client.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:diet_guard_app/services/sync_service.dart'; +import 'package:diet_guard_app/services/sync_settings.dart'; import 'package:diet_guard_app/widgets/autocomplete_suggestion_list.dart'; import 'package:diet_guard_app/widgets/macro_input_row.dart'; import 'package:diet_guard_app/widgets/photo_attach_field.dart'; import 'package:diet_guard_app/widgets/slot_status_bar.dart'; import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; /// Lets the user log one food item, with food-bank autocomplete and /// today's slot status, or hop into [MealBuilderScreen] for a composite /// multi-item meal. class LogMealScreen extends StatefulWidget { /// Creates a [LogMealScreen]. - const LogMealScreen({super.key}); + const LogMealScreen({super.key, this.httpClient}); + + /// Injectable HTTP client for auto-sync; tests pass a [MockClient]. + /// Production leaves this null so [GitHubClient] builds a real one. + final http.Client? httpClient; @override State createState() => _LogMealScreenState(); } -class _LogMealScreenState extends State { +class _LogMealScreenState extends State + with WidgetsBindingObserver { final TextEditingController _descController = TextEditingController(); final MacroControllers _macros = MacroControllers(); List _suggestions = const []; @@ -37,9 +47,13 @@ class _LogMealScreenState extends State { String? _status; String? _imagePath; + /// Single-flight guard so a launch sync and a lifecycle sync never overlap. + bool _autoSyncing = false; + @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _descController.addListener(_onDescChanged); for (final controller in [ _macros.kcal, @@ -53,15 +67,69 @@ class _LogMealScreenState extends State { } unawaited(_refreshSlots()); unawaited(_onDescChanged()); + unawaited(_autoSync()); } @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _descController.dispose(); _macros.dispose(); super.dispose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Pull on resume (catch up on what another device logged while this one + // was backgrounded) and push on pause (keep the remote near-current). + final isResumeOrPause = + state == AppLifecycleState.resumed || state == AppLifecycleState.paused; + if (isResumeOrPause) { + unawaited(_autoSync()); + } + } + + /// Best-effort background sync: silent, skips when unconfigured, and never + /// overlaps itself. Failures are swallowed -- the Settings screen's manual + /// "Sync now" is where errors get surfaced. The try wraps even loading + /// [SyncSettings] itself: under `flutter test`, the shared_preferences and + /// secure-storage platform channels are unmocked by default and throw + /// [MissingPluginException], which must degrade exactly like "offline" + /// rather than crash every screen that mounts this widget. + /// + /// [_refreshSlots] only runs after an actual sync (not on the unconfigured + /// path, which every existing screen test takes): a fire-and-forget tail + /// await here can resolve after a *later* test's `tearDown` has already + /// reset [LogStorageService]'s singleton -- `mounted` alone doesn't bound + /// that, since widget disposal between tests isn't synchronized with a + /// still-pending Future from an earlier one. + Future _autoSync() async { + if (_autoSyncing) return; + _autoSyncing = true; + try { + final settings = await SyncSettings.load(); + if (!settings.isConfigured) return; + final client = GitHubClient( + owner: settings.owner, + repo: settings.repo, + token: settings.token, + httpClient: widget.httpClient, + ); + try { + await runSync(client); + } finally { + client.close(); + } + if (!mounted) return; + await _refreshSlots(); + } on Exception { + // Best-effort: ignore (offline, transient GitHub errors, unmocked + // platform channels under test, etc.). + } finally { + _autoSyncing = false; + } + } + Future _refreshSlots() async { final logged = await LogStorageService.instance.loggedSlotsToday(); if (!mounted) return; @@ -150,6 +218,16 @@ class _LogMealScreenState extends State { ); } + void _onOpenSettings() { + unawaited( + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SettingsScreen(httpClient: widget.httpClient), + ), + ), + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -161,6 +239,11 @@ class _LogMealScreenState extends State { tooltip: 'History', onPressed: _onOpenHistory, ), + IconButton( + icon: const Icon(Icons.settings), + tooltip: 'Sync settings', + onPressed: _onOpenSettings, + ), ], ), body: SingleChildScrollView( diff --git a/app/lib/screens/settings_screen.dart b/app/lib/screens/settings_screen.dart new file mode 100644 index 0000000..ca56456 --- /dev/null +++ b/app/lib/screens/settings_screen.dart @@ -0,0 +1,180 @@ +/// GitHub sync configuration: paste a PAT, test the connection, and trigger +/// a manual sync. Auto-sync (app launch + lifecycle pause/resume) lives in +/// [LogMealScreen] and is silent on failure -- this screen is where errors +/// get surfaced, via [SnackBar]. +library; + +import 'dart:async'; + +import 'package:diet_guard_app/screens/log_meal_screen.dart'; +import 'package:diet_guard_app/services/github_client.dart'; +import 'package:diet_guard_app/services/sync_service.dart'; +import 'package:diet_guard_app/services/sync_settings.dart'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; + +/// Screen for configuring and triggering cross-device sync. +class SettingsScreen extends StatefulWidget { + /// Creates a [SettingsScreen]. + const SettingsScreen({super.key, this.httpClient}); + + /// Injectable HTTP client; tests pass a [MockClient]. + final http.Client? httpClient; + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + final _ownerController = TextEditingController(); + final _repoController = TextEditingController(); + final _tokenController = TextEditingController(); + bool _loading = true; + bool _busy = false; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + /// Loads saved settings, defaulting to blank fields if loading itself + /// fails (e.g. no secret service available yet) -- the screen must still + /// render, not spin forever, so the user can fill them in from scratch. + Future _load() async { + SyncSettings settings; + try { + settings = await SyncSettings.load(); + } on Exception { + settings = const SyncSettings(owner: '', repo: '', token: ''); + } + if (!mounted) return; + _ownerController.text = settings.owner; + _repoController.text = settings.repo; + _tokenController.text = settings.token; + setState(() => _loading = false); + } + + @override + void dispose() { + _ownerController.dispose(); + _repoController.dispose(); + _tokenController.dispose(); + super.dispose(); + } + + SyncSettings _currentSettings() => SyncSettings( + owner: _ownerController.text.trim(), + repo: _repoController.text.trim(), + token: _tokenController.text.trim(), + ); + + void _showMessage(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + + Future _save() async { + setState(() => _busy = true); + await _currentSettings().save(); + if (!mounted) return; + setState(() => _busy = false); + _showMessage('Saved.'); + } + + Future _testConnection() async { + setState(() => _busy = true); + final settings = _currentSettings(); + final client = GitHubClient( + owner: settings.owner, + repo: settings.repo, + token: settings.token, + httpClient: widget.httpClient, + ); + try { + final ok = await client.canAccessRepo(); + _showMessage(ok ? 'Connection OK.' : 'Connection failed.'); + } on Exception catch (e) { + _showMessage('Connection failed: $e'); + } finally { + client.close(); + if (mounted) setState(() => _busy = false); + } + } + + Future _syncNow() async { + setState(() => _busy = true); + final settings = _currentSettings(); + await settings.save(); + final client = GitHubClient( + owner: settings.owner, + repo: settings.repo, + token: settings.token, + httpClient: widget.httpClient, + ); + try { + await runSync(client); + _showMessage('Synced.'); + } on Exception catch (e) { + _showMessage('Sync failed: $e'); + } finally { + client.close(); + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + if (_loading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + return Scaffold( + appBar: AppBar(title: const Text('Sync settings')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _ownerController, + decoration: const InputDecoration(labelText: 'GitHub owner'), + ), + const SizedBox(height: 8), + TextField( + controller: _repoController, + decoration: const InputDecoration(labelText: 'Repo'), + ), + const SizedBox(height: 8), + TextField( + controller: _tokenController, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Personal access token', + ), + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + children: [ + ElevatedButton( + onPressed: _busy ? null : _save, + child: const Text('Save'), + ), + OutlinedButton( + onPressed: _busy ? null : _testConnection, + child: const Text('Test connection'), + ), + ElevatedButton( + onPressed: _busy ? null : _syncNow, + child: const Text('Sync now'), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/services/github_client.dart b/app/lib/services/github_client.dart new file mode 100644 index 0000000..769273c --- /dev/null +++ b/app/lib/services/github_client.dart @@ -0,0 +1,203 @@ +/// Minimal GitHub Contents API client, ported verbatim from `~/todo`'s +/// `sync/github_client.dart` -- it is already generic over owner/repo/token +/// and has nothing todo-specific to strip. +library; + +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// A file entry in a GitHub repository directory listing. +class GitHubFile { + /// Creates a [GitHubFile] from its name/path/sha. + const GitHubFile({ + required this.name, + required this.path, + required this.sha, + }); + + /// The entry's bare file name (no path prefix). + final String name; + + /// The entry's full repo-relative path. + final String path; + + /// Git blob SHA; required to update or delete the file. + final String sha; +} + +/// Raised when the GitHub API returns an unexpected status. +class GitHubApiException implements Exception { + /// Creates an exception for the given [statusCode]/[message]. + GitHubApiException(this.statusCode, this.message); + + /// The HTTP status code returned by the API. + final int statusCode; + + /// A human-readable description of what failed. + final String message; + + @override + String toString() => 'GitHubApiException($statusCode): $message'; +} + +/// Minimal GitHub REST client scoped to the Contents API. +/// +/// This is the only component that holds the access token, mirroring the +/// "server holds credentials" pattern: the rest of the app deals in food +/// entries and merged logs, never in raw HTTP or secrets. +class GitHubClient { + /// Creates a client scoped to one repo, authenticated with [token]. + GitHubClient({ + required this.owner, + required this.repo, + required String token, + http.Client? httpClient, + this.branch = 'main', + }) // Dart forbids private named params, so this can't be an initializing + // formal; assign it explicitly. + // ignore: prefer_initializing_formals + : _token = token, + _http = httpClient ?? http.Client(); + + /// The repo owner/org (e.g. `"kuhyx"`). + final String owner; + + /// The repo name (e.g. `"diet-guard-sync"`). + final String repo; + + /// The branch to read/write against. + final String branch; + final String _token; + final http.Client _http; + + static const _apiBase = 'https://api.github.com'; + + Map get _headers => { + 'Authorization': 'Bearer $_token', + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'diet-guard-app-sync', + }; + + Uri _contentsUri(String path) => + Uri.parse('$_apiBase/repos/$owner/$repo/contents/$path'); + + /// Lists the files directly under [dirPath]. Returns an empty list if the + /// directory does not exist yet (e.g. before the first sync). + Future> listDirectory(String dirPath) async { + final res = await _http.get( + _contentsUri(dirPath).replace(queryParameters: {'ref': branch}), + headers: _headers, + ); + if (res.statusCode == 404) return []; + _ensureOk(res, 'list $dirPath'); + final decoded = jsonDecode(res.body); + if (decoded is! List) return []; + return decoded + .cast>() + .where((e) => e['type'] == 'file') + .map( + (e) => GitHubFile( + name: e['name'] as String, + path: e['path'] as String, + sha: e['sha'] as String, + ), + ) + .toList(); + } + + /// Lists the names of every entry (file *or* directory) directly under + /// [dirPath]. Returns an empty list if the directory does not exist yet. + /// + /// Unlike [listDirectory] (files-only, for a flat layout), this also + /// surfaces subdirectory names -- needed for diet_guard's per-device + /// `devices//food_log.json` layout, where each device id is itself a + /// directory one level above its file. + Future> listEntryNames(String dirPath) async { + final res = await _http.get( + _contentsUri(dirPath).replace(queryParameters: {'ref': branch}), + headers: _headers, + ); + if (res.statusCode == 404) return []; + _ensureOk(res, 'list $dirPath'); + final decoded = jsonDecode(res.body); + if (decoded is! List) return []; + return decoded + .cast>() + .map((e) => e['name'] as String) + .toList(); + } + + /// Fetches and UTF-8-decodes a file's contents. Returns null if absent. + Future getFileText(String path) async { + final res = await _http.get( + _contentsUri(path).replace(queryParameters: {'ref': branch}), + headers: _headers, + ); + if (res.statusCode == 404) return null; + _ensureOk(res, 'get $path'); + final json = jsonDecode(res.body) as Map; + // GitHub base64-encodes file content, wrapping lines at 60 chars. + final raw = (json['content'] as String).replaceAll('\n', ''); + return utf8.decode(base64.decode(raw)); + } + + /// Creates or updates [path] with [text]. Pass the current [sha] when + /// updating an existing file; omit it to create a new one. + Future putFileText( + String path, + String text, { + String? sha, + String? message, + }) async { + final body = { + 'message': message ?? 'sync: update $path', + 'content': base64.encode(utf8.encode(text)), + 'branch': branch, + 'sha': ?sha, + }; + final res = await _http.put( + _contentsUri(path), + headers: _headers, + body: jsonEncode(body), + ); + _ensureOk(res, 'put $path'); + } + + /// Deletes the file at [path] (requires its current [sha]). + Future deleteFile(String path, String sha, {String? message}) async { + final res = await _http.delete( + _contentsUri(path), + headers: _headers, + body: jsonEncode({ + 'message': message ?? 'sync: delete $path', + 'sha': sha, + 'branch': branch, + }), + ); + _ensureOk(res, 'delete $path'); + } + + /// Cheap auth/connectivity probe used by the settings "Test connection" + /// button: succeeds only if the token can read the repo. + Future canAccessRepo() async { + final res = await _http.get( + Uri.parse('$_apiBase/repos/$owner/$repo'), + headers: _headers, + ); + return res.statusCode == 200; + } + + /// Closes the underlying HTTP client. + void close() => _http.close(); + + void _ensureOk(http.Response res, String action) { + if (res.statusCode < 200 || res.statusCode >= 300) { + throw GitHubApiException( + res.statusCode, + 'Failed to $action: ${res.body}', + ); + } + } +} diff --git a/app/lib/services/sync_merge.dart b/app/lib/services/sync_merge.dart new file mode 100644 index 0000000..9fd8f1d --- /dev/null +++ b/app/lib/services/sync_merge.dart @@ -0,0 +1,69 @@ +/// Pure log-merge logic for diet_guard's cross-device sync. +/// +/// No I/O here -- this module is unit-testable purely on in-memory [DayLog] +/// values. Mirrored test-for-test against the Python original +/// (`diet_guard/_sync_merge.py`), so the merge algorithm canonically agrees +/// on both sides of the sync. +library; + +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; + +/// A dedup key: `('id', )` for any entry with one, else +/// `('legacy', (time, desc))` for a pre-id entry written before this field +/// existed -- two devices that both already had that same legacy entry would +/// otherwise end up with two copies of it after a merge. +typedef _MergeKey = (String, Object); + +_MergeKey _entryKey(FoodEntry entry) { + final id = entry.id; + if (id != null && id.isNotEmpty) return ('id', id); + return ('legacy', (entry.time, entry.desc)); +} + +/// Returns true if [candidate] should replace [existing] for one key. +/// +/// A tombstone always wins over a non-tombstoned copy of the same entry -- +/// deletion is sticky, so a stale pre-undo copy pulled from another device +/// can never resurrect something the user explicitly removed. Otherwise, +/// keep whichever copy was seen first: two copies of the same id are +/// expected to be identical in their macros/desc (the body is never mutated +/// after creation, only `deleted`/`hmac`), so which one survives does not +/// change the merged result's content. +bool _tombstoneWins(FoodEntry candidate, FoodEntry existing) => + candidate.deleted && !existing.deleted; + +/// Returns the union of [local] and [remote], tombstones winning by id. +/// +/// Commutative and idempotent: `mergeLogs(a, b) == mergeLogs(b, a)` and +/// `mergeLogs(x, x) == x` (for an `x` with no duplicate keys), so pull-order +/// between devices never matters and a repeated sync tick is a no-op. Each +/// entry is re-bucketed under its own `time`'s date rather than the date key +/// it arrived under, so a merge can't silently leave an entry filed under +/// the wrong day. +DayLog mergeLogs(DayLog local, DayLog remote) { + final byKey = <_MergeKey, FoodEntry>{}; + for (final dayLog in [local, remote]) { + for (final entries in dayLog.values) { + for (final entry in entries) { + final key = _entryKey(entry); + final existing = byKey[key]; + if (existing == null || _tombstoneWins(entry, existing)) { + byKey[key] = entry; + } + } + } + } + + final merged = >{}; + for (final entry in byKey.values) { + final dateKey = entry.time.length >= 10 + ? entry.time.substring(0, 10) + : entry.time; + merged.putIfAbsent(dateKey, () => []).add(entry); + } + for (final entries in merged.values) { + entries.sort((a, b) => a.time.compareTo(b.time)); + } + return merged; +} diff --git a/app/lib/services/sync_service.dart b/app/lib/services/sync_service.dart new file mode 100644 index 0000000..749d406 --- /dev/null +++ b/app/lib/services/sync_service.dart @@ -0,0 +1,144 @@ +/// Cross-device log sync orchestration for the diet_guard companion app. +/// +/// Pulls every other device's pushed log from GitHub-backed dumb storage +/// ([GitHubClient]), merges with the local log ([mergeLogs]), rebuilds the +/// food bank, and pushes this device's own merged log back up. A **new** +/// implementation, not a port of `~/todo`'s CRDT-based sync (this app has no +/// CRDT layer) -- a Dart twin of the same pull-merge-rebuild-push sequence +/// as `diet_guard/_sync.py`, plus one phone-specific step: a pulled copy of +/// an entry never carries `imagePath` (stripped before push, meaningless on +/// another device), so it must not null out a local photo attachment for +/// the same `id` (plan decision 10). +library; + +import 'dart:convert'; + +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/github_client.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:diet_guard_app/services/sync_merge.dart'; + +const _devicesDir = 'devices'; + +/// This device's id in the `devices//food_log.json` layout. The PC +/// pushes under `pc` (`SYNC_DEVICE_ID` in `diet_guard/_constants.py`); the +/// phone is the only other device in this design. +const phoneDeviceId = 'phone'; + +String _deviceLogPath(String deviceId) => '$_devicesDir/$deviceId/food_log.json'; + +/// Runs one full sync tick: pull, merge, preserve photos, persist, push. +/// +/// Returns the merged log as it now sits on disk locally. Propagates any +/// [GitHubApiException] from the client for the caller (auto-sync / the +/// manual "Sync now" action) to decide how to report. +Future runSync(GitHubClient client) async { + final logService = LogStorageService.instance; + final local = await logService.readLog(); + final localImagePaths = _imagePathsById(local); + + var merged = local; + for (final deviceId in await client.listEntryNames(_devicesDir)) { + if (deviceId == phoneDeviceId) continue; + final text = await client.getFileText(_deviceLogPath(deviceId)); + if (text == null) continue; + final remoteLog = _decodeRemoteLog(text); + if (remoteLog == null) continue; + merged = mergeLogs(merged, remoteLog); + } + + merged = _preserveLocalImagePaths(merged, localImagePaths); + + await logService.writeLog(merged); + await FoodBankService.instance.rebuildAndPersist(merged); + + await client.putFileText( + _deviceLogPath(phoneDeviceId), + _encodeForPush(merged), + sha: await _ownFileSha(client), + message: 'diet_guard_app sync', + ); + return merged; +} + +/// Returns this device's current `food_log.json` sha if it has pushed +/// before, so [GitHubClient.putFileText] updates rather than creates. +Future _ownFileSha(GitHubClient client) async { + final files = await client.listDirectory('$_devicesDir/$phoneDeviceId'); + for (final file in files) { + if (file.name == 'food_log.json') return file.sha; + } + return null; +} + +Map _imagePathsById(DayLog log) { + final result = {}; + for (final entries in log.values) { + for (final entry in entries) { + final id = entry.id; + final imagePath = entry.imagePath; + if (id != null && imagePath != null) result[id] = imagePath; + } + } + return result; +} + +DayLog _preserveLocalImagePaths( + DayLog log, + Map imagePathsById, +) { + if (imagePathsById.isEmpty) return log; + return { + for (final mapEntry in log.entries) + mapEntry.key: [ + for (final entry in mapEntry.value) + _withPreservedImagePath(entry, imagePathsById), + ], + }; +} + +FoodEntry _withPreservedImagePath( + FoodEntry entry, + Map imagePathsById, +) { + if (entry.imagePath != null) return entry; + final preserved = imagePathsById[entry.id]; + if (preserved == null) return entry; + return entry.copyWithImagePath(preserved); +} + +/// Parses a device's pushed log, mirroring `_sync._pull_remote_logs`'s +/// tolerance for a corrupt/truncated push: an unparsable file is skipped +/// (returns null) rather than aborting the whole sync tick. +DayLog? _decodeRemoteLog(String text) { + Object? decoded; + try { + decoded = jsonDecode(text); + } on FormatException { + return null; + } + if (decoded is! Map) return null; + final result = >{}; + for (final mapEntry in decoded.entries) { + final key = mapEntry.key; + final value = mapEntry.value; + if (key is! String || value is! List) continue; + result[key] = value + .whereType>() + .map((m) => FoodEntry.fromJson(m.cast())) + .toList(); + } + return result; +} + +/// Serializes the full merged log for push via [FoodEntry.toSyncJson], +/// which excludes [FoodEntry.imagePath] (phone-local only) and `hmac` (the +/// PC re-signs every persisted entry on its own next sync tick regardless). +String _encodeForPush(DayLog log) { + final encoded = { + for (final mapEntry in log.entries) + mapEntry.key: mapEntry.value.map((e) => e.toSyncJson()).toList(), + }; + return jsonEncode(encoded); +} diff --git a/app/lib/services/sync_settings.dart b/app/lib/services/sync_settings.dart new file mode 100644 index 0000000..820ce47 --- /dev/null +++ b/app/lib/services/sync_settings.dart @@ -0,0 +1,121 @@ +/// Locally-stored GitHub sync configuration, ported from `~/todo`'s +/// `sync/sync_settings.dart` -- with the OAuth device-flow fields dropped: +/// the phone leans on a pasted PAT instead (the plan's call to pick +/// "whichever is less code", and pasting is strictly less code here). +library; + +import 'package:flutter/services.dart' show PlatformException; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// The GitHub token is kept in the OS keystore (Android Keystore / libsecret) +/// via [flutter_secure_storage]; only the non-secret owner/repo live in +/// `SharedPreferences`. Older builds stored the token in plaintext prefs; +/// [load]/[save] migrate it transparently and never drop the plaintext copy +/// until a secure write is confirmed (so we degrade to -- never below -- the +/// old behaviour when no secret service is available). +class SyncSettings { + /// Creates a [SyncSettings] from its owner/repo/token. + const SyncSettings({ + required this.owner, + required this.repo, + required this.token, + }); + + /// The repo owner/org (e.g. `"kuhyx"`). + final String owner; + + /// The repo name (e.g. `"diet-guard-sync"`). + final String repo; + + /// A GitHub PAT with contents read/write on [owner]/[repo]. + final String token; + + /// True when enough is set to attempt a sync. + bool get isConfigured => + owner.isNotEmpty && repo.isNotEmpty && token.isNotEmpty; + + static const _kOwner = 'sync.owner'; + static const _kRepo = 'sync.repo'; + // Legacy plaintext location for the token; read-only now and removed once + // the token has been migrated into secure storage. + static const _kToken = 'sync.token'; + + /// Key for the token inside the OS keystore. + static const _secureToken = 'sync.token'; + + /// Default options keep us off the deprecated `encryptedSharedPreferences` + /// path on Android and use libsecret on Linux. + static const _secure = FlutterSecureStorage(); + + /// Loads settings, defaulting the owner/repo to `kuhyx/diet-guard-sync` + /// (matching the PC's `SYNC_REPO_OWNER`/`SYNC_REPO_NAME` constants) so a + /// fresh install needs only a pasted PAT. + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return SyncSettings( + owner: prefs.getString(_kOwner) ?? 'kuhyx', + repo: prefs.getString(_kRepo) ?? 'diet-guard-sync', + token: await _loadToken(prefs), + ); + } + + /// Reads the token, preferring the keystore and falling back to the legacy + /// plaintext value. A legacy value is migrated into the keystore on read, + /// but only dropped from prefs once that secure write succeeds. + static Future _loadToken(SharedPreferences prefs) async { + String? secure; + try { + secure = await _secure.read(key: _secureToken); + } on PlatformException { + // No secret service available -- fall back to the legacy plaintext copy. + secure = null; + } + if (secure != null && secure.isNotEmpty) return secure; + + final legacy = prefs.getString(_kToken) ?? ''; + if (legacy.isNotEmpty && await _writeSecureToken(legacy)) { + await prefs.remove(_kToken); + } + return legacy; + } + + /// Persists [owner]/[repo] to prefs and [token] to the keystore (or + /// plaintext prefs as a fallback -- see [_loadToken]). + Future save() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kOwner, owner); + await prefs.setString(_kRepo, repo); + // Confirm-before-delete: only remove the plaintext copy once the keystore + // write succeeds; otherwise keep persisting it to prefs as before. + if (await _writeSecureToken(token)) { + await prefs.remove(_kToken); + } else { + await prefs.setString(_kToken, token); + } + } + + /// Writes [token] to the keystore (deleting the entry when empty). Returns + /// false if the platform secret service is unavailable. + static Future _writeSecureToken(String token) async { + try { + if (token.isEmpty) { + await _secure.delete(key: _secureToken); + } else { + await _secure.write(key: _secureToken, value: token); + } + return true; + } on PlatformException { + return false; + } + } + + /// Returns a copy of this with only the given fields replaced. + SyncSettings copyWith({String? owner, String? repo, String? token}) { + return SyncSettings( + owner: owner ?? this.owner, + repo: repo ?? this.repo, + token: token ?? this.token, + ); + } +} diff --git a/app/linux/.gitignore b/app/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/app/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/app/linux/CMakeLists.txt b/app/linux/CMakeLists.txt new file mode 100644 index 0000000..d0e8eea --- /dev/null +++ b/app/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "diet_guard_app") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.kuhy.diet_guard_app") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/app/linux/flutter/CMakeLists.txt b/app/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/app/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/app/linux/flutter/generated_plugin_registrant.cc b/app/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..85a2413 --- /dev/null +++ b/app/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,19 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); +} diff --git a/app/linux/flutter/generated_plugin_registrant.h b/app/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/app/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/app/linux/flutter/generated_plugins.cmake b/app/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..7aea3ec --- /dev/null +++ b/app/linux/flutter/generated_plugins.cmake @@ -0,0 +1,26 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux + flutter_secure_storage_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/app/linux/runner/CMakeLists.txt b/app/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/app/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/app/linux/runner/main.cc b/app/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/app/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/app/linux/runner/my_application.cc b/app/linux/runner/my_application.cc new file mode 100644 index 0000000..464436e --- /dev/null +++ b/app/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "diet_guard_app"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "diet_guard_app"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/app/linux/runner/my_application.h b/app/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/app/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/app/pubspec.lock b/app/pubspec.lock index 13c414c..fdd2c60 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -89,6 +89,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" file_selector_linux: dependency: transitive description: @@ -142,6 +158,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.35" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" + url: "https://pub.dev" + source: hosted + version: "10.3.1" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" + url: "https://pub.dev" + source: hosted + version: "0.3.2" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5 + url: "https://pub.dev" + source: hosted + version: "3.0.1" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.dev" + source: hosted + version: "4.2.2" flutter_test: dependency: "direct dev" description: flutter @@ -161,7 +225,7 @@ packages: source: hosted version: "2.0.2" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -424,6 +488,62 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5" + url: "https://pub.dev" + source: hosted + version: "2.4.26" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -525,6 +645,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 + url: "https://pub.dev" + source: hosted + version: "6.3.0" xdg_directories: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 5f5d6ac..50c2a94 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -10,9 +10,12 @@ environment: dependencies: flutter: sdk: flutter + flutter_secure_storage: ^10.3.1 + http: ^1.6.0 image_picker: ^1.1.2 path: ^1.9.1 path_provider: ^2.1.5 + shared_preferences: ^2.5.5 uuid: ^4.5.3 dev_dependencies: diff --git a/app/test/fake_secure_storage.dart b/app/test/fake_secure_storage.dart new file mode 100644 index 0000000..82654ca --- /dev/null +++ b/app/test/fake_secure_storage.dart @@ -0,0 +1,52 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// In-memory fake for the `flutter_secure_storage` platform channel so tests +/// never touch the real OS keystore. Install it from a test (or `setUp`) and +/// it auto-removes on tear down. +/// +/// Pass [throwing] to simulate a host with no secret service: every call +/// raises a [PlatformException], which exercises the plaintext-fallback +/// paths in [SyncSettings]. +void installFakeSecureStorage({ + Map? initial, + bool throwing = false, +}) { + const channel = MethodChannel( + 'plugins.it_nomads.com/flutter_secure_storage', + ); + final store = {...?initial}; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + messenger.setMockMethodCallHandler(channel, (call) async { + if (throwing) { + throw PlatformException(code: 'unavailable'); + } + final args = (call.arguments as Map?) ?? const {}; + final key = args['key'] as String?; + switch (call.method) { + case 'read': + return store[key]; + case 'write': + store[key!] = args['value'] as String; + return null; + case 'delete': + store.remove(key); + return null; + case 'containsKey': + return store.containsKey(key); + case 'readAll': + return Map.from(store); + case 'deleteAll': + store.clear(); + return null; + default: + return null; + } + }); + + addTearDown(() { + messenger.setMockMethodCallHandler(channel, null); + }); +} diff --git a/app/test/screens/log_meal_screen_sync_test.dart b/app/test/screens/log_meal_screen_sync_test.dart new file mode 100644 index 0000000..e2e45d7 --- /dev/null +++ b/app/test/screens/log_meal_screen_sync_test.dart @@ -0,0 +1,131 @@ +// Covers LogMealScreen's auto-sync: triggered on launch and on every +// AppLifecycleState change, best-effort/silent regardless of outcome. + +import 'dart:io'; + +import 'package:diet_guard_app/screens/log_meal_screen.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../fake_secure_storage.dart'; + +void main() { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_autosync_'); + LogStorageService.resetForTesting(testDir: tempDir); + FoodBankService.resetForTesting(testDir: tempDir); + }); + + tearDown(() async { + LogStorageService.resetForTesting(); + FoodBankService.resetForTesting(); + await tempDir.delete(recursive: true); + }); + + Future settle(WidgetTester tester) async { + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + } + + testWidgets( + 'does not push when sync is unconfigured (defaults to off)', + (tester) async { + SharedPreferences.setMockInitialValues({}); + installFakeSecureStorage(); + var puts = 0; + final mock = MockClient((req) async { + if (req.method == 'PUT') puts++; + return http.Response('', 404); + }); + + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp(home: LogMealScreen(httpClient: mock)), + ); + await settle(tester); + + expect(puts, 0); + }); + }, + ); + + testWidgets('pushes on launch when sync is configured', (tester) async { + SharedPreferences.setMockInitialValues({ + 'sync.owner': 'o', + 'sync.repo': 'r', + }); + installFakeSecureStorage(initial: {'sync.token': 't'}); + var puts = 0; + final mock = MockClient((req) async { + if (req.method == 'PUT') puts++; + return http.Response('', 404); + }); + + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp(home: LogMealScreen(httpClient: mock)), + ); + await settle(tester); + + expect(puts, 1); + }); + }); + + testWidgets('pushes again when the app is paused', (tester) async { + SharedPreferences.setMockInitialValues({ + 'sync.owner': 'o', + 'sync.repo': 'r', + }); + installFakeSecureStorage(initial: {'sync.token': 't'}); + var puts = 0; + final mock = MockClient((req) async { + if (req.method == 'PUT') puts++; + return http.Response('', 404); + }); + + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp(home: LogMealScreen(httpClient: mock)), + ); + await settle(tester); + expect(puts, 1); // launch + + // Flutter's AppLifecycleListener enforces a strict transition graph + // (resumed -> inactive -> hidden -> paused -> ...); jumping straight + // from resumed to paused is the one direct transition it allows. + WidgetsBinding.instance.handleAppLifecycleStateChanged( + AppLifecycleState.paused, + ); + await settle(tester); + expect(puts, 2); + }); + }); + + testWidgets('swallows a sync failure without crashing the screen', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({ + 'sync.owner': 'o', + 'sync.repo': 'r', + }); + installFakeSecureStorage(initial: {'sync.token': 't'}); + final mock = MockClient((_) async => http.Response('boom', 500)); + + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp(home: LogMealScreen(httpClient: mock)), + ); + await settle(tester); + + expect(find.byType(LogMealScreen), findsOneWidget); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/app/test/screens/log_meal_screen_test.dart b/app/test/screens/log_meal_screen_test.dart index a6b95d7..619a82b 100644 --- a/app/test/screens/log_meal_screen_test.dart +++ b/app/test/screens/log_meal_screen_test.dart @@ -4,6 +4,7 @@ import 'package:diet_guard_app/models/food_entry.dart'; import 'package:diet_guard_app/screens/log_meal_screen.dart'; import 'package:diet_guard_app/screens/history_screen.dart'; import 'package:diet_guard_app/screens/photo_viewer_screen.dart'; +import 'package:diet_guard_app/screens/settings_screen.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:diet_guard_app/services/photo_attach_service.dart'; @@ -143,6 +144,24 @@ void main() { }); }); + testWidgets('the settings icon navigates to SettingsScreen', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); + await settle(tester); + + // SettingsScreen briefly shows a perpetually-animating + // CircularProgressIndicator while its settings load; pumpAndSettle + // never settles against that, so pump explicit frames instead (see + // history_screen_test.dart's note on the same pitfall). + await tester.tap(find.byIcon(Icons.settings)); + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pump(); + + expect(find.byType(SettingsScreen), findsOneWidget); + }); + }); + testWidgets('logging a manually-typed meal persists it as source manual', ( tester, ) async { diff --git a/app/test/screens/settings_screen_test.dart b/app/test/screens/settings_screen_test.dart new file mode 100644 index 0000000..7b17695 --- /dev/null +++ b/app/test/screens/settings_screen_test.dart @@ -0,0 +1,148 @@ +import 'dart:io'; + +import 'package:diet_guard_app/screens/settings_screen.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../fake_secure_storage.dart'; + +void main() { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_settings_'); + LogStorageService.resetForTesting(testDir: tempDir); + FoodBankService.resetForTesting(testDir: tempDir); + SharedPreferences.setMockInitialValues({}); + installFakeSecureStorage(); + }); + + tearDown(() async { + LogStorageService.resetForTesting(); + FoodBankService.resetForTesting(); + await tempDir.delete(recursive: true); + }); + + // SettingsScreen loads its settings via a fire-and-forget Future in + // initState that Flutter's frame scheduler does not track -- same pitfall + // as HistoryScreen/LogMealScreen. + Future settle(WidgetTester tester) async { + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + } + + testWidgets('shows the kuhyx/diet-guard-sync defaults on a fresh install', ( + tester, + ) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); + await settle(tester); + + expect(find.widgetWithText(TextField, 'kuhyx'), findsOneWidget); + expect(find.widgetWithText(TextField, 'diet-guard-sync'), findsOneWidget); + }); + }); + + testWidgets('Save persists the entered token', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); + await settle(tester); + + await tester.enterText( + find.widgetWithText(TextField, 'Personal access token'), + 'my-pat', + ); + await tester.tap(find.widgetWithText(ElevatedButton, 'Save')); + await settle(tester); + + expect(find.text('Saved.'), findsOneWidget); + }); + }); + + testWidgets('Test connection reports success', (tester) async { + final mock = MockClient( + (_) async => http.Response('{}', 200), + ); + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp(home: SettingsScreen(httpClient: mock)), + ); + await settle(tester); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Test connection')); + await settle(tester); + + expect(find.text('Connection OK.'), findsOneWidget); + }); + }); + + testWidgets('Test connection reports failure', (tester) async { + final mock = MockClient((_) async => http.Response('', 403)); + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp(home: SettingsScreen(httpClient: mock)), + ); + await settle(tester); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Test connection')); + await settle(tester); + + expect(find.text('Connection failed.'), findsOneWidget); + }); + }); + + testWidgets('Sync now runs a sync tick and reports success', ( + tester, + ) async { + final mock = MockClient((req) async { + if (req.method == 'PUT') return http.Response('{}', 200); + return http.Response('', 404); + }); + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp(home: SettingsScreen(httpClient: mock)), + ); + await settle(tester); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Sync now')); + await settle(tester); + + expect(find.text('Synced.'), findsOneWidget); + }); + }); + + testWidgets('Test connection reports a network exception', (tester) async { + final mock = MockClient((_) async => throw const FormatException('no net')); + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp(home: SettingsScreen(httpClient: mock)), + ); + await settle(tester); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Test connection')); + await settle(tester); + + expect(find.textContaining('Connection failed:'), findsOneWidget); + }); + }); + + testWidgets('Sync now reports a GitHub error', (tester) async { + final mock = MockClient((_) async => http.Response('boom', 500)); + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp(home: SettingsScreen(httpClient: mock)), + ); + await settle(tester); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Sync now')); + await settle(tester); + + expect(find.textContaining('Sync failed:'), findsOneWidget); + }); + }); +} diff --git a/app/test/services/github_client_test.dart b/app/test/services/github_client_test.dart new file mode 100644 index 0000000..0f3b9b4 --- /dev/null +++ b/app/test/services/github_client_test.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; + +import 'package:diet_guard_app/services/github_client.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +void main() { + GitHubClient client(MockClient mock) => + GitHubClient(owner: 'o', repo: 'r', token: 't', httpClient: mock); + + test('listDirectory returns only files and ignores subdirectories', () async { + final mock = MockClient((req) async { + expect(req.headers['Authorization'], contains('t')); + return http.Response( + jsonEncode([ + {'type': 'file', 'name': 'a.json', 'path': 'd/a.json', 'sha': 's1'}, + {'type': 'dir', 'name': 'sub', 'path': 'd/sub', 'sha': 's2'}, + ]), + 200, + ); + }); + final files = await client(mock).listDirectory('d'); + expect(files, hasLength(1)); + expect(files.single.name, 'a.json'); + expect(files.single.sha, 's1'); + }); + + test( + 'listDirectory returns empty on 404 (directory not created yet)', + () async { + final files = await client( + MockClient((_) async => http.Response('', 404)), + ).listDirectory('missing'); + expect(files, isEmpty); + }, + ); + + test('listEntryNames returns both file and directory names', () async { + final mock = MockClient( + (_) async => http.Response( + jsonEncode([ + {'type': 'dir', 'name': 'pc', 'path': 'devices/pc', 'sha': 's1'}, + {'type': 'dir', 'name': 'phone', 'path': 'devices/phone', 'sha': 's2'}, + ]), + 200, + ), + ); + expect(await client(mock).listEntryNames('devices'), ['pc', 'phone']); + }); + + test('listEntryNames returns empty on 404', () async { + final files = await client( + MockClient((_) async => http.Response('', 404)), + ).listEntryNames('missing'); + expect(files, isEmpty); + }); + + test('getFileText base64-decodes content; null on 404', () async { + final encoded = base64.encode(utf8.encode('hello world')); + final ok = MockClient( + (_) async => http.Response(jsonEncode({'content': encoded}), 200), + ); + expect(await client(ok).getFileText('f'), 'hello world'); + + final missing = MockClient((_) async => http.Response('', 404)); + expect(await client(missing).getFileText('f'), isNull); + }); + + test( + 'putFileText omits sha when creating, includes it when updating', + () async { + String? sentBody; + final mock = MockClient((req) async { + sentBody = req.body; + return http.Response('{}', 201); + }); + await client(mock).putFileText('f', 'data'); + expect(jsonDecode(sentBody!).containsKey('sha'), isFalse); + + await client(mock).putFileText('f', 'data', sha: 'abc'); + expect(jsonDecode(sentBody!)['sha'], 'abc'); + }, + ); + + test('deleteFile sends the sha', () async { + String? body; + final mock = MockClient((req) async { + body = req.body; + return http.Response('{}', 200); + }); + await client(mock).deleteFile('f', 'sha123'); + expect(jsonDecode(body!)['sha'], 'sha123'); + }); + + test('canAccessRepo reflects the status code', () async { + expect( + await client( + MockClient((_) async => http.Response('{}', 200)), + ).canAccessRepo(), + isTrue, + ); + expect( + await client( + MockClient((_) async => http.Response('', 403)), + ).canAccessRepo(), + isFalse, + ); + }); + + test('throws GitHubApiException on a non-2xx that is not 404', () async { + final mock = MockClient((_) async => http.Response('boom', 500)); + expect( + () => client(mock).getFileText('f'), + throwsA(isA()), + ); + }); + + test('GitHubApiException.toString includes status and message', () { + expect( + GitHubApiException(500, 'boom').toString(), + 'GitHubApiException(500): boom', + ); + }); + + test('creates a default http client when none is injected', () { + // No httpClient → the constructor builds a real http.Client; just make + // sure that branch runs and the client closes cleanly (no request made). + final c = GitHubClient(owner: 'o', repo: 'r', token: 't'); + addTearDown(c.close); + }); +} diff --git a/app/test/services/sync_merge_test.dart b/app/test/services/sync_merge_test.dart new file mode 100644 index 0000000..0029212 --- /dev/null +++ b/app/test/services/sync_merge_test.dart @@ -0,0 +1,170 @@ +// Table-driven `mergeLogs()` tests mirroring `test_sync_merge.py` exactly +// (same cases, same expected outcomes), so both implementations are provably +// testing the same algorithm. + +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/services/sync_merge.dart'; +import 'package:flutter_test/flutter_test.dart'; + +FoodEntry _entry({ + String? id = 'id-1', + String time = '2026-06-22T08:00:00', + String desc = 'oatmeal', + bool deleted = false, +}) => FoodEntry( + id: id, + time: time, + desc: desc, + grams: 200, + kcal: 300, + proteinG: 10, + carbsG: 50, + fatG: 5, + source: 'manual', + deleted: deleted, +); + +void main() { + group('union by id', () { + test('disjoint logs union into one', () { + final a = { + '2026-06-22': [_entry(id: 'a', time: '2026-06-22T08:00:00')], + }; + final b = { + '2026-06-22': [_entry(id: 'b', time: '2026-06-22T12:00:00')], + }; + final merged = mergeLogs(a, b); + expect(merged['2026-06-22']!.map((e) => e.id).toSet(), {'a', 'b'}); + }); + + test('same id in both logs is not duplicated', () { + final shared = _entry(id: 'shared'); + final merged = mergeLogs({ + '2026-06-22': [shared], + }, {'2026-06-22': [shared]}); + expect(merged['2026-06-22'], hasLength(1)); + }); + + test('legacy entries without id dedup by time and desc', () { + final legacyA = _entry( + id: null, + time: '2026-06-20T08:00:00', + desc: 'toast', + ); + final legacyB = _entry( + id: null, + time: '2026-06-20T08:00:00', + desc: 'toast', + ); + final merged = mergeLogs({ + '2026-06-20': [legacyA], + }, {'2026-06-20': [legacyB]}); + expect(merged['2026-06-20'], hasLength(1)); + }); + + test('legacy and id entries with different keys both survive', () { + final legacy = _entry( + id: null, + time: '2026-06-20T08:00:00', + desc: 'toast', + ); + final withId = _entry(id: 'x', time: '2026-06-20T09:00:00', desc: 'eggs'); + final merged = mergeLogs({ + '2026-06-20': [legacy], + }, {'2026-06-20': [withId]}); + expect(merged['2026-06-20'], hasLength(2)); + }); + }); + + group('tombstone wins', () { + test('tombstone beats a non-deleted copy either order', () { + final normal = _entry(id: 'x'); + final tombstoned = _entry(id: 'x', deleted: true); + + final forward = mergeLogs({ + '2026-06-22': [normal], + }, {'2026-06-22': [tombstoned]}); + final backward = mergeLogs({ + '2026-06-22': [tombstoned], + }, {'2026-06-22': [normal]}); + + expect(forward['2026-06-22']!.single.deleted, isTrue); + expect(backward['2026-06-22']!.single.deleted, isTrue); + }); + + test('two tombstoned copies stay tombstoned', () { + final tombstoned = _entry(id: 'x', deleted: true); + final merged = mergeLogs({ + '2026-06-22': [tombstoned], + }, {'2026-06-22': [_entry(id: 'x', deleted: true)]}); + expect(merged['2026-06-22']!.single.deleted, isTrue); + }); + }); + + group('rebucketing and ordering', () { + test( + "entry is filed under its own time's date, not the arrival bucket", + () { + final misfiled = _entry(id: 'x', time: '2026-06-21T23:00:00'); + final merged = mergeLogs({'2026-06-22': [misfiled]}, {}); + expect(merged.keys, ['2026-06-21']); + expect(merged['2026-06-21']!.single.id, 'x'); + }, + ); + + test( + 'an entry with a time shorter than a date key buckets under the ' + 'raw time instead of crashing', + () { + // Dart's substring throws past the string length, unlike Python's + // forgiving slice -- this only matters for malformed/legacy data. + final short = _entry(id: 'x', time: '2026'); + final merged = mergeLogs({'2026-06-22': [short]}, {}); + expect(merged.keys, ['2026']); + }, + ); + + test("a day's entries are sorted oldest-first", () { + final late = _entry(id: 'late', time: '2026-06-22T20:00:00'); + final early = _entry(id: 'early', time: '2026-06-22T08:00:00'); + final merged = mergeLogs({ + '2026-06-22': [late], + }, {'2026-06-22': [early]}); + expect(merged['2026-06-22']!.map((e) => e.id).toList(), [ + 'early', + 'late', + ]); + }); + }); + + group('algebraic properties', () { + test('merge is commutative', () { + final a = {'2026-06-22': [_entry(id: 'a')]}; + final b = { + '2026-06-22': [_entry(id: 'b', time: '2026-06-22T09:00:00')], + }; + final ab = mergeLogs(a, b); + final ba = mergeLogs(b, a); + expect( + ab['2026-06-22']!.map((e) => e.id).toList(), + ba['2026-06-22']!.map((e) => e.id).toList(), + ); + }); + + test('merge is idempotent', () { + final canonical = {'2026-06-22': [_entry(id: 'a')]}; + final merged = mergeLogs(canonical, canonical); + expect(merged['2026-06-22']!.map((e) => e.id).toList(), ['a']); + }); + + test('merging with an empty log is a no-op', () { + final log = {'2026-06-22': [_entry(id: 'a')]}; + expect(mergeLogs(log, {}).keys, log.keys); + expect(mergeLogs({}, log).keys, log.keys); + }); + + test('merging two empty logs is empty', () { + expect(mergeLogs({}, {}), isEmpty); + }); + }); +} diff --git a/app/test/services/sync_service_test.dart b/app/test/services/sync_service_test.dart new file mode 100644 index 0000000..10bd9ad --- /dev/null +++ b/app/test/services/sync_service_test.dart @@ -0,0 +1,252 @@ +// Mirrors `test_sync.py`'s `TestRunSync` cases (own-id-skip, no-prior-push, +// non-object payload, corrupt JSON, remote merge, food bank rebuild), plus +// one Dart-specific case for the phone's `imagePath`-preserve-by-id step +// (plan decision 10) that has no PC-side equivalent. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/models/nutrition.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/github_client.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:diet_guard_app/services/sync_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +const _manual = Nutrition( + kcal: 200, + proteinG: 10, + carbsG: 20, + fatG: 5, + grams: 100, + source: 'manual', +); + +/// A tiny in-memory stand-in for the GitHub Contents API, scoped to exactly +/// the calls [runSync] makes: list `devices`, list a single device's own +/// directory (for its file's sha), get a device's file text, and put this +/// device's file text. +class _FakeGitHub { + _FakeGitHub({this.deviceDirs = const [], Map? files}) + : files = {...?files}; + + final List deviceDirs; + final Map files; + + /// Every `devices//food_log.json` path this fake actually served a + /// file-content GET for (i.e. a real `getFileText` pull, not a listing). + final List fileGets = []; + + /// Every PUT this fake received, decoded. + final List> puts = []; + + GitHubClient buildClient() => GitHubClient( + owner: 'o', + repo: 'r', + token: 't', + httpClient: MockClient(_handle), + ); + + Future _handle(http.Request req) async { + final path = req.url.path.replaceFirst('/repos/o/r/contents/', ''); + if (req.method == 'PUT') { + puts.add(jsonDecode(req.body) as Map); + return http.Response('{}', 200); + } + if (path == 'devices') { + return http.Response( + jsonEncode([ + for (final d in deviceDirs) + {'type': 'dir', 'name': d, 'path': 'devices/$d', 'sha': 'd-$d'}, + ]), + 200, + ); + } + final segments = path.split('/'); + if (segments.length == 2 && segments[0] == 'devices') { + final deviceId = segments[1]; + final filePath = 'devices/$deviceId/food_log.json'; + if (!files.containsKey(filePath)) return http.Response('', 404); + return http.Response( + jsonEncode([ + { + 'type': 'file', + 'name': 'food_log.json', + 'path': filePath, + 'sha': 'f-$deviceId', + }, + ]), + 200, + ); + } + if (!files.containsKey(path)) return http.Response('', 404); + fileGets.add(path); + final content = base64.encode(utf8.encode(files[path]!)); + return http.Response(jsonEncode({'content': content}), 200); + } +} + +void main() { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_sync_test_'); + LogStorageService.resetForTesting(testDir: tempDir); + FoodBankService.resetForTesting(testDir: tempDir); + }); + + tearDown(() async { + LogStorageService.resetForTesting(); + FoodBankService.resetForTesting(); + await tempDir.delete(recursive: true); + }); + + test('pushes the local log when no other devices have synced', () async { + await LogStorageService.instance.logMeal('oatmeal', _manual); + final fake = _FakeGitHub(); + final merged = await runSync(fake.buildClient()); + + expect(merged.values.expand((e) => e).length, 1); + expect(fake.puts, hasLength(1)); + }); + + test("skips its own device id ('phone') when listing", () async { + final fake = _FakeGitHub( + deviceDirs: const ['pc', 'phone'], + files: const {'devices/pc/food_log.json': '{}'}, + ); + await runSync(fake.buildClient()); + expect(fake.fileGets, ['devices/pc/food_log.json']); + }); + + test('skips a device with no pushed file yet', () async { + final fake = _FakeGitHub(deviceDirs: const ['pc']); + final merged = await runSync(fake.buildClient()); + expect(merged, isEmpty); + }); + + test('ignores a device whose pushed file is not a JSON object', () async { + final fake = _FakeGitHub( + deviceDirs: const ['pc'], + files: const {'devices/pc/food_log.json': '[]'}, + ); + final merged = await runSync(fake.buildClient()); + expect(merged, isEmpty); + }); + + test('skips a device whose pushed file is corrupt json', () async { + final fake = _FakeGitHub( + deviceDirs: const ['pc'], + files: const {'devices/pc/food_log.json': '{not valid json'}, + ); + final merged = await runSync(fake.buildClient()); + expect(merged, isEmpty); + }); + + test("merges in a remote device's entries", () async { + final remoteLog = jsonEncode({ + '2026-06-22': [ + { + 'id': 'pc-1', + 'time': '2026-06-22T09:00:00+02:00', + 'desc': 'pc meal', + 'kcal': 400.0, + 'protein_g': 20.0, + 'carbs_g': 40.0, + 'fat_g': 10.0, + 'grams': 300.0, + 'source': 'manual', + }, + ], + }); + final fake = _FakeGitHub( + deviceDirs: const ['pc'], + files: {'devices/pc/food_log.json': remoteLog}, + ); + final merged = await runSync(fake.buildClient()); + final descs = merged.values.expand((e) => e).map((e) => e.desc).toSet(); + expect(descs, contains('pc meal')); + }); + + test('rebuilds the food bank after merge', () async { + await LogStorageService.instance.logMeal('oatmeal', _manual); + final fake = _FakeGitHub(); + await runSync(fake.buildClient()); + + final bank = await FoodBankService.instance.readBank(); + expect(bank.containsKey('oatmeal'), isTrue); + }); + + test('pushes a payload without imagePath or hmac', () async { + await LogStorageService.instance.logMeal( + 'oatmeal', + _manual, + imagePath: '/local/photo.jpg', + ); + final fake = _FakeGitHub(); + await runSync(fake.buildClient()); + + final pushed = fake.puts.single; + final pushedText = utf8.decode(base64.decode(pushed['content'] as String)); + expect(pushedText, isNot(contains('imagePath'))); + expect(pushedText, isNot(contains('hmac'))); + }); + + test("reuses this device's existing sha when it has pushed before", () async { + final fake = _FakeGitHub( + files: const {'devices/phone/food_log.json': '{}'}, + ); + await runSync(fake.buildClient()); + expect(fake.puts.single['sha'], 'f-phone'); + }); + + test( + 'preserves a local imagePath even when a remote tombstone wins the merge', + () async { + await LogStorageService.instance.writeLog({ + '2026-06-22': [ + const FoodEntry( + id: 'x', + time: '2026-06-22T08:00:00', + desc: 'photo meal', + grams: 100, + kcal: 200, + proteinG: 10, + carbsG: 20, + fatG: 5, + source: 'manual', + imagePath: '/local/photo.jpg', + ), + ], + }); + final remoteLog = jsonEncode({ + '2026-06-22': [ + { + 'id': 'x', + 'time': '2026-06-22T08:00:00', + 'desc': 'photo meal', + 'kcal': 200.0, + 'protein_g': 10.0, + 'carbs_g': 20.0, + 'fat_g': 5.0, + 'grams': 100.0, + 'source': 'manual', + 'deleted': true, + }, + ], + }); + final fake = _FakeGitHub( + deviceDirs: const ['pc'], + files: {'devices/pc/food_log.json': remoteLog}, + ); + final merged = await runSync(fake.buildClient()); + + final entry = merged.values.expand((e) => e).single; + expect(entry.deleted, isTrue); + expect(entry.imagePath, '/local/photo.jpg'); + }, + ); +} diff --git a/app/test/services/sync_settings_test.dart b/app/test/services/sync_settings_test.dart new file mode 100644 index 0000000..5a824e3 --- /dev/null +++ b/app/test/services/sync_settings_test.dart @@ -0,0 +1,121 @@ +import 'package:diet_guard_app/services/sync_settings.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../fake_secure_storage.dart'; + +void main() { + // installFakeSecureStorage touches the test binary messenger, which needs + // the binding up first (widget tests get this for free via testWidgets). + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'load returns the kuhyx/diet-guard-sync defaults on a fresh install', + () async { + SharedPreferences.setMockInitialValues({}); + installFakeSecureStorage(); + final s = await SyncSettings.load(); + expect(s.owner, 'kuhyx'); + expect(s.repo, 'diet-guard-sync'); + expect(s.token, ''); + }, + ); + + test('save stores the token in the keystore, not in prefs', () async { + SharedPreferences.setMockInitialValues({}); + installFakeSecureStorage(); + await const SyncSettings(owner: 'me', repo: 'notes', token: 'tok').save(); + + // Token must not linger in plaintext prefs once secured. + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('sync.token'), isNull); + + final s = await SyncSettings.load(); + expect(s.owner, 'me'); + expect(s.repo, 'notes'); + expect(s.token, 'tok'); + }); + + test('load reads the token straight from the keystore', () async { + SharedPreferences.setMockInitialValues({}); + installFakeSecureStorage(initial: {'sync.token': 'fromKeystore'}); + final s = await SyncSettings.load(); + expect(s.token, 'fromKeystore'); + }); + + test('load migrates a legacy plaintext token into the keystore', () async { + SharedPreferences.setMockInitialValues({'sync.token': 'legacy'}); + installFakeSecureStorage(); + + final s = await SyncSettings.load(); + expect(s.token, 'legacy'); + + // The plaintext copy is dropped once the secure write succeeds, and the + // value now resolves from the keystore on the next load. + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('sync.token'), isNull); + final again = await SyncSettings.load(); + expect(again.token, 'legacy'); + }); + + test( + 'load keeps the plaintext token when no secret service is available', + () async { + SharedPreferences.setMockInitialValues({'sync.token': 'plain'}); + installFakeSecureStorage(throwing: true); + + final s = await SyncSettings.load(); + expect(s.token, 'plain'); + // Never drop the only copy when the keystore write can't be confirmed. + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('sync.token'), 'plain'); + }, + ); + + test('save falls back to plaintext prefs when the keystore fails', () async { + SharedPreferences.setMockInitialValues({}); + installFakeSecureStorage(throwing: true); + await const SyncSettings(owner: 'o', repo: 'r', token: 'tok').save(); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('sync.token'), 'tok'); + }); + + test('save with an empty token clears the keystore entry', () async { + // Seed a keystore token, then save an empty token: it must be deleted + // and no plaintext copy written. + SharedPreferences.setMockInitialValues({}); + installFakeSecureStorage(initial: {'sync.token': 'old'}); + await const SyncSettings(owner: 'o', repo: 'r', token: '').save(); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('sync.token'), isNull); + final s = await SyncSettings.load(); + expect(s.token, ''); + }); + + test('isConfigured requires owner, repo and token', () { + expect( + const SyncSettings(owner: 'o', repo: 'r', token: 't').isConfigured, + isTrue, + ); + expect( + const SyncSettings(owner: 'o', repo: 'r', token: '').isConfigured, + isFalse, + ); + }); + + test('copyWith overrides only the given fields', () { + const base = SyncSettings(owner: 'o', repo: 'r', token: 't'); + final next = base.copyWith(token: 'new'); + expect(next.owner, 'o'); + expect(next.repo, 'r'); + expect(next.token, 'new'); + + // No-arg copy exercises the `?? this.x` fallback on every field. + final clone = base.copyWith(); + expect(clone.owner, 'o'); + expect(clone.repo, 'r'); + expect(clone.token, 't'); + }); +}