diet-guard/app/lib/services/firebase_backend.dart
Krzysztof kuhy Rudnicki c77e7d9acf 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
2026-08-15 21:07:24 +02:00

107 lines
4.6 KiB
Dart

/// Wiring for the Firebase backend, during and after the GitHub cutover.
///
/// Split by what is safe to publish, because this repo is public:
///
/// * [kProject] holds the Web API key and database URL. Both are public
/// identifiers that already ship inside the APK; the security rules, not
/// their secrecy, are what protect the data.
/// * The account email and password are entered once per device and kept in
/// the OS keystore, next to the GitHub token this app already stores there.
///
/// Nothing here reads `~/.config/crdt-sync/` — that is the desktop/Python
/// half. On Android there is no such file.
library;
import 'dart:developer';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// The shared `kuhy-syncs` project.
///
/// `databaseUrl` is the **regional** host. The plain `*.firebaseio.com` form
/// answers 404 with a `correctUrl` body rather than an obvious error, which
/// reads like an auth failure and wastes a debugging session.
const kProject = FirebaseProject(
apiKey: 'AIzaSyCF_sA3xCMehAYXK8eND-rAygb9NXXW_8E',
databaseUrl:
'https://kuhy-syncs-default-rtdb.europe-west1.firebasedatabase.app',
);
/// Same options the GitHub token uses: off Android's deprecated
/// `encryptedSharedPreferences` path, and libsecret on Linux.
const _secure = FlutterSecureStorage();
// Everything below reaches the OS keystore through a platform channel, which
// `flutter test` has no binding for -- the same reason `main.dart` and
// `openRepository()` are excluded. The logic these wrap (parsing, the
// public/private split, sign-in) lives in `crdt_sync` and is covered there at
// 100%; what is left here is the two-line adapter.
// coverage:ignore-start
/// The keystore-backed home for the Firebase refresh token.
SecureCredentialStore credentialStore() => SecureCredentialStore(
read: (key) => _secure.read(key: key),
write: (key, value) => _secure.write(key: key, value: value),
delete: (key) => _secure.delete(key: key),
);
/// Reads the per-device account, or null when sync has not been set up.
Future<FirebaseAccount?> loadAccount() async {
try {
final stored = FirebaseAccount.tryParse(
await _secure.read(key: kFirebaseAccountKey),
);
if (stored != null) return stored;
// Disconnect must stick: without this the next launch would silently
// re-adopt the account and the button would look broken.
if (await _secure.read(key: kSyncAccountOptOutKey) != null) return null;
// Desktop only in practice: on Android the wrapper does not exist, the
// request fails, and this returns null exactly as before.
final provisioned = await accountFromWrapper(Uri.base);
if (provisioned != null) await saveAccount(provisioned);
return provisioned;
} on Object catch (error, stackTrace) {
// Still "not configured" rather than crashing the settings screen -- but
// never silent: this hid *why* provisioning failed, which is
// indistinguishable from "no account set" until you say so.
log(
'loadAccount failed; treating this device as not configured',
level: 1000,
error: error,
stackTrace: stackTrace,
);
return null;
}
}
/// Reads the account from the keystore only, with no wrapper fallback.
///
/// [loadAccount] falls back to the desktop wrapper's `/sync-account` route
/// when the keystore is empty, which on Android resolves to `file:///` and
/// throws `No host specified in URI`. Callers reading back an account they
/// just wrote -- where a fallback would be wrong anyway -- use this instead.
/// Verified on the phone: without it, sign-in succeeded and then the settings
/// screen hung on "Signing in..." forever.
Future<FirebaseAccount?> storedAccount() async =>
FirebaseAccount.tryParse(await _secure.read(key: kFirebaseAccountKey));
/// Stores the per-device account. Keystore only — never prefs, never source.
Future<void> saveAccount(FirebaseAccount account) =>
_secure.write(key: kFirebaseAccountKey, value: account.toJsonString());
/// Forgets the account and any cached session.
Future<void> clearAccount() async {
await _secure.delete(key: kFirebaseAccountKey);
// Suppress wrapper re-provisioning; see loadAccount().
await _secure.write(key: kSyncAccountOptOutKey, value: 'true');
await credentialStore().clear();
}
/// Drops just the stored account marker, leaving the opt-out flag alone.
///
/// 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);