todo-app/lib/ui/capture_screen.dart
Krzysztof kuhy Rudnicki d4c8162e0e Split capture_screen.dart into ten files under the cap
739 -> 249 lines. As with note_editor, the ~600-line State class needed
decomposing rather than moving:

  capture_draft.dart         the note being captured and its write-through
                             to storage -- the model behind "every keystroke
                             is already persisted". Lazy row creation stays
                             here; the screen just logs that it happened.
  capture_sync_runner.dart   one sync pass, with no BuildContext. This also
                             de-duplicates the two callers: the manual button
                             and the background tick ran byte-identical
                             runSync blocks differing only in the snackbar
                             and the 'auto' log flag.
  capture_status.dart        SyncStatusStore (the persisted last outcome) and
                             describeSyncResult.
  capture_app_bar.dart       the app bar.
  capture_meta_row.dart      the priority/status pickers.
  capture_status_footer.dart the two status lines.
  capture_screen_{widget,sync}.dart
                             parts, for the same reason as note_editor's.

The mounted checks are preserved exactly, including the one in
SyncStatusStore.restore that must fire AFTER its await rather than before
(writing the notifier post-dispose is the bug it exists to prevent), which
is why it takes a stillLive callback instead of a bool.

Verified: 313 tests pass, analyze clean, every capture file at 100%
coverage. lib/ and bin/ are now down to three files over the cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 16:25:51 +02:00

247 lines
8.9 KiB
Dart

