From 802ee0fe6d05760aa1f0edcbeb54c532161c7c7e Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 20 Jul 2026 16:31:32 +0200 Subject: [PATCH] app: run the companion app's desktop target as a web build Flutter's Linux embedder manages ~20fps at 3840x2160 -- measured on a stock `flutter create` counter app, so it is the toolkit rather than this code -- where the same Dart rendered by Chrome sustains ~144fps (evidence in ~/todo/docs/desktop-performance-findings.md). The desktop target therefore becomes a Flutter *web* build; Android is untouched. `dart:io` does not fail a web *compile* -- it resolves to a stub that throws at runtime -- so every platform edge moves behind a conditional export rather than being discovered later as a blank white window: - storage: `document_store*.dart`, files on Android and IndexedDB in the browser (mirrored to the desktop wrapper, which is what makes a wiped Chrome profile recoverable). Document names stay the exact pre-web filenames so an installed phone keeps reading its existing log. - photos: `blob_store*.dart` plus `attached_image*.dart`, since `Image.file` cannot resolve an IndexedDB key. The picker itself has web support; what changed is the storage and display model. Camera *capture* is genuinely lost on desktop -- and photos never crossed devices anyway, `imagePath` being stripped before push. - scheduling: `background_tasks*.dart`. A browser has no WorkManager equivalent -- a closed tab runs nothing -- so the desktop runs an in-page 5-minute check while its window is open. That loses nothing on the PC, where `diet-guard-gate.timer` already locks the screen on an unlogged slot, which is strictly stronger than a notification. - notifications: `notification_backend*.dart`, the browser Notifications API in place of flutter_local_notifications. - the GitHub token: `token_vault*.dart`. On web it is *not* held by the browser at all -- flutter_secure_storage's web backend is WebCrypto-wrapped localStorage with the wrap key beside the ciphertext, which is obfuscation, not encryption at rest -- so the desktop wrapper owns it and proxies the API (`github_client_factory*.dart`). `Platform.isAndroid` itself throws on web, so every platform branch now tests `kIsWeb` first; the battery-optimization row (permission_handler, no web implementation) is hidden off Android. The desktop pushes under device id `desktop`, not `phone`: sharing an id would have made the two devices overwrite each other's pushed log on every tick. The Python side enumerates device directories generically, so it needs no change. Tests move onto the same seams -- the file-backed stores keep the atomicity and unreadable-file guarantees where they belong. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/.metadata | 12 +- app/lib/main.dart | 28 ++--- app/lib/screens/history_screen.dart | 11 +- app/lib/screens/log_meal_screen.dart | 33 ++--- app/lib/screens/photo_viewer_screen.dart | 17 ++- app/lib/screens/settings_screen.dart | 117 +++++++++++------- app/lib/services/app_settings_service.dart | 61 +++++---- .../services/background_check_service.dart | 38 ++---- app/lib/services/background_sync_service.dart | 9 +- app/lib/services/background_tasks.dart | 10 ++ app/lib/services/background_tasks_io.dart | 46 +++++++ app/lib/services/background_tasks_web.dart | 54 ++++++++ app/lib/services/blob_store.dart | 22 ++++ app/lib/services/blob_store_factory.dart | 4 + app/lib/services/blob_store_io.dart | 49 ++++++++ app/lib/services/blob_store_web.dart | 94 ++++++++++++++ app/lib/services/desktop_wrapper.dart | 32 +++++ app/lib/services/document_store.dart | 24 ++++ app/lib/services/document_store_factory.dart | 9 ++ app/lib/services/document_store_io.dart | 64 ++++++++++ app/lib/services/document_store_web.dart | 96 ++++++++++++++ app/lib/services/due_slot_check.dart | 27 ++++ app/lib/services/foodbank_service.dart | 94 +++++--------- app/lib/services/github_client_factory.dart | 5 + .../services/github_client_factory_io.dart | 34 +++++ .../services/github_client_factory_web.dart | 82 ++++++++++++ app/lib/services/github_device_auth.dart | 29 ++++- app/lib/services/log_storage_service.dart | 57 ++++----- app/lib/services/notification_backend.dart | 28 +++++ .../notification_backend_factory.dart | 5 + app/lib/services/notification_backend_io.dart | 73 +++++++++++ .../services/notification_backend_web.dart | 56 +++++++++ app/lib/services/notification_service.dart | 91 ++++---------- app/lib/services/photo_attach_service.dart | 58 +++++---- app/lib/services/sync_service.dart | 11 +- app/lib/services/sync_settings.dart | 66 ++++------ app/lib/services/token_vault.dart | 34 +++++ app/lib/services/token_vault_factory.dart | 5 + app/lib/services/token_vault_io.dart | 53 ++++++++ app/lib/services/token_vault_web.dart | 65 ++++++++++ app/lib/widgets/attached_image.dart | 10 ++ app/lib/widgets/attached_image_io.dart | 58 +++++++++ app/lib/widgets/attached_image_web.dart | 92 ++++++++++++++ app/lib/widgets/photo_attach_field.dart | 32 ++--- app/pubspec.lock | 58 ++++++--- app/pubspec.yaml | 2 + app/test/screens/calendar_screen_test.dart | 5 +- app/test/screens/edit_entry_screen_test.dart | 5 +- app/test/screens/food_bank_screen_test.dart | 5 +- app/test/screens/history_screen_test.dart | 3 +- .../screens/log_meal_screen_sync_test.dart | 5 +- app/test/screens/log_meal_screen_test.dart | 10 +- .../screens/meal_builder_screen_test.dart | 8 +- app/test/screens/settings_screen_test.dart | 7 +- .../services/app_settings_service_test.dart | 43 +++---- .../background_sync_service_test.dart | 7 +- app/test/services/blob_store_io_test.dart | 65 ++++++++++ ...ice_test.dart => due_slot_check_test.dart} | 10 +- app/test/services/foodbank_service_test.dart | 13 +- .../services/log_storage_service_test.dart | 3 +- .../services/notification_service_test.dart | 13 +- .../services/photo_attach_service_test.dart | 3 +- app/test/services/sync_service_test.dart | 7 +- app/test/widget_test.dart | 5 +- app/web/favicon.png | Bin 0 -> 917 bytes app/web/icons/Icon-192.png | Bin 0 -> 5292 bytes app/web/icons/Icon-512.png | Bin 0 -> 8252 bytes app/web/icons/Icon-maskable-192.png | Bin 0 -> 5594 bytes app/web/icons/Icon-maskable-512.png | Bin 0 -> 20998 bytes app/web/index.html | 46 +++++++ app/web/manifest.json | 35 ++++++ 71 files changed, 1745 insertions(+), 508 deletions(-) create mode 100644 app/lib/services/background_tasks.dart create mode 100644 app/lib/services/background_tasks_io.dart create mode 100644 app/lib/services/background_tasks_web.dart create mode 100644 app/lib/services/blob_store.dart create mode 100644 app/lib/services/blob_store_factory.dart create mode 100644 app/lib/services/blob_store_io.dart create mode 100644 app/lib/services/blob_store_web.dart create mode 100644 app/lib/services/desktop_wrapper.dart create mode 100644 app/lib/services/document_store.dart create mode 100644 app/lib/services/document_store_factory.dart create mode 100644 app/lib/services/document_store_io.dart create mode 100644 app/lib/services/document_store_web.dart create mode 100644 app/lib/services/due_slot_check.dart create mode 100644 app/lib/services/github_client_factory.dart create mode 100644 app/lib/services/github_client_factory_io.dart create mode 100644 app/lib/services/github_client_factory_web.dart create mode 100644 app/lib/services/notification_backend.dart create mode 100644 app/lib/services/notification_backend_factory.dart create mode 100644 app/lib/services/notification_backend_io.dart create mode 100644 app/lib/services/notification_backend_web.dart create mode 100644 app/lib/services/token_vault.dart create mode 100644 app/lib/services/token_vault_factory.dart create mode 100644 app/lib/services/token_vault_io.dart create mode 100644 app/lib/services/token_vault_web.dart create mode 100644 app/lib/widgets/attached_image.dart create mode 100644 app/lib/widgets/attached_image_io.dart create mode 100644 app/lib/widgets/attached_image_web.dart create mode 100644 app/test/services/blob_store_io_test.dart rename app/test/services/{background_check_service_test.dart => due_slot_check_test.dart} (86%) create mode 100644 app/web/favicon.png create mode 100644 app/web/icons/Icon-192.png create mode 100644 app/web/icons/Icon-512.png create mode 100644 app/web/icons/Icon-maskable-192.png create mode 100644 app/web/icons/Icon-maskable-512.png create mode 100644 app/web/index.html create mode 100644 app/web/manifest.json 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 0000000000000000000000000000000000000000..8aaa46ac1ae21512746f852a42ba87e4165dfdd1 GIT binary patch literal 917 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|I14-?iy0X7 zltGxWVyS%@P(fs7NJL45ua8x7ey(0(N`6wRUPW#JP&EUCO@$SZnVVXYs8ErclUHn2 zVXFjIVFhG^g!Ppaz)DK8ZIvQ?0~DO|i&7O#^-S~(l1AfjnEK zjFOT9D}DX)@^Za$W4-*MbbUihOG|wNBYh(yU7!lx;>x^|#0uTKVr7USFmqf|i<65o z3raHc^AtelCMM;Vme?vOfh>Xph&xL%(-1c06+^uR^q@XSM&D4+Kp$>4P^%3{)XKjo zGZknv$b36P8?Z_gF{nK@`XI}Z90TzwSQO}0J1!f2c(B=V`5aP@1P1a|PZ!4!3&Gl8 zTYqUsf!gYFyJnXpu0!n&N*SYAX-%d(5gVjrHJWqXQshj@!Zm{!01WsQrH~9=kTxW#6SvuapgMqt>$=j#%eyGrQzr zP{L-3gsMA^$I1&gsBAEL+vxi1*Igl=8#8`5?A-T5=z-sk46WA1IUT)AIZHx1rdUrf zVJrJn<74DDw`j)Ki#gt}mIT-Q`XRa2-jQXQoI%w`nb|XblvzK${ZzlV)m-XcwC(od z71_OEC5Bt9GEXosOXaPTYOia#R4ID2TiU~`zVMl08TV_C%DnU4^+HE>9(CE4D6?Fz oujB08i7adh9xk7*FX66dWH6F5TM;?E2b5PlUHx3vIVCg!0Dx9vYXATM literal 0 HcmV?d00001 diff --git a/app/web/icons/Icon-192.png b/app/web/icons/Icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b749bfef07473333cf1dd31e9eed89862a5d52aa GIT binary patch literal 5292 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 literal 0 HcmV?d00001 diff --git a/app/web/icons/Icon-512.png b/app/web/icons/Icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..88cfd48dff1169879ba46840804b412fe02fefd6 GIT binary patch literal 8252 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 literal 0 HcmV?d00001 diff --git a/app/web/icons/Icon-maskable-192.png b/app/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000000000000000000000000000000000..eb9b4d76e525556d5d89141648c724331630325d GIT binary patch literal 5594 zcmdT|`#%%j|KDb2V@0DPm$^(Lx5}lO%Yv(=e*7hl@QqKS50#~#^IQPxBmuh|i9sXnt4ch@VT0F7% zMtrs@KWIOo+QV@lSs66A>2pz6-`9Jk=0vv&u?)^F@HZ)-6HT=B7LF;rdj zskUyBfbojcX#CS>WrIWo9D=DIwcXM8=I5D{SGf$~=gh-$LwY?*)cD%38%sCc?5OsX z-XfkyL-1`VavZ?>(pI-xp-kYq=1hsnyP^TLb%0vKRSo^~r{x?ISLY1i7KjSp z*0h&jG(Rkkq2+G_6eS>n&6>&Xk+ngOMcYrk<8KrukQHzfx675^^s$~<@d$9X{VBbg z2Fd4Z%g`!-P}d#`?B4#S-9x*eNlOVRnDrn#jY@~$jfQ-~3Od;A;x-BI1BEDdvr`pI z#D)d)!2_`GiZOUu1crb!hqH=ezs0qk<_xDm_Kkw?r*?0C3|Io6>$!kyDl;eH=aqg$B zsH_|ZD?jP2dc=)|L>DZmGyYKa06~5?C2Lc0#D%62p(YS;%_DRCB1k(+eLGXVMe+=4 zkKiJ%!N6^mxqM=wq`0+yoE#VHF%R<{mMamR9o_1JH8jfnJ?NPLs$9U!9!dq8 z0B{dI2!M|sYGH&9TAY34OlpIsQ4i5bnbG>?cWwat1I13|r|_inLE?FS@Hxdxn_YZN z3jfUO*X9Q@?HZ>Q{W0z60!bbGh557XIKu1?)u|cf%go`pwo}CD=0tau-}t@R2OrSH zQzZr%JfYa`>2!g??76=GJ$%ECbQh7Q2wLRp9QoyiRHP7VE^>JHm>9EqR3<$Y=Z1K^SHuwxCy-5@z3 zVM{XNNm}yM*pRdLKp??+_2&!bp#`=(Lh1vR{~j%n;cJv~9lXeMv)@}Odta)RnK|6* zC+IVSWumLo%{6bLDpn)Gz>6r&;Qs0^+Sz_yx_KNz9Dlt^ax`4>;EWrIT#(lJ_40<= z750fHZ7hI{}%%5`;lwkI4<_FJw@!U^vW;igL0k+mK)-j zYuCK#mCDK3F|SC}tC2>m$ZCqNB7ac-0UFBJ|8RxmG@4a4qdjvMzzS&h9pQmu^x&*= zGvapd1#K%Da&)8f?<9WN`2H^qpd@{7In6DNM&916TRqtF4;3`R|Nhwbw=(4|^Io@T zIjoR?tB8d*sO>PX4vaIHF|W;WVl6L1JvSmStgnRQq zTX4(>1f^5QOAH{=18Q2Vc1JI{V=yOr7yZJf4Vpfo zeHXdhBe{PyY;)yF;=ycMW@Kb>t;yE>;f79~AlJ8k`xWucCxJfsXf2P72bAavWL1G#W z;o%kdH(mYCM{$~yw4({KatNGim49O2HY6O07$B`*K7}MvgI=4x=SKdKVb8C$eJseA$tmSFOztFd*3W`J`yIB_~}k%Sd_bPBK8LxH)?8#jM{^%J_0|L z!gFI|68)G}ex5`Xh{5pB%GtlJ{Z5em*e0sH+sU1UVl7<5%Bq+YrHWL7?X?3LBi1R@_)F-_OqI1Zv`L zb6^Lq#H^2@d_(Z4E6xA9Z4o3kvf78ZDz!5W1#Mp|E;rvJz&4qj2pXVxKB8Vg0}ek%4erou@QM&2t7Cn5GwYqy%{>jI z)4;3SAgqVi#b{kqX#$Mt6L8NhZYgonb7>+r#BHje)bvaZ2c0nAvrN3gez+dNXaV;A zmyR0z@9h4@6~rJik-=2M-T+d`t&@YWhsoP_XP-NsVO}wmo!nR~QVWU?nVlQjNfgcTzE-PkfIX5G z1?&MwaeuzhF=u)X%Vpg_e@>d2yZwxl6-r3OMqDn8_6m^4z3zG##cK0Fsgq8fcvmhu z{73jseR%X%$85H^jRAcrhd&k!i^xL9FrS7qw2$&gwAS8AfAk#g_E_tP;x66fS`Mn@SNVrcn_N;EQm z`Mt3Z%rw%hDqTH-s~6SrIL$hIPKL5^7ejkLTBr46;pHTQDdoErS(B>``t;+1+M zvU&Se9@T_BeK;A^p|n^krIR+6rH~BjvRIugf`&EuX9u69`9C?9ANVL8l(rY6#mu^i z=*5Q)-%o*tWl`#b8p*ZH0I}hn#gV%|jt6V_JanDGuekR*-wF`u;amTCpGG|1;4A5$ zYbHF{?G1vv5;8Ph5%kEW)t|am2_4ik!`7q{ymfHoe^Z99c|$;FAL+NbxE-_zheYbV z3hb0`uZGTsgA5TG(X|GVDSJyJxsyR7V5PS_WSnYgwc_D60m7u*x4b2D79r5UgtL18 zcCHWk+K6N1Pg2c;0#r-)XpwGX?|Iv)^CLWqwF=a}fXUSM?n6E;cCeW5ER^om#{)Jr zJR81pkK?VoFm@N-s%hd7@hBS0xuCD0-UDVLDDkl7Ck=BAj*^ps`393}AJ+Ruq@fl9 z%R(&?5Nc3lnEKGaYMLmRzKXow1+Gh|O-LG7XiNxkG^uyv zpAtLINwMK}IWK65hOw&O>~EJ}x@lDBtB`yKeV1%GtY4PzT%@~wa1VgZn7QRwc7C)_ zpEF~upeDRg_<#w=dLQ)E?AzXUQpbKXYxkp>;c@aOr6A|dHA?KaZkL0svwB^U#zmx0 zzW4^&G!w7YeRxt<9;d@8H=u(j{6+Uj5AuTluvZZD4b+#+6Rp?(yJ`BC9EW9!b&KdPvzJYe5l7 zMJ9aC@S;sA0{F0XyVY{}FzW0Vh)0mPf_BX82E+CD&)wf2!x@{RO~XBYu80TONl3e+ zA7W$ra6LcDW_j4s-`3tI^VhG*sa5lLc+V6ONf=hO@q4|p`CinYqk1Ko*MbZ6_M05k zSwSwkvu;`|I*_Vl=zPd|dVD0lh&Ha)CSJJvV{AEdF{^Kn_Yfsd!{Pc1GNgw}(^~%)jk5~0L~ms|Rez1fiK~s5t(p1ci5Gq$JC#^JrXf?8 z-Y-Zi_Hvi>oBzV8DSRG!7dm|%IlZg3^0{5~;>)8-+Nk&EhAd(}s^7%MuU}lphNW9Q zT)DPo(ob{tB7_?u;4-qGDo!sh&7gHaJfkh43QwL|bbFVi@+oy;i;M zM&CP^v~lx1U`pi9PmSr&Mc<%HAq0DGH?Ft95)WY`P?~7O z`O^Nr{Py9M#Ls4Y7OM?e%Y*Mvrme%=DwQaye^Qut_1pOMrg^!5u(f9p(D%MR%1K>% zRGw%=dYvw@)o}Fw@tOtPjz`45mfpn;OT&V(;z75J*<$52{sB65$gDjwX3Xa!x_wE- z!#RpwHM#WrO*|~f7z}(}o7US(+0FYLM}6de>gQdtPazXz?OcNv4R^oYLJ_BQOd_l172oSK$6!1r@g+B@0ofJ4*{>_AIxfe-#xp>(1 z@Y3Nfd>fmqvjL;?+DmZk*KsfXJf<%~(gcLwEez%>1c6XSboURUh&k=B)MS>6kw9bY z{7vdev7;A}5fy*ZE23DS{J?8at~xwVk`pEwP5^k?XMQ7u64;KmFJ#POzdG#np~F&H ze-BUh@g54)dsS%nkBb}+GuUEKU~pHcYIg4vSo$J(J|U36bs0Use+3A&IMcR%6@jv$ z=+QI+@wW@?iu}Hpyzlvj-EYeop{f65GX0O%>w#0t|V z1-svWk`hU~m`|O$kw5?Yn5UhI%9P-<45A(v0ld1n+%Ziq&TVpBcV9n}L9Tus-TI)f zd_(g+nYCDR@+wYNQm1GwxhUN4tGMLCzDzPqY$~`l<47{+l<{FZ$L6(>J)|}!bi<)| zE35dl{a2)&leQ@LlDxLQOfUDS`;+ZQ4ozrleQwaR-K|@9T{#hB5Z^t#8 zC-d_G;B4;F#8A2EBL58s$zF-=SCr`P#z zNCTnHF&|X@q>SkAoYu>&s9v@zCpv9lLSH-UZzfhJh`EZA{X#%nqw@@aW^vPcfQrlPs(qQxmC|4tp^&sHy!H!2FH5eC{M@g;ElWNzlb-+ zxpfc0m4<}L){4|RZ>KReag2j%Ot_UKkgpJN!7Y_y3;Ssz{9 z!K3isRtaFtQII5^6}cm9RZd5nTp9psk&u1C(BY`(_tolBwzV_@0F*m%3G%Y?2utyS zY`xM0iDRT)yTyYukFeGQ&W@ReM+ADG1xu@ruq&^GK35`+2r}b^V!m1(VgH|QhIPDE X>c!)3PgKfL&lX^$Z>Cpu&6)6jvi^Z! literal 0 HcmV?d00001 diff --git a/app/web/icons/Icon-maskable-512.png b/app/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d69c56691fbdb0b7efa65097c7cc1edac12a6d3e GIT binary patch literal 20998 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 MPgg&ebxsLQ025IeI{*Lx literal 0 HcmV?d00001 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" + } + ] +}