diff --git a/app/.metadata b/app/.metadata index 2ffa74b..c84df4c 100644 --- a/app/.metadata +++ b/app/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "c9a6c484230f8b5e408ec57be1ef71dee1e77020" + revision: "ee80f08bbf97172ec030b8751ceab557177a34a6" channel: "stable" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 - base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 - - platform: linux - create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 - base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 + create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 + base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 + - platform: web + create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 + base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 # User provided section diff --git a/app/lib/main.dart b/app/lib/main.dart index 80cfdc5..476f0df 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -2,16 +2,15 @@ /// background due-slot check, then shows the primary meal-logging screen. library; -import 'dart:io'; +import 'dart:async'; import 'package:diet_guard_app/screens/log_meal_screen.dart'; import 'package:diet_guard_app/services/app_settings_service.dart'; -import 'package:diet_guard_app/services/background_check_service.dart'; +import 'package:diet_guard_app/services/background_tasks.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/notification_service.dart'; import 'package:flutter/material.dart'; -import 'package:workmanager/workmanager.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -19,18 +18,19 @@ Future main() async { await AppSettingsService.init(); await FoodBankService.init(); final notifications = await NotificationService.init(); - await notifications.requestPermission(); - // WorkManager has no Linux/web/desktop implementation -- registering it - // there throws. Guard to the two platforms that ship it. + // Deliberately *not* awaited before the first frame: the browser's + // `Notification.requestPermission()` does not complete until the user + // answers the prompt, so awaiting it here left the desktop app as a blank + // white window behind the permission bubble. Scheduling is likewise + // platform-specific (WorkManager on Android, an in-page timer in the + // browser -- see background_tasks.dart) and equally not worth a delayed + // first frame. // coverage:ignore-start - if (Platform.isAndroid || Platform.isIOS) { - await Workmanager().initialize(backgroundCheckCallbackDispatcher); - await Workmanager().registerPeriodicTask( - backgroundCheckTaskName, - backgroundCheckTaskName, - frequency: const Duration(minutes: 15), - ); - } + unawaited( + notifications.requestPermission().then((_) { + return initBackgroundTasks(); + }), + ); // coverage:ignore-end runApp(const DietGuardApp()); } diff --git a/app/lib/screens/history_screen.dart b/app/lib/screens/history_screen.dart index 9ec6a8b..6f8f489 100644 --- a/app/lib/screens/history_screen.dart +++ b/app/lib/screens/history_screen.dart @@ -2,7 +2,6 @@ library; import 'dart:async'; -import 'dart:io'; import 'package:diet_guard_app/models/food_entry.dart'; import 'package:diet_guard_app/screens/edit_entry_screen.dart'; @@ -10,6 +9,7 @@ import 'package:diet_guard_app/screens/photo_viewer_screen.dart'; import 'package:diet_guard_app/services/app_settings_service.dart'; import 'package:diet_guard_app/services/day_status_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:diet_guard_app/widgets/attached_image.dart'; import 'package:flutter/material.dart'; // --------------------------------------------------------------------------- @@ -636,16 +636,11 @@ class _Thumbnail extends StatelessWidget { ), child: ClipRRect( borderRadius: BorderRadius.circular(6), - child: Image.file( - File(path), + child: AttachedImage( + path: path, width: 40, height: 40, fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => const SizedBox( - width: 40, - height: 40, - child: Icon(Icons.broken_image), - ), ), ), ); diff --git a/app/lib/screens/log_meal_screen.dart b/app/lib/screens/log_meal_screen.dart index e240fa6..58b9f23 100644 --- a/app/lib/screens/log_meal_screen.dart +++ b/app/lib/screens/log_meal_screen.dart @@ -3,7 +3,6 @@ library; import 'dart:async'; -import 'dart:io'; import 'package:crdt_sync/crdt_sync.dart'; import 'package:diet_guard_app/models/food_entry.dart'; @@ -16,8 +15,9 @@ 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/app_settings_service.dart'; -import 'package:diet_guard_app/services/background_sync_service.dart'; +import 'package:diet_guard_app/services/background_tasks.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/github_client_factory.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'; @@ -28,7 +28,6 @@ import 'package:diet_guard_app/widgets/slot_selector_row.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:url_launcher/url_launcher.dart'; -import 'package:workmanager/workmanager.dart'; /// Lets the user log one food item, with food-bank autocomplete and /// today's slot status, or hop into [MealBuilderScreen] for a composite @@ -122,10 +121,8 @@ class _LogMealScreenState extends State try { final settings = await SyncSettings.load(); if (!settings.isConfigured) return; - final client = GitHubClient( - owner: settings.owner, - repo: settings.repo, - token: settings.token, + final client = createGitHubClient( + settings, httpClient: widget.httpClient, ); try { @@ -143,24 +140,12 @@ class _LogMealScreenState extends State } } - /// Queues a connectivity-gated WorkManager push so a meal logged while - /// offline still uploads on reconnect, without the app being reopened. The - /// in-process [_autoSync] covers the online case; this is the backstop. - /// [ExistingWorkPolicy.replace] coalesces a burst of logs into one pending - /// job, and backoff retries a push that fails once connectivity returns. - /// No-op off mobile (WorkManager ships only on Android/iOS). + /// Queues the platform's offline push backstop so a meal logged while + /// offline still uploads on reconnect. The in-process [_autoSync] covers + /// the online case; this is the backstop (a no-op on web, which has no + /// out-of-page scheduler -- see background_tasks.dart). // coverage:ignore-start - Future _enqueueSyncBackstop() async { - if (!Platform.isAndroid && !Platform.isIOS) return; - await Workmanager().registerOneOffTask( - syncPushTaskName, - syncPushTaskName, - constraints: Constraints(networkType: NetworkType.connected), - existingWorkPolicy: ExistingWorkPolicy.replace, - backoffPolicy: BackoffPolicy.linear, - backoffPolicyDelay: const Duration(minutes: 1), - ); - } + Future _enqueueSyncBackstop() => enqueueSyncBackstop(); // coverage:ignore-end diff --git a/app/lib/screens/photo_viewer_screen.dart b/app/lib/screens/photo_viewer_screen.dart index da99404..06e1941 100644 --- a/app/lib/screens/photo_viewer_screen.dart +++ b/app/lib/screens/photo_viewer_screen.dart @@ -1,8 +1,7 @@ /// Full-screen, pinch-to-zoom view of a locally attached meal photo. library; -import 'dart:io'; - +import 'package:diet_guard_app/widgets/attached_image.dart'; import 'package:flutter/material.dart'; /// Shows the image at [path] full-screen, with pinch-to-zoom and a back @@ -11,7 +10,8 @@ class PhotoViewerScreen extends StatelessWidget { /// Creates a [PhotoViewerScreen] for the photo at [path]. const PhotoViewerScreen({required this.path, super.key}); - /// Local filesystem path to the image to display. + /// Blob key of the image to display (a file path on Android, an IndexedDB + /// key in the desktop web build). final String path; @override @@ -24,13 +24,10 @@ class PhotoViewerScreen extends StatelessWidget { ), body: Center( child: InteractiveViewer( - child: Image.file( - File(path), - errorBuilder: (context, error, stackTrace) => const Icon( - Icons.broken_image, - color: Colors.white, - size: 64, - ), + child: AttachedImage( + path: path, + errorIconSize: 64, + errorIconColor: Colors.white, ), ), ), diff --git a/app/lib/screens/settings_screen.dart b/app/lib/screens/settings_screen.dart index cda8a93..189b918 100644 --- a/app/lib/screens/settings_screen.dart +++ b/app/lib/screens/settings_screen.dart @@ -8,12 +8,13 @@ library; import 'dart:async'; -import 'package:crdt_sync/crdt_sync.dart' as crdt_sync; import 'package:diet_guard_app/screens/log_meal_screen.dart'; import 'package:diet_guard_app/services/app_settings_service.dart'; +import 'package:diet_guard_app/services/github_client_factory.dart'; import 'package:diet_guard_app/services/github_device_auth.dart'; import 'package:diet_guard_app/services/sync_service.dart'; import 'package:diet_guard_app/services/sync_settings.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; @@ -75,7 +76,13 @@ class _SettingsScreenState extends State { _rewardUrlController.text = AppSettingsService.rewardUrl ?? ''; _ownerController.text = settings.owner; _repoController.text = settings.repo; - _tokenController.text = settings.token; + // On web the stored "token" is only a stand-in for one the desktop + // wrapper holds (see TokenVault); showing it would invite the user to + // edit a literal. [_storedToken] keeps it for round-tripping. + _storedToken = settings.token; + _tokenController.text = SyncSettings.exposesTokenValue + ? settings.token + : ''; _clientIdController.text = settings.clientId; setState(() => _loading = false); } @@ -92,12 +99,24 @@ class _SettingsScreenState extends State { super.dispose(); } - SyncSettings _currentSettings() => SyncSettings( - owner: _ownerController.text.trim(), - repo: _repoController.text.trim(), - token: _tokenController.text.trim(), - clientId: _clientIdController.text.trim(), - ); + /// The token as loaded, so a platform that cannot display it (web) still + /// round-trips it instead of blanking it on the next save. + String _storedToken = ''; + + SyncSettings _currentSettings() { + final typed = _tokenController.text.trim(); + return SyncSettings( + owner: _ownerController.text.trim(), + repo: _repoController.text.trim(), + // The `_storedToken` side is web-only (the wrapper holds the real + // token), so it is unreachable from a VM test. + token: typed.isEmpty && !SyncSettings.exposesTokenValue + // coverage:ignore-line + ? _storedToken + : typed, + clientId: _clientIdController.text.trim(), + ); + } /// Persists the temptation-bundling reward fields, blank -> null. Future _saveReward() async { @@ -128,10 +147,7 @@ class _SettingsScreenState extends State { setState(() => _clientIdController.text = clientId); await _currentSettings().save(); } - final auth = GitHubDeviceAuth( - clientId: clientId, - httpClient: widget.httpClient, - ); + final auth = createDeviceAuth(clientId, httpClient: widget.httpClient); try { final device = await auth.requestDeviceCode(); if (!mounted) return; @@ -141,7 +157,10 @@ class _SettingsScreenState extends State { builder: (_) => _DeviceCodeDialog(device: device, auth: auth), ); if (token != null && token.isNotEmpty) { - setState(() => _tokenController.text = token); + setState(() { + _storedToken = token; + if (SyncSettings.exposesTokenValue) _tokenController.text = token; + }); _showMessage('Connected — syncing…'); await _currentSettings().save(); await _syncAfterConnect(); @@ -157,12 +176,7 @@ class _SettingsScreenState extends State { /// to work immediately, with clear confirmation either way. Future _syncAfterConnect() async { final settings = _currentSettings(); - final client = crdt_sync.GitHubClient( - owner: settings.owner, - repo: settings.repo, - token: settings.token, - httpClient: widget.httpClient, - ); + final client = createGitHubClient(settings, httpClient: widget.httpClient); try { await runSync(client); _showMessage('Connected and synced.'); @@ -184,12 +198,7 @@ class _SettingsScreenState extends State { Future _testConnection() async { setState(() => _busy = true); final settings = _currentSettings(); - final client = crdt_sync.GitHubClient( - owner: settings.owner, - repo: settings.repo, - token: settings.token, - httpClient: widget.httpClient, - ); + final client = createGitHubClient(settings, httpClient: widget.httpClient); try { final ok = await client.canAccessRepo(); _showMessage(ok ? 'Connection OK.' : 'Connection failed.'); @@ -205,12 +214,7 @@ class _SettingsScreenState extends State { setState(() => _busy = true); final settings = _currentSettings(); await settings.save(); - final client = crdt_sync.GitHubClient( - owner: settings.owner, - repo: settings.repo, - token: settings.token, - httpClient: widget.httpClient, - ); + final client = createGitHubClient(settings, httpClient: widget.httpClient); try { await runSync(client); _showMessage('Synced.'); @@ -241,6 +245,13 @@ class _SettingsScreenState extends State { } } + /// True on the one platform with OEM battery optimization to exempt from. + /// + /// Uses [defaultTargetPlatform] rather than `Platform.isAndroid` because + /// `dart:io` is a throwing stub in the desktop web build. + bool get _isAndroid => + !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + @override Widget build(BuildContext context) { if (_loading) { @@ -335,8 +346,19 @@ class _SettingsScreenState extends State { TextField( controller: _tokenController, obscureText: true, - decoration: const InputDecoration( + decoration: InputDecoration( labelText: 'Personal access token (fallback)', + // Both wrapper hints are web-only; a VM test always takes + // the null branch. + // coverage:ignore-start + helperText: SyncSettings.exposesTokenValue + ? null + : _storedToken.isEmpty + ? 'Stored by the desktop wrapper, never by the browser' + : 'A token is stored by the desktop wrapper; type here ' + 'only to replace it', + // coverage:ignore-end + helperMaxLines: 2, ), ), ], @@ -365,18 +387,29 @@ class _SettingsScreenState extends State { Text('Notifications', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 4), Text( - 'A background check nags you every ~15 min if a meal slot is ' - 'overdue. Aggressive OEM battery optimization (MIUI, some ' - 'Samsung configs) can delay this well past 15 min -- request an ' - 'exemption for reliable nagging.', + _isAndroid + ? 'A background check nags you every ~15 min if a meal slot ' + 'is overdue. Aggressive OEM battery optimization (MIUI, ' + 'some Samsung configs) can delay this well past 15 min ' + '-- request an exemption for reliable nagging.' + : 'A check runs every 5 min while this window is open and ' + 'notifies you about an overdue meal slot. A browser ' + 'cannot run anything once the window is closed -- on ' + 'the PC the real backstop is the diet_guard gate, ' + 'which locks the screen instead.', style: Theme.of(context).textTheme.bodySmall, ), - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: _requestBatteryExemption, - icon: const Icon(Icons.battery_alert), - label: const Text('Disable battery optimization'), - ), + // Battery-optimization exemption is an Android-only concept, and + // `permission_handler` has no web implementation at all; the row + // would throw rather than degrade if it were shown in the browser. + if (_isAndroid) ...[ + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _requestBatteryExemption, + icon: const Icon(Icons.battery_alert), + label: const Text('Disable battery optimization'), + ), + ], if (_status != null) ...[ const SizedBox(height: 16), Text(_status!, style: Theme.of(context).textTheme.bodyMedium), diff --git a/app/lib/services/app_settings_service.dart b/app/lib/services/app_settings_service.dart index c65eb85..6974369 100644 --- a/app/lib/services/app_settings_service.dart +++ b/app/lib/services/app_settings_service.dart @@ -2,11 +2,10 @@ library; import 'dart:convert'; -import 'dart:io'; +import 'package:diet_guard_app/services/document_store.dart'; +import 'package:diet_guard_app/services/document_store_factory.dart'; import 'package:flutter/foundation.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; /// Singleton storing lightweight settings such as the daily kcal goal. /// @@ -18,14 +17,18 @@ import 'package:path_provider/path_provider.dart'; /// nothing of its own to contribute yet, rather than silently syncing the /// unset default. class AppSettingsService { - AppSettingsService._(this._file); + AppSettingsService._(this._store); + + /// Document name this service owns, unchanged from the pre-web on-disk + /// filename so an installed phone keeps reading its existing settings. + static const documentName = 'app_settings.json'; static AppSettingsService? _instance; /// Returns the initialized singleton; throws if [init] was not called. static AppSettingsService get instance => _instance!; - final File _file; + final DocumentStore _store; int _dailyKcalGoal = 2200; DateTime? _dailyKcalGoalUpdatedAt; String? _rewardLabel; @@ -49,13 +52,15 @@ class AppSettingsService { /// Returns the configured temptation-bundling reward URL, or null. static String? get rewardUrl => _instance?._rewardUrl; - /// Initialises the singleton, pointing at the app's documents directory. + /// Initialises the singleton against the platform document store (files on + /// Android, IndexedDB in the browser-hosted desktop app). static Future init() async { if (_instance != null) return _instance!; - final dir = await getApplicationDocumentsDirectory(); - final svc = AppSettingsService._( - File(p.join(dir.path, 'app_settings.json')), - ); + // Resolving the platform store is a plugin call (path_provider / + // IndexedDB), not reachable from `flutter test`. + // coverage:ignore-start + final svc = AppSettingsService._(await openDocumentStore()); + // coverage:ignore-end await svc._load(); _instance = svc; return svc; @@ -63,35 +68,29 @@ class AppSettingsService { /// Resets the singleton so [init] can be called again in tests. /// - /// When [testDir] is given, reads/writes go there instead of the real - /// documents directory. + /// When [store] is given, reads/writes go there instead of the real + /// platform store. @visibleForTesting - static void resetForTesting({Directory? testDir}) { - _instance = testDir == null - ? null - : AppSettingsService._( - File(p.join(testDir.path, 'app_settings.json')), - ); + static void resetForTesting({DocumentStore? store}) { + _instance = store == null ? null : AppSettingsService._(store); } - /// Initialises from [testDir], calling [_load], for use in unit tests. + /// Initialises from [store], calling [_load], for use in unit tests. /// - /// Bypasses [getApplicationDocumentsDirectory] so tests don't need platform - /// channels. + /// Bypasses [openDocumentStore] so tests need no platform channels. @visibleForTesting - static Future initForTesting(Directory testDir) async { - final svc = AppSettingsService._( - File(p.join(testDir.path, 'app_settings.json')), - ); + static Future initForTesting(DocumentStore store) async { + final svc = AppSettingsService._(store); await svc._load(); _instance = svc; return svc; } Future _load() async { - if (!_file.existsSync()) return; + final raw = await _store.read(documentName); + if (raw == null) return; try { - final data = jsonDecode(await _file.readAsString()); + final data = jsonDecode(raw); if (data is Map && data['daily_kcal_goal'] is int) { _dailyKcalGoal = data['daily_kcal_goal'] as int; } @@ -141,12 +140,12 @@ class AppSettingsService { await _writeToDisk(); } - /// Writes the full current in-memory state to disk -- the single write + /// Writes the full current in-memory state to the store -- the single write /// path every setter funnels through, so no two methods can race on - /// independently overwriting the same JSON file. + /// independently overwriting the same document. Future _writeToDisk() async { - await _file.parent.create(recursive: true); - await _file.writeAsString( + await _store.write( + documentName, jsonEncode({ 'daily_kcal_goal': _dailyKcalGoal, 'daily_kcal_goal_updated_at': _dailyKcalGoalUpdatedAt diff --git a/app/lib/services/background_check_service.dart b/app/lib/services/background_check_service.dart index fd32990..6c30233 100644 --- a/app/lib/services/background_check_service.dart +++ b/app/lib/services/background_check_service.dart @@ -1,38 +1,22 @@ -/// WorkManager-driven periodic check: re-runs the same due/missing-slot -/// logic diet_guard's `_gate.py` uses to decide whether to lock the PC, and -/// syncs notifications to match. Registered as a 15-minute periodic task -/// (WorkManager's periodic floor) rather than four fixed exact alarms -- -/// more robust against OEM background-kill behavior, at the cost of ±15 min -/// precision (accepted; see the project plan). Deliberately **not** -/// requesting `SCHEDULE_EXACT_ALARM` for this reason -- don't reach for it -/// to "fix" perceived lateness. +/// WorkManager wiring for the periodic due-slot check (Android/iOS only). +/// +/// Registered as a 15-minute periodic task (WorkManager's periodic floor) +/// rather than four fixed exact alarms -- more robust against OEM +/// background-kill behavior, at the cost of ±15 min precision (accepted; see +/// the project plan). Deliberately **not** requesting `SCHEDULE_EXACT_ALARM` +/// for this reason -- don't reach for it to "fix" perceived lateness. +/// +/// The check itself lives in `due_slot_check.dart` so it stays free of the +/// `workmanager` import, which has no web implementation. library; -import 'package:diet_guard_app/models/slot.dart'; import 'package:diet_guard_app/services/background_sync_service.dart'; -import 'package:diet_guard_app/services/log_storage_service.dart'; -import 'package:diet_guard_app/services/notification_service.dart'; +import 'package:diet_guard_app/services/due_slot_check.dart'; import 'package:workmanager/workmanager.dart'; /// Unique WorkManager task name for the periodic due-slot check. const String backgroundCheckTaskName = 'diet_guard.background_check'; -/// Reads the local log, computes today's due-but-unlogged slots as of -/// [now] (defaults to the real clock), and syncs notifications to match. -/// -/// Extracted from [backgroundCheckCallbackDispatcher] so this logic is -/// unit-testable without the real WorkManager plugin, which only runs as a -/// true background isolate on-device. [now] is injectable for the same -/// reason `slot.dart`'s functions are clock-free: a test should not depend -/// on the wall-clock hour it happens to run at. -Future checkAndNotify({DateTime? now}) async { - await LogStorageService.init(); - await NotificationService.init(); - final logged = await LogStorageService.instance.loggedSlotsToday(); - final due = missingSlots(now ?? DateTime.now(), logged); - await NotificationService.instance.syncToSlots(due); -} - /// WorkManager entry point invoked by the OS on each periodic tick. /// /// Deliberately thin: all logic lives in [checkAndNotify] so it stays unit diff --git a/app/lib/services/background_sync_service.dart b/app/lib/services/background_sync_service.dart index e4fef2c..dff95e3 100644 --- a/app/lib/services/background_sync_service.dart +++ b/app/lib/services/background_sync_service.dart @@ -5,9 +5,9 @@ /// reconnect and uploads the log without the app being reopened. library; -import 'package:crdt_sync/crdt_sync.dart'; import 'package:diet_guard_app/services/app_settings_service.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/github_client_factory.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'; @@ -41,12 +41,7 @@ Future backgroundSyncPush({http.Client? httpClient}) async { return false; // couldn't read config in-isolate; let WorkManager retry } if (!settings.isConfigured) return true; // nothing to push; don't retry - final client = GitHubClient( - owner: settings.owner, - repo: settings.repo, - token: settings.token, - httpClient: httpClient, - ); + final client = createGitHubClient(settings, httpClient: httpClient); try { await runSync(client); return true; diff --git a/app/lib/services/background_tasks.dart b/app/lib/services/background_tasks.dart new file mode 100644 index 0000000..3e59942 --- /dev/null +++ b/app/lib/services/background_tasks.dart @@ -0,0 +1,10 @@ +/// Platform entry point for scheduling the app's recurring work. +/// +/// Conditional export because `workmanager` has no browser implementation at +/// all -- a closed tab runs nothing. Android schedules real OS-managed +/// background work; the browser-hosted desktop app runs an in-page timer that +/// lives only as long as its window (see `background_tasks_web.dart`). +library; + +export 'background_tasks_io.dart' + if (dart.library.js_interop) 'background_tasks_web.dart'; diff --git a/app/lib/services/background_tasks_io.dart b/app/lib/services/background_tasks_io.dart new file mode 100644 index 0000000..fd064c3 --- /dev/null +++ b/app/lib/services/background_tasks_io.dart @@ -0,0 +1,46 @@ +/// WorkManager-backed scheduling, used on Android (and iOS). +library; + +import 'dart:io'; + +import 'package:diet_guard_app/services/background_check_service.dart'; +import 'package:diet_guard_app/services/background_sync_service.dart'; +import 'package:workmanager/workmanager.dart'; + +/// True on the two platforms that ship a WorkManager implementation. +/// +/// Every entry point below is a no-op elsewhere: registering WorkManager on a +/// platform without it throws, which at app start would mean a blank window. +bool get _hasWorkManager => Platform.isAndroid || Platform.isIOS; + +/// Registers the 15-minute periodic due-slot check with the OS. +// coverage:ignore-start +Future initBackgroundTasks() async { + if (!_hasWorkManager) return; + await Workmanager().initialize(backgroundCheckCallbackDispatcher); + await Workmanager().registerPeriodicTask( + backgroundCheckTaskName, + backgroundCheckTaskName, + frequency: const Duration(minutes: 15), + ); +} + +/// Queues a connectivity-gated push so a meal logged while offline still +/// uploads on reconnect, without the app being reopened. +/// +/// The in-process auto-sync covers the online case; this is the backstop. +/// [ExistingWorkPolicy.replace] coalesces a burst of logs into one pending +/// job, and backoff retries a push that fails once connectivity returns. +Future enqueueSyncBackstop() async { + if (!_hasWorkManager) return; + await Workmanager().registerOneOffTask( + syncPushTaskName, + syncPushTaskName, + constraints: Constraints(networkType: NetworkType.connected), + existingWorkPolicy: ExistingWorkPolicy.replace, + backoffPolicy: BackoffPolicy.linear, + backoffPolicyDelay: const Duration(minutes: 1), + ); +} + +// coverage:ignore-end diff --git a/app/lib/services/background_tasks_web.dart b/app/lib/services/background_tasks_web.dart new file mode 100644 index 0000000..4053828 --- /dev/null +++ b/app/lib/services/background_tasks_web.dart @@ -0,0 +1,54 @@ +/// In-page scheduling for the browser-hosted desktop app. +/// +/// A browser has no equivalent of WorkManager: a closed tab runs nothing, so +/// the due-slot check can only run while the desktop window is open. That is +/// an accepted, deliberate reduction rather than an oversight -- on the PC the +/// real backstop is `diet-guard-gate.timer`, which *locks the screen* on an +/// unlogged slot and is strictly stronger than any notification this app could +/// raise. +library; + +import 'dart:async'; + +import 'package:diet_guard_app/services/due_slot_check.dart'; + +/// How often the in-page check re-evaluates due slots. +/// +/// Five minutes rather than WorkManager's 15: a foreground timer is cheap, +/// and the window is usually open for a short session, so a long period would +/// often mean the check never runs at all. +const inPageCheckInterval = Duration(minutes: 5); + +Timer? _timer; + +/// Starts the in-page periodic due-slot check (idempotent). +/// +/// Runs one check immediately so opening the window is itself a check, then +/// repeats every [inPageCheckInterval] for as long as the page lives. +Future initBackgroundTasks() async { + if (_timer != null) return; + _timer = Timer.periodic(inPageCheckInterval, (_) => _runCheck()); + await _runCheck(); +} + +/// No-op on web: there is no out-of-page scheduler to hand a retry to. +/// +/// A push that fails while the window is open is retried by the next +/// foreground sync; one that fails as the window closes is picked up on the +/// next launch. +Future enqueueSyncBackstop() async {} + +/// Cancels the in-page timer. Exposed so a test can stop the periodic work it +/// started rather than leaking it into the next test. +void stopBackgroundTasks() { + _timer?.cancel(); + _timer = null; +} + +Future _runCheck() async { + try { + await checkAndNotify(); + } on Exception { + // A failed check must never take the app down: the next tick retries. + } +} diff --git a/app/lib/services/blob_store.dart b/app/lib/services/blob_store.dart new file mode 100644 index 0000000..613dc70 --- /dev/null +++ b/app/lib/services/blob_store.dart @@ -0,0 +1,22 @@ +/// Byte-blob persistence for attached meal photos. +library; + +import 'dart:typed_data'; + +/// Stores and retrieves photo bytes under an opaque key. +/// +/// The key is what ends up in [FoodEntry.imagePath]: an absolute file path on +/// Android (unchanged from before the web build existed, so installed phones +/// keep resolving their existing photos) and an IndexedDB key in the browser. +/// Callers must treat it as opaque and never join, split or stat it. +/// +/// Deliberately free of `dart:io` -- see `document_store.dart` for why that +/// matters for the web build. +abstract class BlobStore { + /// Stores [bytes] as a new blob whose key ends with [extension] (`.jpg`), + /// returning the key. + Future put(String extension, Uint8List bytes); + + /// Returns the bytes stored under [key], or null when it is gone. + Future get(String key); +} diff --git a/app/lib/services/blob_store_factory.dart b/app/lib/services/blob_store_factory.dart new file mode 100644 index 0000000..459ceac --- /dev/null +++ b/app/lib/services/blob_store_factory.dart @@ -0,0 +1,4 @@ +/// Platform entry point for opening the photo blob store. +library; + +export 'blob_store_io.dart' if (dart.library.js_interop) 'blob_store_web.dart'; diff --git a/app/lib/services/blob_store_io.dart b/app/lib/services/blob_store_io.dart new file mode 100644 index 0000000..c2ae5a1 --- /dev/null +++ b/app/lib/services/blob_store_io.dart @@ -0,0 +1,49 @@ +/// File-backed [BlobStore] used on Android. +library; + +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:diet_guard_app/services/blob_store.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:uuid/uuid.dart'; + +/// [BlobStore] keeping one file per photo under `/images/`. +/// +/// Keys are absolute paths, exactly as they were before photos went through +/// a store, so an already-installed phone keeps resolving the `imagePath` of +/// every photo it has ever attached. +class FileBlobStore implements BlobStore { + /// Creates a store keeping its blobs under `<[documentsDirectory]>/images`. + FileBlobStore(this.documentsDirectory); + + /// Directory whose `images/` subdirectory holds the photos. + final Directory documentsDirectory; + + @override + Future put(String extension, Uint8List bytes) async { + final imagesDir = Directory(p.join(documentsDirectory.path, 'images')); + await imagesDir.create(recursive: true); + final dest = File(p.join(imagesDir.path, '${const Uuid().v4()}$extension')); + await dest.writeAsBytes(bytes); + return dest.path; + } + + @override + Future get(String key) async { + final file = File(key); + if (!file.existsSync()) return null; + try { + return await file.readAsBytes(); + } on FileSystemException { + return null; + } + } +} + +/// Opens the platform blob store (the app's documents directory). +// coverage:ignore-start +Future openBlobStore() async => + FileBlobStore(await getApplicationDocumentsDirectory()); +// coverage:ignore-end diff --git a/app/lib/services/blob_store_web.dart b/app/lib/services/blob_store_web.dart new file mode 100644 index 0000000..785a9d9 --- /dev/null +++ b/app/lib/services/blob_store_web.dart @@ -0,0 +1,94 @@ +/// IndexedDB-backed [BlobStore] for the browser-hosted desktop app. +library; + +import 'dart:typed_data'; + +import 'package:diet_guard_app/services/blob_store.dart'; +import 'package:diet_guard_app/services/desktop_wrapper.dart'; +import 'package:http/http.dart' as http; +import 'package:idb_shim/idb_browser.dart'; +import 'package:uuid/uuid.dart'; + +/// [BlobStore] keeping photo bytes in IndexedDB, mirrored to the desktop +/// wrapper's on-disk copy. +/// +/// Photos never sync (`imagePath` is stripped before push and ignored on +/// pull), so the wrapper mirror is the *only* thing standing between a wiped +/// Chrome profile and permanently losing them -- unlike the food log, which +/// GitHub would eventually restore. +class IndexedDbBlobStore implements BlobStore { + /// Creates a store over an already-open [database], mirroring to [baseUrl]. + IndexedDbBlobStore( + this._database, { + this.baseUrl = desktopWrapperOrigin, + http.Client? httpClient, + }) : _client = httpClient ?? http.Client(); + + final Database _database; + final http.Client _client; + + /// Origin of the desktop wrapper. + final String baseUrl; + + /// Object store holding one entry per photo. + static const storeName = 'blobs'; + + /// IndexedDB database name. + static const databaseName = 'diet_guard_blobs'; + + @override + Future put(String extension, Uint8List bytes) async { + final key = '${const Uuid().v4()}$extension'; + final txn = _database.transaction(storeName, idbModeReadWrite); + await txn.objectStore(storeName).put(bytes, key); + await txn.completed; + await _writeMirror(key, bytes); + return key; + } + + @override + Future get(String key) async { + final txn = _database.transaction(storeName, idbModeReadOnly); + final value = await txn.objectStore(storeName).getObject(key); + await txn.completed; + if (value is Uint8List) return value; + if (value is List) return Uint8List.fromList(value); + // Missing locally: recover from the wrapper's disk copy (cleared profile). + return _readMirror(key); + } + + Future _readMirror(String key) async { + try { + final response = await _client.get(_uriFor(key)); + if (response.statusCode != 200 || response.bodyBytes.isEmpty) return null; + return response.bodyBytes; + } on Exception { + return null; + } + } + + Future _writeMirror(String key, Uint8List bytes) async { + try { + await _client.post(_uriFor(key), body: bytes); + } on Exception { + // Best-effort by design, like the document mirror. + } + } + + Uri _uriFor(String key) => Uri.parse('$baseUrl${WrapperPaths.blobs}$key'); +} + +/// Opens (creating if needed) the IndexedDB-backed blob store. +Future openBlobStore() async { + final database = await idbFactoryBrowser.open( + IndexedDbBlobStore.databaseName, + version: 1, + onUpgradeNeeded: (event) { + final db = event.database; + if (!db.objectStoreNames.contains(IndexedDbBlobStore.storeName)) { + db.createObjectStore(IndexedDbBlobStore.storeName); + } + }, + ); + return IndexedDbBlobStore(database); +} diff --git a/app/lib/services/desktop_wrapper.dart b/app/lib/services/desktop_wrapper.dart new file mode 100644 index 0000000..a334d2e --- /dev/null +++ b/app/lib/services/desktop_wrapper.dart @@ -0,0 +1,32 @@ +/// Shared constants between the web app and the desktop wrapper that serves it. +library; + +/// The fixed port the desktop wrapper serves on. +/// +/// **Do not change this casually.** IndexedDB (the food log, the food bank, +/// the settings and the photo blobs) is keyed by origin, so a different port +/// looks like a different app with no history at all. The launcher, the +/// packaging and `bin/diet_guard_desktop.dart` must all use this value, and +/// the Chrome `--user-data-dir` must stay equally fixed for the same reason. +/// +/// 8730 is `~/todo`'s wrapper and 8731 is `~/habit_stack`'s; this is the next +/// free one so all three can run at once. +const desktopWrapperPort = 8732; + +/// Origin of the desktop wrapper, e.g. `http://localhost:8732`. +const desktopWrapperOrigin = 'http://localhost:$desktopWrapperPort'; + +/// URL paths the wrapper serves. +/// +/// Kept beside the port so the client and the server cannot drift apart; the +/// server mirrors these constants rather than re-spelling the strings. +abstract final class WrapperPaths { + /// Prefix for document reads/writes: `/documents/`. + static const documents = '/documents/'; + + /// Prefix for photo-blob reads/writes: `/blobs/`. + static const blobs = '/blobs/'; + + /// Prefix for the GitHub proxy (see `wrapper_server.dart`). + static const github = '/github/'; +} diff --git a/app/lib/services/document_store.dart b/app/lib/services/document_store.dart new file mode 100644 index 0000000..cb0aa06 --- /dev/null +++ b/app/lib/services/document_store.dart @@ -0,0 +1,24 @@ +/// Named-document persistence, independent of where the documents live. +library; + +/// Reads and writes whole documents by name (`food_log.json`, +/// `food_bank.json`, `app_settings.json`). +/// +/// Deliberately free of `dart:io` so the storage services compile for web: +/// the desktop app is a Flutter web build, and a single `dart:io` import +/// anywhere in the graph turns into a stub that throws at runtime (a blank +/// white window, not a build error). Implementations live in +/// `document_store_io.dart` (files) and `document_store_web.dart` +/// (IndexedDB), selected by the conditional export in +/// `document_store_factory.dart`. +/// +/// Whole-document rather than key-value because every caller already +/// serialises a complete JSON structure, and a partial write of any of them +/// would be worse than none. +abstract class DocumentStore { + /// Returns the stored contents of [name], or null if nothing is stored. + Future read(String name); + + /// Overwrites [name] with [contents]. + Future write(String name, String contents); +} diff --git a/app/lib/services/document_store_factory.dart b/app/lib/services/document_store_factory.dart new file mode 100644 index 0000000..47b14fc --- /dev/null +++ b/app/lib/services/document_store_factory.dart @@ -0,0 +1,9 @@ +/// Platform entry point for opening the document store. +/// +/// Conditional export because `dart:io` cannot even be *imported* in a web +/// compile: Android keeps the file-backed store, the browser-hosted desktop +/// app gets IndexedDB. +library; + +export 'document_store_io.dart' + if (dart.library.js_interop) 'document_store_web.dart'; diff --git a/app/lib/services/document_store_io.dart b/app/lib/services/document_store_io.dart new file mode 100644 index 0000000..868ddb1 --- /dev/null +++ b/app/lib/services/document_store_io.dart @@ -0,0 +1,64 @@ +/// File-backed [DocumentStore] used on Android (and any `dart:io` platform). +library; + +import 'dart:io'; + +import 'package:diet_guard_app/services/document_store.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +/// [DocumentStore] backed by one file per document. +/// +/// Document names are the full on-disk filenames (`food_log.json`, …) rather +/// than bare keys, so the files an already-installed phone holds keep their +/// exact paths through this refactor -- renaming them would look to the app +/// like a device with no history. +class FileDocumentStore implements DocumentStore { + /// Creates a store keeping its documents under [directory]. + FileDocumentStore(this.directory); + + /// Directory holding the document files. + final Directory directory; + + File _fileFor(String name) => File(p.join(directory.path, name)); + + @override + Future read(String name) async { + final file = _fileFor(name); + if (!file.existsSync()) return null; + try { + return await file.readAsString(); + } on FileSystemException { + // An unreadable file is treated as absent; callers fall back to empty + // rather than failing to start. + return null; + } + } + + /// Writes to a unique temp file then atomically renames it over the real + /// one, so a concurrent reader never observes a half-written document. + /// + /// The temp name carries both the pid and a per-process counter: the pid + /// separates the foreground app from the connectivity-gated background + /// push, which can `runSync` -> write from a separate isolate, and the + /// counter separates two overlapping writes *within* one process (two + /// setters awaiting the same document would otherwise both create the same + /// temp file, and the second rename would fail with the first's file + /// already gone). + @override + Future write(String name, String contents) async { + final file = _fileFor(name); + await file.parent.create(recursive: true); + final tmp = File('${file.path}.$pid.${_writeCounter++}.tmp'); + await tmp.writeAsString(contents); + await tmp.rename(file.path); + } + + static int _writeCounter = 0; +} + +/// Opens the platform document store (the app's documents directory). +// coverage:ignore-start +Future openDocumentStore() async => + FileDocumentStore(await getApplicationDocumentsDirectory()); +// coverage:ignore-end diff --git a/app/lib/services/document_store_web.dart b/app/lib/services/document_store_web.dart new file mode 100644 index 0000000..b8695cb --- /dev/null +++ b/app/lib/services/document_store_web.dart @@ -0,0 +1,96 @@ +/// IndexedDB-backed [DocumentStore] used by the browser-hosted desktop app. +library; + +import 'package:diet_guard_app/services/desktop_wrapper.dart'; +import 'package:diet_guard_app/services/document_store.dart'; +import 'package:http/http.dart' as http; +import 'package:idb_shim/idb_browser.dart'; + +/// [DocumentStore] backed by IndexedDB, mirrored to the desktop wrapper's +/// on-disk copy. +/// +/// IndexedDB rather than `localStorage`: the latter caps at roughly 5-10MB per +/// origin and is evicted more eagerly, and these documents are the desktop +/// device's primary copy of the food log. +/// +/// The wrapper mirror is the recovery path for a wiped Chrome profile. GitHub +/// sync would eventually restore the log too, but only what other devices have +/// already seen -- a meal logged on this desktop and never pushed exists +/// nowhere else. Reads fall back to the mirror when IndexedDB is empty; writes +/// to it are best-effort so the app still works opened in a plain browser tab +/// with no wrapper running. +class IndexedDbDocumentStore implements DocumentStore { + /// Creates a store over an already-open [database], mirroring to [baseUrl]. + IndexedDbDocumentStore( + this._database, { + this.baseUrl = desktopWrapperOrigin, + http.Client? httpClient, + }) : _client = httpClient ?? http.Client(); + + final Database _database; + final http.Client _client; + + /// Origin of the desktop wrapper. + final String baseUrl; + + /// Object store holding one entry per document name. + static const storeName = 'documents'; + + /// IndexedDB database name. + static const databaseName = 'diet_guard'; + + @override + Future read(String name) async { + final txn = _database.transaction(storeName, idbModeReadOnly); + final value = await txn.objectStore(storeName).getObject(name); + await txn.completed; + if (value is String) return value; + // Empty IndexedDB: either a first run or a cleared profile. Recover from + // the wrapper's disk copy rather than silently starting from nothing. + return _readMirror(name); + } + + @override + Future write(String name, String contents) async { + final txn = _database.transaction(storeName, idbModeReadWrite); + await txn.objectStore(storeName).put(contents, name); + await txn.completed; + await _writeMirror(name, contents); + } + + Future _readMirror(String name) async { + try { + final response = await _client.get(_uriFor(name)); + if (response.statusCode != 200 || response.body.isEmpty) return null; + return response.body; + } on Exception { + return null; + } + } + + Future _writeMirror(String name, String contents) async { + try { + await _client.post(_uriFor(name), body: contents); + } on Exception { + // Best-effort by design; see the class docs. + } + } + + Uri _uriFor(String name) => + Uri.parse('$baseUrl${WrapperPaths.documents}$name'); +} + +/// Opens (creating if needed) the IndexedDB-backed document store. +Future openDocumentStore() async { + final database = await idbFactoryBrowser.open( + IndexedDbDocumentStore.databaseName, + version: 1, + onUpgradeNeeded: (event) { + final db = event.database; + if (!db.objectStoreNames.contains(IndexedDbDocumentStore.storeName)) { + db.createObjectStore(IndexedDbDocumentStore.storeName); + } + }, + ); + return IndexedDbDocumentStore(database); +} diff --git a/app/lib/services/due_slot_check.dart b/app/lib/services/due_slot_check.dart new file mode 100644 index 0000000..606a437 --- /dev/null +++ b/app/lib/services/due_slot_check.dart @@ -0,0 +1,27 @@ +/// The periodic due/missing-slot check, shared by every platform's scheduler. +/// +/// Re-runs the same due/missing-slot logic diet_guard's `_gate.py` uses to +/// decide whether to lock the PC, and syncs notifications to match. Kept in +/// its own library, free of any scheduler plugin, because the two platforms +/// drive it very differently: Android hands it to WorkManager as a real +/// background isolate, while the browser-hosted desktop app can only run it +/// from an in-page timer while its window is open (see +/// `background_tasks_web.dart`). +library; + +import 'package:diet_guard_app/models/slot.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:diet_guard_app/services/notification_service.dart'; + +/// Reads the local log, computes today's due-but-unlogged slots as of +/// [now] (defaults to the real clock), and syncs notifications to match. +/// +/// [now] is injectable so a test does not depend on the wall-clock hour it +/// happens to run at, the same reason `slot.dart`'s functions are clock-free. +Future checkAndNotify({DateTime? now}) async { + await LogStorageService.init(); + await NotificationService.init(); + final logged = await LogStorageService.instance.loggedSlotsToday(); + final due = missingSlots(now ?? DateTime.now(), logged); + await NotificationService.instance.syncToSlots(due); +} diff --git a/app/lib/services/foodbank_service.dart b/app/lib/services/foodbank_service.dart index 4f2c5b6..94200f3 100644 --- a/app/lib/services/foodbank_service.dart +++ b/app/lib/services/foodbank_service.dart @@ -5,16 +5,15 @@ library; import 'dart:convert'; -import 'dart:io'; import 'package:diet_guard_app/models/food_bank_record.dart'; import 'package:diet_guard_app/models/food_suggestion.dart'; import 'package:diet_guard_app/models/nutrition.dart'; +import 'package:diet_guard_app/services/document_store.dart'; +import 'package:diet_guard_app/services/document_store_factory.dart'; import 'package:diet_guard_app/services/fuzzy.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:flutter/foundation.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; /// Below this similarity ratio a non-substring candidate is dropped. /// Mirrors `_foodbank._FUZZY_THRESHOLD`. @@ -40,41 +39,46 @@ String _displayName(FoodBankRecord record, String key) => /// Singleton service for the locally-rebuilt food bank. class FoodBankService { - FoodBankService._(this._file); + FoodBankService._(this._store); + + /// Document holding the log-derived bank. Unchanged from the pre-web + /// on-disk filename so an installed phone keeps its existing bank. + static const documentName = 'food_bank.json'; + + /// Document holding the manually-curated bank. + static const manualDocumentName = 'food_bank_manual.json'; static FoodBankService? _instance; /// Returns the initialized singleton; throws if [init] was not called. static FoodBankService get instance => _instance!; - final File _file; + final DocumentStore _store; /// Initializes the singleton, pointing at the app's documents directory. static Future init() async { if (_instance != null) return _instance!; - final dir = await getApplicationDocumentsDirectory(); - final svc = FoodBankService._(File(p.join(dir.path, 'food_bank.json'))); + final svc = FoodBankService._(await openDocumentStore()); _instance = svc; return svc; } /// Resets the singleton so [init] can be called again in tests. @visibleForTesting - static void resetForTesting({Directory? testDir}) { - _instance = testDir == null - ? null - : FoodBankService._(File(p.join(testDir.path, 'food_bank.json'))); + static void resetForTesting({DocumentStore? store}) { + _instance = store == null ? null : FoodBankService._(store); } - /// Reads the persisted bank (empty map on a missing/unparsable file). - Future> readBank() async { - if (!_file.existsSync()) return {}; - String raw; - try { - raw = await _file.readAsString(); - } on FileSystemException { - return {}; - } + /// Reads the persisted bank (empty map on a missing/unparsable document). + Future> readBank() => _readBank(documentName); + + /// Persists [bank]. + Future writeBank(Map bank) => + _writeBank(documentName, bank); + + Future> _readBank(String name) async { + final raw = await _store.read(name); + if (raw == null) return {}; Object? data; try { data = jsonDecode(raw); @@ -93,14 +97,15 @@ class FoodBankService { return result; } - /// Persists [bank] to disk, creating the parent directory if needed. - Future writeBank(Map bank) async { - await _file.parent.create(recursive: true); + Future _writeBank( + String name, + Map bank, + ) async { final encoded = { for (final mapEntry in bank.entries) mapEntry.key: mapEntry.value.toJson(), }; - await _file.writeAsString(jsonEncode(encoded)); + await _store.write(name, jsonEncode(encoded)); } /// Rebuilds the bank by replaying [log]'s entries in a fixed, device- @@ -204,44 +209,11 @@ class FoodBankService { // Manual bank (food items added directly without logging them as eaten) // --------------------------------------------------------------------------- - File get _manualFile => - File(p.join(_file.parent.path, 'food_bank_manual.json')); + Future> _readManualBank() => + _readBank(manualDocumentName); - Future> _readManualBank() async { - final file = _manualFile; - if (!file.existsSync()) return {}; - String raw; - try { - raw = await file.readAsString(); - } on FileSystemException { - return {}; - } - Object? data; - try { - data = jsonDecode(raw); - } on FormatException { - return {}; - } - if (data is! Map) return {}; - final result = {}; - for (final entry in data.entries) { - final key = entry.key; - final value = entry.value; - if (key is String && value is Map) { - result[key] = FoodBankRecord.fromJson(value.cast()); - } - } - return result; - } - - Future _writeManualBank(Map bank) async { - final file = _manualFile; - await file.parent.create(recursive: true); - final encoded = { - for (final entry in bank.entries) entry.key: entry.value.toJson(), - }; - await file.writeAsString(jsonEncode(encoded)); - } + Future _writeManualBank(Map bank) => + _writeBank(manualDocumentName, bank); /// Adds or updates [record] in the manually-curated bank without logging it /// as eaten. A repeated call with the same normalized name overwrites the diff --git a/app/lib/services/github_client_factory.dart b/app/lib/services/github_client_factory.dart new file mode 100644 index 0000000..5bfa72f --- /dev/null +++ b/app/lib/services/github_client_factory.dart @@ -0,0 +1,5 @@ +/// Platform entry point for building the GitHub sync client. +library; + +export 'github_client_factory_io.dart' + if (dart.library.js_interop) 'github_client_factory_web.dart'; diff --git a/app/lib/services/github_client_factory_io.dart b/app/lib/services/github_client_factory_io.dart new file mode 100644 index 0000000..894aa50 --- /dev/null +++ b/app/lib/services/github_client_factory_io.dart @@ -0,0 +1,34 @@ +/// Direct GitHub client, used on Android. +library; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:diet_guard_app/services/github_device_auth.dart'; +import 'package:diet_guard_app/services/sync_settings.dart'; +import 'package:http/http.dart' as http; + +/// This device's id in the `diet-guard-sync/devices//food_log.json` +/// layout. +/// +/// The PC's Python side pushes under `pc` (`SYNC_DEVICE_ID` in +/// `diet_guard/_constants.py`) and the desktop web build under `desktop`; +/// every device must have its own, or two of them overwrite each other's +/// pushed log on every tick. +const syncDeviceId = 'phone'; + +/// Builds a client that talks to `api.github.com` directly with [settings]' +/// token. +GitHubClient createGitHubClient( + SyncSettings settings, { + http.Client? httpClient, +}) => GitHubClient( + owner: settings.owner, + repo: settings.repo, + token: settings.token, + httpClient: httpClient, +); + +/// Builds the device-flow client, talking to GitHub directly. +GitHubDeviceAuth createDeviceAuth( + String clientId, { + http.Client? httpClient, +}) => GitHubDeviceAuth(clientId: clientId, httpClient: httpClient); diff --git a/app/lib/services/github_client_factory_web.dart b/app/lib/services/github_client_factory_web.dart new file mode 100644 index 0000000..9fee01b --- /dev/null +++ b/app/lib/services/github_client_factory_web.dart @@ -0,0 +1,82 @@ +/// Wrapper-proxied GitHub client, used by the desktop web build. +library; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:diet_guard_app/services/desktop_wrapper.dart'; +import 'package:diet_guard_app/services/github_device_auth.dart'; +import 'package:diet_guard_app/services/sync_settings.dart'; +import 'package:http/http.dart' as http; + +/// This device's id under `diet-guard-sync/devices/`. +/// +/// Distinct from the phone's `phone` and the Python side's `pc`: the desktop +/// app is a third device, and sharing an id would make two devices overwrite +/// each other's pushed log on every tick. +const syncDeviceId = 'desktop'; + +/// Builds a client whose requests go through the desktop wrapper. +/// +/// The token is deliberately empty: the wrapper attaches the real one +/// server-side (see `desktop/github_proxy.dart`), so it never enters the +/// browser. `settings.token` on web is only ever the stand-in constant. +GitHubClient createGitHubClient( + SyncSettings settings, { + http.Client? httpClient, +}) => GitHubClient( + owner: settings.owner, + repo: settings.repo, + token: '', + httpClient: WrapperProxyClient(inner: httpClient), +); + +/// Rewrites `https://api.github.com/...` to the wrapper's proxy endpoint. +/// +/// A decorator rather than a `baseUrl` option on [GitHubClient] so the shared +/// `crdt_sync` library needs no web-specific knowledge; the rewrite is a +/// property of how this app is hosted, not of the transport. +class WrapperProxyClient extends http.BaseClient { + /// Creates a client delegating to [inner] after rewriting GitHub URLs. + WrapperProxyClient({http.Client? inner, this.baseUrl = desktopWrapperOrigin}) + : _inner = inner ?? http.Client(); + + final http.Client _inner; + + /// Origin of the desktop wrapper. + final String baseUrl; + + @override + Future send(http.BaseRequest request) { + if (request.url.host != 'api.github.com' || request is! http.Request) { + return _inner.send(request); + } + final rewritten = + http.Request( + request.method, + Uri.parse( + '$baseUrl${WrapperPaths.github}api' + '${request.url.path}', + ).replace(query: request.url.hasQuery ? request.url.query : null), + ) + ..headers.addAll(request.headers) + ..bodyBytes = request.bodyBytes + ..followRedirects = request.followRedirects; + return _inner.send(rewritten); + } + + @override + void close() => _inner.close(); +} + +/// Builds the device-flow client, routed through the desktop wrapper. +/// +/// GitHub's device-flow endpoints send no CORS headers, so the page cannot +/// call them; the wrapper does, and keeps the resulting token. +GitHubDeviceAuth createDeviceAuth( + String clientId, { + http.Client? httpClient, +}) => GitHubDeviceAuth( + clientId: clientId, + deviceCodeUrl: '$desktopWrapperOrigin${WrapperPaths.github}auth/device/start', + tokenUrl: '$desktopWrapperOrigin${WrapperPaths.github}auth/device/poll', + httpClient: httpClient, +); diff --git a/app/lib/services/github_device_auth.dart b/app/lib/services/github_device_auth.dart index 7db6787..ff980c3 100644 --- a/app/lib/services/github_device_auth.dart +++ b/app/lib/services/github_device_auth.dart @@ -1,8 +1,15 @@ import 'dart:async'; import 'dart:convert'; +import 'package:diet_guard_app/services/token_vault.dart'; import 'package:http/http.dart' as http; +/// GitHub's device-code endpoint. +const githubDeviceCodeUrl = 'https://github.com/login/device/code'; + +/// GitHub's device-flow token endpoint. +const githubTokenUrl = 'https://github.com/login/oauth/access_token'; + /// First-stage response of the GitHub OAuth Device Flow: the code the user /// types on github.com and the URL to type it into. class DeviceCodeResponse { @@ -72,6 +79,8 @@ class GitHubDeviceAuth { GitHubDeviceAuth({ required this.clientId, this.scope = 'repo', + this.deviceCodeUrl = githubDeviceCodeUrl, + this.tokenUrl = githubTokenUrl, http.Client? httpClient, Future Function(Duration)? delay, }) : _http = httpClient ?? http.Client(), @@ -84,17 +93,26 @@ class GitHubDeviceAuth { /// OAuth scope requested. `repo` is required for private-repo contents. final String scope; + /// Endpoint asked for a device + user code. + /// + /// GitHub's own URL on Android. The desktop web build points both this and + /// [tokenUrl] at the local wrapper instead, because GitHub's device-flow + /// endpoints send no CORS headers and a page cannot call them at all (see + /// `desktop/github_proxy.dart`). + final String deviceCodeUrl; + + /// Endpoint polled for the access token. See [deviceCodeUrl]. + final String tokenUrl; + final http.Client _http; final Future Function(Duration) _delay; - static const _deviceCodeUrl = 'https://github.com/login/device/code'; - static const _tokenUrl = 'https://github.com/login/oauth/access_token'; static const _grantType = 'urn:ietf:params:oauth:grant-type:device_code'; /// Step 1: ask GitHub for a device + user code. Future requestDeviceCode() async { final res = await _http.post( - Uri.parse(_deviceCodeUrl), + Uri.parse(deviceCodeUrl), headers: const {'Accept': 'application/json'}, body: {'client_id': clientId, 'scope': scope}, ); @@ -118,7 +136,7 @@ class GitHubDeviceAuth { while (DateTime.now().isBefore(deadline)) { await _delay(Duration(seconds: intervalSeconds)); final res = await _http.post( - Uri.parse(_tokenUrl), + Uri.parse(tokenUrl), headers: const {'Accept': 'application/json'}, body: { 'client_id': clientId, @@ -130,6 +148,9 @@ class GitHubDeviceAuth { final token = json['access_token'] as String?; if (token != null) return token; + // The wrapper-proxied flow deliberately keeps the token server-side and + // answers with a bare acknowledgement instead (see [deviceCodeUrl]). + if (json['status'] == 'ok') return wrapperManagedToken; switch (json['error'] as String?) { case 'authorization_pending': diff --git a/app/lib/services/log_storage_service.dart b/app/lib/services/log_storage_service.dart index abf1de8..047a45d 100644 --- a/app/lib/services/log_storage_service.dart +++ b/app/lib/services/log_storage_service.dart @@ -2,15 +2,14 @@ library; import 'dart:convert'; -import 'dart:io'; import 'package:diet_guard_app/models/food_entry.dart'; import 'package:diet_guard_app/models/local_time.dart'; import 'package:diet_guard_app/models/meal_component.dart'; import 'package:diet_guard_app/models/nutrition.dart'; +import 'package:diet_guard_app/services/document_store.dart'; +import 'package:diet_guard_app/services/document_store_factory.dart'; import 'package:flutter/foundation.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; /// The on-disk log shape: date key (`YYYY-MM-DD`) to that day's entries. @@ -25,34 +24,37 @@ typedef DayLog = Map>; /// autocomplete is small-corpus fuzzy string matching, not a relational /// query. class LogStorageService { - LogStorageService._(this._file); + LogStorageService._(this._store); + + /// Document name this service owns, unchanged from the pre-web on-disk + /// filename so an installed phone keeps reading its existing log. + static const documentName = 'food_log.json'; static LogStorageService? _instance; /// Returns the initialized singleton; throws if [init] was not called. static LogStorageService get instance => _instance!; - final File _file; + final DocumentStore _store; - /// Initializes the singleton, pointing at the app's documents directory - /// (phone-sandboxed; no external-storage permission needed). + /// Initializes the singleton against the platform document store: files in + /// the app's documents directory on Android (phone-sandboxed; no + /// external-storage permission needed), IndexedDB in the browser-hosted + /// desktop app. static Future init() async { if (_instance != null) return _instance!; - final dir = await getApplicationDocumentsDirectory(); - final svc = LogStorageService._(File(p.join(dir.path, 'food_log.json'))); + final svc = LogStorageService._(await openDocumentStore()); _instance = svc; return svc; } /// Resets the singleton so [init] can be called again in tests. /// - /// When [testDir] is given, subsequent reads/writes go there instead of - /// the real documents directory, so a test can never touch real data. + /// When [store] is given, subsequent reads/writes go there instead of the + /// real platform store, so a test can never touch real data. @visibleForTesting - static void resetForTesting({Directory? testDir}) { - _instance = testDir == null - ? null - : LogStorageService._(File(p.join(testDir.path, 'food_log.json'))); + static void resetForTesting({DocumentStore? store}) { + _instance = store == null ? null : LogStorageService._(store); } /// Reads the full log, including tombstoned entries. @@ -60,13 +62,8 @@ class LogStorageService { /// Returns an empty log on a missing or unparsable file, mirroring /// `_state._read_raw_log`'s defensive read. Future readLog() async { - if (!_file.existsSync()) return {}; - String raw; - try { - raw = await _file.readAsString(); - } on FileSystemException { - return {}; - } + final raw = await _store.read(documentName); + if (raw == null) return {}; Object? data; try { data = jsonDecode(raw); @@ -87,22 +84,16 @@ class LogStorageService { return result; } - /// Persists the full log to disk, creating the parent directory if - /// needed, mirroring `_state._write_log`. + /// Persists the full log to the store, mirroring `_state._write_log`. + /// + /// Atomicity against a concurrent reader is the store's job (see + /// `FileDocumentStore.write`'s temp-file-and-rename), not this method's. Future writeLog(DayLog log) async { - await _file.parent.create(recursive: true); final encoded = { for (final mapEntry in log.entries) mapEntry.key: mapEntry.value.map((e) => e.toLocalJson()).toList(), }; - // Write to a per-process temp file then atomically rename it over the - // real one, so a concurrent reader never sees a half-written file. The - // connectivity-gated background push (see background_sync_service.dart) - // can `runSync` -> writeLog in a separate isolate while the foreground - // app also writes; the pid in the temp name keeps the two from colliding. - final tmp = File('${_file.path}.$pid.tmp'); - await tmp.writeAsString(jsonEncode(encoded)); - await tmp.rename(_file.path); + await _store.write(documentName, jsonEncode(encoded)); } /// Appends a signed-on-PC-eventually entry for [desc] to today's log. diff --git a/app/lib/services/notification_backend.dart b/app/lib/services/notification_backend.dart new file mode 100644 index 0000000..438aa6e --- /dev/null +++ b/app/lib/services/notification_backend.dart @@ -0,0 +1,28 @@ +/// The platform notification surface behind [NotificationService]. +library; + +/// Shows and cancels one notification per meal slot. +/// +/// Free of any plugin import so [NotificationService]'s due-slot logic +/// compiles for web: the implementations are +/// `notification_backend_io.dart` (`flutter_local_notifications`) and +/// `notification_backend_web.dart` (the browser's Notifications API), +/// selected by the conditional export in +/// `notification_backend_factory.dart`. +abstract class NotificationBackend { + /// Prepares the platform surface. Called once, before any [show]/[cancel]. + Future initialize(); + + /// Asks the platform for permission to post notifications. + /// + /// Returns null where the concept does not apply, which callers treat the + /// same as false: notifications degrade silently rather than blocking use + /// of the app. + Future requestPermission(); + + /// Posts (or replaces) the notification for [slot]. + Future show(int slot, String title, String body); + + /// Removes the notification for [slot], if one is showing. + Future cancel(int slot); +} diff --git a/app/lib/services/notification_backend_factory.dart b/app/lib/services/notification_backend_factory.dart new file mode 100644 index 0000000..49807da --- /dev/null +++ b/app/lib/services/notification_backend_factory.dart @@ -0,0 +1,5 @@ +/// Platform entry point for opening the notification backend. +library; + +export 'notification_backend_io.dart' + if (dart.library.js_interop) 'notification_backend_web.dart'; diff --git a/app/lib/services/notification_backend_io.dart b/app/lib/services/notification_backend_io.dart new file mode 100644 index 0000000..d69eac8 --- /dev/null +++ b/app/lib/services/notification_backend_io.dart @@ -0,0 +1,73 @@ +/// `flutter_local_notifications`-backed [NotificationBackend] (Android). +library; + +import 'package:diet_guard_app/services/notification_backend.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +/// Wraps [FlutterLocalNotificationsPlugin] so the due-slot logic in +/// `notification_service.dart` is unit-testable against a fake platform +/// channel, independent of the real plugin's native implementation. +class LocalNotificationsBackend implements NotificationBackend { + /// Creates a backend over [plugin]. + LocalNotificationsBackend(this.plugin); + + /// The wrapped plugin. Injectable so a test can point it at a fake channel. + final FlutterLocalNotificationsPlugin plugin; + + static const _channelId = 'diet_guard_due_slot'; + static const _channelName = 'Meal reminders'; + + @override + Future initialize() async { + const settings = InitializationSettings( + android: AndroidInitializationSettings('@mipmap/ic_launcher'), + ); + await plugin.initialize(settings: settings); + } + + /// Requests Android 13+'s runtime `POST_NOTIFICATIONS` permission. + /// + /// Returns null on platforms where this Android-specific call doesn't + /// apply -- the caller treats null and false the same way (don't block on + /// it; notifications degrade silently if denied, matching the rest of this + /// service's silent-on-failure stance). Since the desktop target became a + /// web build, `android/` is this backend's only real platform, so in + /// production the non-null path always runs; the fallback is not reachable + /// from `flutter test` without polluting the process-global plugin + /// registration other tests in this file rely on, so it's excluded from + /// coverage rather than chased with a fragile test-ordering trick. + @override + Future requestPermission() => + plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.requestNotificationsPermission() ?? + // coverage:ignore-line + Future.value(); + + @override + Future show(int slot, String title, String body) async { + const details = NotificationDetails( + android: AndroidNotificationDetails( + _channelId, + _channelName, + importance: Importance.high, + priority: Priority.high, + ), + ); + await plugin.show( + id: slot, + title: title, + body: body, + notificationDetails: details, + ); + } + + @override + Future cancel(int slot) => plugin.cancel(id: slot); +} + +/// Opens the platform notification backend. +NotificationBackend openNotificationBackend() => + LocalNotificationsBackend(FlutterLocalNotificationsPlugin()); diff --git a/app/lib/services/notification_backend_web.dart b/app/lib/services/notification_backend_web.dart new file mode 100644 index 0000000..0f00186 --- /dev/null +++ b/app/lib/services/notification_backend_web.dart @@ -0,0 +1,56 @@ +/// Browser-Notifications-API [NotificationBackend] for the desktop app. +/// +/// A browser notification only exists while the page that raised it is open, +/// so this is a genuinely weaker surface than Android's: closing the desktop +/// window drops every pending reminder. That is accepted -- the PC's real +/// backstop is `diet-guard-gate.timer`, which locks the screen rather than +/// asking politely. +library; + +import 'dart:js_interop'; + +import 'package:diet_guard_app/services/notification_backend.dart'; +import 'package:web/web.dart' as web; + +/// [NotificationBackend] over the browser's `Notification` API. +class WebNotificationBackend implements NotificationBackend { + /// Live notifications by slot, so [cancel] can close the right one. The + /// `tag` also makes the browser replace rather than stack a re-shown slot; + /// the map is what makes an explicit close possible. + final _shown = {}; + + bool _granted = false; + + @override + Future initialize() async {} + + @override + Future requestPermission() async { + if (web.Notification.permission == 'granted') { + _granted = true; + return true; + } + if (web.Notification.permission == 'denied') return false; + final result = await web.Notification.requestPermission().toDart; + return _granted = result.toDart == 'granted'; + } + + @override + Future show(int slot, String title, String body) async { + // Posting without permission throws; a silent no-op matches this + // service's degrade-quietly stance everywhere else. + if (!_granted) return; + _shown[slot] = web.Notification( + title, + web.NotificationOptions(body: body, tag: 'diet_guard_slot_$slot'), + ); + } + + @override + Future cancel(int slot) async { + _shown.remove(slot)?.close(); + } +} + +/// Opens the platform notification backend. +NotificationBackend openNotificationBackend() => WebNotificationBackend(); diff --git a/app/lib/services/notification_service.dart b/app/lib/services/notification_service.dart index 3e6ca91..db2305d 100644 --- a/app/lib/services/notification_service.dart +++ b/app/lib/services/notification_service.dart @@ -5,77 +5,51 @@ library; import 'package:diet_guard_app/models/slot.dart'; +import 'package:diet_guard_app/services/notification_backend.dart'; +import 'package:diet_guard_app/services/notification_backend_factory.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -/// Wraps [FlutterLocalNotificationsPlugin] so the due-slot notification -/// logic ([syncToSlots]) is unit-testable against a fake platform channel, -/// independent of the real plugin's native implementation. +/// Owns the due-slot notification logic ([syncToSlots]) independently of the +/// platform surface that actually posts them: `flutter_local_notifications` +/// on Android, the browser's Notifications API in the desktop web build (see +/// `notification_backend.dart`). class NotificationService { - NotificationService._(this._plugin); + NotificationService._(this._backend); static NotificationService? _instance; - final FlutterLocalNotificationsPlugin _plugin; + final NotificationBackend _backend; bool _initialized = false; - static const _channelId = 'diet_guard_due_slot'; - static const _channelName = 'Meal reminders'; - /// Returns the initialized singleton; throws if [init] was not called. static NotificationService get instance => _instance!; - /// Initializes the singleton with the real plugin (idempotent -- a + /// Initializes the singleton with the platform backend (idempotent -- a /// second call returns the already-initialized instance without /// re-running platform setup). static Future init() async { - final svc = _instance ??= NotificationService._( - FlutterLocalNotificationsPlugin(), - ); + final svc = _instance ??= NotificationService._(openNotificationBackend()); if (!svc._initialized) { - // `linux:` is required whenever the app runs on Linux (the desktop - // build used to visually verify screens); this app has no real - // Linux target otherwise. - const settings = InitializationSettings( - android: AndroidInitializationSettings('@mipmap/ic_launcher'), - linux: LinuxInitializationSettings(defaultActionName: 'Open'), - ); - await svc._plugin.initialize(settings: settings); + await svc._backend.initialize(); svc._initialized = true; } return svc; } - /// Resets the singleton so tests can inject a plugin pointed at a fake - /// platform channel. A subsequent [init] call drives that fake's - /// `initialize` codepath, same as production. + /// Resets the singleton so tests can inject a backend -- typically one over + /// a plugin pointed at a fake platform channel. A subsequent [init] call + /// drives that backend's `initialize` codepath, same as production. @visibleForTesting - static void resetForTesting({FlutterLocalNotificationsPlugin? plugin}) { - _instance = plugin == null ? null : NotificationService._(plugin); + static void resetForTesting({NotificationBackend? backend}) { + _instance = backend == null ? null : NotificationService._(backend); } - /// Requests Android 13+'s runtime `POST_NOTIFICATIONS` permission. + /// Requests the platform's notification permission. /// - /// Returns null on platforms where this Android-specific call doesn't - /// apply -- the caller treats null and false the same way (don't block on - /// it; notifications degrade silently if denied, matching the rest of - /// this service's silent-on-failure stance). This app only ships an - /// `android/` target, so in production the non-null path always runs; - /// the fallback exists for the Linux desktop build used to visually - /// verify this screen, where `resolvePlatformSpecificImplementation` - /// correctly resolves to null -- not reachable from `flutter test` - /// without polluting the process-global plugin registration other tests - /// in this file rely on, so it's excluded from coverage rather than - /// chased with a fragile test-ordering trick. - Future requestPermission() => - _plugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >() - ?.requestNotificationsPermission() ?? - // coverage:ignore-line - Future.value(); + /// Returns null where the concept doesn't apply; callers treat null and + /// false the same way. + Future requestPermission() => _backend.requestPermission(); /// Shows a notification for every slot in [dueSlots] and cancels one for /// every other known slot. @@ -88,27 +62,14 @@ class NotificationService { final due = dueSlots.toSet(); for (final slot in daySlots()) { if (due.contains(slot)) { - await _show(slot); + await _backend.show( + slot, + 'Meal not logged', + "You haven't logged your ${slotLabel(slot)} meal yet.", + ); } else { - await _plugin.cancel(id: slot); + await _backend.cancel(slot); } } } - - Future _show(int slot) async { - const details = NotificationDetails( - android: AndroidNotificationDetails( - _channelId, - _channelName, - importance: Importance.high, - priority: Priority.high, - ), - ); - await _plugin.show( - id: slot, - title: 'Meal not logged', - body: "You haven't logged your ${slotLabel(slot)} meal yet.", - notificationDetails: details, - ); - } } diff --git a/app/lib/services/photo_attach_service.dart b/app/lib/services/photo_attach_service.dart index cee31b6..9717747 100644 --- a/app/lib/services/photo_attach_service.dart +++ b/app/lib/services/photo_attach_service.dart @@ -1,52 +1,56 @@ -/// Picks a photo and copies it into permanent phone-local storage. +/// Picks a photo and copies it into permanent app-local storage. library; -import 'dart:io'; - +import 'package:diet_guard_app/services/blob_store.dart'; +import 'package:diet_guard_app/services/blob_store_factory.dart'; import 'package:flutter/foundation.dart'; import 'package:image_picker/image_picker.dart'; import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; -import 'package:uuid/uuid.dart'; -/// Wraps [ImagePicker] and persists the result under the app's documents -/// directory, so the returned path survives after the picker's own -/// (possibly cache-cleared) temp file is gone. +/// Wraps [ImagePicker] and persists the result in a [BlobStore], so the +/// returned key survives after the picker's own (possibly cache-cleared) +/// temp file is gone. /// -/// Photos are phone-local only: per the sync plan (Milestone 3), a logged +/// Photos are device-local only: per the sync plan (Milestone 3), a logged /// entry's `imagePath` is stripped before push and never read from a pulled -/// remote copy, so no storage here needs to be sync-aware. +/// remote copy, so nothing here needs to be sync-aware. That also means a +/// photo attached on the desktop is never visible on the phone, and vice +/// versa -- by design, not by omission. class PhotoAttachService { - PhotoAttachService._(this._testDir); + PhotoAttachService._(this._store); static PhotoAttachService _instance = PhotoAttachService._(null); /// The singleton instance. static PhotoAttachService get instance => _instance; - final Directory? _testDir; + BlobStore? _store; - /// Redirects where picked photos are copied to, so a test never touches - /// the real documents directory. Pass null to restore default behavior. + /// Redirects where picked photos are stored, so a test never touches the + /// real store. Pass null to restore default behavior. @visibleForTesting - static void resetForTesting({Directory? testDir}) { - _instance = PhotoAttachService._(testDir); + static void resetForTesting({BlobStore? store}) { + _instance = PhotoAttachService._(store); } - /// Opens [source] (camera or gallery), and on a successful pick, copies - /// the image into `/images/.`. + Future _resolveStore() async => _store ??= await openBlobStore(); + + /// Opens [source] (camera or gallery) and stores the picked image. /// - /// Returns the new permanent path, or null if the user cancelled the - /// picker. + /// Returns the new blob key -- an absolute path on Android, an IndexedDB + /// key in the browser -- or null if the user cancelled the picker. Future pickAndStore(ImageSource source) async { final picked = await ImagePicker().pickImage(source: source); if (picked == null) return null; - final docsDir = _testDir ?? await getApplicationDocumentsDirectory(); - final imagesDir = Directory(p.join(docsDir.path, 'images')); - await imagesDir.create(recursive: true); - final ext = p.extension(picked.path); - final dest = p.join(imagesDir.path, '${const Uuid().v4()}$ext'); - await File(picked.path).copy(dest); - return dest; + final store = await _resolveStore(); + // `readAsBytes` rather than a file copy: the browser's picker hands back + // a blob URL with no filesystem behind it. + return store.put(p.extension(picked.path), await picked.readAsBytes()); } + + /// Returns the bytes stored under [key], or null when the photo is gone. + /// + /// Backs the web image widget, which cannot render from a path. + Future readBytes(String key) async => + (await _resolveStore()).get(key); } diff --git a/app/lib/services/sync_service.dart b/app/lib/services/sync_service.dart index 7adff84..64de0c5 100644 --- a/app/lib/services/sync_service.dart +++ b/app/lib/services/sync_service.dart @@ -20,17 +20,12 @@ import 'package:crdt_sync/crdt_sync.dart'; import 'package:diet_guard_app/models/food_entry.dart'; import 'package:diet_guard_app/services/app_settings_service.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/github_client_factory.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:diet_guard_app/services/sync_merge.dart'; const _devicesDir = 'diet-guard-sync/devices'; -/// This device's id in the `diet-guard-sync/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'; - /// Runs one full sync tick: pull, merge, preserve photos, persist, push. /// /// Returns the merged log as it now sits on disk locally. Propagates any @@ -43,7 +38,7 @@ Future runSync(GitHubClient client) async { final mergedLog = await syncLog( client: client, - deviceId: phoneDeviceId, + deviceId: syncDeviceId, pathPrefix: _devicesDir, localLog: dayLogToLog(local), encode: encodeLogForPush, @@ -82,7 +77,7 @@ Future _syncBudget(GitHubClient client) async { final mergedBudgetLog = await syncLog( client: client, - deviceId: phoneDeviceId, + deviceId: syncDeviceId, pathPrefix: _devicesDir, localLog: budgetToLog(localRecord), encode: encodeBudgetForPush, diff --git a/app/lib/services/sync_settings.dart b/app/lib/services/sync_settings.dart index fc1eec4..a65b1d4 100644 --- a/app/lib/services/sync_settings.dart +++ b/app/lib/services/sync_settings.dart @@ -4,16 +4,17 @@ /// manual fallback. library; -import 'package:flutter/services.dart' show PlatformException; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:diet_guard_app/services/token_vault.dart'; +import 'package:diet_guard_app/services/token_vault_factory.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). +/// The GitHub token is kept out of `SharedPreferences`, which holds only the +/// non-secret owner/repo: on Android it lives in the OS keystore, and in the +/// desktop web build it lives in the wrapper process rather than the browser +/// at all (see [TokenVault]). Older builds stored the token in plaintext +/// prefs; [load]/[save] migrate it transparently and never drop the plaintext +/// copy until a vault write is confirmed (so we degrade to -- never below -- +/// the old behaviour when no secure store is available). class SyncSettings { /// Creates a [SyncSettings] from its owner/repo/token. const SyncSettings({ @@ -59,12 +60,10 @@ class SyncSettings { // 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'; + static final TokenVault _vault = openTokenVault(); - /// Default options keep us off the deprecated `encryptedSharedPreferences` - /// path on Android and use libsecret on Linux. - static const _secure = FlutterSecureStorage(); + /// True when this platform can show the stored token back to the user. + static bool get exposesTokenValue => _vault.exposesTokenValue; /// Loads settings, defaulting the owner/repo to `kuhyx/syncs` /// (matching the PC's `SYNC_REPO_OWNER`/`SYNC_REPO_NAME` constants) and the @@ -90,57 +89,36 @@ class SyncSettings { ); } - /// 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. + /// Reads the token, preferring the vault and falling back to the legacy + /// plaintext value. A legacy value is migrated into the vault on read, but + /// only dropped from prefs once that 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 stored = await _vault.read(); + if (stored.isNotEmpty) return stored; final legacy = prefs.getString(_kToken) ?? ''; - if (legacy.isNotEmpty && await _writeSecureToken(legacy)) { + if (legacy.isNotEmpty && await _vault.write(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]). + /// Persists [owner]/[repo] to prefs and [token] to the vault (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); await prefs.setString(_kClientId, clientId); - // Confirm-before-delete: only remove the plaintext copy once the keystore + // Confirm-before-delete: only remove the plaintext copy once the vault // write succeeds; otherwise keep persisting it to prefs as before. - if (await _writeSecureToken(token)) { + if (await _vault.write(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, diff --git a/app/lib/services/token_vault.dart b/app/lib/services/token_vault.dart new file mode 100644 index 0000000..86cc230 --- /dev/null +++ b/app/lib/services/token_vault.dart @@ -0,0 +1,34 @@ +/// Where the GitHub sync token lives, per platform. +library; + +/// Reads and writes the GitHub sync token. +/// +/// Two very different implementations sit behind this: +/// +/// * Android keeps the token in the OS keystore via `flutter_secure_storage` +/// (`token_vault_io.dart`), migrating any legacy plaintext copy. +/// * The desktop web build never holds the token at all: the wrapper owns it +/// on disk and attaches it to proxied requests, so the vault only reports +/// *whether* one exists (`token_vault_web.dart`). +abstract class TokenVault { + /// Returns the token, or the empty string when none is stored. + /// + /// On web this returns [wrapperManagedToken] as a stand-in whenever the + /// wrapper holds a token: callers only ever test it for emptiness or hand + /// it to a client whose requests are proxied, and the real value stays out + /// of the browser. + Future read(); + + /// Stores [token] (clearing it when empty). Returns false when the platform + /// has nowhere secure to put it, so the caller can fall back. + Future write(String token); + + /// True when this platform can show the token back to the user. + /// + /// False on web -- the settings screen hides the PAT field rather than + /// displaying a placeholder that cannot be edited meaningfully. + bool get exposesTokenValue; +} + +/// Stand-in value used on web in place of the real, wrapper-held token. +const wrapperManagedToken = 'wrapper-managed'; diff --git a/app/lib/services/token_vault_factory.dart b/app/lib/services/token_vault_factory.dart new file mode 100644 index 0000000..0e8305d --- /dev/null +++ b/app/lib/services/token_vault_factory.dart @@ -0,0 +1,5 @@ +/// Platform entry point for the GitHub sync token vault. +library; + +export 'token_vault_io.dart' + if (dart.library.js_interop) 'token_vault_web.dart'; diff --git a/app/lib/services/token_vault_io.dart b/app/lib/services/token_vault_io.dart new file mode 100644 index 0000000..2c21d8b --- /dev/null +++ b/app/lib/services/token_vault_io.dart @@ -0,0 +1,53 @@ +/// Keystore-backed [TokenVault] used on Android. +library; + +import 'package:diet_guard_app/services/token_vault.dart'; +import 'package:flutter/services.dart' show PlatformException; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Keeps the token in the OS keystore (Android Keystore) via +/// [FlutterSecureStorage]. +/// +/// Default options keep us off the deprecated `encryptedSharedPreferences` +/// path on Android. +class SecureStorageTokenVault implements TokenVault { + /// Creates a vault over [storage]. + const SecureStorageTokenVault({this.storage = const FlutterSecureStorage()}); + + /// The wrapped keystore. Injectable so a test can supply a fake. + final FlutterSecureStorage storage; + + /// Key for the token inside the OS keystore. + static const _secureToken = 'sync.token'; + + @override + bool get exposesTokenValue => true; + + @override + Future read() async { + try { + return await storage.read(key: _secureToken) ?? ''; + } on PlatformException { + // No secret service available -- the caller falls back to the legacy + // plaintext copy in SharedPreferences. + return ''; + } + } + + @override + Future write(String token) async { + try { + if (token.isEmpty) { + await storage.delete(key: _secureToken); + } else { + await storage.write(key: _secureToken, value: token); + } + return true; + } on PlatformException { + return false; + } + } +} + +/// Opens the platform token vault. +TokenVault openTokenVault() => const SecureStorageTokenVault(); diff --git a/app/lib/services/token_vault_web.dart b/app/lib/services/token_vault_web.dart new file mode 100644 index 0000000..7a27a4b --- /dev/null +++ b/app/lib/services/token_vault_web.dart @@ -0,0 +1,65 @@ +/// Wrapper-backed [TokenVault] for the desktop web build. +library; + +import 'dart:convert'; + +import 'package:diet_guard_app/services/desktop_wrapper.dart'; +import 'package:diet_guard_app/services/token_vault.dart'; +import 'package:http/http.dart' as http; + +/// Delegates token storage to the desktop wrapper. +/// +/// The browser never holds the token: [read] reports only whether the wrapper +/// has one, and [write] hands a pasted PAT straight through to the wrapper's +/// 600-mode token file. This is deliberately *not* `flutter_secure_storage`'s +/// web backend, which is WebCrypto-wrapped `localStorage` with the wrap key +/// stored beside the ciphertext -- obfuscation, not encryption at rest. +class WrapperTokenVault implements TokenVault { + /// Creates a vault talking to the wrapper at [baseUrl]. + WrapperTokenVault({this.baseUrl = desktopWrapperOrigin, http.Client? client}) + : _client = client ?? http.Client(); + + /// Origin of the desktop wrapper. + final String baseUrl; + + final http.Client _client; + + @override + bool get exposesTokenValue => false; + + @override + Future read() async { + try { + final response = await _client.get( + Uri.parse('$baseUrl${WrapperPaths.github}auth/status'), + ); + if (response.statusCode != 200) return ''; + final body = jsonDecode(response.body); + final configured = body is Map && body['configured'] == true; + return configured ? wrapperManagedToken : ''; + } on Exception { + // No wrapper running (a plain browser tab): behave as unconfigured + // rather than failing the whole settings screen. + return ''; + } + } + + @override + Future write(String token) async { + // The stand-in is what `read` handed out; writing it back would replace a + // real token with a literal. + if (token == wrapperManagedToken) return true; + try { + final response = await _client.post( + Uri.parse('$baseUrl${WrapperPaths.github}auth/token'), + body: token, + ); + return response.statusCode == 204; + } on Exception { + return false; + } + } +} + +/// Opens the platform token vault. +TokenVault openTokenVault() => WrapperTokenVault(); diff --git a/app/lib/widgets/attached_image.dart b/app/lib/widgets/attached_image.dart new file mode 100644 index 0000000..494c039 --- /dev/null +++ b/app/lib/widgets/attached_image.dart @@ -0,0 +1,10 @@ +/// Platform entry point for rendering an attached meal photo. +/// +/// Conditional export because the two platforms resolve a photo differently: +/// Android has a real file at the stored path, while the browser-hosted +/// desktop app has to read the bytes back out of IndexedDB before it can show +/// anything (`Image.file` is a `dart:io` stub there and would throw). +library; + +export 'attached_image_io.dart' + if (dart.library.js_interop) 'attached_image_web.dart'; diff --git a/app/lib/widgets/attached_image_io.dart b/app/lib/widgets/attached_image_io.dart new file mode 100644 index 0000000..dabe005 --- /dev/null +++ b/app/lib/widgets/attached_image_io.dart @@ -0,0 +1,58 @@ +/// File-backed attached-photo widget (Android). +library; + +import 'dart:io'; + +import 'package:flutter/material.dart'; + +/// Renders the photo stored under [path], falling back to a broken-image +/// icon when it cannot be read. +class AttachedImage extends StatelessWidget { + /// Creates an [AttachedImage] for the blob key [path]. + const AttachedImage({ + required this.path, + this.width, + this.height, + this.fit, + this.errorIconSize, + this.errorIconColor, + super.key, + }); + + /// Blob key of the photo -- an absolute file path on this platform. + final String path; + + /// Optional fixed width; null renders at the image's own size. + final double? width; + + /// Optional fixed height; null renders at the image's own size. + final double? height; + + /// How to inscribe the image into its box. + final BoxFit? fit; + + /// Size of the fallback broken-image icon. + final double? errorIconSize; + + /// Colour of the fallback broken-image icon. + final Color? errorIconColor; + + @override + Widget build(BuildContext context) { + return Image.file( + File(path), + width: width, + height: height, + fit: fit, + errorBuilder: (context, error, stackTrace) => SizedBox( + width: width, + height: height, + child: Icon( + Icons.broken_image, + size: errorIconSize, + color: errorIconColor, + ), + ), + ); + } +} diff --git a/app/lib/widgets/attached_image_web.dart b/app/lib/widgets/attached_image_web.dart new file mode 100644 index 0000000..16f89eb --- /dev/null +++ b/app/lib/widgets/attached_image_web.dart @@ -0,0 +1,92 @@ +/// IndexedDB-backed attached-photo widget (desktop web build). +library; + +import 'dart:typed_data'; + +import 'package:diet_guard_app/services/photo_attach_service.dart'; +import 'package:flutter/material.dart'; + +/// Renders the photo stored under [path], falling back to a broken-image +/// icon when it cannot be read. +/// +/// Unlike the Android build this is inherently asynchronous: the bytes come +/// from IndexedDB (or, for a cleared profile, the desktop wrapper's disk +/// mirror), not from a path the rasteriser can open itself. +class AttachedImage extends StatefulWidget { + /// Creates an [AttachedImage] for the blob key [path]. + const AttachedImage({ + required this.path, + this.width, + this.height, + this.fit, + this.errorIconSize, + this.errorIconColor, + super.key, + }); + + /// Blob key of the photo -- an IndexedDB key on this platform. + final String path; + + /// Optional fixed width; null renders at the image's own size. + final double? width; + + /// Optional fixed height; null renders at the image's own size. + final double? height; + + /// How to inscribe the image into its box. + final BoxFit? fit; + + /// Size of the fallback broken-image icon. + final double? errorIconSize; + + /// Colour of the fallback broken-image icon. + final Color? errorIconColor; + + @override + State createState() => _AttachedImageState(); +} + +class _AttachedImageState extends State { + late Future _bytes = _load(); + + Future _load() => PhotoAttachService.instance.readBytes( + widget.path, + ); + + @override + void didUpdateWidget(AttachedImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.path != widget.path) _bytes = _load(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _bytes, + builder: (context, snapshot) { + final bytes = snapshot.data; + if (bytes == null) { + // Covers both "still loading" and "gone": a spinner that flashes + // for one frame per thumbnail would be worse than an empty box. + return SizedBox( + width: widget.width, + height: widget.height, + child: snapshot.connectionState == ConnectionState.done + ? Icon( + Icons.broken_image, + size: widget.errorIconSize, + color: widget.errorIconColor, + ) + : null, + ); + } + return Image.memory( + bytes, + width: widget.width, + height: widget.height, + fit: widget.fit, + ); + }, + ); + } +} diff --git a/app/lib/widgets/photo_attach_field.dart b/app/lib/widgets/photo_attach_field.dart index b64ff11..9a4b209 100644 --- a/app/lib/widgets/photo_attach_field.dart +++ b/app/lib/widgets/photo_attach_field.dart @@ -1,10 +1,10 @@ /// Shared attach/preview/remove control for a meal entry's optional photo. library; -import 'dart:io'; - import 'package:diet_guard_app/screens/photo_viewer_screen.dart'; import 'package:diet_guard_app/services/photo_attach_service.dart'; +import 'package:diet_guard_app/widgets/attached_image.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; @@ -41,14 +41,21 @@ class PhotoAttachField extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - ListTile( - leading: const Icon(Icons.photo_camera), - title: const Text('Take a photo'), - onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera), - ), + // No camera row on web: the browser's picker is a file input, + // and `ImageSource.camera` silently falls back to the same file + // dialog -- an option that lies about what it does is worse than + // no option. + if (!kIsWeb) + ListTile( + leading: const Icon(Icons.photo_camera), + title: const Text('Take a photo'), + onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera), + ), ListTile( leading: const Icon(Icons.photo_library), - title: const Text('Choose from gallery'), + title: const Text( + kIsWeb ? 'Choose a file' : 'Choose from gallery', + ), onTap: () => Navigator.of(sheetContext).pop(ImageSource.gallery), ), ], @@ -86,16 +93,11 @@ class PhotoAttachField extends StatelessWidget { ), child: ClipRRect( borderRadius: BorderRadius.circular(8), - child: Image.file( - File(path), + child: AttachedImage( + path: path, width: thumbnailSize, height: thumbnailSize, fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => SizedBox( - width: thumbnailSize, - height: thumbnailSize, - child: const Icon(Icons.broken_image), - ), ), ), ); diff --git a/app/pubspec.lock b/app/pubspec.lock index 74c0e00..1ad5ccf 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -94,10 +94,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" url: "https://pub.dev" source: hosted - version: "0.3.5+2" + version: "0.3.5+4" crypto: dependency: "direct main" description: @@ -203,10 +203,10 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: a0d7141f14cabcee42967470a858dfc99dd6cfb70d3cab404bacfcafa9e84e70 + sha256: "40c6a69189a622bda89ddcf50a139f4ba0f0eb9c0fef6718845b1f8b95452ed6" url: "https://pub.dev" source: hosted - version: "22.0.1" + version: "22.1.0" flutter_local_notifications_linux: dependency: transitive description: @@ -219,10 +219,10 @@ packages: dependency: transitive description: name: flutter_local_notifications_platform_interface - sha256: ff0013eae795e8dc8fad4a8992a209e64d3ba2fbd8bf5e43c36bf448f95bd814 + sha256: "4dfbae10debab9ac975d8b01943fed94c0c19dad43f825964b29f3c3f9a8a852" url: "https://pub.dev" source: hosted - version: "12.0.0" + version: "12.0.1" flutter_local_notifications_web: dependency: transitive description: @@ -329,6 +329,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + idb_shim: + dependency: "direct main" + description: + name: idb_shim + sha256: "3448298f244bc76a14aca71461eeab6add2b8a877930521c42c2e8b71b644590" + url: "https://pub.dev" + source: hosted + version: "2.9.6+2" image: dependency: transitive description: @@ -341,10 +349,10 @@ packages: dependency: "direct main" description: name: image_picker - sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac" + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.2.3" image_picker_android: dependency: transitive description: @@ -541,10 +549,10 @@ packages: dependency: transitive description: name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_platform_interface: dependency: transitive description: @@ -657,6 +665,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + sembast: + dependency: transitive + description: + name: sembast + sha256: a58b26925e23071cf0f4754d8449aabe829e9a9930a872fb18e6ae4c2c88e025 + url: "https://pub.dev" + source: hosted + version: "3.8.9+1" shared_preferences: dependency: "direct main" description: @@ -669,10 +685,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5" + sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" url: "https://pub.dev" source: hosted - version: "2.4.26" + version: "2.4.27" shared_preferences_foundation: dependency: transitive description: @@ -750,6 +766,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153" + url: "https://pub.dev" + source: hosted + version: "3.4.1" term_glyph: dependency: transitive description: @@ -770,10 +794,10 @@ packages: dependency: transitive description: name: timezone - sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b" + sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" url: "https://pub.dev" source: hosted - version: "0.11.0" + version: "0.11.1" typed_data: dependency: transitive description: @@ -850,10 +874,10 @@ packages: dependency: "direct main" description: name: uuid - sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" url: "https://pub.dev" source: hosted - version: "4.5.3" + version: "4.6.0" vector_math: dependency: transitive description: @@ -879,7 +903,7 @@ packages: source: hosted version: "15.2.0" web: - dependency: transitive + dependency: "direct main" description: name: web sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index c2eef19..e61a373 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: flutter_local_notifications: ^22.0.1 flutter_secure_storage: ^10.3.1 http: ^1.6.0 + idb_shim: ^2.9.6+2 image_picker: ^1.1.2 path: ^1.9.1 path_provider: ^2.1.5 @@ -26,6 +27,7 @@ dependencies: shared_preferences: ^2.5.5 url_launcher: ^6.3.2 uuid: ^4.5.3 + web: ^1.1.1 workmanager: ^0.9.0+3 dev_dependencies: diff --git a/app/test/screens/calendar_screen_test.dart b/app/test/screens/calendar_screen_test.dart index e3afc07..80f30b6 100644 --- a/app/test/screens/calendar_screen_test.dart +++ b/app/test/screens/calendar_screen_test.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/screens/calendar_screen.dart'; import 'package:diet_guard_app/screens/history_screen.dart'; import 'package:diet_guard_app/services/app_settings_service.dart'; @@ -29,8 +30,8 @@ void main() { setUp(() async { tempDir = await Directory.systemTemp.createTemp('diet_guard_calendar_'); - LogStorageService.resetForTesting(testDir: tempDir); - await AppSettingsService.initForTesting(tempDir); + LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); + await AppSettingsService.initForTesting(FileDocumentStore(tempDir)); }); tearDown(() async { diff --git a/app/test/screens/edit_entry_screen_test.dart b/app/test/screens/edit_entry_screen_test.dart index 004b894..36c7b70 100644 --- a/app/test/screens/edit_entry_screen_test.dart +++ b/app/test/screens/edit_entry_screen_test.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/models/food_bank_record.dart'; import 'package:diet_guard_app/models/food_entry.dart'; import 'package:diet_guard_app/screens/edit_entry_screen.dart'; @@ -26,8 +27,8 @@ void main() { setUp(() async { tempDir = await Directory.systemTemp.createTemp('diet_guard_edit_test_'); - LogStorageService.resetForTesting(testDir: tempDir); - FoodBankService.resetForTesting(testDir: tempDir); + LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); + FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); }); tearDown(() async { diff --git a/app/test/screens/food_bank_screen_test.dart b/app/test/screens/food_bank_screen_test.dart index 9ac840d..44e3d6d 100644 --- a/app/test/screens/food_bank_screen_test.dart +++ b/app/test/screens/food_bank_screen_test.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/models/food_bank_record.dart'; import 'package:diet_guard_app/models/food_entry.dart'; import 'package:diet_guard_app/screens/food_bank_screen.dart'; @@ -13,8 +14,8 @@ void main() { setUp(() async { tempDir = await Directory.systemTemp.createTemp('diet_guard_fb_screen_'); - FoodBankService.resetForTesting(testDir: tempDir); - LogStorageService.resetForTesting(testDir: tempDir); + FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); + LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); }); tearDown(() async { diff --git a/app/test/screens/history_screen_test.dart b/app/test/screens/history_screen_test.dart index 81ecfd5..aecb59d 100644 --- a/app/test/screens/history_screen_test.dart +++ b/app/test/screens/history_screen_test.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/models/food_entry.dart'; import 'package:diet_guard_app/screens/history_screen.dart'; import 'package:diet_guard_app/screens/photo_viewer_screen.dart'; @@ -12,7 +13,7 @@ void main() { setUp(() async { tempDir = await Directory.systemTemp.createTemp('diet_guard_history_'); - LogStorageService.resetForTesting(testDir: tempDir); + LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); }); tearDown(() async { diff --git a/app/test/screens/log_meal_screen_sync_test.dart b/app/test/screens/log_meal_screen_sync_test.dart index 76d3859..6d1d48b 100644 --- a/app/test/screens/log_meal_screen_sync_test.dart +++ b/app/test/screens/log_meal_screen_sync_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; +import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/models/nutrition.dart'; import 'package:diet_guard_app/screens/log_meal_screen.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; @@ -20,8 +21,8 @@ void main() { setUp(() async { tempDir = await Directory.systemTemp.createTemp('diet_guard_autosync_'); - LogStorageService.resetForTesting(testDir: tempDir); - FoodBankService.resetForTesting(testDir: tempDir); + LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); + FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); }); tearDown(() async { diff --git a/app/test/screens/log_meal_screen_test.dart b/app/test/screens/log_meal_screen_test.dart index 404f017..030669a 100644 --- a/app/test/screens/log_meal_screen_test.dart +++ b/app/test/screens/log_meal_screen_test.dart @@ -1,5 +1,7 @@ import 'dart:io'; +import 'package:diet_guard_app/services/blob_store_io.dart'; +import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/models/food_entry.dart'; import 'package:diet_guard_app/models/nutrition.dart'; import 'package:diet_guard_app/models/slot.dart'; @@ -133,10 +135,10 @@ void main() { setUp(() async { tempDir = await Directory.systemTemp.createTemp('diet_guard_screen_'); - LogStorageService.resetForTesting(testDir: tempDir); - FoodBankService.resetForTesting(testDir: tempDir); - PhotoAttachService.resetForTesting(testDir: tempDir); - AppSettingsService.resetForTesting(testDir: tempDir); + LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); + FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); + PhotoAttachService.resetForTesting(store: FileBlobStore(tempDir)); + AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir)); originalImagePickerPlatform = ImagePickerPlatform.instance; }); diff --git a/app/test/screens/meal_builder_screen_test.dart b/app/test/screens/meal_builder_screen_test.dart index 7ac6656..30ea3e4 100644 --- a/app/test/screens/meal_builder_screen_test.dart +++ b/app/test/screens/meal_builder_screen_test.dart @@ -1,5 +1,7 @@ import 'dart:io'; +import 'package:diet_guard_app/services/blob_store_io.dart'; +import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/screens/meal_builder_screen.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; @@ -28,9 +30,9 @@ void main() { setUp(() async { tempDir = await Directory.systemTemp.createTemp('diet_guard_builder_'); - LogStorageService.resetForTesting(testDir: tempDir); - FoodBankService.resetForTesting(testDir: tempDir); - PhotoAttachService.resetForTesting(testDir: tempDir); + LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); + FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); + PhotoAttachService.resetForTesting(store: FileBlobStore(tempDir)); originalImagePickerPlatform = ImagePickerPlatform.instance; }); diff --git a/app/test/screens/settings_screen_test.dart b/app/test/screens/settings_screen_test.dart index 9cf602d..6389e88 100644 --- a/app/test/screens/settings_screen_test.dart +++ b/app/test/screens/settings_screen_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/screens/settings_screen.dart'; import 'package:diet_guard_app/services/app_settings_service.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; @@ -43,9 +44,9 @@ void main() { setUp(() async { tempDir = await Directory.systemTemp.createTemp('diet_guard_settings_'); - LogStorageService.resetForTesting(testDir: tempDir); - FoodBankService.resetForTesting(testDir: tempDir); - AppSettingsService.resetForTesting(testDir: tempDir); + LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); + FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); + AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir)); SharedPreferences.setMockInitialValues({}); installFakeSecureStorage(); }); diff --git a/app/test/services/app_settings_service_test.dart b/app/test/services/app_settings_service_test.dart index 59d5529..1d9d7ce 100644 --- a/app/test/services/app_settings_service_test.dart +++ b/app/test/services/app_settings_service_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/services/app_settings_service.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -28,12 +29,12 @@ void main() { group('resetForTesting', () { test('with testDir creates a working instance', () { - AppSettingsService.resetForTesting(testDir: tempDir); + AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir)); expect(AppSettingsService.instance, isNotNull); }); test('without testDir nulls the singleton', () { - AppSettingsService.resetForTesting(testDir: tempDir); + AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir)); AppSettingsService.resetForTesting(); // instance getter throws when null — verify via dailyKcalGoal fallback. expect(AppSettingsService.dailyKcalGoal, 2200); @@ -42,7 +43,7 @@ void main() { group('init early-return', () { test('returns existing instance without re-initialising', () async { - AppSettingsService.resetForTesting(testDir: tempDir); + AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir)); final first = AppSettingsService.instance; // init() sees _instance != null and returns early (no platform channel). final second = await AppSettingsService.init(); @@ -52,7 +53,7 @@ void main() { group('saveDailyKcalGoal', () { test('updates in-memory value and persists to file', () async { - AppSettingsService.resetForTesting(testDir: tempDir); + AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir)); await AppSettingsService.instance.saveDailyKcalGoal(1800); expect(AppSettingsService.dailyKcalGoal, 1800); @@ -65,7 +66,7 @@ void main() { }); test('stamps a fresh dailyKcalGoalUpdatedAt', () async { - AppSettingsService.resetForTesting(testDir: tempDir); + AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir)); final before = DateTime.now(); await AppSettingsService.instance.saveDailyKcalGoal(1800); final after = DateTime.now(); @@ -81,7 +82,7 @@ void main() { test( 'persists the given value and updatedAt verbatim, not "now"', () async { - AppSettingsService.resetForTesting(testDir: tempDir); + AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir)); final winningEdit = DateTime.utc(2020); await AppSettingsService.instance.applySyncedBudget( @@ -105,7 +106,7 @@ void main() { ); test('a null updatedAt is accepted and persisted as null', () async { - AppSettingsService.resetForTesting(testDir: tempDir); + AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir)); await AppSettingsService.instance.applySyncedBudget(1700); expect(AppSettingsService.dailyKcalGoalUpdatedAt, isNull); }); @@ -117,13 +118,13 @@ void main() { '${tempDir.path}/app_settings.json', ).writeAsString(jsonEncode({'daily_kcal_goal': 1600})); - await AppSettingsService.initForTesting(tempDir); + await AppSettingsService.initForTesting(FileDocumentStore(tempDir)); expect(AppSettingsService.dailyKcalGoal, 1600); }); test('keeps default 2200 when file does not exist', () async { - await AppSettingsService.initForTesting(tempDir); + await AppSettingsService.initForTesting(FileDocumentStore(tempDir)); expect(AppSettingsService.dailyKcalGoal, 2200); }); @@ -133,7 +134,7 @@ void main() { '${tempDir.path}/app_settings.json', ).writeAsString('not json at all'); - await AppSettingsService.initForTesting(tempDir); + await AppSettingsService.initForTesting(FileDocumentStore(tempDir)); expect(AppSettingsService.dailyKcalGoal, 2200); }); @@ -143,7 +144,7 @@ void main() { '${tempDir.path}/app_settings.json', ).writeAsString(jsonEncode([1, 2, 3])); - await AppSettingsService.initForTesting(tempDir); + await AppSettingsService.initForTesting(FileDocumentStore(tempDir)); expect(AppSettingsService.dailyKcalGoal, 2200); }); @@ -153,7 +154,7 @@ void main() { '${tempDir.path}/app_settings.json', ).writeAsString(jsonEncode({'daily_kcal_goal': 'two thousand'})); - await AppSettingsService.initForTesting(tempDir); + await AppSettingsService.initForTesting(FileDocumentStore(tempDir)); expect(AppSettingsService.dailyKcalGoal, 2200); }); @@ -166,7 +167,7 @@ void main() { }), ); - await AppSettingsService.initForTesting(tempDir); + await AppSettingsService.initForTesting(FileDocumentStore(tempDir)); expect( AppSettingsService.dailyKcalGoalUpdatedAt, @@ -181,7 +182,7 @@ void main() { '${tempDir.path}/app_settings.json', ).writeAsString(jsonEncode({'daily_kcal_goal': 1600})); - await AppSettingsService.initForTesting(tempDir); + await AppSettingsService.initForTesting(FileDocumentStore(tempDir)); expect(AppSettingsService.dailyKcalGoalUpdatedAt, isNull); }, @@ -197,7 +198,7 @@ void main() { }), ); - await AppSettingsService.initForTesting(tempDir); + await AppSettingsService.initForTesting(FileDocumentStore(tempDir)); expect(AppSettingsService.dailyKcalGoalUpdatedAt, isNull); }, @@ -212,7 +213,7 @@ void main() { }), ); - await AppSettingsService.initForTesting(tempDir); + await AppSettingsService.initForTesting(FileDocumentStore(tempDir)); expect(AppSettingsService.rewardLabel, 'Podcast'); expect(AppSettingsService.rewardUrl, 'https://example.com/podcast'); @@ -223,7 +224,7 @@ void main() { '${tempDir.path}/app_settings.json', ).writeAsString(jsonEncode({'daily_kcal_goal': 1600})); - await AppSettingsService.initForTesting(tempDir); + await AppSettingsService.initForTesting(FileDocumentStore(tempDir)); expect(AppSettingsService.rewardLabel, isNull); expect(AppSettingsService.rewardUrl, isNull); @@ -238,7 +239,7 @@ void main() { }), ); - await AppSettingsService.initForTesting(tempDir); + await AppSettingsService.initForTesting(FileDocumentStore(tempDir)); expect(AppSettingsService.rewardLabel, isNull); expect(AppSettingsService.rewardUrl, isNull); @@ -254,7 +255,7 @@ void main() { group('saveReward', () { test('persists label and url, readable back via static getters', () async { - AppSettingsService.resetForTesting(testDir: tempDir); + AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir)); await AppSettingsService.instance.saveReward( label: 'Podcast', url: 'https://example.com/podcast', @@ -272,7 +273,7 @@ void main() { }); test('clears fields back to null when saved as null', () async { - AppSettingsService.resetForTesting(testDir: tempDir); + AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir)); await AppSettingsService.instance.saveReward( label: 'Podcast', url: 'https://example.com/podcast', @@ -284,7 +285,7 @@ void main() { }); test('does not disturb the existing kcal goal fields', () async { - AppSettingsService.resetForTesting(testDir: tempDir); + AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir)); await AppSettingsService.instance.saveDailyKcalGoal(1800); await AppSettingsService.instance.saveReward( label: 'Podcast', diff --git a/app/test/services/background_sync_service_test.dart b/app/test/services/background_sync_service_test.dart index 2dd310a..7dfe8c0 100644 --- a/app/test/services/background_sync_service_test.dart +++ b/app/test/services/background_sync_service_test.dart @@ -5,6 +5,7 @@ import 'dart:io'; +import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/services/app_settings_service.dart'; import 'package:diet_guard_app/services/background_sync_service.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; @@ -22,12 +23,12 @@ void main() { setUp(() async { tempDir = await Directory.systemTemp.createTemp('diet_guard_bg_sync_'); - LogStorageService.resetForTesting(testDir: tempDir); - FoodBankService.resetForTesting(testDir: tempDir); + LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); + FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); // Pre-seeds the singleton so backgroundSyncPush's own AppSettingsService // .init() call short-circuits instead of hitting the real (unmocked in // this test) path_provider channel. - AppSettingsService.resetForTesting(testDir: tempDir); + AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir)); }); tearDown(() async { diff --git a/app/test/services/blob_store_io_test.dart b/app/test/services/blob_store_io_test.dart new file mode 100644 index 0000000..ea633a4 --- /dev/null +++ b/app/test/services/blob_store_io_test.dart @@ -0,0 +1,65 @@ +// Photo bytes went behind a BlobStore when the desktop target became a web +// build: Android keeps files, the browser keeps IndexedDB entries. These +// tests pin the file backend, which is what the phone actually runs. +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:diet_guard_app/services/blob_store_io.dart'; +import 'package:diet_guard_app/services/photo_attach_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory tempDir; + late FileBlobStore store; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_blob_'); + store = FileBlobStore(tempDir); + }); + + tearDown(() async => tempDir.delete(recursive: true)); + + test('put writes under images/ with the given extension', () async { + final key = await store.put('.jpg', Uint8List.fromList([1, 2, 3])); + + expect(key, startsWith(p.join(tempDir.path, 'images'))); + expect(key, endsWith('.jpg')); + expect(File(key).readAsBytesSync(), [1, 2, 3]); + }); + + test('put gives each blob its own key', () async { + final first = await store.put('.jpg', Uint8List.fromList([1])); + final second = await store.put('.jpg', Uint8List.fromList([2])); + + expect(first, isNot(second)); + }); + + test('get returns the stored bytes', () async { + final key = await store.put('.png', Uint8List.fromList([9, 8])); + + expect(await store.get(key), [9, 8]); + }); + + test('get returns null for a photo that is gone', () async { + // The entry outlives its file when the user clears app storage; the UI + // shows a broken-image icon rather than failing to build. + expect(await store.get(p.join(tempDir.path, 'images', 'missing.jpg')), null); + }); + + test('get returns null when the path is not a readable file', () async { + final directoryPath = p.join(tempDir.path, 'a_directory')..toString(); + Directory(directoryPath).createSync(); + + expect(await store.get(directoryPath), null); + }); + + test('PhotoAttachService.readBytes reads through its store', () async { + // Backs the web image widget, which cannot render from a path. + PhotoAttachService.resetForTesting(store: store); + addTearDown(PhotoAttachService.resetForTesting); + final key = await store.put('.jpg', Uint8List.fromList([4, 5])); + + expect(await PhotoAttachService.instance.readBytes(key), [4, 5]); + }); +} diff --git a/app/test/services/background_check_service_test.dart b/app/test/services/due_slot_check_test.dart similarity index 86% rename from app/test/services/background_check_service_test.dart rename to app/test/services/due_slot_check_test.dart index ad21563..55f8e6f 100644 --- a/app/test/services/background_check_service_test.dart +++ b/app/test/services/due_slot_check_test.dart @@ -1,13 +1,15 @@ -// `checkAndNotify` is the unit-testable half of the WorkManager periodic +// `checkAndNotify` is the scheduler-independent half of the periodic // check; `backgroundCheckCallbackDispatcher` itself is integration-only // (real WorkManager isolate, manual on-device smoke test) per the project // plan, and is excluded from coverage. import 'dart:io'; +import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/models/nutrition.dart'; -import 'package:diet_guard_app/services/background_check_service.dart'; +import 'package:diet_guard_app/services/due_slot_check.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:diet_guard_app/services/notification_backend_io.dart'; import 'package:diet_guard_app/services/notification_service.dart'; import 'package:flutter/services.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; @@ -31,10 +33,10 @@ void main() { setUp(() async { tempDir = await Directory.systemTemp.createTemp('diet_guard_bg_check_'); - LogStorageService.resetForTesting(testDir: tempDir); + LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); notificationLog = installFakeAndroidNotifications(); NotificationService.resetForTesting( - plugin: FlutterLocalNotificationsPlugin(), + backend: LocalNotificationsBackend(FlutterLocalNotificationsPlugin()), ); }); diff --git a/app/test/services/foodbank_service_test.dart b/app/test/services/foodbank_service_test.dart index 1f6790c..1db3e99 100644 --- a/app/test/services/foodbank_service_test.dart +++ b/app/test/services/foodbank_service_test.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/models/food_bank_record.dart'; import 'package:diet_guard_app/models/food_entry.dart'; import 'package:diet_guard_app/models/meal_component.dart'; @@ -129,8 +130,8 @@ void main() { setUp(() async { tempDir = await Directory.systemTemp.createTemp('diet_guard_bank_'); - FoodBankService.resetForTesting(testDir: tempDir); - LogStorageService.resetForTesting(testDir: tempDir); + FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); + LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); }); tearDown(() async { @@ -189,8 +190,8 @@ void main() { setUp(() async { tempDir = await Directory.systemTemp.createTemp('diet_guard_fb_manual_'); - FoodBankService.resetForTesting(testDir: tempDir); - LogStorageService.resetForTesting(testDir: tempDir); + FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); + LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); }); tearDown(() async { @@ -338,8 +339,8 @@ void main() { setUp(() async { tempDir = await Directory.systemTemp.createTemp('diet_guard_fb_err_'); - FoodBankService.resetForTesting(testDir: tempDir); - LogStorageService.resetForTesting(testDir: tempDir); + FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); + LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); }); tearDown(() async { diff --git a/app/test/services/log_storage_service_test.dart b/app/test/services/log_storage_service_test.dart index 718bd2c..5b5966c 100644 --- a/app/test/services/log_storage_service_test.dart +++ b/app/test/services/log_storage_service_test.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/models/food_entry.dart'; import 'package:diet_guard_app/models/local_time.dart'; import 'package:diet_guard_app/models/meal_component.dart'; @@ -21,7 +22,7 @@ void main() { setUp(() async { tempDir = await Directory.systemTemp.createTemp('diet_guard_test_'); - LogStorageService.resetForTesting(testDir: tempDir); + LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); }); tearDown(() async { diff --git a/app/test/services/notification_service_test.dart b/app/test/services/notification_service_test.dart index fe141db..d3beb2d 100644 --- a/app/test/services/notification_service_test.dart +++ b/app/test/services/notification_service_test.dart @@ -1,3 +1,4 @@ +import 'package:diet_guard_app/services/notification_backend_io.dart'; import 'package:diet_guard_app/services/notification_service.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -21,7 +22,7 @@ void main() { test('init calls the platform initialize method, idempotently', () async { final log = installFakeAndroidNotifications(); NotificationService.resetForTesting( - plugin: FlutterLocalNotificationsPlugin(), + backend: LocalNotificationsBackend(FlutterLocalNotificationsPlugin()), ); await NotificationService.init(); @@ -33,7 +34,7 @@ void main() { test('requestPermission delegates to the Android implementation', () async { installFakeAndroidNotifications(); NotificationService.resetForTesting( - plugin: FlutterLocalNotificationsPlugin(), + backend: LocalNotificationsBackend(FlutterLocalNotificationsPlugin()), ); await NotificationService.init(); @@ -43,7 +44,7 @@ void main() { test('syncToSlots shows due slots and cancels the rest', () async { final log = installFakeAndroidNotifications(); NotificationService.resetForTesting( - plugin: FlutterLocalNotificationsPlugin(), + backend: LocalNotificationsBackend(FlutterLocalNotificationsPlugin()), ); await NotificationService.init(); log.clear(); @@ -65,7 +66,7 @@ void main() { test('syncToSlots with no due slots cancels every known slot', () async { final log = installFakeAndroidNotifications(); NotificationService.resetForTesting( - plugin: FlutterLocalNotificationsPlugin(), + backend: LocalNotificationsBackend(FlutterLocalNotificationsPlugin()), ); await NotificationService.init(); log.clear(); @@ -81,7 +82,9 @@ void main() { () async { final log = installFakeAndroidNotifications(); NotificationService.resetForTesting( - plugin: FlutterLocalNotificationsPlugin(), + backend: LocalNotificationsBackend( + FlutterLocalNotificationsPlugin(), + ), ); await NotificationService.init(); diff --git a/app/test/services/photo_attach_service_test.dart b/app/test/services/photo_attach_service_test.dart index 6415b67..d1bc643 100644 --- a/app/test/services/photo_attach_service_test.dart +++ b/app/test/services/photo_attach_service_test.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:diet_guard_app/services/blob_store_io.dart'; import 'package:diet_guard_app/services/photo_attach_service.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:image_picker/image_picker.dart'; @@ -26,7 +27,7 @@ void main() { setUp(() async { tempDir = await Directory.systemTemp.createTemp('diet_guard_photo_'); originalPlatform = ImagePickerPlatform.instance; - PhotoAttachService.resetForTesting(testDir: tempDir); + PhotoAttachService.resetForTesting(store: FileBlobStore(tempDir)); }); tearDown(() async { diff --git a/app/test/services/sync_service_test.dart b/app/test/services/sync_service_test.dart index 6e856b5..3432cfd 100644 --- a/app/test/services/sync_service_test.dart +++ b/app/test/services/sync_service_test.dart @@ -7,6 +7,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:crdt_sync/crdt_sync.dart'; import 'package:diet_guard_app/models/food_entry.dart'; import 'package:diet_guard_app/models/nutrition.dart'; @@ -121,9 +122,9 @@ void main() { setUp(() async { tempDir = await Directory.systemTemp.createTemp('diet_guard_sync_test_'); - LogStorageService.resetForTesting(testDir: tempDir); - FoodBankService.resetForTesting(testDir: tempDir); - await AppSettingsService.initForTesting(tempDir); + LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); + FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); + await AppSettingsService.initForTesting(FileDocumentStore(tempDir)); }); tearDown(() async { diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index dcd11a8..cb523cc 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/main.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; @@ -10,8 +11,8 @@ void main() { setUp(() async { tempDir = await Directory.systemTemp.createTemp('diet_guard_app_'); - LogStorageService.resetForTesting(testDir: tempDir); - FoodBankService.resetForTesting(testDir: tempDir); + LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); + FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); }); tearDown(() async { diff --git a/app/web/favicon.png b/app/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/app/web/favicon.png differ diff --git a/app/web/icons/Icon-192.png b/app/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/app/web/icons/Icon-192.png differ diff --git a/app/web/icons/Icon-512.png b/app/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/app/web/icons/Icon-512.png differ diff --git a/app/web/icons/Icon-maskable-192.png b/app/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/app/web/icons/Icon-maskable-192.png differ diff --git a/app/web/icons/Icon-maskable-512.png b/app/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/app/web/icons/Icon-maskable-512.png differ diff --git a/app/web/index.html b/app/web/index.html new file mode 100644 index 0000000..4e79933 --- /dev/null +++ b/app/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + diet_guard_app + + + + + + + diff --git a/app/web/manifest.json b/app/web/manifest.json new file mode 100644 index 0000000..5c91eed --- /dev/null +++ b/app/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "diet_guard_app", + "short_name": "diet_guard_app", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +}