mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 13:07:40 +01:00
Split the last four Dart lib files; file-length gate now reports zero
Final batch of the 250-line sweep. `check_file_length.sh --all` exits 0: every file in the repo is at or under 250 lines. github_mirror_screen.dart 391 -> 205 (+ actions, client_id_dialog parts) foodbank_service.dart 314 -> 209 (+ foodbank_service_manual part) settings_screen.dart 286 -> 237 (+ settings_kcal_goal part) firebase_backend.dart 282 -> 100 (+ firebase_client.dart) Three of the four are `part` files rather than separate libraries, and that is forced rather than stylistic: the moved code drives private state (`_busy`, `_status`, the text controllers) and calls private helpers (`_readBank`, `_normalize`), none of which are visible across a library boundary in Dart. Widening them to public just to split a file would leak internals the rest of the app has no business calling. Within those parts, mixins rather than extensions, for a concrete reason: an extension is not a subclass of `State`, so calling `setState` from one is an `invalid_use_of_protected_member`. Fields the State must own (the controllers it creates and disposes) stay declared there and are reached through abstract getters. `firebase_client.dart` is a real library split -- opening an authenticated client has no private coupling to the keystore-backed account record, only to its public API. The one exception, dropping the account marker without setting the opt-out flag, became `forgetAccountMarker()` rather than exposing the secure store. Verified: flutter test 555 (unchanged), flutter analyze clean, 707 Python tests / 100% branch coverage, check_patch_targets 0, pre-commit clean, check_file_length.sh --all exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
This commit is contained in:
parent
3c67cf8c9c
commit
c77e7d9acf
83
app/lib/screens/github_client_id_dialog.dart
Normal file
83
app/lib/screens/github_client_id_dialog.dart
Normal file
@ -0,0 +1,83 @@
|
||||
// The "paste your OAuth client id" dialog shown before the device flow.
|
||||
//
|
||||
// Split out of `github_mirror_screen.dart` for the repo's 250-line cap. A
|
||||
// `part` so the class can stay library-private: it is an implementation
|
||||
// detail of that screen and nothing else may construct it.
|
||||
|
||||
part of 'github_mirror_screen.dart';
|
||||
|
||||
/// 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
|
||||
/// 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/syncs.',
|
||||
),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
145
app/lib/screens/github_mirror_actions.dart
Normal file
145
app/lib/screens/github_mirror_actions.dart
Normal file
@ -0,0 +1,145 @@
|
||||
// Connect / save / test / sync actions for the GitHub mirror screen.
|
||||
//
|
||||
// Split out of `github_mirror_screen.dart` for the repo's 250-line cap. A
|
||||
// `part` rather than a separate library: every one of these methods drives
|
||||
// the screen's private state (`_busy`, `_status`, `_storedToken`, the four
|
||||
// controllers) and calls its private helpers, none of which are reachable
|
||||
// across a library boundary in Dart.
|
||||
//
|
||||
// A mixin rather than an extension, because these call `setState`, which is
|
||||
// `@protected` -- an extension is not a subclass of `State`, so the analyzer
|
||||
// (correctly) rejects it there.
|
||||
|
||||
part of 'github_mirror_screen.dart';
|
||||
|
||||
mixin _GitHubMirrorActions on State<GitHubMirrorScreen> {
|
||||
// The four text controllers stay declared on the State, which creates and
|
||||
// disposes them; the mixin only reads and writes their text.
|
||||
TextEditingController get _ownerController;
|
||||
TextEditingController get _repoController;
|
||||
TextEditingController get _tokenController;
|
||||
TextEditingController get _clientIdController;
|
||||
|
||||
bool _busy = false;
|
||||
String? _status;
|
||||
String _storedToken = '';
|
||||
SyncSettings _currentSettings() {
|
||||
final typed = _tokenController.text.trim();
|
||||
return SyncSettings(
|
||||
owner: _ownerController.text.trim(),
|
||||
repo: _repoController.text.trim(),
|
||||
// The `_storedToken` side is web-only (the wrapper holds the real
|
||||
// token), so it is unreachable from a VM test.
|
||||
token: typed.isEmpty && !SyncSettings.exposesTokenValue
|
||||
// coverage:ignore-line
|
||||
? _storedToken
|
||||
: typed,
|
||||
clientId: _clientIdController.text.trim(),
|
||||
);
|
||||
}
|
||||
void _showMessage(String message) {
|
||||
if (!mounted) return;
|
||||
setState(() => _status = message);
|
||||
}
|
||||
|
||||
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 = createDeviceAuth(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(() {
|
||||
_storedToken = token;
|
||||
if (SyncSettings.exposesTokenValue) _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 = createGitHubClient(settings, httpClient: widget.httpClient);
|
||||
try {
|
||||
await runSync(await syncBackend(client));
|
||||
await SyncHealth.recordSuccess();
|
||||
_showMessage('Connected and synced.');
|
||||
} on Exception catch (e) {
|
||||
await SyncHealth.recordFailure();
|
||||
_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 = createGitHubClient(settings, httpClient: widget.httpClient);
|
||||
try {
|
||||
final ok = await client.canAccessRepo();
|
||||
_showMessage(
|
||||
ok ? 'GitHub connection OK.' : 'GitHub connection failed.',
|
||||
);
|
||||
} on Exception catch (e) {
|
||||
_showMessage('GitHub 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 = createGitHubClient(settings, httpClient: widget.httpClient);
|
||||
try {
|
||||
await runSync(await syncBackend(client));
|
||||
// Clears any stored failure: this is the button a user reaches
|
||||
// *because* the banner told them syncing had stopped, so a successful
|
||||
// run here must dismiss the warning it caused.
|
||||
await SyncHealth.recordSuccess();
|
||||
_showMessage('Synced.');
|
||||
} on Exception catch (e) {
|
||||
await SyncHealth.recordFailure();
|
||||
_showMessage('Sync failed: $e');
|
||||
} finally {
|
||||
client.close();
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,7 @@ library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:diet_guard_app/services/firebase_backend.dart';
|
||||
import 'package:diet_guard_app/services/firebase_client.dart';
|
||||
import 'package:diet_guard_app/services/github_client_factory.dart';
|
||||
import 'package:diet_guard_app/services/sync_health.dart';
|
||||
import 'package:diet_guard_app/services/sync_service.dart';
|
||||
@ -18,6 +18,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:github_device_auth/github_device_auth.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
part 'github_client_id_dialog.dart';
|
||||
part 'github_mirror_actions.dart';
|
||||
|
||||
/// Screen for configuring and triggering the GitHub mirror sync.
|
||||
class GitHubMirrorScreen extends StatefulWidget {
|
||||
/// Creates a [GitHubMirrorScreen].
|
||||
@ -30,14 +33,17 @@ class GitHubMirrorScreen extends StatefulWidget {
|
||||
State<GitHubMirrorScreen> createState() => _GitHubMirrorScreenState();
|
||||
}
|
||||
|
||||
class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
||||
class _GitHubMirrorScreenState extends State<GitHubMirrorScreen>
|
||||
with _GitHubMirrorActions {
|
||||
@override
|
||||
final _ownerController = TextEditingController();
|
||||
@override
|
||||
final _repoController = TextEditingController();
|
||||
@override
|
||||
final _tokenController = TextEditingController();
|
||||
@override
|
||||
final _clientIdController = TextEditingController();
|
||||
bool _loading = true;
|
||||
bool _busy = false;
|
||||
String? _status;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -87,129 +93,13 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
||||
|
||||
/// The token as loaded, so a platform that cannot display it (web) still
|
||||
/// round-trips it instead of blanking it on the next save.
|
||||
String _storedToken = '';
|
||||
|
||||
SyncSettings _currentSettings() {
|
||||
final typed = _tokenController.text.trim();
|
||||
return SyncSettings(
|
||||
owner: _ownerController.text.trim(),
|
||||
repo: _repoController.text.trim(),
|
||||
// The `_storedToken` side is web-only (the wrapper holds the real
|
||||
// token), so it is unreachable from a VM test.
|
||||
token: typed.isEmpty && !SyncSettings.exposesTokenValue
|
||||
// coverage:ignore-line
|
||||
? _storedToken
|
||||
: typed,
|
||||
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 = createDeviceAuth(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(() {
|
||||
_storedToken = token;
|
||||
if (SyncSettings.exposesTokenValue) _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 = createGitHubClient(settings, httpClient: widget.httpClient);
|
||||
try {
|
||||
await runSync(await syncBackend(client));
|
||||
await SyncHealth.recordSuccess();
|
||||
_showMessage('Connected and synced.');
|
||||
} on Exception catch (e) {
|
||||
await SyncHealth.recordFailure();
|
||||
_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 = createGitHubClient(settings, httpClient: widget.httpClient);
|
||||
try {
|
||||
final ok = await client.canAccessRepo();
|
||||
_showMessage(
|
||||
ok ? 'GitHub connection OK.' : 'GitHub connection failed.',
|
||||
);
|
||||
} on Exception catch (e) {
|
||||
_showMessage('GitHub 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 = createGitHubClient(settings, httpClient: widget.httpClient);
|
||||
try {
|
||||
await runSync(await syncBackend(client));
|
||||
// Clears any stored failure: this is the button a user reaches
|
||||
// *because* the banner told them syncing had stopped, so a successful
|
||||
// run here must dismiss the warning it caused.
|
||||
await SyncHealth.recordSuccess();
|
||||
_showMessage('Synced.');
|
||||
} on Exception catch (e) {
|
||||
await SyncHealth.recordFailure();
|
||||
_showMessage('Sync failed: $e');
|
||||
} finally {
|
||||
client.close();
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -313,79 +203,3 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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/syncs.',
|
||||
),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:diet_guard_app/services/background_tasks.dart';
|
||||
import 'package:diet_guard_app/services/firebase_backend.dart';
|
||||
import 'package:diet_guard_app/services/firebase_client.dart';
|
||||
import 'package:diet_guard_app/services/github_client_factory.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
import 'package:diet_guard_app/services/sync_health.dart';
|
||||
|
||||
62
app/lib/screens/settings_kcal_goal.dart
Normal file
62
app/lib/screens/settings_kcal_goal.dart
Normal file
@ -0,0 +1,62 @@
|
||||
// The debounced daily-kcal-goal field and the battery-exemption request.
|
||||
//
|
||||
// Split out of `settings_screen.dart` for the repo's 250-line cap. A `part`
|
||||
// mixin: these drive the screen's private state and call `setState`, which is
|
||||
// `@protected` and therefore unreachable from an extension.
|
||||
|
||||
part of 'settings_screen.dart';
|
||||
|
||||
mixin _SettingsKcalGoal on State<SettingsScreen> {
|
||||
String? _status;
|
||||
|
||||
void _showMessage(String message) {
|
||||
if (!mounted) return;
|
||||
setState(() => _status = message);
|
||||
}
|
||||
|
||||
Timer? _kcalGoalDebounce;
|
||||
int? _pendingKcalGoal;
|
||||
|
||||
/// Persists the pending goal, if any, and clears it.
|
||||
void _flushKcalGoal() {
|
||||
final goal = _pendingKcalGoal;
|
||||
if (goal == null) return;
|
||||
_pendingKcalGoal = null;
|
||||
unawaited(AppSettingsService.instance.saveDailyKcalGoal(goal));
|
||||
}
|
||||
|
||||
/// Saves the typed goal once typing settles.
|
||||
///
|
||||
/// Debounced because every keystroke would otherwise be a real edit:
|
||||
/// typing "2000" saved 2, 20, 200, 2000 in turn, and a sync tick landing
|
||||
/// between keystrokes would push a nonsense budget to the other device.
|
||||
void _onKcalGoalChanged(String value) {
|
||||
_kcalGoalDebounce?.cancel();
|
||||
final goal = int.tryParse(value);
|
||||
if (goal == null || goal <= 0) return;
|
||||
_pendingKcalGoal = goal;
|
||||
_kcalGoalDebounce = Timer(
|
||||
const Duration(milliseconds: 600),
|
||||
_flushKcalGoal,
|
||||
);
|
||||
}
|
||||
|
||||
/// 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,7 @@ import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:diet_guard_app/screens/github_mirror_screen.dart';
|
||||
import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||
import 'package:diet_guard_app/services/firebase_backend.dart';
|
||||
import 'package:diet_guard_app/services/firebase_client.dart';
|
||||
import 'package:diet_guard_app/services/google_sign_in_backend.dart';
|
||||
import 'package:diet_guard_app/ui/theme.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
@ -23,6 +24,8 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:sync_settings_ui/sync_settings_ui.dart';
|
||||
|
||||
part 'settings_kcal_goal.dart';
|
||||
|
||||
/// Screen for app-specific settings and links to sync configuration.
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
/// Creates a [SettingsScreen].
|
||||
@ -87,7 +90,8 @@ class SettingsScreen extends StatefulWidget {
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
class _SettingsScreenState extends State<SettingsScreen>
|
||||
with _SettingsKcalGoal {
|
||||
final _kcalGoalController = TextEditingController();
|
||||
|
||||
@override
|
||||
@ -106,59 +110,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Timer? _kcalGoalDebounce;
|
||||
int? _pendingKcalGoal;
|
||||
|
||||
/// Persists the pending goal, if any, and clears it.
|
||||
void _flushKcalGoal() {
|
||||
final goal = _pendingKcalGoal;
|
||||
if (goal == null) return;
|
||||
_pendingKcalGoal = null;
|
||||
unawaited(AppSettingsService.instance.saveDailyKcalGoal(goal));
|
||||
}
|
||||
|
||||
/// Saves the typed goal once typing settles.
|
||||
///
|
||||
/// Debounced because every keystroke would otherwise be a real edit:
|
||||
/// typing "2000" saved 2, 20, 200, 2000 in turn, and a sync tick landing
|
||||
/// between keystrokes would push a nonsense budget to the other device.
|
||||
void _onKcalGoalChanged(String value) {
|
||||
_kcalGoalDebounce?.cancel();
|
||||
final goal = int.tryParse(value);
|
||||
if (goal == null || goal <= 0) return;
|
||||
_pendingKcalGoal = goal;
|
||||
_kcalGoalDebounce = Timer(
|
||||
const Duration(milliseconds: 600),
|
||||
_flushKcalGoal,
|
||||
);
|
||||
}
|
||||
|
||||
/// 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');
|
||||
}
|
||||
}
|
||||
|
||||
String? _status;
|
||||
|
||||
void _showMessage(String message) {
|
||||
if (!mounted) return;
|
||||
setState(() => _status = message);
|
||||
}
|
||||
|
||||
/// True on the one platform with OEM battery optimization to exempt from.
|
||||
///
|
||||
/// Uses [defaultTargetPlatform] rather than `Platform.isAndroid` because
|
||||
|
||||
@ -8,7 +8,7 @@ library;
|
||||
import 'dart:developer';
|
||||
import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||
import 'package:diet_guard_app/services/budget_history_service.dart';
|
||||
import 'package:diet_guard_app/services/firebase_backend.dart';
|
||||
import 'package:diet_guard_app/services/firebase_client.dart';
|
||||
import 'package:diet_guard_app/services/foodbank_service.dart';
|
||||
import 'package:diet_guard_app/services/github_client_factory.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
|
||||
@ -14,9 +14,7 @@ library;
|
||||
|
||||
import 'dart:developer';
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:diet_guard_app/services/google_sign_in_backend.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// The shared `kuhy-syncs` project.
|
||||
///
|
||||
@ -99,184 +97,10 @@ Future<void> clearAccount() async {
|
||||
await credentialStore().clear();
|
||||
}
|
||||
|
||||
/// Returns a signed-in Firebase client, or null when not configured.
|
||||
/// Drops just the stored account marker, leaving the opt-out flag alone.
|
||||
///
|
||||
/// Signs in with the stored password only when there is no cached refresh
|
||||
/// token, so the usual path costs no authentication round trip.
|
||||
Future<FirebaseRestClient?> openFirebase() async {
|
||||
final account = await loadAccount();
|
||||
if (account == null) {
|
||||
// A stored refresh token is a signed-in device, even with no account
|
||||
// marker beside it. Treating the marker as the source of truth is what
|
||||
// turned one missing write into "syncs over GitHub and 401s forever":
|
||||
// the credential that actually authenticates was sitting in the keystore
|
||||
// the whole time, unused. Ask the store directly before giving up.
|
||||
return _clientFromStoredSession();
|
||||
}
|
||||
try {
|
||||
return await firebaseClientFor(
|
||||
config: kProject.configFor(account.email),
|
||||
store: credentialStore(),
|
||||
// A Google-provisioned account stores an empty password.
|
||||
// Passing '' would make firebaseClientFor treat it as a usable
|
||||
// credential and sign in with it, which fails; null correctly
|
||||
// means "no password on this device".
|
||||
password: account.password.isEmpty ? null : account.password,
|
||||
// Deliberately NOT offering Google here. This path runs from background
|
||||
// timers and, in some apps, before runApp -- offering it would let a
|
||||
// non-interactive tick raise the OS account picker with no user action
|
||||
// behind it. Interactive sign-in uses openFirebaseWithGoogle instead.
|
||||
expectedUid: kSyncUid,
|
||||
);
|
||||
} on Object catch (error, stackTrace) {
|
||||
// Unreachable keystore or a rejected sign-in must not fail the tick: the
|
||||
// app keeps syncing over GitHub, exactly as before the cutover.
|
||||
// Broader than Exception because an uninitialised platform binding
|
||||
// raises a FlutterError, which is an Error.
|
||||
//
|
||||
// Never silent, for the same reason loadAccount() above says why: a
|
||||
// signed-in-but-failing device looked exactly like an unconfigured one,
|
||||
// which is how the phone went days without publishing a single meal.
|
||||
log(
|
||||
'openFirebase failed; falling back to the GitHub mirror',
|
||||
level: 1000,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this device can actually authenticate against Firebase.
|
||||
///
|
||||
/// True when either half of the state is present: the account marker
|
||||
/// [loadAccount] reads, or a stored refresh token. The token is the half that
|
||||
/// matters -- it is what signs requests -- so a settings screen that reports
|
||||
/// the marker alone calls a working device "Not connected", which is how a
|
||||
/// phone that was in fact syncing looked broken for a whole session.
|
||||
Future<bool> isFirebaseConfigured() async {
|
||||
try {
|
||||
final auth = FirebaseTokenProvider(
|
||||
apiKey: kProject.apiKey,
|
||||
store: credentialStore(),
|
||||
);
|
||||
if (await auth.hasSession()) return true;
|
||||
// storedAccount, not loadAccount: on Android the latter falls back to the
|
||||
// desktop wrapper's /sync-account route, which resolves to file:/// and
|
||||
// throws "No host specified in URI" -- observed on the phone, where it
|
||||
// turned a successful sign-in into "Google sign-in failed".
|
||||
if (await storedAccount() == null) return false;
|
||||
// A marker with no session behind it cannot sign a single request. Drop
|
||||
// just the marker so the settings screen offers a sign-in instead of a
|
||||
// dead banner -- not clearAccount(), which also sets the opt-out flag and
|
||||
// would stop the desktop wrapper re-provisioning after the next sign-in.
|
||||
await _secure.delete(key: kFirebaseAccountKey);
|
||||
return false;
|
||||
} on Object catch (error, stackTrace) {
|
||||
log(
|
||||
'session probe failed; reporting this device as not configured',
|
||||
level: 1000,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a client built from the keystore's refresh token alone, or null.
|
||||
///
|
||||
/// The recovery path for a device that has a live session but no account
|
||||
/// marker -- the state a Google sign-in used to leave behind. Costs one
|
||||
/// keystore read and no network round trip when there is no session, so it is
|
||||
/// safe on the background-tick path that [openFirebase] also serves.
|
||||
///
|
||||
/// Does not re-save the account: a device that reaches this is already signed
|
||||
/// in, and writing a marker from a read path would hide the condition rather
|
||||
/// than repair it. [openFirebaseWithGoogle] is what writes the marker.
|
||||
Future<FirebaseRestClient?> _clientFromStoredSession() async {
|
||||
try {
|
||||
final auth = FirebaseTokenProvider(
|
||||
apiKey: kProject.apiKey,
|
||||
store: credentialStore(),
|
||||
);
|
||||
if (!await auth.hasSession()) return null;
|
||||
return FirebaseRestClient(databaseUrl: kProject.databaseUrl, auth: auth);
|
||||
} on Object catch (error, stackTrace) {
|
||||
// Same contract as openFirebase's own catch: an unreachable keystore
|
||||
// degrades to the GitHub mirror instead of failing the tick, but says so.
|
||||
log(
|
||||
'stored-session recovery failed; falling back to the GitHub mirror',
|
||||
level: 1000,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Signs in with Google alone, for a device that has no account stored yet.
|
||||
///
|
||||
/// This is the one-tap path: [openFirebase] needs an account in the keystore
|
||||
/// to know which email to use, but a fresh install has none. The Google token
|
||||
/// carries the identity, so nothing needs to be typed.
|
||||
///
|
||||
/// The account is stored under the email **Firebase reports**, never one read
|
||||
/// from the UI: a fresh install has no email anywhere on the device, so taking
|
||||
/// it from a text field would persist an empty account and send the next
|
||||
/// launch down the password path with ''.
|
||||
///
|
||||
/// Returns null when the user dismisses the picker; throws [FirebaseAuthError]
|
||||
/// when Google succeeds but resolves to a uid the security rules do not pin,
|
||||
/// which would otherwise authenticate fine and then be denied every read and
|
||||
/// write with no other symptom.
|
||||
Future<FirebaseRestClient?> openFirebaseWithGoogle({
|
||||
Future<String?> Function()? tokenFetcher,
|
||||
Future<void> Function(FirebaseAccount)? accountSaver,
|
||||
http.Client? httpClient,
|
||||
}) async {
|
||||
final token = await (tokenFetcher ?? googleIdToken)();
|
||||
if (token == null) return null;
|
||||
final auth = FirebaseTokenProvider(
|
||||
apiKey: kProject.apiKey,
|
||||
store: credentialStore(),
|
||||
httpClient: httpClient,
|
||||
);
|
||||
final email = await auth.signInWithGoogle(
|
||||
idToken: token,
|
||||
expectedUid: kSyncUid,
|
||||
);
|
||||
// The account must be saved unconditionally, and the signed-in email is not
|
||||
// reliable enough to gate it on. `signInWithIdp` omits `email` whenever the
|
||||
// Google account hides it, so gating the save on a non-null email produced
|
||||
// the worst possible outcome: this call returned a perfectly working client
|
||||
// -- "Connected to Firebase" -- while the device stayed unconfigured, so the
|
||||
// next launch found no account, `openFirebase()` returned null, and
|
||||
// `syncBackend()` silently dropped to GitHub-only and 401'd. The session is
|
||||
// already durable at this point (`signInWithGoogle` stores the refresh token
|
||||
// via `_adopt`); only this marker was missing, which is exactly why the
|
||||
// failure survived a restart and looked like a sign-in that never happened.
|
||||
//
|
||||
// The email is a display convenience, not the credential -- the refresh
|
||||
// token in the keystore is. An empty string is the honest value when Google
|
||||
// withholds it, and `openFirebase()` already treats an empty password as
|
||||
// "no password on this device" and authenticates from the stored token.
|
||||
await (accountSaver ?? saveAccount)(
|
||||
FirebaseAccount(email: email ?? '', password: ''),
|
||||
);
|
||||
return FirebaseRestClient(databaseUrl: kProject.databaseUrl, auth: auth);
|
||||
}
|
||||
|
||||
// coverage:ignore-end
|
||||
|
||||
/// Wraps [github] so Firebase is primary and GitHub is kept as a mirror.
|
||||
///
|
||||
/// Returns [github] unchanged when this device has not been set up for
|
||||
/// Firebase — a normal state during the cutover, not an error, and also the
|
||||
/// rollback. Reads union both, so a device that has not moved yet still
|
||||
/// converges.
|
||||
Future<RemoteStore> syncBackend(RemoteStore github) async {
|
||||
final firebase = await openFirebase();
|
||||
return firebase == null
|
||||
? github
|
||||
: MirrorStore(primary: firebase, mirror: github);
|
||||
}
|
||||
/// Deliberately narrower than [clearAccount]: a marker with no session behind
|
||||
/// it cannot sign a request, but setting the opt-out flag as well would stop
|
||||
/// the desktop wrapper re-provisioning after the next sign-in.
|
||||
Future<void> forgetAccountMarker() =>
|
||||
_secure.delete(key: kFirebaseAccountKey);
|
||||
|
||||
197
app/lib/services/firebase_client.dart
Normal file
197
app/lib/services/firebase_client.dart
Normal file
@ -0,0 +1,197 @@
|
||||
/// Opening an authenticated Firebase REST client, and choosing a backend.
|
||||
///
|
||||
/// Split out of `firebase_backend.dart` for the repo's 250-line cap. That
|
||||
/// module owns the project constants and the keystore-backed account record;
|
||||
/// this one turns a stored account (or a Google sign-in) into a live client,
|
||||
/// and decides whether a sync tick runs against Firebase or the GitHub
|
||||
/// mirror.
|
||||
library;
|
||||
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:diet_guard_app/services/firebase_backend.dart';
|
||||
import 'package:diet_guard_app/services/google_sign_in_backend.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Returns a signed-in Firebase client, or null when not configured.
|
||||
///
|
||||
/// Signs in with the stored password only when there is no cached refresh
|
||||
/// token, so the usual path costs no authentication round trip.
|
||||
Future<FirebaseRestClient?> openFirebase() async {
|
||||
final account = await loadAccount();
|
||||
if (account == null) {
|
||||
// A stored refresh token is a signed-in device, even with no account
|
||||
// marker beside it. Treating the marker as the source of truth is what
|
||||
// turned one missing write into "syncs over GitHub and 401s forever":
|
||||
// the credential that actually authenticates was sitting in the keystore
|
||||
// the whole time, unused. Ask the store directly before giving up.
|
||||
return _clientFromStoredSession();
|
||||
}
|
||||
try {
|
||||
return await firebaseClientFor(
|
||||
config: kProject.configFor(account.email),
|
||||
store: credentialStore(),
|
||||
// A Google-provisioned account stores an empty password.
|
||||
// Passing '' would make firebaseClientFor treat it as a usable
|
||||
// credential and sign in with it, which fails; null correctly
|
||||
// means "no password on this device".
|
||||
password: account.password.isEmpty ? null : account.password,
|
||||
// Deliberately NOT offering Google here. This path runs from background
|
||||
// timers and, in some apps, before runApp -- offering it would let a
|
||||
// non-interactive tick raise the OS account picker with no user action
|
||||
// behind it. Interactive sign-in uses openFirebaseWithGoogle instead.
|
||||
expectedUid: kSyncUid,
|
||||
);
|
||||
} on Object catch (error, stackTrace) {
|
||||
// Unreachable keystore or a rejected sign-in must not fail the tick: the
|
||||
// app keeps syncing over GitHub, exactly as before the cutover.
|
||||
// Broader than Exception because an uninitialised platform binding
|
||||
// raises a FlutterError, which is an Error.
|
||||
//
|
||||
// Never silent, for the same reason loadAccount() above says why: a
|
||||
// signed-in-but-failing device looked exactly like an unconfigured one,
|
||||
// which is how the phone went days without publishing a single meal.
|
||||
log(
|
||||
'openFirebase failed; falling back to the GitHub mirror',
|
||||
level: 1000,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this device can actually authenticate against Firebase.
|
||||
///
|
||||
/// True when either half of the state is present: the account marker
|
||||
/// [loadAccount] reads, or a stored refresh token. The token is the half that
|
||||
/// matters -- it is what signs requests -- so a settings screen that reports
|
||||
/// the marker alone calls a working device "Not connected", which is how a
|
||||
/// phone that was in fact syncing looked broken for a whole session.
|
||||
Future<bool> isFirebaseConfigured() async {
|
||||
try {
|
||||
final auth = FirebaseTokenProvider(
|
||||
apiKey: kProject.apiKey,
|
||||
store: credentialStore(),
|
||||
);
|
||||
if (await auth.hasSession()) return true;
|
||||
// storedAccount, not loadAccount: on Android the latter falls back to the
|
||||
// desktop wrapper's /sync-account route, which resolves to file:/// and
|
||||
// throws "No host specified in URI" -- observed on the phone, where it
|
||||
// turned a successful sign-in into "Google sign-in failed".
|
||||
if (await storedAccount() == null) return false;
|
||||
// A marker with no session behind it cannot sign a single request. Drop
|
||||
// just the marker so the settings screen offers a sign-in instead of a
|
||||
// dead banner -- not clearAccount(), which also sets the opt-out flag and
|
||||
// would stop the desktop wrapper re-provisioning after the next sign-in.
|
||||
await forgetAccountMarker();
|
||||
return false;
|
||||
} on Object catch (error, stackTrace) {
|
||||
log(
|
||||
'session probe failed; reporting this device as not configured',
|
||||
level: 1000,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a client built from the keystore's refresh token alone, or null.
|
||||
///
|
||||
/// The recovery path for a device that has a live session but no account
|
||||
/// marker -- the state a Google sign-in used to leave behind. Costs one
|
||||
/// keystore read and no network round trip when there is no session, so it is
|
||||
/// safe on the background-tick path that [openFirebase] also serves.
|
||||
///
|
||||
/// Does not re-save the account: a device that reaches this is already signed
|
||||
/// in, and writing a marker from a read path would hide the condition rather
|
||||
/// than repair it. [openFirebaseWithGoogle] is what writes the marker.
|
||||
Future<FirebaseRestClient?> _clientFromStoredSession() async {
|
||||
try {
|
||||
final auth = FirebaseTokenProvider(
|
||||
apiKey: kProject.apiKey,
|
||||
store: credentialStore(),
|
||||
);
|
||||
if (!await auth.hasSession()) return null;
|
||||
return FirebaseRestClient(databaseUrl: kProject.databaseUrl, auth: auth);
|
||||
} on Object catch (error, stackTrace) {
|
||||
// Same contract as openFirebase's own catch: an unreachable keystore
|
||||
// degrades to the GitHub mirror instead of failing the tick, but says so.
|
||||
log(
|
||||
'stored-session recovery failed; falling back to the GitHub mirror',
|
||||
level: 1000,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Signs in with Google alone, for a device that has no account stored yet.
|
||||
///
|
||||
/// This is the one-tap path: [openFirebase] needs an account in the keystore
|
||||
/// to know which email to use, but a fresh install has none. The Google token
|
||||
/// carries the identity, so nothing needs to be typed.
|
||||
///
|
||||
/// The account is stored under the email **Firebase reports**, never one read
|
||||
/// from the UI: a fresh install has no email anywhere on the device, so taking
|
||||
/// it from a text field would persist an empty account and send the next
|
||||
/// launch down the password path with ''.
|
||||
///
|
||||
/// Returns null when the user dismisses the picker; throws [FirebaseAuthError]
|
||||
/// when Google succeeds but resolves to a uid the security rules do not pin,
|
||||
/// which would otherwise authenticate fine and then be denied every read and
|
||||
/// write with no other symptom.
|
||||
Future<FirebaseRestClient?> openFirebaseWithGoogle({
|
||||
Future<String?> Function()? tokenFetcher,
|
||||
Future<void> Function(FirebaseAccount)? accountSaver,
|
||||
http.Client? httpClient,
|
||||
}) async {
|
||||
final token = await (tokenFetcher ?? googleIdToken)();
|
||||
if (token == null) return null;
|
||||
final auth = FirebaseTokenProvider(
|
||||
apiKey: kProject.apiKey,
|
||||
store: credentialStore(),
|
||||
httpClient: httpClient,
|
||||
);
|
||||
final email = await auth.signInWithGoogle(
|
||||
idToken: token,
|
||||
expectedUid: kSyncUid,
|
||||
);
|
||||
// The account must be saved unconditionally, and the signed-in email is not
|
||||
// reliable enough to gate it on. `signInWithIdp` omits `email` whenever the
|
||||
// Google account hides it, so gating the save on a non-null email produced
|
||||
// the worst possible outcome: this call returned a perfectly working client
|
||||
// -- "Connected to Firebase" -- while the device stayed unconfigured, so the
|
||||
// next launch found no account, `openFirebase()` returned null, and
|
||||
// `syncBackend()` silently dropped to GitHub-only and 401'd. The session is
|
||||
// already durable at this point (`signInWithGoogle` stores the refresh token
|
||||
// via `_adopt`); only this marker was missing, which is exactly why the
|
||||
// failure survived a restart and looked like a sign-in that never happened.
|
||||
//
|
||||
// The email is a display convenience, not the credential -- the refresh
|
||||
// token in the keystore is. An empty string is the honest value when Google
|
||||
// withholds it, and `openFirebase()` already treats an empty password as
|
||||
// "no password on this device" and authenticates from the stored token.
|
||||
await (accountSaver ?? saveAccount)(
|
||||
FirebaseAccount(email: email ?? '', password: ''),
|
||||
);
|
||||
return FirebaseRestClient(databaseUrl: kProject.databaseUrl, auth: auth);
|
||||
}
|
||||
|
||||
// coverage:ignore-end
|
||||
|
||||
/// Wraps [github] so Firebase is primary and GitHub is kept as a mirror.
|
||||
///
|
||||
/// Returns [github] unchanged when this device has not been set up for
|
||||
/// Firebase — a normal state during the cutover, not an error, and also the
|
||||
/// rollback. Reads union both, so a device that has not moved yet still
|
||||
/// converges.
|
||||
Future<RemoteStore> syncBackend(RemoteStore github) async {
|
||||
final firebase = await openFirebase();
|
||||
return firebase == null
|
||||
? github
|
||||
: MirrorStore(primary: firebase, mirror: github);
|
||||
}
|
||||
@ -15,6 +15,8 @@ import 'package:diet_guard_app/services/fuzzy.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
part 'foodbank_service_manual.dart';
|
||||
|
||||
/// Below this similarity ratio a non-substring candidate is dropped.
|
||||
/// Mirrors `_foodbank._FUZZY_THRESHOLD`.
|
||||
const double fuzzyThreshold = 0.6;
|
||||
@ -204,111 +206,4 @@ class FoodBankService {
|
||||
await writeBank(bank);
|
||||
return bank;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manual bank (food items added directly without logging them as eaten)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Future<Map<String, FoodBankRecord>> _readManualBank() =>
|
||||
_readBank(manualDocumentName);
|
||||
|
||||
Future<void> _writeManualBank(Map<String, FoodBankRecord> bank) =>
|
||||
_writeBank(manualDocumentName, bank);
|
||||
|
||||
/// Adds or updates [record] in the manually-curated bank without logging it
|
||||
/// as eaten. A repeated call with the same normalized name overwrites the
|
||||
/// previous entry.
|
||||
Future<void> addManualEntry(FoodBankRecord record) async {
|
||||
final bank = await _readManualBank();
|
||||
bank[_normalize(record.desc)] = FoodBankRecord(
|
||||
desc: record.desc,
|
||||
kcal: record.kcal,
|
||||
proteinG: record.proteinG,
|
||||
carbsG: record.carbsG,
|
||||
fatG: record.fatG,
|
||||
grams: record.grams,
|
||||
count: record.count,
|
||||
components: record.components,
|
||||
// Stamped here, not by callers: this is what the cross-device merge
|
||||
// orders by, so it must be set on every write without exception.
|
||||
editedAt: DateTime.now().toIso8601String(),
|
||||
);
|
||||
await _writeManualBank(bank);
|
||||
}
|
||||
|
||||
/// Returns the hand-curated bank, for the sync layer.
|
||||
Future<Map<String, FoodBankRecord>> readManualBank() => _readManualBank();
|
||||
|
||||
/// Replaces the hand-curated bank with a merge result from the sync layer.
|
||||
Future<void> applyMergedManualBank(Map<String, FoodBankRecord> bank) =>
|
||||
_writeManualBank(bank);
|
||||
|
||||
/// All known food records: log-derived entries merged with manually-added
|
||||
/// ones, sorted by count descending.
|
||||
///
|
||||
/// Log-derived records (from [readBank]) take precedence over manual records
|
||||
/// with the same normalized name.
|
||||
Future<List<FoodBankRecord>> mergedEntries() async {
|
||||
final logBank = await readBank();
|
||||
final manualBank = await _readManualBank();
|
||||
final merged = <String, FoodBankRecord>{...manualBank, ...logBank};
|
||||
return merged.values.toList()..sort((a, b) => b.count.compareTo(a.count));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns banked foods matching [query], best match first.
|
||||
///
|
||||
/// An empty query returns the most-logged foods. Mirrors
|
||||
/// `_foodbank.search_foods`. Searches both log-derived and manually-added
|
||||
/// entries; log-derived entries win on name collision.
|
||||
Future<List<FoodSuggestion>> search(
|
||||
String query, {
|
||||
int limit = defaultSuggestions,
|
||||
}) async {
|
||||
final logBank = await readBank();
|
||||
final manualBank = await _readManualBank();
|
||||
final bank = <String, FoodBankRecord>{...manualBank, ...logBank};
|
||||
final normalized = _normalize(query);
|
||||
if (normalized.isEmpty) return _rankedAll(bank, limit);
|
||||
|
||||
final scored = <(double score, double count, FoodSuggestion suggestion)>[];
|
||||
for (final mapEntry in bank.entries) {
|
||||
final score = matchScore(normalized, mapEntry.key);
|
||||
if (score < fuzzyThreshold) continue;
|
||||
final record = mapEntry.value;
|
||||
scored.add((
|
||||
score,
|
||||
record.count,
|
||||
FoodSuggestion(
|
||||
name: _displayName(record, mapEntry.key),
|
||||
nutrition: _recordToNutrition(record),
|
||||
),
|
||||
));
|
||||
}
|
||||
scored.sort((a, b) {
|
||||
final byScore = b.$1.compareTo(a.$1);
|
||||
return byScore != 0 ? byScore : b.$2.compareTo(a.$2);
|
||||
});
|
||||
return scored.take(limit).map((s) => s.$3).toList();
|
||||
}
|
||||
|
||||
List<FoodSuggestion> _rankedAll(
|
||||
Map<String, FoodBankRecord> bank,
|
||||
int limit,
|
||||
) {
|
||||
final ranked = bank.entries.toList()
|
||||
..sort((a, b) => b.value.count.compareTo(a.value.count));
|
||||
return ranked
|
||||
.take(limit)
|
||||
.map(
|
||||
(mapEntry) => FoodSuggestion(
|
||||
name: _displayName(mapEntry.value, mapEntry.key),
|
||||
nutrition: _recordToNutrition(mapEntry.value),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
121
app/lib/services/foodbank_service_manual.dart
Normal file
121
app/lib/services/foodbank_service_manual.dart
Normal file
@ -0,0 +1,121 @@
|
||||
// The curated ("manual") half of the food bank, plus ranked search across
|
||||
// both halves.
|
||||
//
|
||||
// Split out of `foodbank_service.dart` for the repo's 250-line cap. It is a
|
||||
// `part` rather than a separate library because these methods reach the
|
||||
// service's private members (`_readBank`, `_writeBank`, `_normalize`,
|
||||
// `_recordToNutrition`), which a `library`-private name makes invisible
|
||||
// across a file boundary -- and widening them to public just to split a file
|
||||
// would leak internals the rest of the app has no business calling.
|
||||
|
||||
part of 'foodbank_service.dart';
|
||||
|
||||
/// The curated bank and cross-bank search, split out for the file cap.
|
||||
extension FoodBankManual on FoodBankService {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manual bank (food items added directly without logging them as eaten)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Future<Map<String, FoodBankRecord>> _readManualBank() =>
|
||||
_readBank(FoodBankService.manualDocumentName);
|
||||
|
||||
Future<void> _writeManualBank(Map<String, FoodBankRecord> bank) =>
|
||||
_writeBank(FoodBankService.manualDocumentName, bank);
|
||||
|
||||
/// Adds or updates [record] in the manually-curated bank without logging it
|
||||
/// as eaten. A repeated call with the same normalized name overwrites the
|
||||
/// previous entry.
|
||||
Future<void> addManualEntry(FoodBankRecord record) async {
|
||||
final bank = await _readManualBank();
|
||||
bank[_normalize(record.desc)] = FoodBankRecord(
|
||||
desc: record.desc,
|
||||
kcal: record.kcal,
|
||||
proteinG: record.proteinG,
|
||||
carbsG: record.carbsG,
|
||||
fatG: record.fatG,
|
||||
grams: record.grams,
|
||||
count: record.count,
|
||||
components: record.components,
|
||||
// Stamped here, not by callers: this is what the cross-device merge
|
||||
// orders by, so it must be set on every write without exception.
|
||||
editedAt: DateTime.now().toIso8601String(),
|
||||
);
|
||||
await _writeManualBank(bank);
|
||||
}
|
||||
|
||||
/// Returns the hand-curated bank, for the sync layer.
|
||||
Future<Map<String, FoodBankRecord>> readManualBank() => _readManualBank();
|
||||
|
||||
/// Replaces the hand-curated bank with a merge result from the sync layer.
|
||||
Future<void> applyMergedManualBank(Map<String, FoodBankRecord> bank) =>
|
||||
_writeManualBank(bank);
|
||||
|
||||
/// All known food records: log-derived entries merged with manually-added
|
||||
/// ones, sorted by count descending.
|
||||
///
|
||||
/// Log-derived records (from [readBank]) take precedence over manual records
|
||||
/// with the same normalized name.
|
||||
Future<List<FoodBankRecord>> mergedEntries() async {
|
||||
final logBank = await readBank();
|
||||
final manualBank = await _readManualBank();
|
||||
final merged = <String, FoodBankRecord>{...manualBank, ...logBank};
|
||||
return merged.values.toList()..sort((a, b) => b.count.compareTo(a.count));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns banked foods matching [query], best match first.
|
||||
///
|
||||
/// An empty query returns the most-logged foods. Mirrors
|
||||
/// `_foodbank.search_foods`. Searches both log-derived and manually-added
|
||||
/// entries; log-derived entries win on name collision.
|
||||
Future<List<FoodSuggestion>> search(
|
||||
String query, {
|
||||
int limit = defaultSuggestions,
|
||||
}) async {
|
||||
final logBank = await readBank();
|
||||
final manualBank = await _readManualBank();
|
||||
final bank = <String, FoodBankRecord>{...manualBank, ...logBank};
|
||||
final normalized = _normalize(query);
|
||||
if (normalized.isEmpty) return _rankedAll(bank, limit);
|
||||
|
||||
final scored = <(double score, double count, FoodSuggestion suggestion)>[];
|
||||
for (final mapEntry in bank.entries) {
|
||||
final score = matchScore(normalized, mapEntry.key);
|
||||
if (score < fuzzyThreshold) continue;
|
||||
final record = mapEntry.value;
|
||||
scored.add((
|
||||
score,
|
||||
record.count,
|
||||
FoodSuggestion(
|
||||
name: _displayName(record, mapEntry.key),
|
||||
nutrition: _recordToNutrition(record),
|
||||
),
|
||||
));
|
||||
}
|
||||
scored.sort((a, b) {
|
||||
final byScore = b.$1.compareTo(a.$1);
|
||||
return byScore != 0 ? byScore : b.$2.compareTo(a.$2);
|
||||
});
|
||||
return scored.take(limit).map((s) => s.$3).toList();
|
||||
}
|
||||
|
||||
List<FoodSuggestion> _rankedAll(
|
||||
Map<String, FoodBankRecord> bank,
|
||||
int limit,
|
||||
) {
|
||||
final ranked = bank.entries.toList()
|
||||
..sort((a, b) => b.value.count.compareTo(a.value.count));
|
||||
return ranked
|
||||
.take(limit)
|
||||
.map(
|
||||
(mapEntry) => FoodSuggestion(
|
||||
name: _displayName(mapEntry.value, mapEntry.key),
|
||||
nutrition: _recordToNutrition(mapEntry.value),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@ name: diet_guard_app
|
||||
description: "Companion phone app for diet_guard: log meals on the go."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.26+1
|
||||
version: 1.0.27+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:diet_guard_app/services/firebase_client.dart';
|
||||
import 'package:diet_guard_app/services/google_sign_in_backend.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart' as http_testing;
|
||||
import 'package:diet_guard_app/services/firebase_backend.dart';
|
||||
import 'package:diet_guard_app/services/google_sign_in_backend.dart';
|
||||
|
||||
import 'fake_secure_storage.dart';
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ library;
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:diet_guard_app/services/firebase_backend.dart';
|
||||
import 'package:diet_guard_app/services/firebase_client.dart';
|
||||
import 'package:diet_guard_app/services/sync_state_factory_io.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user