mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-07-04 13:43:30 +02:00
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:
parent
0ddad00ab9
commit
adbfb20e9a
@ -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
|
||||
|
||||
@ -3,6 +3,26 @@
|
||||
intent, but declaring this avoids picker failures on OEMs that check
|
||||
for it before honoring the intent. -->
|
||||
<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
|
||||
android:label="diet_guard_app"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@ -1,16 +1,35 @@
|
||||
/// App entry point: initializes local storage services, then shows the
|
||||
/// primary meal-logging screen.
|
||||
/// App entry point: initializes local storage services, registers the
|
||||
/// background due-slot check, then shows the primary meal-logging screen.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
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/log_storage_service.dart';
|
||||
import 'package:diet_guard_app/services/notification_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
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());
|
||||
}
|
||||
|
||||
|
||||
@ -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<PermissionStatus> Function()? requestBatteryExemption;
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
@ -29,8 +44,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
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<SettingsScreen> {
|
||||
_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<SettingsScreen> {
|
||||
_ownerController.dispose();
|
||||
_repoController.dispose();
|
||||
_tokenController.dispose();
|
||||
_clientIdController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -67,13 +86,71 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
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<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 {
|
||||
@ -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
|
||||
Widget build(BuildContext context) {
|
||||
if (_loading) {
|
||||
@ -132,49 +228,257 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
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<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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
52
app/lib/services/background_check_service.dart
Normal file
52
app/lib/services/background_check_service.dart
Normal 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
|
||||
154
app/lib/services/github_device_auth.dart
Normal file
154
app/lib/services/github_device_auth.dart
Normal 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();
|
||||
}
|
||||
114
app/lib/services/notification_service.dart
Normal file
114
app/lib/services/notification_service.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<SyncSettings> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
|
||||
#include <file_selector_linux/file_selector_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) {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
216
app/pubspec.lock
216
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:
|
||||
|
||||
@ -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:
|
||||
|
||||
40
app/test/fake_notifications.dart
Normal file
40
app/test/fake_notifications.dart
Normal 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;
|
||||
}
|
||||
@ -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<bool> supportsMode(PreferredLaunchMode mode) async => true;
|
||||
|
||||
@override
|
||||
Future<bool> 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<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 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', (
|
||||
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<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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
87
app/test/services/background_check_service_test.dart
Normal file
87
app/test/services/background_check_service_test.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
175
app/test/services/github_device_auth_test.dart
Normal file
175
app/test/services/github_device_auth_test.dart
Normal 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');
|
||||
});
|
||||
}
|
||||
105
app/test/services/notification_service_test.dart
Normal file
105
app/test/services/notification_service_test.dart
Normal 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));
|
||||
});
|
||||
}
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user