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:
Krzysztof Rudnicki 2026-08-15 21:07:24 +02:00
parent 3c67cf8c9c
commit c77e7d9acf
14 changed files with 639 additions and 547 deletions

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

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

View File

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

View File

@ -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';

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

View File

@ -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

View File

@ -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';

View File

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

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

View File

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

View 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();
}
}

View File

@ -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

View File

@ -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';

View File

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