Add OAuth device flow, background notifications, and fix AGP9 release crash (Milestones 3–4)

M3 – GitHub OAuth device flow: replace PAT-paste with a guided "Connect
GitHub" button that runs the device-code flow; tapping with no client id
now opens a setup dialog (instructions + inline paste field) rather than
a buried inline hint. Bakes in the app's own OAuth App client id so fresh
installs work with zero manual config. Auto-syncs immediately after
connect. Verified end-to-end on the real phone: OAuth flow → token saved
→ PC's 48-entry log merged in (confirmed via food-bank vs manual source
labels in History).

M4 – Background meal-slot notifications: WorkManager periodic task (15 min
floor) checks for overdue slots and posts/cancels notifications via
flutter_local_notifications. New permissions: POST_NOTIFICATIONS,
WAKE_LOCK, RECEIVE_BOOT_COMPLETED, REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
INTERNET (was missing — latent sync bug). "Disable battery optimization"
button in Settings. Verified on real phone: WorkManager registered, forced
run posted a real notification ("Meal not logged / You haven't logged your
16:00 meal yet."), isolated to background path (only caller is the
WorkManager dispatcher, not any foreground lifecycle hook).

AGP9 release crash fix: AGP 9 defaults isMinifyEnabled/isShrinkResources
to true for release even with no proguard config; R8 stripped
WorkDatabase_Impl's reflection-only constructor, crashing every launch
with NoSuchMethodException. Explicitly disabled both flags in
build.gradle.kts. Verified via dexdump (constructor present) and on-device
launch (no crash). Proper R8 keep rules are the long-term fix; tracked.

177 tests, flutter analyze clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWPUBzE24Ls9i9GMRwXnnn
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-06-25 17:29:23 +02:00
parent 0ddad00ab9
commit adbfb20e9a
18 changed files with 1828 additions and 56 deletions

View File

