diet-guard/app/lib/screens/settings_screen.dart
Krzysztof kuhy Rudnicki adbfb20e9a 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
2026-06-25 17:29:23 +02:00

485 lines
15 KiB
Dart

/// 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,
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();
}
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() {
super.initState();
unawaited(_load());
}
/// Loads saved settings, defaulting to blank fields if loading itself
/// fails (e.g. no secret service available yet) -- the screen must still
/// render, not spin forever, so the user can fill them in from scratch.
Future<void> _load() async {
SyncSettings settings;
try {
settings = await SyncSettings.load();
} on Exception {
settings = const SyncSettings(owner: '', repo: '', token: '');
}
if (!mounted) return;
_ownerController.text = settings.owner;
_repoController.text = settings.repo;
_tokenController.text = settings.token;
_clientIdController.text = settings.clientId;
setState(() => _loading = false);
}
@override
void dispose() {
_ownerController.dispose();
_repoController.dispose();
_tokenController.dispose();
_clientIdController.dispose();
super.dispose();
}
SyncSettings _currentSettings() => SyncSettings(
owner: _ownerController.text.trim(),
repo: _repoController.text.trim(),
token: _tokenController.text.trim(),
clientId: _clientIdController.text.trim(),
);
void _showMessage(String message) {
if (!mounted) return;
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 {
setState(() => _busy = true);
await _currentSettings().save();
if (!mounted) return;
setState(() => _busy = false);
_showMessage('Saved.');
}
Future<void> _testConnection() async {
setState(() => _busy = true);
final settings = _currentSettings();
final client = GitHubClient(
owner: settings.owner,
repo: settings.repo,
token: settings.token,
httpClient: widget.httpClient,
);
try {
final ok = await client.canAccessRepo();
_showMessage(ok ? 'Connection OK.' : 'Connection failed.');
} on Exception catch (e) {
_showMessage('Connection failed: $e');
} finally {
client.close();
if (mounted) setState(() => _busy = false);
}
}
Future<void> _syncNow() async {
setState(() => _busy = true);
final settings = _currentSettings();
await settings.save();
final client = GitHubClient(
owner: settings.owner,
repo: settings.repo,
token: settings.token,
httpClient: widget.httpClient,
);
try {
await runSync(client);
_showMessage('Synced.');
} on Exception catch (e) {
_showMessage('Sync failed: $e');
} finally {
client.close();
if (mounted) setState(() => _busy = false);
}
}
/// 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) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
appBar: AppBar(title: const Text('Sync settings')),
body: ListView(
padding: const EdgeInsets.all(16),
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),
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'),
),
],
);
}
}