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'); }); }