@ -12,6 +12,9 @@ android {
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = 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 { defaultConfig {
@ -30,10 +33,22 @@ android {
// TODO: Add your own signing config for the release build. // TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works. // Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug") 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 { kotlin {
compilerOptions { compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17

View File

@ -3,6 +3,26 @@
intent, but declaring this avoids picker failures on OEMs that check intent, but declaring this avoids picker failures on OEMs that check
for it before honoring the intent. --> for it before honoring the intent. -->
<uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.CAMERA"/>
<!-- Sync (github_client.dart) needs raw socket access. -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Android 13+ requires runtime opt-in to post notifications; requested
at launch via NotificationService.requestPermission(). -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- WorkManager needs this to keep the CPU awake long enough to finish
a periodic due-slot check. -->
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<!-- Lets WorkManager re-register the periodic task after a reboot. -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<!-- Needed for the "Disable battery optimization" button in settings
(permission_handler's ignoreBatteryOptimizations) to actually show
the system dialog instead of silently returning denied. -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<!-- Deliberately NOT requesting SCHEDULE_EXACT_ALARM: the background
check is a 15-minute WorkManager periodic task, not exact alarms
(more robust against OEM background-kill; +/-15 min is accepted, see
background_check_service.dart). Don't add this permission to "fix"
perceived lateness, which trades robustness for precision we don't
need. -->
<application <application
android:label="diet_guard_app" android:label="diet_guard_app"
android:name="${applicationName}" android:name="${applicationName}"

View File

@ -1,16 +1,35 @@
/// App entry point: initializes local storage services, then shows the /// App entry point: initializes local storage services, registers the
/// primary meal-logging screen. /// background due-slot check, then shows the primary meal-logging screen.
library; library;
import 'dart:io';
import 'package:diet_guard_app/screens/log_meal_screen.dart'; import 'package:diet_guard_app/screens/log_meal_screen.dart';
import 'package:diet_guard_app/services/background_check_service.dart';
import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/notification_service.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:workmanager/workmanager.dart';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await LogStorageService.init(); await LogStorageService.init();
await FoodBankService.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()); runApp(const DietGuardApp());
} }

View File

@ -1,26 +1,41 @@
/// GitHub sync configuration: paste a PAT, test the connection, and trigger /// GitHub sync configuration. Primary path: "Connect GitHub" runs the OAuth
/// a manual sync. Auto-sync (app launch + lifecycle pause/resume) lives in /// **device flow** (authorize in a browser, no token pasting). A manually
/// [LogMealScreen] and is silent on failure -- this screen is where errors /// pasted PAT remains as a fallback under "Advanced". Auto-sync (app launch
/// get surfaced, via [SnackBar]. /// + lifecycle pause/resume) lives in [LogMealScreen] and is silent on
/// failure -- this screen is where errors get surfaced, as inline status
/// text.
library; library;
import 'dart:async'; import 'dart:async';
import 'package:diet_guard_app/screens/log_meal_screen.dart'; 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_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_service.dart';
import 'package:diet_guard_app/services/sync_settings.dart'; import 'package:diet_guard_app/services/sync_settings.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http; 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. /// Screen for configuring and triggering cross-device sync.
class SettingsScreen extends StatefulWidget { class SettingsScreen extends StatefulWidget {
/// Creates a [SettingsScreen]. /// Creates a [SettingsScreen].
const SettingsScreen({super.key, this.httpClient}); const SettingsScreen({
super.key,
this.httpClient,
this.requestBatteryExemption,
});
/// Injectable HTTP client; tests pass a [MockClient]. /// Injectable HTTP client; tests pass a [MockClient].
final http.Client? httpClient; final http.Client? httpClient;
/// Injectable battery-optimization-exemption request; tests pass a fake.
/// Production defaults to
/// `Permission.ignoreBatteryOptimizations.request()`.
final Future<PermissionStatus> Function()? requestBatteryExemption;
@override @override
State<SettingsScreen> createState() => _SettingsScreenState(); State<SettingsScreen> createState() => _SettingsScreenState();
} }
@ -29,8 +44,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
final _ownerController = TextEditingController(); final _ownerController = TextEditingController();
final _repoController = TextEditingController(); final _repoController = TextEditingController();
final _tokenController = TextEditingController(); final _tokenController = TextEditingController();
final _clientIdController = TextEditingController();
bool _loading = true; bool _loading = true;
bool _busy = false; bool _busy = false;
String? _status;
@override @override
void initState() { void initState() {
@ -52,6 +69,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_ownerController.text = settings.owner; _ownerController.text = settings.owner;
_repoController.text = settings.repo; _repoController.text = settings.repo;
_tokenController.text = settings.token; _tokenController.text = settings.token;
_clientIdController.text = settings.clientId;
setState(() => _loading = false); setState(() => _loading = false);
} }
@ -60,6 +78,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_ownerController.dispose(); _ownerController.dispose();
_repoController.dispose(); _repoController.dispose();
_tokenController.dispose(); _tokenController.dispose();
_clientIdController.dispose();
super.dispose(); super.dispose();
} }
@ -67,13 +86,71 @@ class _SettingsScreenState extends State<SettingsScreen> {
owner: _ownerController.text.trim(), owner: _ownerController.text.trim(),
repo: _repoController.text.trim(), repo: _repoController.text.trim(),
token: _tokenController.text.trim(), token: _tokenController.text.trim(),
clientId: _clientIdController.text.trim(),
); );
void _showMessage(String message) { void _showMessage(String message) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( setState(() => _status = message);
SnackBar(content: Text(message)), }
/// Runs the OAuth device flow and, on success, fills in the token field.
Future<void> _connectGitHub() async {
var clientId = _clientIdController.text.trim();
if (clientId.isEmpty) {
final entered = await showDialog<String>(
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<String>(
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<void> _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<void> _save() async { Future<void> _save() async {
@ -125,6 +202,25 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
} }
/// 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<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_loading) { if (_loading) {
@ -132,49 +228,257 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Sync settings')), appBar: AppBar(title: const Text('Sync settings')),
body: Padding( body: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, Text(
children: [ 'Authorize in your browser — no token to paste. Syncs to '
TextField( 'kuhyx/diet-guard-sync by default.',
controller: _ownerController, style: Theme.of(context).textTheme.bodySmall,
decoration: const InputDecoration(labelText: 'GitHub owner'), ),
), const SizedBox(height: 12),
const SizedBox(height: 8), FilledButton.icon(
TextField( onPressed: _connectGitHub,
controller: _repoController, icon: const Icon(Icons.login),
decoration: const InputDecoration(labelText: 'Repo'), label: const Text('Connect GitHub'),
), ),
const SizedBox(height: 8), const SizedBox(height: 16),
TextField( TextField(
controller: _tokenController, controller: _ownerController,
obscureText: true, decoration: const InputDecoration(labelText: 'GitHub owner'),
decoration: const InputDecoration( ),
labelText: 'Personal access token', 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), const SizedBox(height: 16),
Wrap( Text(_status!, style: Theme.of(context).textTheme.bodyMedium),
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'),
),
],
),
], ],
), ],
), ),
); );
} }
} }
/// 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<void> _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<void> _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'),
),
],
);
}
}

