diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts
index 1e1a8a0..8ab020a 100644
--- a/app/android/app/build.gradle.kts
+++ b/app/android/app/build.gradle.kts
@@ -12,6 +12,9 @@ android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
+ // flutter_local_notifications requires this (java.time APIs on
+ // pre-API-26 devices via backport).
+ isCoreLibraryDesugaringEnabled = true
}
defaultConfig {
@@ -30,10 +33,22 @@ android {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
+ // AGP 9 defaults release minification (and resource shrinking,
+ // which requires it) to true. R8 then strips
+ // WorkDatabase_Impl's reflection-only no-arg constructor (no
+ // keep rule covers it), crashing every release launch with
+ // NoSuchMethodException. Disable both until proper
+ // Room/WorkManager keep rules are added.
+ isMinifyEnabled = false
+ isShrinkResources = false
}
}
}
+dependencies {
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
+}
+
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml
index e8a7642..497993a 100644
--- a/app/android/app/src/main/AndroidManifest.xml
+++ b/app/android/app/src/main/AndroidManifest.xml
@@ -3,6 +3,26 @@
intent, but declaring this avoids picker failures on OEMs that check
for it before honoring the intent. -->
+
+
+
+
+
+
+
+
+
+
+
main() async {
WidgetsFlutterBinding.ensureInitialized();
await LogStorageService.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.
+ // coverage:ignore-start
+ if (Platform.isAndroid || Platform.isIOS) {
+ await Workmanager().initialize(backgroundCheckCallbackDispatcher);
+ await Workmanager().registerPeriodicTask(
+ backgroundCheckTaskName,
+ backgroundCheckTaskName,
+ frequency: const Duration(minutes: 15),
+ );
+ }
+ // coverage:ignore-end
runApp(const DietGuardApp());
}
diff --git a/app/lib/screens/settings_screen.dart b/app/lib/screens/settings_screen.dart
index ca56456..008e32d 100644
--- a/app/lib/screens/settings_screen.dart
+++ b/app/lib/screens/settings_screen.dart
@@ -1,26 +1,41 @@
-/// GitHub sync configuration: paste a PAT, test the connection, and trigger
-/// a manual sync. Auto-sync (app launch + lifecycle pause/resume) lives in
-/// [LogMealScreen] and is silent on failure -- this screen is where errors
-/// get surfaced, via [SnackBar].
+/// GitHub sync configuration. Primary path: "Connect GitHub" runs the OAuth
+/// **device flow** (authorize in a browser, no token pasting). A manually
+/// pasted PAT remains as a fallback under "Advanced". Auto-sync (app launch
+/// + lifecycle pause/resume) lives in [LogMealScreen] and is silent on
+/// failure -- this screen is where errors get surfaced, as inline status
+/// text.
library;
import 'dart:async';
import 'package:diet_guard_app/screens/log_meal_screen.dart';
import 'package:diet_guard_app/services/github_client.dart';
+import 'package:diet_guard_app/services/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/material.dart';
+import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
+import 'package:permission_handler/permission_handler.dart';
+import 'package:url_launcher/url_launcher.dart';
/// Screen for configuring and triggering cross-device sync.
class SettingsScreen extends StatefulWidget {
/// Creates a [SettingsScreen].
- const SettingsScreen({super.key, this.httpClient});
+ const SettingsScreen({
+ super.key,
+ this.httpClient,
+ this.requestBatteryExemption,
+ });
/// Injectable HTTP client; tests pass a [MockClient].
final http.Client? httpClient;
+ /// Injectable battery-optimization-exemption request; tests pass a fake.
+ /// Production defaults to
+ /// `Permission.ignoreBatteryOptimizations.request()`.
+ final Future Function()? requestBatteryExemption;
+
@override
State createState() => _SettingsScreenState();
}
@@ -29,8 +44,10 @@ class _SettingsScreenState extends State {
final _ownerController = TextEditingController();
final _repoController = TextEditingController();
final _tokenController = TextEditingController();
+ final _clientIdController = TextEditingController();
bool _loading = true;
bool _busy = false;
+ String? _status;
@override
void initState() {
@@ -52,6 +69,7 @@ class _SettingsScreenState extends State {
_ownerController.text = settings.owner;
_repoController.text = settings.repo;
_tokenController.text = settings.token;
+ _clientIdController.text = settings.clientId;
setState(() => _loading = false);
}
@@ -60,6 +78,7 @@ class _SettingsScreenState extends State {
_ownerController.dispose();
_repoController.dispose();
_tokenController.dispose();
+ _clientIdController.dispose();
super.dispose();
}
@@ -67,13 +86,71 @@ class _SettingsScreenState extends State {
owner: _ownerController.text.trim(),
repo: _repoController.text.trim(),
token: _tokenController.text.trim(),
+ clientId: _clientIdController.text.trim(),
);
void _showMessage(String message) {
if (!mounted) return;
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(message)),
+ setState(() => _status = message);
+ }
+
+ /// Runs the OAuth device flow and, on success, fills in the token field.
+ Future _connectGitHub() async {
+ var clientId = _clientIdController.text.trim();
+ if (clientId.isEmpty) {
+ final entered = await showDialog(
+ context: context,
+ builder: (_) => const _ClientIdSetupDialog(),
+ );
+ if (entered == null || entered.isEmpty) return;
+ clientId = entered;
+ if (!mounted) return;
+ setState(() => _clientIdController.text = clientId);
+ await _currentSettings().save();
+ }
+ final auth = GitHubDeviceAuth(
+ clientId: clientId,
+ httpClient: widget.httpClient,
);
+ try {
+ final device = await auth.requestDeviceCode();
+ if (!mounted) return;
+ final token = await showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (_) => _DeviceCodeDialog(device: device, auth: auth),
+ );
+ if (token != null && token.isNotEmpty) {
+ setState(() => _tokenController.text = token);
+ _showMessage('Connected — syncing…');
+ await _currentSettings().save();
+ await _syncAfterConnect();
+ }
+ } on Exception catch (e) {
+ _showMessage('Could not start device flow: $e');
+ } finally {
+ auth.close();
+ }
+ }
+
+ /// Runs a sync right after connecting so the device-flow token is proven
+ /// to work immediately, with clear confirmation either way.
+ Future _syncAfterConnect() async {
+ final settings = _currentSettings();
+ final client = GitHubClient(
+ owner: settings.owner,
+ repo: settings.repo,
+ token: settings.token,
+ httpClient: widget.httpClient,
+ );
+ try {
+ await runSync(client);
+ _showMessage('Connected and synced.');
+ } on Exception catch (e) {
+ _showMessage('Connected, but sync failed: $e');
+ } finally {
+ client.close();
+ }
}
Future _save() async {
@@ -125,6 +202,25 @@ class _SettingsScreenState extends State {
}
}
+ /// Requests exemption from OEM battery optimization (MIUI, some Samsung
+ /// configs), which can otherwise degrade the 15-minute background-check
+ /// reliability well past its accepted ±15 min target.
+ Future _requestBatteryExemption() async {
+ final request =
+ widget.requestBatteryExemption ??
+ () => Permission.ignoreBatteryOptimizations.request();
+ try {
+ final status = await request();
+ _showMessage(
+ status.isGranted
+ ? 'Battery optimization exemption granted.'
+ : 'Exemption not granted -- notifications may be delayed.',
+ );
+ } on Exception catch (e) {
+ _showMessage('Could not request exemption: $e');
+ }
+ }
+
@override
Widget build(BuildContext context) {
if (_loading) {
@@ -132,49 +228,257 @@ class _SettingsScreenState extends State {
}
return Scaffold(
appBar: AppBar(title: const Text('Sync settings')),
- body: Padding(
+ body: ListView(
padding: const EdgeInsets.all(16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- TextField(
- controller: _ownerController,
- decoration: const InputDecoration(labelText: 'GitHub owner'),
- ),
- const SizedBox(height: 8),
- TextField(
- controller: _repoController,
- decoration: const InputDecoration(labelText: 'Repo'),
- ),
- const SizedBox(height: 8),
- TextField(
- controller: _tokenController,
- obscureText: true,
- decoration: const InputDecoration(
- labelText: 'Personal access token',
+ children: [
+ Text(
+ 'Authorize in your browser — no token to paste. Syncs to '
+ 'kuhyx/diet-guard-sync by default.',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ const SizedBox(height: 12),
+ FilledButton.icon(
+ onPressed: _connectGitHub,
+ icon: const Icon(Icons.login),
+ label: const Text('Connect GitHub'),
+ ),
+ const SizedBox(height: 16),
+ TextField(
+ controller: _ownerController,
+ decoration: const InputDecoration(labelText: 'GitHub owner'),
+ ),
+ const SizedBox(height: 8),
+ TextField(
+ controller: _repoController,
+ decoration: const InputDecoration(labelText: 'Repo'),
+ ),
+ const SizedBox(height: 8),
+ ExpansionTile(
+ title: const Text('Advanced'),
+ tilePadding: EdgeInsets.zero,
+ childrenPadding: const EdgeInsets.only(bottom: 8),
+ children: [
+ TextField(
+ controller: _clientIdController,
+ decoration: const InputDecoration(
+ labelText: 'OAuth App client id',
+ helperText: 'Needed for the Connect GitHub button',
+ ),
),
- ),
+ const SizedBox(height: 8),
+ TextField(
+ controller: _tokenController,
+ obscureText: true,
+ decoration: const InputDecoration(
+ labelText: 'Personal access token (fallback)',
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+ Wrap(
+ spacing: 8,
+ children: [
+ ElevatedButton(
+ onPressed: _busy ? null : _save,
+ child: const Text('Save'),
+ ),
+ OutlinedButton(
+ onPressed: _busy ? null : _testConnection,
+ child: const Text('Test connection'),
+ ),
+ ElevatedButton(
+ onPressed: _busy ? null : _syncNow,
+ child: const Text('Sync now'),
+ ),
+ ],
+ ),
+ const SizedBox(height: 24),
+ const Divider(),
+ const SizedBox(height: 8),
+ 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.',
+ 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'),
+ ),
+ if (_status != null) ...[
const SizedBox(height: 16),
- Wrap(
- spacing: 8,
- children: [
- ElevatedButton(
- onPressed: _busy ? null : _save,
- child: const Text('Save'),
- ),
- OutlinedButton(
- onPressed: _busy ? null : _testConnection,
- child: const Text('Test connection'),
- ),
- ElevatedButton(
- onPressed: _busy ? null : _syncNow,
- child: const Text('Sync now'),
- ),
- ],
- ),
+ Text(_status!, style: Theme.of(context).textTheme.bodyMedium),
],
- ),
+ ],
),
);
}
}
+
+/// Dialog shown when "Connect GitHub" is tapped with no OAuth App client id
+/// configured yet. Explains what it is, how to get one, and lets the user
+/// paste it in directly — rather than leaving them to discover a buried
+/// "Advanced" field on their own. Pops the trimmed client id, or null if
+/// cancelled.
+class _ClientIdSetupDialog extends StatefulWidget {
+ const _ClientIdSetupDialog();
+
+ @override
+ State<_ClientIdSetupDialog> createState() => _ClientIdSetupDialogState();
+}
+
+class _ClientIdSetupDialogState extends State<_ClientIdSetupDialog> {
+ final _controller = TextEditingController();
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return AlertDialog(
+ title: const Text('One-time GitHub setup needed'),
+ content: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ 'Diet Guard signs in via a GitHub OAuth App (no password '
+ 'typed into this app). You only have to set this up once:',
+ ),
+ const SizedBox(height: 12),
+ const Text(
+ '1. On any device, open '
+ 'github.com/settings/developers → "New OAuth App".\n'
+ '2. Name/Homepage/Callback URL can be anything (device flow '
+ "doesn't use the callback) — e.g. "
+ '"Diet Guard" and your GitHub profile URL.\n'
+ '3. Check "Enable Device Flow", then click "Register '
+ 'application".\n'
+ "4. Copy the Client ID shown on the app's page and paste it "
+ 'below.',
+ ),
+ const SizedBox(height: 12),
+ const Text(
+ 'When you connect below, log in with the GitHub account that '
+ 'has write access to kuhyx/diet-guard-sync.',
+ ),
+ const SizedBox(height: 16),
+ TextField(
+ controller: _controller,
+ autofocus: true,
+ decoration: const InputDecoration(labelText: 'Client ID'),
+ ),
+ ],
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('Cancel'),
+ ),
+ FilledButton(
+ onPressed: () {
+ final id = _controller.text.trim();
+ if (id.isNotEmpty) Navigator.of(context).pop(id);
+ },
+ child: const Text('Continue'),
+ ),
+ ],
+ );
+ }
+}
+
+/// Dialog shown during the device flow: displays the user code, opens the
+/// verification page, and polls until authorized — popping the token (or
+/// null if cancelled / failed).
+class _DeviceCodeDialog extends StatefulWidget {
+ const _DeviceCodeDialog({required this.device, required this.auth});
+
+ final DeviceCodeResponse device;
+ final GitHubDeviceAuth auth;
+
+ @override
+ State<_DeviceCodeDialog> createState() => _DeviceCodeDialogState();
+}
+
+class _DeviceCodeDialogState extends State<_DeviceCodeDialog> {
+ String? _error;
+
+ @override
+ void initState() {
+ super.initState();
+ unawaited(_poll());
+ }
+
+ Future _poll() async {
+ try {
+ final token = await widget.auth.pollForToken(widget.device);
+ if (mounted) Navigator.of(context).pop(token);
+ } on Exception catch (e) {
+ if (mounted) setState(() => _error = '$e');
+ }
+ }
+
+ Future _openPage() async {
+ await Clipboard.setData(ClipboardData(text: widget.device.userCode));
+ await launchUrl(
+ Uri.parse(widget.device.verificationUri),
+ mode: LaunchMode.externalApplication,
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return AlertDialog(
+ title: const Text('Authorize on GitHub'),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text('Enter this code on GitHub:'),
+ const SizedBox(height: 8),
+ SelectableText(
+ widget.device.userCode,
+ style: Theme.of(context).textTheme.headlineSmall,
+ ),
+ const SizedBox(height: 16),
+ if (_error == null)
+ const Row(
+ children: [
+ SizedBox(
+ width: 16,
+ height: 16,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ ),
+ SizedBox(width: 12),
+ Expanded(child: Text('Waiting for authorization…')),
+ ],
+ )
+ else
+ Text(_error!, style: const TextStyle(color: Colors.red)),
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('Cancel'),
+ ),
+ FilledButton.icon(
+ onPressed: _openPage,
+ icon: const Icon(Icons.open_in_new),
+ label: const Text('Open GitHub & copy code'),
+ ),
+ ],
+ );
+ }
+}
diff --git a/app/lib/services/background_check_service.dart b/app/lib/services/background_check_service.dart
new file mode 100644
index 0000000..3a476b5
--- /dev/null
+++ b/app/lib/services/background_check_service.dart
@@ -0,0 +1,52 @@
+/// 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.
+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';
+import 'package:workmanager/workmanager.dart';
+
+/// Unique WorkManager task name for the periodic due-slot check.
+const String backgroundCheckTaskName = 'diet_guard.background_check';
+
+/// Reads the local log, computes today's due-but-unlogged slots as of
+/// [now] (defaults to the real clock), and syncs notifications to match.
+///
+/// Extracted from [backgroundCheckCallbackDispatcher] so this logic is
+/// unit-testable without the real WorkManager plugin, which only runs as a
+/// true background isolate on-device. [now] is injectable for the same
+/// reason `slot.dart`'s functions are clock-free: a test should not depend
+/// on the wall-clock hour it happens to run at.
+Future checkAndNotify({DateTime? now}) async {
+ await LogStorageService.init();
+ await NotificationService.init();
+ final logged = await LogStorageService.instance.loggedSlotsToday();
+ final due = missingSlots(now ?? DateTime.now(), logged);
+ await NotificationService.instance.syncToSlots(due);
+}
+
+/// WorkManager entry point invoked by the OS on each periodic tick.
+///
+/// Deliberately thin: all logic lives in [checkAndNotify] so it stays unit
+/// testable. This dispatcher itself is integration-only -- manually
+/// smoke-tested on-device (see the project plan's verification section),
+/// not chased for unit coverage.
+// coverage:ignore-start
+@pragma('vm:entry-point')
+void backgroundCheckCallbackDispatcher() {
+ Workmanager().executeTask((taskName, inputData) async {
+ if (taskName == backgroundCheckTaskName) {
+ await checkAndNotify();
+ }
+ return true;
+ });
+}
+
+// coverage:ignore-end
diff --git a/app/lib/services/github_device_auth.dart b/app/lib/services/github_device_auth.dart
new file mode 100644
index 0000000..7db6787
--- /dev/null
+++ b/app/lib/services/github_device_auth.dart
@@ -0,0 +1,154 @@
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:http/http.dart' as http;
+
+/// 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 {
+ /// Creates a [DeviceCodeResponse].
+ const DeviceCodeResponse({
+ required this.deviceCode,
+ required this.userCode,
+ required this.verificationUri,
+ required this.interval,
+ required this.expiresIn,
+ });
+
+ /// Parses GitHub's `POST /login/device/code` response body.
+ factory DeviceCodeResponse.fromJson(Map json) {
+ return DeviceCodeResponse(
+ deviceCode: json['device_code'] as String,
+ userCode: json['user_code'] as String,
+ verificationUri: json['verification_uri'] as String,
+ interval: (json['interval'] as int?) ?? 5,
+ expiresIn: (json['expires_in'] as int?) ?? 900,
+ );
+ }
+
+ /// Opaque code the client polls with (not shown to the user).
+ final String deviceCode;
+
+ /// Short code the user enters on the verification page.
+ final String userCode;
+
+ /// Page the user opens to enter [userCode] (github.com/login/device).
+ final String verificationUri;
+
+ /// Minimum seconds to wait between polls.
+ final int interval;
+
+ /// Seconds until [deviceCode] expires.
+ final int expiresIn;
+}
+
+/// Raised when the device-flow authorization fails or is declined.
+class DeviceAuthException implements Exception {
+ /// Creates a [DeviceAuthException] for the given GitHub error [code].
+ DeviceAuthException(this.code, this.message);
+
+ /// GitHub error code, e.g. `access_denied`, `expired_token`.
+ final String code;
+
+ /// Human-readable description of [code].
+ final String message;
+
+ @override
+ String toString() => 'DeviceAuthException($code): $message';
+}
+
+/// Implements the GitHub OAuth **Device Flow** so the user can authorize the
+/// app by visiting a URL and entering a short code — no token pasting.
+///
+/// Device flow needs only a public `client_id` (no client secret), which
+/// makes it safe for a distributed app. The resulting access token is then
+/// used exactly like a PAT by [GitHubClient].
+///
+/// References:
+/// - POST https://github.com/login/device/code
+/// - POST https://github.com/login/oauth/access_token
+class GitHubDeviceAuth {
+ /// Creates a [GitHubDeviceAuth] for the given OAuth App [clientId].
+ GitHubDeviceAuth({
+ required this.clientId,
+ this.scope = 'repo',
+ http.Client? httpClient,
+ Future Function(Duration)? delay,
+ }) : _http = httpClient ?? http.Client(),
+ // Indirection so tests can skip real waiting between polls.
+ _delay = delay ?? Future.delayed;
+
+ /// The GitHub OAuth App's public client id.
+ final String clientId;
+
+ /// OAuth scope requested. `repo` is required for private-repo contents.
+ final String scope;
+
+ final http.Client _http;
+ final Future Function(Duration) _delay;
+
+ static const _deviceCodeUrl = 'https://github.com/login/device/code';
+ static const _tokenUrl = 'https://github.com/login/oauth/access_token';
+ static const _grantType = 'urn:ietf:params:oauth:grant-type:device_code';
+
+ /// Step 1: ask GitHub for a device + user code.
+ Future requestDeviceCode() async {
+ final res = await _http.post(
+ Uri.parse(_deviceCodeUrl),
+ headers: const {'Accept': 'application/json'},
+ body: {'client_id': clientId, 'scope': scope},
+ );
+ if (res.statusCode != 200) {
+ throw DeviceAuthException('http_${res.statusCode}', res.body);
+ }
+ return DeviceCodeResponse.fromJson(
+ jsonDecode(res.body) as Map,
+ );
+ }
+
+ /// Step 2: poll until the user authorizes, returning the access token.
+ ///
+ /// Honors GitHub's pacing protocol: `authorization_pending` keeps polling,
+ /// `slow_down` increases the interval, and terminal errors throw a
+ /// [DeviceAuthException].
+ Future pollForToken(DeviceCodeResponse device) async {
+ var intervalSeconds = device.interval;
+ final deadline = DateTime.now().add(Duration(seconds: device.expiresIn));
+
+ while (DateTime.now().isBefore(deadline)) {
+ await _delay(Duration(seconds: intervalSeconds));
+ final res = await _http.post(
+ Uri.parse(_tokenUrl),
+ headers: const {'Accept': 'application/json'},
+ body: {
+ 'client_id': clientId,
+ 'device_code': device.deviceCode,
+ 'grant_type': _grantType,
+ },
+ );
+ final json = jsonDecode(res.body) as Map;
+
+ final token = json['access_token'] as String?;
+ if (token != null) return token;
+
+ switch (json['error'] as String?) {
+ case 'authorization_pending':
+ continue; // User has not finished authorizing yet.
+ case 'slow_down':
+ // GitHub asks us to back off; obey its new interval.
+ intervalSeconds = (json['interval'] as int?) ?? intervalSeconds + 5;
+ case final String error:
+ throw DeviceAuthException(
+ error,
+ (json['error_description'] as String?) ?? error,
+ );
+ case null:
+ throw DeviceAuthException('unknown', 'Unexpected response: $json');
+ }
+ }
+ throw DeviceAuthException('expired_token', 'Device code expired.');
+ }
+
+ /// Closes the underlying HTTP client.
+ void close() => _http.close();
+}
diff --git a/app/lib/services/notification_service.dart b/app/lib/services/notification_service.dart
new file mode 100644
index 0000000..3e6ca91
--- /dev/null
+++ b/app/lib/services/notification_service.dart
@@ -0,0 +1,114 @@
+/// Shows/cancels the per-slot "meal not logged" notification, mirroring
+/// diet_guard's `_gate.py` lock decision -- but as a notification rather
+/// than a screen-grab, and re-evaluated on every background check tick
+/// rather than fired once.
+library;
+
+import 'package:diet_guard_app/models/slot.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.
+class NotificationService {
+ NotificationService._(this._plugin);
+
+ static NotificationService? _instance;
+
+ final FlutterLocalNotificationsPlugin _plugin;
+
+ 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
+ /// second call returns the already-initialized instance without
+ /// re-running platform setup).
+ static Future init() async {
+ final svc = _instance ??= NotificationService._(
+ FlutterLocalNotificationsPlugin(),
+ );
+ 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);
+ 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.
+ @visibleForTesting
+ static void resetForTesting({FlutterLocalNotificationsPlugin? plugin}) {
+ _instance = plugin == null ? null : NotificationService._(plugin);
+ }
+
+ /// 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). This app only ships an
+ /// `android/` target, so in production the non-null path always runs;
+ /// the fallback exists for the Linux desktop build used to visually
+ /// verify this screen, where `resolvePlatformSpecificImplementation`
+ /// correctly resolves to null -- not reachable from `flutter test`
+ /// without polluting the process-global plugin registration other tests
+ /// in this file rely on, so it's excluded from coverage rather than
+ /// chased with a fragile test-ordering trick.
+ Future requestPermission() =>
+ _plugin
+ .resolvePlatformSpecificImplementation<
+ AndroidFlutterLocalNotificationsPlugin
+ >()
+ ?.requestNotificationsPermission() ??
+ // coverage:ignore-line
+ Future.value();
+
+ /// Shows a notification for every slot in [dueSlots] and cancels one for
+ /// every other known slot.
+ ///
+ /// Idempotent and re-evaluated every tick: a slot logged after its
+ /// notification fired gets that notification cancelled on the very next
+ /// call, mirroring `_gate.gate_is_due()`'s re-evaluate-every-tick
+ /// behavior rather than firing once and forgetting.
+ Future syncToSlots(List dueSlots) async {
+ final due = dueSlots.toSet();
+ for (final slot in daySlots()) {
+ if (due.contains(slot)) {
+ await _show(slot);
+ } else {
+ await _plugin.cancel(id: slot);
+ }
+ }
+ }
+
+ Future _show(int slot) async {
+ const details = NotificationDetails(
+ android: AndroidNotificationDetails(
+ _channelId,
+ _channelName,
+ importance: Importance.high,
+ priority: Priority.high,
+ ),
+ );
+ await _plugin.show(
+ id: slot,
+ title: 'Meal not logged',
+ body: "You haven't logged your ${slotLabel(slot)} meal yet.",
+ notificationDetails: details,
+ );
+ }
+}
diff --git a/app/lib/services/sync_settings.dart b/app/lib/services/sync_settings.dart
index 820ce47..bbfac26 100644
--- a/app/lib/services/sync_settings.dart
+++ b/app/lib/services/sync_settings.dart
@@ -1,7 +1,7 @@
/// Locally-stored GitHub sync configuration, ported from `~/todo`'s
-/// `sync/sync_settings.dart` -- with the OAuth device-flow fields dropped:
-/// the phone leans on a pasted PAT instead (the plan's call to pick
-/// "whichever is less code", and pasting is strictly less code here).
+/// `sync/sync_settings.dart`, including the OAuth device-flow fields: the
+/// "Connect GitHub" button is the primary path, with a pasted PAT kept as a
+/// manual fallback.
library;
import 'package:flutter/services.dart' show PlatformException;
@@ -20,6 +20,7 @@ class SyncSettings {
required this.owner,
required this.repo,
required this.token,
+ this.clientId = '',
});
/// The repo owner/org (e.g. `"kuhyx"`).
@@ -31,12 +32,29 @@ class SyncSettings {
/// A GitHub PAT with contents read/write on [owner]/[repo].
final String token;
+ /// GitHub OAuth App client id used by the device-flow "Connect" button.
+ /// Not a secret (device flow needs no client secret), so it is safe to
+ /// ship as a compile-time default and commit to source — see
+ /// [defaultClientId].
+ final String clientId;
+
+ /// The app's own GitHub OAuth App (device-flow enabled) client id, baked
+ /// in so "Connect GitHub" works with zero setup. Registered 2026-06-23 at
+ /// github.com/settings/developers, device flow enabled — distinct from
+ /// the sibling notes app's OAuth App, which belongs to a different
+ /// product.
+ static const defaultClientId = 'Ov23li8wIQBai3qtbsqa';
+
/// True when enough is set to attempt a sync.
bool get isConfigured =>
owner.isNotEmpty && repo.isNotEmpty && token.isNotEmpty;
+ /// True when device-flow "Connect GitHub" can be offered.
+ bool get canUseDeviceFlow => clientId.isNotEmpty;
+
static const _kOwner = 'sync.owner';
static const _kRepo = 'sync.repo';
+ static const _kClientId = 'sync.clientId';
// Legacy plaintext location for the token; read-only now and removed once
// the token has been migrated into secure storage.
static const _kToken = 'sync.token';
@@ -49,14 +67,16 @@ class SyncSettings {
static const _secure = FlutterSecureStorage();
/// Loads settings, defaulting the owner/repo to `kuhyx/diet-guard-sync`
- /// (matching the PC's `SYNC_REPO_OWNER`/`SYNC_REPO_NAME` constants) so a
- /// fresh install needs only a pasted PAT.
+ /// (matching the PC's `SYNC_REPO_OWNER`/`SYNC_REPO_NAME` constants) and the
+ /// client id to the baked-in [defaultClientId], so a fresh install needs
+ /// only "Connect GitHub" (once an OAuth App is registered) or a pasted PAT.
static Future load() async {
final prefs = await SharedPreferences.getInstance();
return SyncSettings(
owner: prefs.getString(_kOwner) ?? 'kuhyx',
repo: prefs.getString(_kRepo) ?? 'diet-guard-sync',
token: await _loadToken(prefs),
+ clientId: prefs.getString(_kClientId) ?? defaultClientId,
);
}
@@ -86,6 +106,7 @@ class SyncSettings {
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
// write succeeds; otherwise keep persisting it to prefs as before.
if (await _writeSecureToken(token)) {
@@ -111,11 +132,17 @@ class SyncSettings {
}
/// Returns a copy of this with only the given fields replaced.
- SyncSettings copyWith({String? owner, String? repo, String? token}) {
+ SyncSettings copyWith({
+ String? owner,
+ String? repo,
+ String? token,
+ String? clientId,
+ }) {
return SyncSettings(
owner: owner ?? this.owner,
repo: repo ?? this.repo,
token: token ?? this.token,
+ clientId: clientId ?? this.clientId,
);
}
}
diff --git a/app/linux/flutter/generated_plugin_registrant.cc b/app/linux/flutter/generated_plugin_registrant.cc
index 85a2413..3ccd551 100644
--- a/app/linux/flutter/generated_plugin_registrant.cc
+++ b/app/linux/flutter/generated_plugin_registrant.cc
@@ -8,6 +8,7 @@
#include
#include
+#include
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
@@ -16,4 +17,7 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
+ g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
+ fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
+ url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}
diff --git a/app/linux/flutter/generated_plugins.cmake b/app/linux/flutter/generated_plugins.cmake
index 7aea3ec..fbedf4a 100644
--- a/app/linux/flutter/generated_plugins.cmake
+++ b/app/linux/flutter/generated_plugins.cmake
@@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
flutter_secure_storage_linux
+ url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
diff --git a/app/pubspec.lock b/app/pubspec.lock
index fdd2c60..66f2d51 100644
--- a/app/pubspec.lock
+++ b/app/pubspec.lock
@@ -73,6 +73,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
+ dbus:
+ dependency: transitive
+ description:
+ name: dbus
+ sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.7.13"
fake_async:
dependency: transitive
description:
@@ -150,6 +158,46 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
+ flutter_local_notifications:
+ dependency: "direct main"
+ description:
+ name: flutter_local_notifications
+ sha256: a0d7141f14cabcee42967470a858dfc99dd6cfb70d3cab404bacfcafa9e84e70
+ url: "https://pub.dev"
+ source: hosted
+ version: "22.0.1"
+ flutter_local_notifications_linux:
+ dependency: transitive
+ description:
+ name: flutter_local_notifications_linux
+ sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b"
+ url: "https://pub.dev"
+ source: hosted
+ version: "8.0.1"
+ flutter_local_notifications_platform_interface:
+ dependency: transitive
+ description:
+ name: flutter_local_notifications_platform_interface
+ sha256: ff0013eae795e8dc8fad4a8992a209e64d3ba2fbd8bf5e43c36bf448f95bd814
+ url: "https://pub.dev"
+ source: hosted
+ version: "12.0.0"
+ flutter_local_notifications_web:
+ dependency: transitive
+ description:
+ name: flutter_local_notifications_web
+ sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.0"
+ flutter_local_notifications_windows:
+ dependency: transitive
+ description:
+ name: flutter_local_notifications_windows
+ sha256: "6f43bdd03b171b7a90f22647506fea33e2bb12294b7c7c7a3d690e960a382945"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.1"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@@ -456,6 +504,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
+ permission_handler:
+ dependency: "direct main"
+ description:
+ name: permission_handler
+ sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6
+ url: "https://pub.dev"
+ source: hosted
+ version: "12.0.3"
+ permission_handler_android:
+ dependency: transitive
+ description:
+ name: permission_handler_android
+ sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
+ url: "https://pub.dev"
+ source: hosted
+ version: "13.0.1"
+ permission_handler_apple:
+ dependency: transitive
+ description:
+ name: permission_handler_apple
+ sha256: "79dfa1df734798aa3cfdad166d3a3698c206d8813de13516ea1071b5d7e2f420"
+ url: "https://pub.dev"
+ source: hosted
+ version: "9.4.10"
+ permission_handler_html:
+ dependency: transitive
+ description:
+ name: permission_handler_html
+ sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.1.3+5"
+ permission_handler_platform_interface:
+ dependency: transitive
+ description:
+ name: permission_handler_platform_interface
+ sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.3.0"
+ permission_handler_windows:
+ dependency: transitive
+ description:
+ name: permission_handler_windows
+ sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.2.1"
+ petitparser:
+ dependency: transitive
+ description:
+ name: petitparser
+ sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
+ url: "https://pub.dev"
+ source: hosted
+ version: "7.0.2"
platform:
dependency: transitive
description:
@@ -597,6 +701,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.11"
+ timezone:
+ dependency: transitive
+ description:
+ name: timezone
+ sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.11.0"
typed_data:
dependency: transitive
description:
@@ -605,6 +717,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
+ url_launcher:
+ dependency: "direct main"
+ description:
+ name: url_launcher
+ sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.3.2"
+ url_launcher_android:
+ dependency: transitive
+ description:
+ name: url_launcher_android
+ sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.3.32"
+ url_launcher_ios:
+ dependency: transitive
+ description:
+ name: url_launcher_ios
+ sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.4.1"
+ url_launcher_linux:
+ dependency: transitive
+ description:
+ name: url_launcher_linux
+ sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.2.2"
+ url_launcher_macos:
+ dependency: transitive
+ description:
+ name: url_launcher_macos
+ sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.2.5"
+ url_launcher_platform_interface:
+ dependency: "direct dev"
+ description:
+ name: url_launcher_platform_interface
+ sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.3.2"
+ url_launcher_web:
+ dependency: transitive
+ description:
+ name: url_launcher_web
+ sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.3"
+ url_launcher_windows:
+ dependency: transitive
+ description:
+ name: url_launcher_windows
+ sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.5"
uuid:
dependency: "direct main"
description:
@@ -653,6 +829,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.3.0"
+ workmanager:
+ dependency: "direct main"
+ description:
+ name: workmanager
+ sha256: "065673b2a465865183093806925419d311a9a5e0995aa74ccf8920fd695e2d10"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.9.0+3"
+ workmanager_android:
+ dependency: transitive
+ description:
+ name: workmanager_android
+ sha256: "9ae744db4ef891f5fcd2fb8671fccc712f4f96489a487a1411e0c8675e5e8cb7"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.9.0+2"
+ workmanager_apple:
+ dependency: transitive
+ description:
+ name: workmanager_apple
+ sha256: "1cc12ae3cbf5535e72f7ba4fde0c12dd11b757caf493a28e22d684052701f2ca"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.9.1+2"
+ workmanager_platform_interface:
+ dependency: transitive
+ description:
+ name: workmanager_platform_interface
+ sha256: f40422f10b970c67abb84230b44da22b075147637532ac501729256fcea10a47
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.9.1+1"
xdg_directories:
dependency: transitive
description:
@@ -661,6 +869,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
+ xml:
+ dependency: transitive
+ description:
+ name: xml
+ sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
+ url: "https://pub.dev"
+ source: hosted
+ version: "7.0.1"
yaml:
dependency: transitive
description:
diff --git a/app/pubspec.yaml b/app/pubspec.yaml
index 50c2a94..21d584d 100644
--- a/app/pubspec.yaml
+++ b/app/pubspec.yaml
@@ -10,18 +10,23 @@ environment:
dependencies:
flutter:
sdk: flutter
+ flutter_local_notifications: ^22.0.1
flutter_secure_storage: ^10.3.1
http: ^1.6.0
image_picker: ^1.1.2
path: ^1.9.1
path_provider: ^2.1.5
+ permission_handler: ^12.0.3
shared_preferences: ^2.5.5
+ url_launcher: ^6.3.2
uuid: ^4.5.3
+ workmanager: ^0.9.0+3
dev_dependencies:
flutter_test:
sdk: flutter
image_picker_platform_interface: ^2.10.0
+ url_launcher_platform_interface: ^2.3.2
very_good_analysis: ^10.2.0
flutter:
diff --git a/app/test/fake_notifications.dart b/app/test/fake_notifications.dart
new file mode 100644
index 0000000..d3da36f
--- /dev/null
+++ b/app/test/fake_notifications.dart
@@ -0,0 +1,40 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_local_notifications/flutter_local_notifications.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+/// Mocks the raw `dexterous.com/flutter/local_notifications` MethodChannel
+/// the way the package's own test suite does
+/// (`android_flutter_local_notifications_test.dart`), so
+/// [NotificationService] can be exercised end-to-end (init/show/cancel)
+/// without a real Android plugin.
+///
+/// Returns the call log so a test can assert which slots were shown vs.
+/// cancelled.
+List installFakeAndroidNotifications() {
+ AndroidFlutterLocalNotificationsPlugin.registerWith();
+ debugDefaultTargetPlatformOverride = TargetPlatform.android;
+ const channel = MethodChannel('dexterous.com/flutter/local_notifications');
+ final log = [];
+
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+ .setMockMethodCallHandler(channel, (call) async {
+ log.add(call);
+ switch (call.method) {
+ case 'initialize':
+ return true;
+ case 'requestNotificationsPermission':
+ return true;
+ default:
+ return null;
+ }
+ });
+
+ addTearDown(() {
+ debugDefaultTargetPlatformOverride = null;
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+ .setMockMethodCallHandler(channel, null);
+ });
+
+ return log;
+}
diff --git a/app/test/screens/settings_screen_test.dart b/app/test/screens/settings_screen_test.dart
index 7b17695..0c080a8 100644
--- a/app/test/screens/settings_screen_test.dart
+++ b/app/test/screens/settings_screen_test.dart
@@ -1,16 +1,42 @@
+import 'dart:convert';
import 'dart:io';
import 'package:diet_guard_app/screens/settings_screen.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
+import 'package:permission_handler/permission_handler.dart';
+import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:shared_preferences/shared_preferences.dart';
+import 'package:url_launcher_platform_interface/link.dart';
+import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
import '../fake_secure_storage.dart';
+/// Stub launcher that records the URL instead of opening it, so the device
+/// dialog's "Open GitHub & copy code" can be exercised without a real
+/// platform channel.
+class _FakeUrlLauncher extends UrlLauncherPlatform
+ with MockPlatformInterfaceMixin {
+ String? launched;
+
+ @override
+ final LinkDelegate? linkDelegate = null;
+
+ @override
+ Future supportsMode(PreferredLaunchMode mode) async => true;
+
+ @override
+ Future launchUrl(String url, LaunchOptions options) async {
+ launched = url;
+ return true;
+ }
+}
+
void main() {
late Directory tempDir;
@@ -30,12 +56,33 @@ void main() {
// SettingsScreen loads its settings via a fire-and-forget Future in
// initState that Flutter's frame scheduler does not track -- same pitfall
- // as HistoryScreen/LogMealScreen.
+ // as HistoryScreen/LogMealScreen. Also grows the test viewport: the
+ // Notifications section pushes earlier fields/buttons below the default
+ // 800x600 fold, making them unreachable to tester.tap otherwise.
Future settle(WidgetTester tester) async {
+ tester.view.physicalSize = const Size(800, 1400);
+ tester.view.devicePixelRatio = 1.0;
+ addTearDown(tester.view.resetPhysicalSize);
+ addTearDown(tester.view.resetDevicePixelRatio);
await Future.delayed(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
}
+ /// Drains the device flow's real `Future.delayed` poll (GitHubDeviceAuth
+ /// injects no test delay, so under `runAsync` it is a genuine Timer, not
+ /// the fake-clock one `tester.pump(duration)` advances) by interleaving
+ /// real waits with frame pumps until [done] is true or [maxTries] is hit.
+ Future pumpUntil(
+ WidgetTester tester,
+ bool Function() done, {
+ int maxTries = 200,
+ }) async {
+ for (var i = 0; i < maxTries && !done(); i++) {
+ await Future.delayed(const Duration(milliseconds: 10));
+ await tester.pump();
+ }
+ }
+
testWidgets('shows the kuhyx/diet-guard-sync defaults on a fresh install', (
tester,
) async {
@@ -53,8 +100,10 @@ void main() {
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
await settle(tester);
+ await tester.tap(find.text('Advanced'));
+ await tester.pumpAndSettle();
await tester.enterText(
- find.widgetWithText(TextField, 'Personal access token'),
+ find.widgetWithText(TextField, 'Personal access token (fallback)'),
'my-pat',
);
await tester.tap(find.widgetWithText(ElevatedButton, 'Save'));
@@ -145,4 +194,351 @@ void main() {
expect(find.textContaining('Sync failed:'), findsOneWidget);
});
});
+
+ testWidgets('shows the Connect GitHub button', (tester) async {
+ await tester.runAsync(() async {
+ await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
+ await settle(tester);
+
+ expect(find.text('Connect GitHub'), findsOneWidget);
+ });
+ });
+
+ /// Expands "Advanced" and types [clientId] into the client-id field.
+ Future enterClientId(WidgetTester tester, String clientId) async {
+ await tester.tap(find.text('Advanced'));
+ await tester.pumpAndSettle();
+ await tester.enterText(
+ find.widgetWithText(TextField, 'OAuth App client id'),
+ clientId,
+ );
+ }
+
+ testWidgets('Connect GitHub without a client id opens setup guidance', (
+ tester,
+ ) async {
+ await tester.runAsync(() async {
+ await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
+ await settle(tester);
+ await enterClientId(tester, '');
+
+ await tester.tap(find.text('Connect GitHub'));
+ await settle(tester);
+
+ expect(find.text('One-time GitHub setup needed'), findsOneWidget);
+ expect(find.widgetWithText(TextField, 'Client ID'), findsOneWidget);
+ });
+ });
+
+ testWidgets('cancelling the client id setup dialog aborts the connect', (
+ tester,
+ ) async {
+ await tester.runAsync(() async {
+ await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
+ await settle(tester);
+ await enterClientId(tester, '');
+
+ await tester.tap(find.text('Connect GitHub'));
+ await settle(tester);
+ await tester.tap(find.text('Cancel'));
+ await settle(tester);
+
+ expect(find.text('One-time GitHub setup needed'), findsNothing);
+ });
+ });
+
+ testWidgets(
+ 'entering a client id in the setup dialog saves it and proceeds',
+ (tester) async {
+ final mock = MockClient((_) async => http.Response('nope', 422));
+ await tester.runAsync(() async {
+ await tester.pumpWidget(
+ MaterialApp(home: SettingsScreen(httpClient: mock)),
+ );
+ await settle(tester);
+ await enterClientId(tester, '');
+
+ await tester.tap(find.text('Connect GitHub'));
+ await settle(tester);
+ await tester.enterText(
+ find.widgetWithText(TextField, 'Client ID'),
+ 'cid',
+ );
+ await tester.tap(find.text('Continue'));
+ await settle(tester);
+
+ expect(
+ find.textContaining('Could not start device flow'),
+ findsOneWidget,
+ );
+ expect(
+ find.widgetWithText(TextField, 'OAuth App client id'),
+ findsOneWidget,
+ );
+ expect(
+ (tester.widget(
+ find.widgetWithText(TextField, 'OAuth App client id'),
+ )
+ as TextField)
+ .controller!
+ .text,
+ 'cid',
+ );
+ });
+ },
+ );
+
+ testWidgets('device flow failure to start shows a message', (tester) async {
+ final mock = MockClient((_) async => http.Response('nope', 422));
+ await tester.runAsync(() async {
+ await tester.pumpWidget(
+ MaterialApp(home: SettingsScreen(httpClient: mock)),
+ );
+ await settle(tester);
+ await enterClientId(tester, 'cid');
+
+ await tester.tap(find.text('Connect GitHub'));
+ await settle(tester);
+
+ expect(
+ find.textContaining('Could not start device flow'),
+ findsOneWidget,
+ );
+ });
+ });
+
+ testWidgets('device flow happy path saves the token and syncs', (
+ tester,
+ ) async {
+ final mock = MockClient((req) async {
+ if (req.url.path.contains('device/code')) {
+ return http.Response(
+ jsonEncode({
+ 'device_code': 'dev123',
+ 'user_code': 'WXYZ-1234',
+ 'verification_uri': 'https://github.com/login/device',
+ 'interval': 0,
+ 'expires_in': 900,
+ }),
+ 200,
+ );
+ }
+ if (req.url.path.contains('login/oauth/access_token')) {
+ return http.Response(jsonEncode({'access_token': 'gho_test'}), 200);
+ }
+ if (req.method == 'PUT') return http.Response('{}', 200);
+ return http.Response('', 404); // sync's pull-side list/read calls
+ });
+
+ await tester.runAsync(() async {
+ await tester.pumpWidget(
+ MaterialApp(home: SettingsScreen(httpClient: mock)),
+ );
+ await settle(tester);
+ await enterClientId(tester, 'cid');
+
+ await tester.tap(find.text('Connect GitHub'));
+ await pumpUntil(
+ tester,
+ () => find.text('WXYZ-1234').evaluate().isNotEmpty,
+ );
+ expect(find.text('WXYZ-1234'), findsOneWidget);
+
+ // Let the dialog poll (interval 0) and resolve the token, then the
+ // post-connect sync runs against the mock.
+ await pumpUntil(
+ tester,
+ () => find.textContaining('Connected and synced').evaluate().isNotEmpty,
+ );
+
+ expect(find.textContaining('Connected and synced'), findsOneWidget);
+ });
+ });
+
+ testWidgets(
+ 'device flow connects but surfaces a post-connect sync failure',
+ (tester) async {
+ final mock = MockClient((req) async {
+ if (req.url.path.contains('device/code')) {
+ return http.Response(
+ jsonEncode({
+ 'device_code': 'dev123',
+ 'user_code': 'WXYZ-1234',
+ 'verification_uri': 'https://github.com/login/device',
+ 'interval': 0,
+ 'expires_in': 900,
+ }),
+ 200,
+ );
+ }
+ if (req.url.path.contains('login/oauth/access_token')) {
+ return http.Response(jsonEncode({'access_token': 'gho_test'}), 200);
+ }
+ return http.Response('boom', 500); // the sync's repo calls fail
+ });
+
+ await tester.runAsync(() async {
+ await tester.pumpWidget(
+ MaterialApp(home: SettingsScreen(httpClient: mock)),
+ );
+ await settle(tester);
+ await enterClientId(tester, 'cid');
+
+ await tester.tap(find.text('Connect GitHub'));
+ await pumpUntil(
+ tester,
+ () => find.textContaining('sync failed').evaluate().isNotEmpty,
+ );
+
+ expect(find.textContaining('sync failed'), findsOneWidget);
+ });
+ },
+ );
+
+ testWidgets('device dialog: failed poll shows the error and Open launches', (
+ tester,
+ ) async {
+ final launcher = _FakeUrlLauncher();
+ UrlLauncherPlatform.instance = launcher;
+
+ // The dialog's Open button copies the code to the clipboard first;
+ // there's no clipboard plugin in the test host, so stub the channel.
+ final messenger =
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
+ messenger.setMockMethodCallHandler(
+ SystemChannels.platform,
+ (call) async => null,
+ );
+ addTearDown(
+ () => messenger.setMockMethodCallHandler(SystemChannels.platform, null),
+ );
+
+ final mock = MockClient((req) async {
+ if (req.url.path.contains('device/code')) {
+ return http.Response(
+ jsonEncode({
+ 'device_code': 'dev123',
+ 'user_code': 'WXYZ-1234',
+ 'verification_uri': 'https://github.com/login/device',
+ 'interval': 0,
+ 'expires_in': 900,
+ }),
+ 200,
+ );
+ }
+ return http.Response(
+ jsonEncode({'error': 'access_denied', 'error_description': 'no'}),
+ 200,
+ );
+ });
+
+ await tester.runAsync(() async {
+ await tester.pumpWidget(
+ MaterialApp(home: SettingsScreen(httpClient: mock)),
+ );
+ await settle(tester);
+ await enterClientId(tester, 'cid');
+
+ await tester.tap(find.text('Connect GitHub'));
+ await pumpUntil(
+ tester,
+ () => find.text('WXYZ-1234').evaluate().isNotEmpty,
+ );
+ expect(find.text('WXYZ-1234'), findsOneWidget);
+
+ await pumpUntil(
+ tester,
+ () => find.textContaining('access_denied').evaluate().isNotEmpty,
+ );
+
+ expect(find.textContaining('access_denied'), findsOneWidget);
+
+ await tester.tap(find.text('Open GitHub & copy code'));
+ await tester.pump();
+ expect(launcher.launched, 'https://github.com/login/device');
+
+ await tester.tap(find.text('Cancel'));
+ await settle(tester);
+ });
+ });
+
+ testWidgets('battery exemption button reports a granted status', (
+ tester,
+ ) async {
+ await tester.runAsync(() async {
+ await tester.pumpWidget(
+ MaterialApp(
+ home: SettingsScreen(
+ requestBatteryExemption: () async => PermissionStatus.granted,
+ ),
+ ),
+ );
+ await settle(tester);
+
+ await tester.tap(find.text('Disable battery optimization'));
+ await settle(tester);
+
+ expect(find.textContaining('exemption granted'), findsOneWidget);
+ });
+ });
+
+ testWidgets('battery exemption button reports a denied status', (
+ tester,
+ ) async {
+ await tester.runAsync(() async {
+ await tester.pumpWidget(
+ MaterialApp(
+ home: SettingsScreen(
+ requestBatteryExemption: () async => PermissionStatus.denied,
+ ),
+ ),
+ );
+ await settle(tester);
+
+ await tester.tap(find.text('Disable battery optimization'));
+ await settle(tester);
+
+ expect(find.textContaining('not granted'), findsOneWidget);
+ });
+ });
+
+ testWidgets('battery exemption defaults to the real permission_handler '
+ 'call, which fails predictably under test', (tester) async {
+ await tester.runAsync(() async {
+ await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
+ await settle(tester);
+
+ await tester.tap(find.text('Disable battery optimization'));
+ await settle(tester);
+
+ expect(
+ find.textContaining('Could not request exemption'),
+ findsOneWidget,
+ );
+ });
+ });
+
+ testWidgets('battery exemption button surfaces a request failure', (
+ tester,
+ ) async {
+ await tester.runAsync(() async {
+ await tester.pumpWidget(
+ MaterialApp(
+ home: SettingsScreen(
+ requestBatteryExemption: () async =>
+ throw Exception('no permission service'),
+ ),
+ ),
+ );
+ await settle(tester);
+
+ await tester.tap(find.text('Disable battery optimization'));
+ await settle(tester);
+
+ expect(
+ find.textContaining('Could not request exemption'),
+ findsOneWidget,
+ );
+ });
+ });
}
diff --git a/app/test/services/background_check_service_test.dart b/app/test/services/background_check_service_test.dart
new file mode 100644
index 0000000..ad21563
--- /dev/null
+++ b/app/test/services/background_check_service_test.dart
@@ -0,0 +1,87 @@
+// `checkAndNotify` is the unit-testable half of the WorkManager 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/models/nutrition.dart';
+import 'package:diet_guard_app/services/background_check_service.dart';
+import 'package:diet_guard_app/services/log_storage_service.dart';
+import 'package:diet_guard_app/services/notification_service.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_local_notifications/flutter_local_notifications.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import '../fake_notifications.dart';
+
+const _manual = Nutrition(
+ kcal: 200,
+ proteinG: 10,
+ carbsG: 20,
+ fatG: 5,
+ grams: 100,
+ source: 'manual',
+);
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+ late Directory tempDir;
+ late List notificationLog;
+
+ setUp(() async {
+ tempDir = await Directory.systemTemp.createTemp('diet_guard_bg_check_');
+ LogStorageService.resetForTesting(testDir: tempDir);
+ notificationLog = installFakeAndroidNotifications();
+ NotificationService.resetForTesting(
+ plugin: FlutterLocalNotificationsPlugin(),
+ );
+ });
+
+ tearDown(() async {
+ LogStorageService.resetForTesting();
+ NotificationService.resetForTesting();
+ await tempDir.delete(recursive: true);
+ });
+
+ test(
+ 'shows due-and-unlogged slots, cancels logged and upcoming ones',
+ () async {
+ await LogStorageService.instance.logMeal('lunch', _manual, slot: 12);
+
+ await checkAndNotify(now: DateTime(2026, 1, 1, 16));
+
+ final shown = notificationLog
+ .where((c) => c.method == 'show')
+ .map((c) => (c.arguments as Map)['id'])
+ .toSet();
+ final cancelled = notificationLog
+ .where((c) => c.method == 'cancel')
+ .map((c) => (c.arguments as Map)['id'])
+ .toSet();
+ expect(shown, {8, 16});
+ expect(cancelled, {12, 20});
+ },
+ );
+
+ test('cancels everything when every due slot is logged', () async {
+ await LogStorageService.instance.logMeal('breakfast', _manual, slot: 8);
+
+ await checkAndNotify(now: DateTime(2026, 1, 1, 8));
+
+ expect(notificationLog.where((c) => c.method == 'show'), isEmpty);
+ expect(notificationLog.where((c) => c.method == 'cancel'), hasLength(4));
+ });
+
+ test('uses the real clock when now is omitted', () async {
+ // Just exercises the `now ?? DateTime.now()` branch without asserting
+ // on specific slots (which depend on the actual time the test runs).
+ await checkAndNotify();
+ expect(
+ notificationLog.where(
+ (c) => c.method == 'show' || c.method == 'cancel',
+ ),
+ isNotEmpty,
+ );
+ });
+}
diff --git a/app/test/services/github_device_auth_test.dart b/app/test/services/github_device_auth_test.dart
new file mode 100644
index 0000000..4408030
--- /dev/null
+++ b/app/test/services/github_device_auth_test.dart
@@ -0,0 +1,175 @@
+import 'dart:convert';
+
+import 'package:diet_guard_app/services/github_device_auth.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:http/http.dart' as http;
+import 'package:http/testing.dart';
+
+/// Builds an auth instance whose polls resolve instantly (no real waiting).
+GitHubDeviceAuth authWith(http.Client client) => GitHubDeviceAuth(
+ clientId: 'test-client-id',
+ httpClient: client,
+ delay: (_) => Future.value(),
+);
+
+const _device = DeviceCodeResponse(
+ deviceCode: 'dev-123',
+ userCode: 'WXYZ-1234',
+ verificationUri: 'https://github.com/login/device',
+ interval: 1,
+ expiresIn: 900,
+);
+
+void main() {
+ test('requestDeviceCode parses the device + user code', () async {
+ final client = MockClient((req) async {
+ expect(req.url.toString(), contains('login/device/code'));
+ expect(req.bodyFields['client_id'], 'test-client-id');
+ expect(req.bodyFields['scope'], 'repo');
+ return http.Response(
+ jsonEncode({
+ 'device_code': 'dev-123',
+ 'user_code': 'WXYZ-1234',
+ 'verification_uri': 'https://github.com/login/device',
+ 'interval': 5,
+ 'expires_in': 900,
+ }),
+ 200,
+ );
+ });
+
+ final res = await authWith(client).requestDeviceCode();
+ expect(res.deviceCode, 'dev-123');
+ expect(res.userCode, 'WXYZ-1234');
+ expect(res.verificationUri, 'https://github.com/login/device');
+ });
+
+ test('requestDeviceCode throws on a non-200 response', () async {
+ final client = MockClient((_) async => http.Response('nope', 422));
+ expect(
+ () => authWith(client).requestDeviceCode(),
+ throwsA(isA()),
+ );
+ });
+
+ test('pollForToken returns the token after authorization_pending', () async {
+ var calls = 0;
+ final client = MockClient((req) async {
+ calls++;
+ // Pending on the first two polls, then success.
+ if (calls < 3) {
+ return http.Response(
+ jsonEncode({'error': 'authorization_pending'}),
+ 200,
+ );
+ }
+ return http.Response(
+ jsonEncode({'access_token': 'gho_abc', 'token_type': 'bearer'}),
+ 200,
+ );
+ });
+
+ final token = await authWith(client).pollForToken(_device);
+ expect(token, 'gho_abc');
+ expect(calls, 3);
+ });
+
+ test('pollForToken obeys slow_down and still succeeds', () async {
+ var calls = 0;
+ final client = MockClient((req) async {
+ calls++;
+ if (calls == 1) {
+ return http.Response(
+ jsonEncode({'error': 'slow_down', 'interval': 1}),
+ 200,
+ );
+ }
+ return http.Response(jsonEncode({'access_token': 'gho_xyz'}), 200);
+ });
+
+ final token = await authWith(client).pollForToken(_device);
+ expect(token, 'gho_xyz');
+ });
+
+ test('pollForToken throws on access_denied', () async {
+ final client = MockClient(
+ (req) async => http.Response(
+ jsonEncode({'error': 'access_denied', 'error_description': 'no'}),
+ 200,
+ ),
+ );
+
+ expect(
+ () => authWith(client).pollForToken(_device),
+ throwsA(
+ isA().having(
+ (e) => e.code,
+ 'code',
+ 'access_denied',
+ ),
+ ),
+ );
+ });
+
+ test('pollForToken honors slow_down then succeeds', () async {
+ var calls = 0;
+ final client = MockClient((req) async {
+ calls++;
+ if (calls == 1) {
+ return http.Response(
+ jsonEncode({'error': 'slow_down', 'interval': 0}),
+ 200,
+ );
+ }
+ return http.Response(jsonEncode({'access_token': 'gho_ok'}), 200);
+ });
+
+ expect(await authWith(client).pollForToken(_device), 'gho_ok');
+ expect(calls, 2);
+ });
+
+ test('pollForToken throws on an unexpected response shape', () async {
+ final client = MockClient(
+ (_) async => http.Response(jsonEncode({'foo': 'bar'}), 200),
+ );
+ expect(
+ () => authWith(client).pollForToken(_device),
+ throwsA(isA()),
+ );
+ });
+
+ test('pollForToken throws when the device code has expired', () async {
+ final client = MockClient(
+ (_) async => http.Response(jsonEncode({'access_token': 'x'}), 200),
+ );
+ const expired = DeviceCodeResponse(
+ deviceCode: 'd',
+ userCode: 'u',
+ verificationUri: 'v',
+ interval: 1,
+ expiresIn: 0, // deadline is now → loop body never runs
+ );
+ expect(
+ () => authWith(client).pollForToken(expired),
+ throwsA(
+ isA().having(
+ (e) => e.code,
+ 'code',
+ 'expired_token',
+ ),
+ ),
+ );
+ });
+
+ test('defaults to a real http client and delay when none are injected', () {
+ // Omitting httpClient/delay exercises the `?? http.Client()` and
+ // `?? Future.delayed` constructor fallbacks; no request is made.
+ final auth = GitHubDeviceAuth(clientId: 'c');
+ addTearDown(auth.close);
+ });
+
+ test('DeviceAuthException.toString includes code and message', () {
+ final e = DeviceAuthException('access_denied', 'no');
+ expect(e.toString(), 'DeviceAuthException(access_denied): no');
+ });
+}
diff --git a/app/test/services/notification_service_test.dart b/app/test/services/notification_service_test.dart
new file mode 100644
index 0000000..fe141db
--- /dev/null
+++ b/app/test/services/notification_service_test.dart
@@ -0,0 +1,105 @@
+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';
+
+import '../fake_notifications.dart';
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+ tearDown(NotificationService.resetForTesting);
+
+ group('on Android', () {
+ test('init constructs the real plugin singleton on first use', () async {
+ final log = installFakeAndroidNotifications();
+ NotificationService.resetForTesting(); // no _instance yet
+
+ await NotificationService.init();
+
+ expect(log.where((c) => c.method == 'initialize'), hasLength(1));
+ });
+
+ test('init calls the platform initialize method, idempotently', () async {
+ final log = installFakeAndroidNotifications();
+ NotificationService.resetForTesting(
+ plugin: FlutterLocalNotificationsPlugin(),
+ );
+
+ await NotificationService.init();
+ await NotificationService.init(); // second call must be a no-op
+
+ expect(log.where((c) => c.method == 'initialize'), hasLength(1));
+ });
+
+ test('requestPermission delegates to the Android implementation', () async {
+ installFakeAndroidNotifications();
+ NotificationService.resetForTesting(
+ plugin: FlutterLocalNotificationsPlugin(),
+ );
+ await NotificationService.init();
+
+ expect(await NotificationService.instance.requestPermission(), isTrue);
+ });
+
+ test('syncToSlots shows due slots and cancels the rest', () async {
+ final log = installFakeAndroidNotifications();
+ NotificationService.resetForTesting(
+ plugin: FlutterLocalNotificationsPlugin(),
+ );
+ await NotificationService.init();
+ log.clear();
+
+ await NotificationService.instance.syncToSlots([12, 20]);
+
+ final shown = log
+ .where((c) => c.method == 'show')
+ .map((c) => (c.arguments as Map)['id'])
+ .toSet();
+ final cancelled = log
+ .where((c) => c.method == 'cancel')
+ .map((c) => (c.arguments as Map)['id'])
+ .toSet();
+ expect(shown, {12, 20});
+ expect(cancelled, {8, 16});
+ });
+
+ test('syncToSlots with no due slots cancels every known slot', () async {
+ final log = installFakeAndroidNotifications();
+ NotificationService.resetForTesting(
+ plugin: FlutterLocalNotificationsPlugin(),
+ );
+ await NotificationService.init();
+ log.clear();
+
+ await NotificationService.instance.syncToSlots(const []);
+
+ expect(log.where((c) => c.method == 'show'), isEmpty);
+ expect(log.where((c) => c.method == 'cancel'), hasLength(4));
+ });
+
+ test(
+ 'syncToSlots cancels a slot whose meal was logged after it fired',
+ () async {
+ final log = installFakeAndroidNotifications();
+ NotificationService.resetForTesting(
+ plugin: FlutterLocalNotificationsPlugin(),
+ );
+ await NotificationService.init();
+
+ await NotificationService.instance.syncToSlots([12]);
+ log.clear();
+ await NotificationService.instance.syncToSlots(const []); // logged
+
+ expect(
+ log
+ .where((c) => c.method == 'cancel')
+ .map((c) => (c.arguments as Map)['id']),
+ contains(12),
+ );
+ },
+ );
+ });
+
+ test('instance throws before init has ever been called', () {
+ expect(() => NotificationService.instance, throwsA(anything));
+ });
+}
diff --git a/app/test/services/sync_settings_test.dart b/app/test/services/sync_settings_test.dart
index 5a824e3..3895161 100644
--- a/app/test/services/sync_settings_test.dart
+++ b/app/test/services/sync_settings_test.dart
@@ -18,6 +18,7 @@ void main() {
expect(s.owner, 'kuhyx');
expect(s.repo, 'diet-guard-sync');
expect(s.token, '');
+ expect(s.clientId, SyncSettings.defaultClientId);
},
);
@@ -105,17 +106,54 @@ void main() {
);
});
+ test('canUseDeviceFlow is true only when a client id is set', () {
+ expect(
+ const SyncSettings(owner: 'o', repo: 'r', token: '').canUseDeviceFlow,
+ isFalse,
+ );
+ expect(
+ const SyncSettings(
+ owner: 'o',
+ repo: 'r',
+ token: '',
+ clientId: 'cid',
+ ).canUseDeviceFlow,
+ isTrue,
+ );
+ });
+
+ test('save persists the client id and load reads it back', () async {
+ SharedPreferences.setMockInitialValues({});
+ installFakeSecureStorage();
+ await const SyncSettings(
+ owner: 'o',
+ repo: 'r',
+ token: '',
+ clientId: 'cid123',
+ ).save();
+
+ final s = await SyncSettings.load();
+ expect(s.clientId, 'cid123');
+ });
+
test('copyWith overrides only the given fields', () {
- const base = SyncSettings(owner: 'o', repo: 'r', token: 't');
+ const base = SyncSettings(
+ owner: 'o',
+ repo: 'r',
+ token: 't',
+ clientId: 'cid',
+ );
final next = base.copyWith(token: 'new');
expect(next.owner, 'o');
expect(next.repo, 'r');
expect(next.token, 'new');
+ expect(next.clientId, 'cid');
// No-arg copy exercises the `?? this.x` fallback on every field.
final clone = base.copyWith();
expect(clone.owner, 'o');
expect(clone.repo, 'r');
expect(clone.token, 't');
+ expect(clone.clientId, 'cid');
});
}