import 'dart:async';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:todo/analytics/analytics_event.dart';
import 'package:todo/analytics/analytics_service.dart';
import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/data/note_template.dart';
import 'package:todo/sync/firebase_backend.dart';
import 'package:todo/sync/local_backup.dart';
import 'package:todo/sync/sync_settings.dart';
import 'package:todo/ui/capture_app_bar.dart';
import 'package:todo/ui/capture_draft.dart';
import 'package:todo/ui/capture_meta_row.dart';
import 'package:todo/ui/capture_status.dart';
import 'package:todo/ui/capture_status_footer.dart';
import 'package:todo/ui/capture_sync_runner.dart';
import 'package:todo/ui/note_editor.dart';
import 'package:todo/ui/notes_list_screen.dart';
import 'package:todo/ui/settings_screen.dart';
part 'capture_screen_sync.dart';
part 'capture_screen_widget.dart';
class _CaptureScreenState extends State<CaptureScreen>
with WidgetsBindingObserver {
/// Single-flight guard so a launch sync and a background sync never overlap.
bool _autoSyncing = false;
/// Keeps an always-current Markdown backup on local disk and recovers from
/// it on launch (third durability layer beside sync + Android Auto Backup).
late final LocalBackup _localBackup;
StreamSubscription<void>? _changesSub;
/// Latest assembled text from the editor; persisted on change and re-saved
/// when only priority/status change.
/// Bumped on save to recreate the editor with a fresh, empty template.
int _editorGeneration = 0;
/// Id of the note currently being edited, or null before the first
/// keystroke of a fresh draft.
/// Time of the last local save, shown in the tiny save indicator. A notifier
/// (not a `setState` field) so a keystroke updates only that one line of text
/// instead of rebuilding the AppBar, dropdowns, and editor every character.
/// Priority/status applied to the current draft. Chosen before or during
/// typing; persisted on the first keystroke and on every later change.
SyncSettings? _settings;
bool _syncing = false;
/// Outcome of the most recent sync attempt (manual or automatic), driving
/// the tiny status line under the editor. Persisted so a failure that
/// happened just before the app closed is still visible on next launch.
/// The note being captured, and its write-through to storage.
late final CaptureDraft _draft = CaptureDraft(widget.repository);
final SyncStatusStore _syncStatus = SyncStatusStore();
/// Runs sync passes and records their outcome; see [CaptureSyncRunner].
late final CaptureSyncRunner _runner = CaptureSyncRunner(
repository: widget.repository,
status: _syncStatus,
analytics: widget.analytics,
httpClient: widget.httpClient,
firebaseFactory: widget.firebaseFactory,
stateStore: widget.stateStore,
);
/// Debounces focus-loss sync triggers so desktop alt-tab flicker doesn't
/// hammer GitHub with a request per focus change.
Timer? _autoSyncDebounce;
/// Hides the Priority/Status row while the editor's own bare-guided chrome
/// (template/mode selectors) is also hidden, so the two stay in lockstep.
bool _chromeVisible = true;
/// When this session started, for the duration on the `app_close` event.
final DateTime _sessionStart = DateTime.now();
@override
void initState() {
super.initState();
_logEvent('app_open');
WidgetsBinding.instance.addObserver(this);
_localBackup = widget.localBackup ?? platformLocalBackup(widget.repository);
// Recover from the local backup first (covers an empty DB after a wipe),
// then keep the backup current as notes change. The change tick is O(1) per
// write; the backup pulls the notes itself only when its debounce fires.
unawaited(_recoverFromBackup());
_changesSub = widget.repository.changes.listen(
(_) => _localBackup.schedule(),
);
// Also refresh the on-disk backup once on launch (the change tick only
// fires on writes), so a deleted/stale BACKLOG.md is regenerated even if
// the user makes no edits this session.
_localBackup.schedule();
unawaited(_restoreSyncStatus());
unawaited(
SyncSettings.load().then((s) {
if (!mounted) return;
setState(() => _settings = s);
// Pull on launch so a reinstalled device recovers its notes.
unawaited(_autoSync());
}),
);
}
@override
void dispose() {
_logEvent(
'app_close',
params: {
'durationMs': DateTime.now().difference(_sessionStart).inMilliseconds,
},
);
WidgetsBinding.instance.removeObserver(this);
_autoSyncDebounce?.cancel();
unawaited(_changesSub?.cancel());
_localBackup.dispose();
_draft.dispose();
_syncStatus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<AppSettings>(
valueListenable: widget.appSettings,
builder: (context, settings, _) {
final advanced = settings.advancedMode;
return Scaffold(
appBar: CaptureAppBar(
repository: widget.repository,
advanced: advanced,
syncing: _syncing,
onNewNote: _saveAndReset,
onSync: _sync,
onOpenList: _openList,
onOpenSettings: _openSettings,
),
body: _captureBody(advanced: advanced),
);
},
);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Push on background so the remote (the durable store) stays near-current.
if (state == AppLifecycleState.paused) {
// The realistic "user left the app" signal on Android: the process may
// be killed right after, so dispose() is not guaranteed to run.
_logEvent(
'app_close',
params: {
'durationMs': DateTime.now().difference(_sessionStart).inMilliseconds,
},
);
// Immediate: Android may kill the app before a debounce timer fires.
_autoSyncDebounce?.cancel();
unawaited(_autoSync());
} else if (state == AppLifecycleState.inactive ||
state == AppLifecycleState.hidden) {
// Focus loss — the only background signal Flutter Linux delivers
// reliably (`paused` rarely arrives on desktop). Debounced so alt-tab
// flicker doesn't fire a request per focus change.
_autoSyncDebounce?.cancel();
_autoSyncDebounce = Timer(CaptureScreen.autoSyncDebounce, _autoSync);
}
}
/// Opens the settings screen and adopts any saved configuration.
Future<void> _openSettings() async {
_logEvent('screen_view', params: {'screen': 'settings'});
final current = _settings ?? await SyncSettings.load();
if (!mounted) return;
await Navigator.of(context).push<SyncSettings>(
MaterialPageRoute(
builder: (_) => SettingsScreen(
initial: current,
repository: widget.repository,
appSettings: widget.appSettings,
analytics: widget.analytics,
httpClient: widget.httpClient,
),
),
);
if (!mounted) return;
// Always reload from storage: a device-flow "Connect" saves the token
// without popping a result, so relying on the pop value would miss it and
// leave us syncing with stale (token-less) settings.
final fresh = await SyncSettings.load();
setState(() => _settings = fresh);
}
/// Runs a full sync, routing to settings first if not yet configured.
Future<void> _sync() async {
final settings = _settings ?? await SyncSettings.load();
if (!settings.isConfigured && await openFirebase() == null) {
_showSnack('Connect Firebase (or add a GitHub token) in settings');
await _openSettings();
return;
}
setState(() => _syncing = true);
try {
final outcome = await _runner.run(
settings: settings,
appSettings: widget.appSettings.value,
live: () => mounted,
);
if (outcome.ok) _adoptReconciledSettings(outcome.reconciled);
_logEvent(
'sync_result',
params: {
'ok': outcome.ok,
'auto': false,
if (outcome.ok) 'detail': outcome.detail,
},
);
_showSnack(
outcome.ok
? 'Synced: ${outcome.detail}'
: 'Sync failed: ${outcome.detail}',
);
} finally {
if (mounted) setState(() => _syncing = false);
}
}
/// Mirrors the editor's chrome visibility so this screen's own
/// priority/status row hides and shows in step with it.
void _setChromeVisible(bool visible) {
setState(() => _chromeVisible = visible);
}
/// setState, reachable from this screen's `part` files.
///
/// setState is protected to State subclasses, so an extension in a part
/// cannot call it directly even though it is the same library.
void _rebuild(VoidCallback change) => setState(change);
}