View File

@ -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<void> checkAndNotify({DateTime? now}) async {
await LogStorageService.init();
await NotificationService.init();
final logged = await LogStorageService.instance.loggedSlotsToday();
final due = missingSlots(now ?? DateTime.now(), logged);
await NotificationService.instance.syncToSlots(due);
}
/// WorkManager entry point invoked by the OS on each periodic tick.
///
/// Deliberately thin: all logic lives in [checkAndNotify] so it stays unit
/// 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

View File

@ -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<String, dynamic> 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<void> Function(Duration)? delay,
}) : _http = httpClient ?? http.Client(),
// Indirection so tests can skip real waiting between polls.
_delay = delay ?? Future<void>.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<void> Function(Duration) _delay;
static const _deviceCodeUrl = 'https://github.com/login/device/code';
static const _tokenUrl = 'https://github.com/login/oauth/access_token';
static const _grantType = 'urn:ietf:params:oauth:grant-type:device_code';
/// Step 1: ask GitHub for a device + user code.
Future<DeviceCodeResponse> requestDeviceCode() async {
final res = await _http.post(
Uri.parse(_deviceCodeUrl),
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<String, dynamic>,
);
}
/// 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<String> 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<String, dynamic>;
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();
}

View File

@ -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<NotificationService> 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<bool?> 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<void> syncToSlots(List<int> 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<void> _show(int slot) async {
const details = NotificationDetails(
android: AndroidNotificationDetails(
_channelId,
_channelName,
importance: Importance.high,
priority: Priority.high,
),
);
await _plugin.show(
id: slot,
title: 'Meal not logged',
body: "You haven't logged your ${slotLabel(slot)} meal yet.",
notificationDetails: details,
);
}
}

View File

