diet-guard/app/lib/screens/github_mirror_actions.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

146 lines
5.0 KiB
Dart

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