mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 13:27:39 +01:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
10364dc1ff
commit
802ee0fe6d
@ -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
|
||||
|
||||
|
||||
@ -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<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@ -19,18 +18,19 @@ Future<void> 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());
|
||||
}
|
||||
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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<LogMealScreen>
|
||||
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<LogMealScreen>
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<void> _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<void> _enqueueSyncBackstop() => enqueueSyncBackstop();
|
||||
|
||||
// coverage:ignore-end
|
||||
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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<SettingsScreen> {
|
||||
_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<SettingsScreen> {
|
||||
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<void> _saveReward() async {
|
||||
@ -128,10 +147,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
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<SettingsScreen> {
|
||||
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<SettingsScreen> {
|
||||
/// to work immediately, with clear confirmation either way.
|
||||
Future<void> _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<SettingsScreen> {
|
||||
Future<void> _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<SettingsScreen> {
|
||||
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<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<SettingsScreen> {
|
||||
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<SettingsScreen> {
|
||||
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),
|
||||
|
||||
@ -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<AppSettingsService> 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<AppSettingsService> initForTesting(Directory testDir) async {
|
||||
final svc = AppSettingsService._(
|
||||
File(p.join(testDir.path, 'app_settings.json')),
|
||||
);
|
||||
static Future<AppSettingsService> initForTesting(DocumentStore store) async {
|
||||
final svc = AppSettingsService._(store);
|
||||
await svc._load();
|
||||
_instance = svc;
|
||||
return svc;
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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
|
||||
|
||||
@ -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<void> 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
|
||||
|
||||
@ -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<bool> 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;
|
||||
|
||||
10
app/lib/services/background_tasks.dart
Normal file
10
app/lib/services/background_tasks.dart
Normal file
@ -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';
|
||||
46
app/lib/services/background_tasks_io.dart
Normal file
46
app/lib/services/background_tasks_io.dart
Normal file
@ -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<void> 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<void> 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
|
||||
54
app/lib/services/background_tasks_web.dart
Normal file
54
app/lib/services/background_tasks_web.dart
Normal file
@ -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<void> 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<void> 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<void> _runCheck() async {
|
||||
try {
|
||||
await checkAndNotify();
|
||||
} on Exception {
|
||||
// A failed check must never take the app down: the next tick retries.
|
||||
}
|
||||
}
|
||||
22
app/lib/services/blob_store.dart
Normal file
22
app/lib/services/blob_store.dart
Normal file
@ -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<String> put(String extension, Uint8List bytes);
|
||||
|
||||
/// Returns the bytes stored under [key], or null when it is gone.
|
||||
Future<Uint8List?> get(String key);
|
||||
}
|
||||
4
app/lib/services/blob_store_factory.dart
Normal file
4
app/lib/services/blob_store_factory.dart
Normal file
@ -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';
|
||||
49
app/lib/services/blob_store_io.dart
Normal file
49
app/lib/services/blob_store_io.dart
Normal file
@ -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 `<documents>/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<String> 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<Uint8List?> 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<BlobStore> openBlobStore() async =>
|
||||
FileBlobStore(await getApplicationDocumentsDirectory());
|
||||
// coverage:ignore-end
|
||||
94
app/lib/services/blob_store_web.dart
Normal file
94
app/lib/services/blob_store_web.dart
Normal file
@ -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<String> 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<Uint8List?> 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<int>) return Uint8List.fromList(value);
|
||||
// Missing locally: recover from the wrapper's disk copy (cleared profile).
|
||||
return _readMirror(key);
|
||||
}
|
||||
|
||||
Future<Uint8List?> _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<void> _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<BlobStore> 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);
|
||||
}
|
||||
32
app/lib/services/desktop_wrapper.dart
Normal file
32
app/lib/services/desktop_wrapper.dart
Normal file
@ -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/<name>`.
|
||||
static const documents = '/documents/';
|
||||
|
||||
/// Prefix for photo-blob reads/writes: `/blobs/<key>`.
|
||||
static const blobs = '/blobs/';
|
||||
|
||||
/// Prefix for the GitHub proxy (see `wrapper_server.dart`).
|
||||
static const github = '/github/';
|
||||
}
|
||||
24
app/lib/services/document_store.dart
Normal file
24
app/lib/services/document_store.dart
Normal file
@ -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<String?> read(String name);
|
||||
|
||||
/// Overwrites [name] with [contents].
|
||||
Future<void> write(String name, String contents);
|
||||
}
|
||||
9
app/lib/services/document_store_factory.dart
Normal file
9
app/lib/services/document_store_factory.dart
Normal file
@ -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';
|
||||
64
app/lib/services/document_store_io.dart
Normal file
64
app/lib/services/document_store_io.dart
Normal file
@ -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<String?> 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<void> 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<DocumentStore> openDocumentStore() async =>
|
||||
FileDocumentStore(await getApplicationDocumentsDirectory());
|
||||
// coverage:ignore-end
|
||||
96
app/lib/services/document_store_web.dart
Normal file
96
app/lib/services/document_store_web.dart
Normal file
@ -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<String?> 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<void> 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<String?> _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<void> _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<DocumentStore> 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);
|
||||
}
|
||||
27
app/lib/services/due_slot_check.dart
Normal file
27
app/lib/services/due_slot_check.dart
Normal file
@ -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<void> 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);
|
||||
}
|
||||
@ -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<FoodBankService> 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<Map<String, FoodBankRecord>> 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<Map<String, FoodBankRecord>> readBank() => _readBank(documentName);
|
||||
|
||||
/// Persists [bank].
|
||||
Future<void> writeBank(Map<String, FoodBankRecord> bank) =>
|
||||
_writeBank(documentName, bank);
|
||||
|
||||
Future<Map<String, FoodBankRecord>> _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<void> writeBank(Map<String, FoodBankRecord> bank) async {
|
||||
await _file.parent.create(recursive: true);
|
||||
Future<void> _writeBank(
|
||||
String name,
|
||||
Map<String, FoodBankRecord> bank,
|
||||
) async {
|
||||
final encoded = <String, Object?>{
|
||||
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<Map<String, FoodBankRecord>> _readManualBank() =>
|
||||
_readBank(manualDocumentName);
|
||||
|
||||
Future<Map<String, FoodBankRecord>> _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 = <String, FoodBankRecord>{};
|
||||
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<String, dynamic>());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> _writeManualBank(Map<String, FoodBankRecord> bank) async {
|
||||
final file = _manualFile;
|
||||
await file.parent.create(recursive: true);
|
||||
final encoded = <String, Object?>{
|
||||
for (final entry in bank.entries) entry.key: entry.value.toJson(),
|
||||
};
|
||||
await file.writeAsString(jsonEncode(encoded));
|
||||
}
|
||||
Future<void> _writeManualBank(Map<String, FoodBankRecord> 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
|
||||
|
||||
5
app/lib/services/github_client_factory.dart
Normal file
5
app/lib/services/github_client_factory.dart
Normal file
@ -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';
|
||||
34
app/lib/services/github_client_factory_io.dart
Normal file
34
app/lib/services/github_client_factory_io.dart
Normal file
@ -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/<id>/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);
|
||||
82
app/lib/services/github_client_factory_web.dart
Normal file
82
app/lib/services/github_client_factory_web.dart
Normal file
@ -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<http.StreamedResponse> 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,
|
||||
);
|
||||
@ -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<void> 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<void> 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<DeviceCodeResponse> 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':
|
||||
|
||||
@ -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<String, List<FoodEntry>>;
|
||||
/// 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<LogStorageService> 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<DayLog> 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<void> writeLog(DayLog log) async {
|
||||
await _file.parent.create(recursive: true);
|
||||
final encoded = <String, Object?>{
|
||||
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.
|
||||
|
||||
28
app/lib/services/notification_backend.dart
Normal file
28
app/lib/services/notification_backend.dart
Normal file
@ -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<void> 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<bool?> requestPermission();
|
||||
|
||||
/// Posts (or replaces) the notification for [slot].
|
||||
Future<void> show(int slot, String title, String body);
|
||||
|
||||
/// Removes the notification for [slot], if one is showing.
|
||||
Future<void> cancel(int slot);
|
||||
}
|
||||
5
app/lib/services/notification_backend_factory.dart
Normal file
5
app/lib/services/notification_backend_factory.dart
Normal file
@ -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';
|
||||
73
app/lib/services/notification_backend_io.dart
Normal file
73
app/lib/services/notification_backend_io.dart
Normal file
@ -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<void> 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<bool?> requestPermission() =>
|
||||
plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.requestNotificationsPermission() ??
|
||||
// coverage:ignore-line
|
||||
Future.value();
|
||||
|
||||
@override
|
||||
Future<void> 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<void> cancel(int slot) => plugin.cancel(id: slot);
|
||||
}
|
||||
|
||||
/// Opens the platform notification backend.
|
||||
NotificationBackend openNotificationBackend() =>
|
||||
LocalNotificationsBackend(FlutterLocalNotificationsPlugin());
|
||||
56
app/lib/services/notification_backend_web.dart
Normal file
56
app/lib/services/notification_backend_web.dart
Normal file
@ -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 = <int, web.Notification>{};
|
||||
|
||||
bool _granted = false;
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {}
|
||||
|
||||
@override
|
||||
Future<bool?> 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<void> 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<void> cancel(int slot) async {
|
||||
_shown.remove(slot)?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the platform notification backend.
|
||||
NotificationBackend openNotificationBackend() => WebNotificationBackend();
|
||||
@ -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<NotificationService> 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<bool?> 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<bool?> 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<void> _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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 `<app documents>/images/<uuid>.<ext>`.
|
||||
Future<BlobStore> _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<String?> 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<Uint8List?> readBytes(String key) async =>
|
||||
(await _resolveStore()).get(key);
|
||||
}
|
||||
|
||||
@ -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/<id>/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<DayLog> 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<void> _syncBudget(GitHubClient client) async {
|
||||
|
||||
final mergedBudgetLog = await syncLog(
|
||||
client: client,
|
||||
deviceId: phoneDeviceId,
|
||||
deviceId: syncDeviceId,
|
||||
pathPrefix: _devicesDir,
|
||||
localLog: budgetToLog(localRecord),
|
||||
encode: encodeBudgetForPush,
|
||||
|
||||
@ -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<String> _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<void> 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<bool> _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,
|
||||
|
||||
34
app/lib/services/token_vault.dart
Normal file
34
app/lib/services/token_vault.dart
Normal file
@ -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<String> 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<bool> 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';
|
||||
5
app/lib/services/token_vault_factory.dart
Normal file
5
app/lib/services/token_vault_factory.dart
Normal file
@ -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';
|
||||
53
app/lib/services/token_vault_io.dart
Normal file
53
app/lib/services/token_vault_io.dart
Normal file
@ -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<String> 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<bool> 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();
|
||||
65
app/lib/services/token_vault_web.dart
Normal file
65
app/lib/services/token_vault_web.dart
Normal file
@ -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<String> 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<bool> 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();
|
||||
10
app/lib/widgets/attached_image.dart
Normal file
10
app/lib/widgets/attached_image.dart
Normal file
@ -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';
|
||||
58
app/lib/widgets/attached_image_io.dart
Normal file
58
app/lib/widgets/attached_image_io.dart
Normal file
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
92
app/lib/widgets/attached_image_web.dart
Normal file
92
app/lib/widgets/attached_image_web.dart
Normal file
@ -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<AttachedImage> createState() => _AttachedImageState();
|
||||
}
|
||||
|
||||
class _AttachedImageState extends State<AttachedImage> {
|
||||
late Future<Uint8List?> _bytes = _load();
|
||||
|
||||
Future<Uint8List?> _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<Uint8List?>(
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 {
|
||||
|
||||
65
app/test/services/blob_store_io_test.dart
Normal file
65
app/test/services/blob_store_io_test.dart
Normal file
@ -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]);
|
||||
});
|
||||
}
|
||||
@ -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()),
|
||||
);
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
BIN
app/web/favicon.png
Normal file
BIN
app/web/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 917 B |
BIN
app/web/icons/Icon-192.png
Normal file
BIN
app/web/icons/Icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
app/web/icons/Icon-512.png
Normal file
BIN
app/web/icons/Icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
BIN
app/web/icons/Icon-maskable-192.png
Normal file
BIN
app/web/icons/Icon-maskable-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
BIN
app/web/icons/Icon-maskable-512.png
Normal file
BIN
app/web/icons/Icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
46
app/web/index.html
Normal file
46
app/web/index.html
Normal file
@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
|
||||
The path provided below has to start and end with a slash "/" in order for
|
||||
it to work correctly.
|
||||
|
||||
For more details:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||
|
||||
This is a placeholder for base href that will be replaced by the value of
|
||||
the `--base-href` argument provided to `flutter build`.
|
||||
-->
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="A new Flutter project.">
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="diet_guard_app">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
|
||||
<title>diet_guard_app</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
You can customize the "flutter_bootstrap.js" script.
|
||||
This is useful to provide a custom configuration to the Flutter loader
|
||||
or to give the user feedback during the initialization process.
|
||||
|
||||
For more details:
|
||||
* https://docs.flutter.dev/platform-integration/web/initialization
|
||||
-->
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
35
app/web/manifest.json
Normal file
35
app/web/manifest.json
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user