@ -1,7 +1,7 @@
/// Locally-stored GitHub sync configuration, ported from `~/todo`'s /// Locally-stored GitHub sync configuration, ported from `~/todo`'s
/// `sync/sync_settings.dart` -- with the OAuth device-flow fields dropped: /// `sync/sync_settings.dart`, including the OAuth device-flow fields: the
/// the phone leans on a pasted PAT instead (the plan's call to pick /// "Connect GitHub" button is the primary path, with a pasted PAT kept as a
/// "whichever is less code", and pasting is strictly less code here). /// manual fallback.
library; library;
import 'package:flutter/services.dart' show PlatformException; import 'package:flutter/services.dart' show PlatformException;
@ -20,6 +20,7 @@ class SyncSettings {
required this.owner, required this.owner,
required this.repo, required this.repo,
required this.token, required this.token,
this.clientId = '',
}); });
/// The repo owner/org (e.g. `"kuhyx"`). /// The repo owner/org (e.g. `"kuhyx"`).
@ -31,12 +32,29 @@ class SyncSettings {
/// A GitHub PAT with contents read/write on [owner]/[repo]. /// A GitHub PAT with contents read/write on [owner]/[repo].
final String token; 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. /// True when enough is set to attempt a sync.
bool get isConfigured => bool get isConfigured =>
owner.isNotEmpty && repo.isNotEmpty && token.isNotEmpty; 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 _kOwner = 'sync.owner';
static const _kRepo = 'sync.repo'; static const _kRepo = 'sync.repo';
static const _kClientId = 'sync.clientId';
// Legacy plaintext location for the token; read-only now and removed once // Legacy plaintext location for the token; read-only now and removed once
// the token has been migrated into secure storage. // the token has been migrated into secure storage.
static const _kToken = 'sync.token'; static const _kToken = 'sync.token';
@ -49,14 +67,16 @@ class SyncSettings {
static const _secure = FlutterSecureStorage(); static const _secure = FlutterSecureStorage();
/// Loads settings, defaulting the owner/repo to `kuhyx/diet-guard-sync` /// Loads settings, defaulting the owner/repo to `kuhyx/diet-guard-sync`
/// (matching the PC's `SYNC_REPO_OWNER`/`SYNC_REPO_NAME` constants) so a /// (matching the PC's `SYNC_REPO_OWNER`/`SYNC_REPO_NAME` constants) and the
/// fresh install needs only a pasted PAT. /// 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<SyncSettings> load() async { static Future<SyncSettings> load() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
return SyncSettings( return SyncSettings(
owner: prefs.getString(_kOwner) ?? 'kuhyx', owner: prefs.getString(_kOwner) ?? 'kuhyx',
repo: prefs.getString(_kRepo) ?? 'diet-guard-sync', repo: prefs.getString(_kRepo) ?? 'diet-guard-sync',
token: await _loadToken(prefs), token: await _loadToken(prefs),
clientId: prefs.getString(_kClientId) ?? defaultClientId,
); );
} }
@ -86,6 +106,7 @@ class SyncSettings {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kOwner, owner); await prefs.setString(_kOwner, owner);
await prefs.setString(_kRepo, repo); await prefs.setString(_kRepo, repo);
await prefs.setString(_kClientId, clientId);
// Confirm-before-delete: only remove the plaintext copy once the keystore // Confirm-before-delete: only remove the plaintext copy once the keystore
// write succeeds; otherwise keep persisting it to prefs as before. // write succeeds; otherwise keep persisting it to prefs as before.
if (await _writeSecureToken(token)) { if (await _writeSecureToken(token)) {
@ -111,11 +132,17 @@ class SyncSettings {
} }
/// Returns a copy of this with only the given fields replaced. /// 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( return SyncSettings(
owner: owner ?? this.owner, owner: owner ?? this.owner,
repo: repo ?? this.repo, repo: repo ?? this.repo,
token: token ?? this.token, token: token ?? this.token,
clientId: clientId ?? this.clientId,
); );
} }
} }

View File

@ -8,6 +8,7 @@
#include <file_selector_linux/file_selector_plugin.h> #include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h> #include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = 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 = g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); 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);
} }

View File

@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux file_selector_linux
flutter_secure_storage_linux flutter_secure_storage_linux
url_launcher_linux
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@ -73,6 +73,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
dbus:
dependency: transitive
description:
name: dbus
sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91"
url: "https://pub.dev"
source: hosted
version: "0.7.13"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -150,6 +158,46 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@ -456,6 +504,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" 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: platform:
dependency: transitive dependency: transitive
description: description:
@ -597,6 +701,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" version: "0.7.11"
timezone:
dependency: transitive
description:
name: timezone
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
url: "https://pub.dev"
source: hosted
version: "0.11.0"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@ -605,6 +717,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" 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: uuid:
dependency: "direct main" dependency: "direct main"
description: description:
@ -653,6 +829,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.0" 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: xdg_directories:
dependency: transitive dependency: transitive
description: description:
@ -661,6 +869,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:

View File

@ -10,18 +10,23 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
flutter_local_notifications: ^22.0.1
flutter_secure_storage: ^10.3.1 flutter_secure_storage: ^10.3.1
http: ^1.6.0 http: ^1.6.0
image_picker: ^1.1.2 image_picker: ^1.1.2
path: ^1.9.1 path: ^1.9.1
path_provider: ^2.1.5 path_provider: ^2.1.5
permission_handler: ^12.0.3
shared_preferences: ^2.5.5 shared_preferences: ^2.5.5
url_launcher: ^6.3.2
uuid: ^4.5.3 uuid: ^4.5.3
workmanager: ^0.9.0+3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
image_picker_platform_interface: ^2.10.0 image_picker_platform_interface: ^2.10.0
url_launcher_platform_interface: ^2.3.2
very_good_analysis: ^10.2.0 very_good_analysis: ^10.2.0
flutter: flutter:

View File

@ -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<MethodCall> installFakeAndroidNotifications() {
AndroidFlutterLocalNotificationsPlugin.registerWith();
debugDefaultTargetPlatformOverride = TargetPlatform.android;
const channel = MethodChannel('dexterous.com/flutter/local_notifications');
final log = <MethodCall>[];
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;
}

View File

@ -1,16 +1,42 @@
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:diet_guard_app/screens/settings_screen.dart'; import 'package:diet_guard_app/screens/settings_screen.dart';
import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:http/testing.dart'; 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: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'; 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<bool> supportsMode(PreferredLaunchMode mode) async => true;
@override
Future<bool> launchUrl(String url, LaunchOptions options) async {
launched = url;
return true;
}
}
void main() { void main() {
late Directory tempDir; late Directory tempDir;
@ -30,12 +56,33 @@ void main() {
// SettingsScreen loads its settings via a fire-and-forget Future in // SettingsScreen loads its settings via a fire-and-forget Future in
// initState that Flutter's frame scheduler does not track -- same pitfall // 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<void> settle(WidgetTester tester) async { Future<void> 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<void>.delayed(const Duration(milliseconds: 200)); await Future<void>.delayed(const Duration(milliseconds: 200));
await tester.pumpAndSettle(); 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<void> pumpUntil(
WidgetTester tester,
bool Function() done, {
int maxTries = 200,
}) async {
for (var i = 0; i < maxTries && !done(); i++) {
await Future<void>.delayed(const Duration(milliseconds: 10));
await tester.pump();
}
}
testWidgets('shows the kuhyx/diet-guard-sync defaults on a fresh install', ( testWidgets('shows the kuhyx/diet-guard-sync defaults on a fresh install', (
tester, tester,
) async { ) async {
@ -53,8 +100,10 @@ void main() {
await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
await settle(tester); await settle(tester);
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
await tester.enterText( await tester.enterText(
find.widgetWithText(TextField, 'Personal access token'), find.widgetWithText(TextField, 'Personal access token (fallback)'),
'my-pat', 'my-pat',
); );
await tester.tap(find.widgetWithText(ElevatedButton, 'Save')); await tester.tap(find.widgetWithText(ElevatedButton, 'Save'));
@ -145,4 +194,351 @@ void main() {
expect(find.textContaining('Sync failed:'), findsOneWidget); 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<void> 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,
);
});
});
} }

View File

@ -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<MethodCall> 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,
);
});
}

View File

@ -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<void>.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<DeviceAuthException>()),
);
});
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<DeviceAuthException>().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<DeviceAuthException>()),
);
});
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<DeviceAuthException>().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');
});
}

View File

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

View File

@ -18,6 +18,7 @@ void main() {
expect(s.owner, 'kuhyx'); expect(s.owner, 'kuhyx');
expect(s.repo, 'diet-guard-sync'); expect(s.repo, 'diet-guard-sync');
expect(s.token, ''); 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', () { 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'); final next = base.copyWith(token: 'new');
expect(next.owner, 'o'); expect(next.owner, 'o');
expect(next.repo, 'r'); expect(next.repo, 'r');
expect(next.token, 'new'); expect(next.token, 'new');
expect(next.clientId, 'cid');
// No-arg copy exercises the `?? this.x` fallback on every field. // No-arg copy exercises the `?? this.x` fallback on every field.
final clone = base.copyWith(); final clone = base.copyWith();
expect(clone.owner, 'o'); expect(clone.owner, 'o');
expect(clone.repo, 'r'); expect(clone.repo, 'r');
expect(clone.token, 't'); expect(clone.token, 't');
expect(clone.clientId, 'cid');
}); });
} }