diff --git a/lib/ui/capture_app_bar.dart b/lib/ui/capture_app_bar.dart new file mode 100644 index 0000000..33bebad --- /dev/null +++ b/lib/ui/capture_app_bar.dart @@ -0,0 +1,121 @@ +/// The capture screen's app bar: note count, and the four actions. +/// +/// Split out of `capture_screen.dart` for file size. Callback-only — the +/// screen still owns every action; this just lays the row out. +library; + +import 'package:flutter/material.dart'; + +import 'package:todo/data/note_repository.dart'; +import 'package:todo/frame_stats.dart'; + +/// App bar for the capture screen. +class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget { + /// Creates the capture screen's app bar. + const CaptureAppBar({ + required this.repository, + required this.advanced, + required this.syncing, + required this.onNewNote, + required this.onSync, + required this.onOpenList, + required this.onOpenSettings, + super.key, + }); + + /// Store whose note count is shown live. + final NoteRepository repository; + + /// Whether advanced mode is on; hides the manual Sync action when off. + final bool advanced; + + /// Whether a sync is in flight, which disables and spins the Sync action. + final bool syncing; + + /// Saves the current draft and clears the box for a new one. + final VoidCallback onNewNote; + + /// Runs a manual sync. + final VoidCallback onSync; + + /// Opens the notes list. + final VoidCallback onOpenList; + + /// Opens the settings screen. + final VoidCallback onOpenSettings; + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); + + @override + Widget build(BuildContext context) { + return AppBar( + actions: [ + // TEMPORARY: forces continuous frame production so raster cost + // can be sampled at each window size. Armed by + // TODO_FRAME_STATS=1, which is never set under the test + // runner, so the branch is unreachable. + // coverage:ignore-start + if (frameStatsEnabled) + const Padding( + padding: EdgeInsets.only(right: 8), + child: Center( + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ), + // coverage:ignore-end + // Live count of stored notes, proving local persistence. + StreamBuilder( + stream: repository.watchCount(), + builder: (context, snapshot) { + final count = snapshot.data ?? 0; + return Padding( + padding: const EdgeInsets.only(right: 4), + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.list_alt_outlined, size: 18), + const SizedBox(width: 4), + Text('$count'), + ], + ), + ), + ); + }, + ), + IconButton( + tooltip: 'New note', + onPressed: onNewNote, + icon: const Icon(Icons.add), + ), + if (advanced) + IconButton( + tooltip: 'Sync', + onPressed: syncing ? null : onSync, + icon: syncing + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.sync), + ), + IconButton( + tooltip: 'Notes', + onPressed: onOpenList, + icon: const Icon(Icons.list), + ), + IconButton( + tooltip: 'Settings', + onPressed: onOpenSettings, + icon: const Icon(Icons.settings), + ), + ], + ); + } +} diff --git a/lib/ui/capture_draft.dart b/lib/ui/capture_draft.dart new file mode 100644 index 0000000..0d9a848 --- /dev/null +++ b/lib/ui/capture_draft.dart @@ -0,0 +1,111 @@ +/// The capture screen's in-progress note, and how it reaches storage. +/// +/// Split out of `capture_screen.dart` for file size. It is the model behind +/// the app's central invariant — every keystroke is already persisted — so it +/// is kept free of Flutter state: the screen decides when to rebuild, this +/// decides what is saved. +/// +/// The row is created lazily, on the first non-empty keystroke, so an +/// untouched template never reaches storage. +library; + +import 'package:flutter/foundation.dart'; + +import 'package:todo/data/note.dart'; +import 'package:todo/data/note_repository.dart'; +import 'package:todo/sync/local_backup.dart'; +import 'package:todo/sync/local_backup_factory.dart'; +import 'package:uuid/uuid.dart'; + +/// Holds the draft being captured and writes it through to [repository]. +class CaptureDraft { + /// Creates a draft writing to [repository]. + CaptureDraft(this.repository); + + /// Where the draft is persisted on every change. + final NoteRepository repository; + + final Uuid _uuid = const Uuid(); + + /// When the draft was last written locally; null until the first save. + final ValueNotifier lastSavedAt = ValueNotifier(null); + + String _text = ''; + String? _id; + DateTime? _createdAt; + + /// The draft's priority, applied to every write. + Priority priority = Priority.defaultValue; + + /// The draft's status, applied to every write. + Status status = Status.todo; + + /// Persists [text], creating the note row on the first non-empty keystroke. + /// + /// Returns whether the row was created by this call, so the caller can log + /// it once rather than on every keystroke. + Future write(String text, {required bool Function() live}) async { + _text = text; + var created = false; + if (_id == null) { + // A note is only created once the user actually fills something in, so + // an empty template (no section typed yet) never hits storage. + if (text.trim().isEmpty) return false; + _id = _uuid.v4(); + _createdAt = DateTime.now(); + created = true; + } + await _save(live: live); + return created; + } + + /// Re-saves when only priority/status changed. + /// + /// Does nothing before the row exists: the new value is already held here + /// and will be applied by the first keystroke that creates it. + Future persistMetadata({required bool Function() live}) async { + if (_id == null) return; + await _save(live: live); + } + + Future _save({required bool Function() live}) async { + final now = DateTime.now(); + await repository.upsert( + Note( + id: _id!, + text: _text, + priority: priority, + status: status, + createdAt: _createdAt!, + updatedAt: now, + ), + ); + if (live()) lastSavedAt.value = now; + } + + /// Forgets the current draft so the next keystroke starts a new note. + /// + /// Nothing is lost: every keystroke was already persisted, which is why + /// the field going blank is the only feedback the screen needs to give. + void reset() { + _text = ''; + _id = null; + _createdAt = null; + priority = Priority.defaultValue; + status = Status.todo; + lastSavedAt.value = null; + } + + /// Releases the notifier. + void dispose() => lastSavedAt.dispose(); +} + +// coverage:ignore-start +// Platform file IO for the local backup: BACKLOG.md under ~/todo on desktop +// (the path the user's workflow already reads), or the app documents dir on +// mobile (which Android Auto Backup includes). Exercised by running the app; +// tests inject an in-memory LocalBackup instead. +/// The local backup implementation for whichever platform this is. +LocalBackup platformLocalBackup(NoteRepository repository) => + createLocalBackup(repository); +// coverage:ignore-end diff --git a/lib/ui/capture_meta_row.dart b/lib/ui/capture_meta_row.dart new file mode 100644 index 0000000..9df6f50 --- /dev/null +++ b/lib/ui/capture_meta_row.dart @@ -0,0 +1,122 @@ +/// The capture screen's metadata controls: the priority/status row and the +/// generic dropdown behind it. +/// +/// Split out of `capture_status.dart` for file size. Public only because Dart +/// privacy is library-scoped. +library; + +import 'package:flutter/material.dart'; + +import 'package:todo/data/note.dart'; + +/// A compact labelled dropdown for picking an enum value (priority/status). +/// +/// Generic over the enum type [T] so the same control drives both pickers +/// without duplication; [labelOf] maps a value to its display string. +class MetaDropdown extends StatelessWidget { + /// Creates a dropdown showing [value] out of [values]. + const MetaDropdown({ + required this.label, + required this.value, + required this.values, + required this.labelOf, + required this.onChanged, + super.key, + }); + + /// Label shown above the control. + final String label; + + /// Currently selected value. + final T value; + + /// Every selectable value. + final List values; + + /// Maps a value to its display string. + final String Function(T) labelOf; + + /// Called with the newly picked value. + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return InputDecorator( + decoration: InputDecoration( + labelText: label, + isDense: true, + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + isDense: true, + isExpanded: true, + items: [ + for (final v in values) + DropdownMenuItem(value: v, child: Text(labelOf(v))), + ], + onChanged: (v) { + if (v != null) onChanged(v); + }, + ), + ), + ); + } +} + +/// The draft's priority and status pickers, side by side. +/// +/// Hidden together with the editor's own chrome while the bare guided +/// stepper or its entry wizard is up, so the top of the screen stays free of +/// noise -- the screen decides that; this only lays the row out. +class CaptureMetaRow extends StatelessWidget { + /// Creates the metadata row for the current draft values. + const CaptureMetaRow({ + required this.priority, + required this.status, + required this.onPriorityChanged, + required this.onStatusChanged, + super.key, + }); + + /// The draft's current priority. + final Priority priority; + + /// The draft's current status. + final Status status; + + /// Called with a newly picked priority. + final ValueChanged onPriorityChanged; + + /// Called with a newly picked status. + final ValueChanged onStatusChanged; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: MetaDropdown( + label: 'Priority', + value: priority, + values: Priority.values, + labelOf: (p) => p.label, + onChanged: onPriorityChanged, + ), + ), + const SizedBox(width: 12), + Expanded( + child: MetaDropdown( + label: 'Status', + value: status, + values: Status.values, + labelOf: (s) => s.label, + onChanged: onStatusChanged, + ), + ), + ], + ); + } +} diff --git a/lib/ui/capture_screen.dart b/lib/ui/capture_screen.dart index 8679908..3ea4a55 100644 --- a/lib/ui/capture_screen.dart +++ b/lib/ui/capture_screen.dart @@ -2,86 +2,30 @@ import 'dart:async'; import 'package:crdt_sync/crdt_sync.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; -import 'package:shared_preferences/shared_preferences.dart'; 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/frame_stats.dart'; import 'package:todo/sync/firebase_backend.dart'; import 'package:todo/sync/local_backup.dart'; -import 'package:todo/sync/local_backup_factory.dart'; -import 'package:todo/sync/run_sync.dart'; -import 'package:todo/sync/sync_service.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'; -import 'package:uuid/uuid.dart'; -/// The landing screen: an always-focused text box for jotting an idea. -/// -/// Per the product goal "no interruptions, immediate", text is persisted -/// to local storage on *every* keystroke. A note row is created lazily on -/// the first character typed, then updated in place. The "new note" action -/// finalises the current idea and clears the field for the next one. -class CaptureScreen extends StatefulWidget { - /// Creates a [CaptureScreen] backed by [repository]. - const CaptureScreen({ - required this.repository, - required this.appSettings, - this.analytics, - this.httpClient, - this.localBackup, - this.firebaseFactory, - this.stateStore, - super.key, - }); - - /// The store the captured note is persisted to on every keystroke. - final NoteRepository repository; - - /// App-wide preferences, currently just `advancedMode`: whether the - /// priority/status row and the routine save/sync status line are shown. - /// A sync failure is always surfaced regardless of this flag. - final ValueNotifier appSettings; - - /// Interaction-only usage analytics (taps, navigation, sync outcomes — - /// never note text). Null in tests that don't exercise it, so the prefs - /// round-trip `logEvent` does on every call never has to be awaited by a - /// test that didn't ask for it. - final AnalyticsService? analytics; - - /// Builds the Firebase backend. Injected so tests can supply a fake, or - /// null to assert the pre-migration GitHub-only path still works. - final Future Function()? firebaseFactory; - - /// Revision cache. Injected so tests need no application-support directory. - final SyncStateStore? stateStore; - - /// Injectable HTTP client for the sync path. Production leaves this null - /// (the GitHubClient creates its own); tests pass a mock so the configured - /// sync flow can be exercised without real network access. - final http.Client? httpClient; - - /// Injectable local-disk backup. Production leaves this null (a platform - /// file-backed instance is created); tests pass a fake with in-memory IO. - final LocalBackup? localBackup; - - /// How long focus must stay lost before a background sync fires. Public so - /// tests can pump exactly past it. - static const autoSyncDebounce = Duration(seconds: 5); - - @override - State createState() => _CaptureScreenState(); -} +part 'capture_screen_sync.dart'; +part 'capture_screen_widget.dart'; class _CaptureScreenState extends State with WidgetsBindingObserver { - static const _uuid = Uuid(); - /// Single-flight guard so a launch sync and a background sync never overlap. bool _autoSyncing = false; @@ -92,25 +36,19 @@ class _CaptureScreenState extends State /// Latest assembled text from the editor; persisted on change and re-saved /// when only priority/status change. - String _draftText = ''; /// 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. - String? _draftId; - DateTime? _draftCreatedAt; /// 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. - final ValueNotifier _lastSavedAt = ValueNotifier(null); /// Priority/status applied to the current draft. Chosen before or during /// typing; persisted on the first keystroke and on every later change. - Priority _draftPriority = Priority.defaultValue; - Status _draftStatus = Status.todo; SyncSettings? _settings; bool _syncing = false; @@ -118,16 +56,25 @@ class _CaptureScreenState extends State /// 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. - final ValueNotifier _lastSync = ValueNotifier(null); + /// 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; - static const _kLastSyncTime = 'sync.lastTime'; - static const _kLastSyncOk = 'sync.lastOk'; - static const _kLastSyncDetail = 'sync.lastDetail'; - /// 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; @@ -135,26 +82,12 @@ class _CaptureScreenState extends State /// When this session started, for the duration on the `app_close` event. final DateTime _sessionStart = DateTime.now(); - /// Logs an interaction event without blocking the caller on the prefs - /// round-trip `AnalyticsService.logEvent` does. A no-op when - /// [CaptureScreen.analytics] is null (tests that don't exercise it). - void _logEvent(String name, {Map params = const {}}) { - final analytics = widget.analytics; - if (analytics == null) return; - unawaited( - analytics.logEvent( - AnalyticsEvent(name: name, timestamp: DateTime.now(), params: params), - ), - ); - } - @override void initState() { super.initState(); _logEvent('app_open'); WidgetsBinding.instance.addObserver(this); - _localBackup = - widget.localBackup ?? _platformLocalBackup(widget.repository); + _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. @@ -189,74 +122,33 @@ class _CaptureScreenState extends State _autoSyncDebounce?.cancel(); unawaited(_changesSub?.cancel()); _localBackup.dispose(); - _lastSavedAt.dispose(); - _lastSync.dispose(); + _draft.dispose(); + _syncStatus.dispose(); super.dispose(); } - /// Reloads the persisted last-sync outcome so the status line survives a - /// restart (a failure just before closing must not disappear). - Future _restoreSyncStatus() async { - final prefs = await SharedPreferences.getInstance(); - final time = DateTime.tryParse(prefs.getString(_kLastSyncTime) ?? ''); - if (!mounted || time == null || _lastSync.value != null) return; - _lastSync.value = SyncStatus( - time: time, - ok: prefs.getBool(_kLastSyncOk) ?? true, - detail: prefs.getString(_kLastSyncDetail) ?? '', + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + 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), + ); + }, ); } - /// Records a sync outcome in the UI notifier and persists it. - Future _recordSyncStatus({ - required bool ok, - required String detail, - }) async { - final status = SyncStatus(time: DateTime.now(), ok: ok, detail: detail); - if (mounted) _lastSync.value = status; - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_kLastSyncTime, status.time.toIso8601String()); - await prefs.setBool(_kLastSyncOk, ok); - await prefs.setString(_kLastSyncDetail, detail); - } - - /// Human-readable one-liner for a sync outcome, shared by the snackbar and - /// the status line. Skipped files are called out so a peer device silently - /// dropping out of the merge is visible. - /// - /// Firebase-vs-GitHub-only is called out too, and first, because it used - /// to read identically either way: "Synced ... merged N device(s))" while - /// disconnected from Firebase looked exactly like success, which is - /// precisely how a desktop stuck syncing over the GitHub mirror alone - /// went unnoticed for days. - static String _describe(SyncResult result) { - final skipped = result.skippedFiles.length; - final backend = result.firebaseConnected - ? '' - : 'GitHub-only (Firebase not connected) — '; - return '$backend' - 'merged ${result.mergedDevices} device(s)' - '${skipped == 0 ? '' : ', skipped $skipped unreadable file(s)'}'; - } - - /// Restores notes from the local backup file, but only into an empty DB so a - /// stale backup never clobbers existing notes (merge-by-id stays safe too). - Future _recoverFromBackup() async { - final existing = await widget.repository.listNotes(); - if (existing.isNotEmpty) return; - final recovered = await _localBackup.recover(); - if (recovered.isNotEmpty) await widget.repository.importNotes(recovered); - } - - // coverage:ignore-start - // Platform file IO for the local backup: BACKLOG.md under ~/todo on desktop - // (the path the user's workflow already reads), or the app documents dir on - // mobile (which Android Auto Backup includes). Exercised by running the app; - // tests inject an in-memory LocalBackup instead. - static LocalBackup _platformLocalBackup(NoteRepository repository) => - createLocalBackup(repository); - // coverage:ignore-end - @override void didChangeAppLifecycleState(AppLifecycleState state) { // Push on background so the remote (the durable store) stays near-current. @@ -282,53 +174,6 @@ class _CaptureScreenState extends State } } - /// Best-effort background sync: no snackbar, skips when unconfigured, and - /// never overlaps itself. Failures are not swallowed silently — the outcome - /// lands in the status line so drift can't go unnoticed for days. - Future _autoSync() async { - final settings = _settings; - if (_autoSyncing || settings == null) return; - // Either backend counts. `isConfigured` means "has a GitHub token", so - // gating on it alone silently skips every sync on a device connected - // only to Firebase -- and once the mirror is retired that is every - // device. - if (!settings.isConfigured && await openFirebase() == null) return; - _autoSyncing = true; - try { - final run = await runSync( - widget.repository, - settings, - appSettings: widget.appSettings.value, - analytics: widget.analytics, - httpClient: widget.httpClient, - firebaseFactory: widget.firebaseFactory, - stateStore: widget.stateStore, - ); - _adoptReconciledSettings(run.appSettings); - final detail = _describe(run.syncResult); - await _recordSyncStatus(ok: true, detail: detail); - _logEvent( - 'sync_result', - params: {'ok': true, 'auto': true, 'detail': detail}, - ); - } on Exception catch (e) { - // Offline or a transient GitHub error: still no snackbar (this path - // must never interrupt capture), but the status line shows it. - await _recordSyncStatus(ok: false, detail: '$e'); - _logEvent('sync_result', params: {'ok': false, 'auto': true}); - } finally { - _autoSyncing = false; - } - } - - /// Applies a settings snapshot reconciled during a sync pass, when it is - /// newer than what this screen already holds (the toggle in Settings may - /// have written a newer value locally in between, which must win). - void _adoptReconciledSettings(AppSettings? reconciled) { - if (!mounted) return; - widget.appSettings.value = widget.appSettings.value.adopt(reconciled); - } - /// Opens the settings screen and adopts any saved configuration. Future _openSettings() async { _logEvent('screen_view', params: {'screen': 'settings'}); @@ -353,17 +198,6 @@ class _CaptureScreenState extends State setState(() => _settings = fresh); } - void _openList() { - _logEvent('screen_view', params: {'screen': 'notes_list'}); - unawaited( - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => NotesListScreen(repository: widget.repository), - ), - ), - ); - } - /// Runs a full sync, routing to settings first if not yet configured. Future _sync() async { final settings = _settings ?? await SyncSettings.load(); @@ -374,366 +208,39 @@ class _CaptureScreenState extends State } setState(() => _syncing = true); try { - final run = await runSync( - widget.repository, - settings, + final outcome = await _runner.run( + settings: settings, appSettings: widget.appSettings.value, - analytics: widget.analytics, - httpClient: widget.httpClient, - firebaseFactory: widget.firebaseFactory, - stateStore: widget.stateStore, + live: () => mounted, ); - _adoptReconciledSettings(run.appSettings); - final detail = _describe(run.syncResult); - await _recordSyncStatus(ok: true, detail: detail); + if (outcome.ok) _adoptReconciledSettings(outcome.reconciled); _logEvent( 'sync_result', - params: {'ok': true, 'auto': false, 'detail': detail}, + params: { + 'ok': outcome.ok, + 'auto': false, + if (outcome.ok) 'detail': outcome.detail, + }, + ); + _showSnack( + outcome.ok + ? 'Synced: ${outcome.detail}' + : 'Sync failed: ${outcome.detail}', ); - _showSnack('Synced: $detail'); - } on Exception catch (e) { - await _recordSyncStatus(ok: false, detail: '$e'); - _logEvent('sync_result', params: {'ok': false, 'auto': false}); - _showSnack('Sync failed: $e'); } finally { if (mounted) setState(() => _syncing = false); } } - void _showSnack(String message) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message), duration: const Duration(seconds: 2)), - ); + /// 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); } - /// Persists the current text on every change. Creates the note row on - /// the first non-empty keystroke so empty drafts never hit storage. - Future _onChanged(String text) async { - _draftText = text; - if (_draftId == null) { - // A note is only created once the user actually fills something in, so - // an empty template (no section typed yet) never hits storage. - if (text.trim().isEmpty) return; - _draftId = _uuid.v4(); - _draftCreatedAt = DateTime.now(); - _logEvent('note_created'); - } - final now = DateTime.now(); - await widget.repository.upsert( - Note( - id: _draftId!, - text: text, - priority: _draftPriority, - status: _draftStatus, - createdAt: _draftCreatedAt!, - updatedAt: now, - ), - ); - if (mounted) _lastSavedAt.value = now; - } - - /// Applies a new priority to the draft, persisting immediately if a note - /// row already exists (otherwise it is applied on the first keystroke). - Future _setPriority(Priority priority) async { - _logEvent( - 'priority_changed', - params: {'from': _draftPriority.name, 'to': priority.name}, - ); - setState(() => _draftPriority = priority); - await _persistDraftMeta(); - } - - /// Applies a new status to the draft, persisting immediately if a note - /// row already exists. - Future _setStatus(Status status) async { - _logEvent( - 'status_changed', - params: {'from': _draftStatus.name, 'to': status.name}, - ); - setState(() => _draftStatus = status); - await _persistDraftMeta(); - } - - /// Re-saves the draft's metadata when only priority/status changed. - Future _persistDraftMeta() async { - if (_draftId == null) return; - final now = DateTime.now(); - await widget.repository.upsert( - Note( - id: _draftId!, - text: _draftText, - priority: _draftPriority, - status: _draftStatus, - createdAt: _draftCreatedAt!, - updatedAt: now, - ), - ); - if (mounted) _lastSavedAt.value = now; - } - - /// Finalises the current idea and resets the field to a fresh template. - /// The field going blank is the only feedback needed — autosave already - /// persisted every keystroke, so there is nothing left to confirm. - void _saveAndReset() { - _logEvent('new_note_reset'); - setState(() { - _editorGeneration++; // recreate the editor with a fresh template - _draftText = ''; - _draftId = null; - _draftCreatedAt = null; - _draftPriority = Priority.defaultValue; - _draftStatus = Status.todo; - _chromeVisible = true; - }); - _lastSavedAt.value = null; - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return ValueListenableBuilder( - valueListenable: widget.appSettings, - builder: (context, settings, _) { - final advanced = settings.advancedMode; - return Scaffold( - appBar: AppBar( - actions: [ - // TEMPORARY: forces continuous frame production so raster cost - // can be sampled at each window size. Armed by - // TODO_FRAME_STATS=1, which is never set under the test - // runner, so the branch is unreachable. - // coverage:ignore-start - if (frameStatsEnabled) - const Padding( - padding: EdgeInsets.only(right: 8), - child: Center( - child: SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - ), - // coverage:ignore-end - // Live count of stored notes, proving local persistence. - StreamBuilder( - stream: widget.repository.watchCount(), - builder: (context, snapshot) { - final count = snapshot.data ?? 0; - return Padding( - padding: const EdgeInsets.only(right: 4), - child: Center( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.list_alt_outlined, size: 18), - const SizedBox(width: 4), - Text('$count'), - ], - ), - ), - ); - }, - ), - IconButton( - tooltip: 'New note', - onPressed: _saveAndReset, - icon: const Icon(Icons.add), - ), - if (advanced) - IconButton( - tooltip: 'Sync', - onPressed: _syncing ? null : _sync, - icon: _syncing - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.sync), - ), - IconButton( - tooltip: 'Notes', - onPressed: _openList, - icon: const Icon(Icons.list), - ), - IconButton( - tooltip: 'Settings', - onPressed: _openSettings, - icon: const Icon(Icons.settings), - ), - ], - ), - body: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Hidden together with the editor's own chrome while the - // bare guided stepper or its entry wizard is up, so the top - // of the screen stays free of noise. Also hidden outright - // when advanced mode is off. - if (advanced && _chromeVisible) ...[ - Row( - children: [ - Expanded( - child: _MetaDropdown( - label: 'Priority', - value: _draftPriority, - values: Priority.values, - labelOf: (p) => p.label, - onChanged: _setPriority, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _MetaDropdown( - label: 'Status', - value: _draftStatus, - values: Status.values, - labelOf: (s) => s.label, - onChanged: _setStatus, - ), - ), - ], - ), - const SizedBox(height: 12), - ], - Expanded( - // The key lives on this Expanded, not on NoteEditor itself: - // the Row above is conditionally present, which shifts - // this Expanded's position in the Column whenever - // advancedMode changes at runtime. A key one level too - // deep (previously on NoteEditor) doesn't survive that - // reposition — Flutter can't match it at the new index, - // so it tears down and remounts NoteEditor with an empty - // draft, discarding whatever the user was typing. - key: ValueKey(_editorGeneration), - child: NoteEditor( - initialTemplate: NoteTemplate.defaultTemplate, - initialMode: NoteEditorMode.raw, - advancedMode: advanced, - priority: _draftPriority, - onPriorityChanged: _setPriority, - onChromeVisibleChanged: (visible) => - setState(() => _chromeVisible = visible), - autofocus: true, - onChanged: _onChanged, - ), - ), - // Sync is automatic; routine "saved"/"synced" chatter is not - // worth showing. Only a sync failure is surfaced, since that - // is the one state the user might need to act on. - ValueListenableBuilder( - valueListenable: _lastSync, - builder: (context, status, _) { - if (status == null || status.ok) { - return const SizedBox.shrink(); - } - return Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - 'Sync failed at ${_formatTime(status.time)} · ' - '${status.detail}', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.error, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ); - }, - ), - if (advanced) - Padding( - padding: const EdgeInsets.only(top: 4), - child: ValueListenableBuilder( - valueListenable: _lastSavedAt, - builder: (context, savedAt, _) => Text( - savedAt == null - ? 'Autosaves as you type' - : 'Saved locally at ${_formatTime(savedAt)}', - style: theme.textTheme.bodySmall, - ), - ), - ), - ], - ), - ), - ); - }, - ); - } - - /// Formats a timestamp as zero-padded HH:mm:ss for the save indicator. - String _formatTime(DateTime t) { - String two(int n) => n.toString().padLeft(2, '0'); - return '${two(t.hour)}:${two(t.minute)}:${two(t.second)}'; - } -} - -/// Outcome of a sync attempt, shown in the capture screen's status line. -class SyncStatus { - /// Creates a [SyncStatus] from a completed sync attempt's outcome. - const SyncStatus({ - required this.time, - required this.ok, - required this.detail, - }); - - /// When the attempt finished. - final DateTime time; - - /// Whether the sync succeeded. - final bool ok; - - /// One-line summary: merged/skipped counts on success, the error otherwise. - final String detail; -} - -/// A compact labelled dropdown for picking an enum value (priority/status). -/// -/// Generic over the enum type [T] so the same control drives both pickers -/// without duplication; [labelOf] maps a value to its display string. -class _MetaDropdown extends StatelessWidget { - const _MetaDropdown({ - required this.label, - required this.value, - required this.values, - required this.labelOf, - required this.onChanged, - }); - - final String label; - final T value; - final List values; - final String Function(T) labelOf; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - return InputDecorator( - decoration: InputDecoration( - labelText: label, - isDense: true, - border: const OutlineInputBorder(), - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: value, - isDense: true, - isExpanded: true, - items: [ - for (final v in values) - DropdownMenuItem(value: v, child: Text(labelOf(v))), - ], - onChanged: (v) { - if (v != null) onChanged(v); - }, - ), - ), - ); - } + /// 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); } diff --git a/lib/ui/capture_screen_sync.dart b/lib/ui/capture_screen_sync.dart new file mode 100644 index 0000000..a7377da --- /dev/null +++ b/lib/ui/capture_screen_sync.dart @@ -0,0 +1,193 @@ +/// The capture screen's sync and navigation behaviour: the lifecycle hook +/// that triggers a background push, the manual and automatic sync paths, and +/// the two screens this one opens. +/// +/// A `part` of `capture_screen.dart` because every method here reads private +/// state (`_settings`, `_syncing`, `_runner`) that Dart scopes to the library. +/// The work itself lives in [CaptureSyncRunner]; what stays here is when to +/// run it and what the user is shown. +part of 'capture_screen.dart'; + +extension _CaptureSyncBehaviour on _CaptureScreenState { + /// Best-effort background sync: no snackbar, skips when unconfigured, and + /// never overlaps itself. Failures are not swallowed silently — the outcome + /// lands in the status line so drift can't go unnoticed for days. + Future _autoSync() async { + final settings = _settings; + if (_autoSyncing || settings == null) return; + // Either backend counts. `isConfigured` means "has a GitHub token", so + // gating on it alone silently skips every sync on a device connected + // only to Firebase -- and once the mirror is retired that is every + // device. + if (!settings.isConfigured && await openFirebase() == null) return; + _autoSyncing = true; + try { + // Offline or a transient GitHub error is reported the same way as + // success here: no snackbar (this path must never interrupt capture), + // just the status line the runner has already written. + 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': true, + if (outcome.ok) 'detail': outcome.detail, + }, + ); + } finally { + _autoSyncing = false; + } + } + + /// Applies a settings snapshot reconciled during a sync pass, when it is + /// newer than what this screen already holds (the toggle in Settings may + /// have written a newer value locally in between, which must win). + void _adoptReconciledSettings(AppSettings? reconciled) { + if (!mounted) return; + widget.appSettings.value = widget.appSettings.value.adopt(reconciled); + } + + void _openList() { + _logEvent('screen_view', params: {'screen': 'notes_list'}); + unawaited( + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => NotesListScreen(repository: widget.repository), + ), + ), + ); + } + + void _showSnack(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), duration: const Duration(seconds: 2)), + ); + } + + /// Reloads the persisted last-sync outcome so the status line survives a + /// restart. Skipped when the screen went away while the read was in + /// flight -- the notifier must not be written after dispose. + Future _restoreSyncStatus() async { + await _syncStatus.restore(stillLive: () => mounted); + } + + /// Restores notes from the local backup file, but only into an empty DB so a + /// stale backup never clobbers existing notes (merge-by-id stays safe too). + Future _recoverFromBackup() async { + final existing = await widget.repository.listNotes(); + if (existing.isNotEmpty) return; + final recovered = await _localBackup.recover(); + if (recovered.isNotEmpty) await widget.repository.importNotes(recovered); + } + + /// The composer column: metadata row, editor, and status footer. + Widget _captureBody({required bool advanced}) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Hidden together with the editor's own chrome while the + // bare guided stepper or its entry wizard is up, so the top + // of the screen stays free of noise. Also hidden outright + // when advanced mode is off. + if (advanced && _chromeVisible) ...[ + CaptureMetaRow( + priority: _draft.priority, + status: _draft.status, + onPriorityChanged: _setPriority, + onStatusChanged: _setStatus, + ), + const SizedBox(height: 12), + ], + Expanded( + // The key lives on this Expanded, not on NoteEditor itself: + // the Row above is conditionally present, which shifts + // this Expanded's position in the Column whenever + // advancedMode changes at runtime. A key one level too + // deep (previously on NoteEditor) doesn't survive that + // reposition — Flutter can't match it at the new index, + // so it tears down and remounts NoteEditor with an empty + // draft, discarding whatever the user was typing. + key: ValueKey(_editorGeneration), + child: NoteEditor( + initialTemplate: NoteTemplate.defaultTemplate, + initialMode: NoteEditorMode.raw, + advancedMode: advanced, + priority: _draft.priority, + onPriorityChanged: _setPriority, + onChromeVisibleChanged: _setChromeVisible, + autofocus: true, + onChanged: _onChanged, + ), + ), + CaptureStatusFooter( + syncStatus: _syncStatus.latest, + lastSavedAt: _draft.lastSavedAt, + showSavedAt: advanced, + ), + ], + ), + ); + } + + /// Persists the current text on every change. The note row is created on + /// the first non-empty keystroke, which is the one worth logging. + Future _onChanged(String text) async { + final created = await _draft.write(text, live: () => mounted); + if (created) _logEvent('note_created'); + } + + /// Applies a new priority to the draft, persisting immediately if a note + /// row already exists (otherwise it is applied on the first keystroke). + Future _setPriority(Priority priority) async { + _logEvent( + 'priority_changed', + params: {'from': _draft.priority.name, 'to': priority.name}, + ); + _rebuild(() => _draft.priority = priority); + await _draft.persistMetadata(live: () => mounted); + } + + /// Applies a new status to the draft, persisting immediately if a note + /// row already exists. + Future _setStatus(Status status) async { + _logEvent( + 'status_changed', + params: {'from': _draft.status.name, 'to': status.name}, + ); + _rebuild(() => _draft.status = status); + await _draft.persistMetadata(live: () => mounted); + } + + /// Finalises the current idea and resets the field to a fresh template. + /// The field going blank is the only feedback needed — autosave already + /// persisted every keystroke, so there is nothing left to confirm. + void _saveAndReset() { + _logEvent('new_note_reset'); + _rebuild(() { + _editorGeneration++; // recreate the editor with a fresh template + _draft.reset(); + _chromeVisible = true; + }); + } + + /// Logs an interaction event without blocking the caller on the prefs + /// round-trip `AnalyticsService.logEvent` does. A no-op when + /// [CaptureScreen.analytics] is null (tests that don't exercise it). + void _logEvent(String name, {Map params = const {}}) { + final analytics = widget.analytics; + if (analytics == null) return; + unawaited( + analytics.logEvent( + AnalyticsEvent(name: name, timestamp: DateTime.now(), params: params), + ), + ); + } +} diff --git a/lib/ui/capture_screen_widget.dart b/lib/ui/capture_screen_widget.dart new file mode 100644 index 0000000..25cc767 --- /dev/null +++ b/lib/ui/capture_screen_widget.dart @@ -0,0 +1,64 @@ +/// The [CaptureScreen] widget's public surface: its parameters and their +/// contracts. The state that drives them lives in `capture_screen.dart`. +/// +/// A `part` rather than a separate library because `createState` returns a +/// private class, so the widget and its State cannot be split across +/// libraries. +part of 'capture_screen.dart'; + +/// The landing screen: an always-focused text box for jotting an idea. +/// +/// Per the product goal "no interruptions, immediate", text is persisted +/// to local storage on *every* keystroke. A note row is created lazily on +/// the first character typed, then updated in place. The "new note" action +/// finalises the current idea and clears the field for the next one. +class CaptureScreen extends StatefulWidget { + /// Creates a [CaptureScreen] backed by [repository]. + const CaptureScreen({ + required this.repository, + required this.appSettings, + this.analytics, + this.httpClient, + this.localBackup, + this.firebaseFactory, + this.stateStore, + super.key, + }); + + /// The store the captured note is persisted to on every keystroke. + final NoteRepository repository; + + /// App-wide preferences, currently just `advancedMode`: whether the + /// priority/status row and the routine save/sync status line are shown. + /// A sync failure is always surfaced regardless of this flag. + final ValueNotifier appSettings; + + /// Interaction-only usage analytics (taps, navigation, sync outcomes — + /// never note text). Null in tests that don't exercise it, so the prefs + /// round-trip `logEvent` does on every call never has to be awaited by a + /// test that didn't ask for it. + final AnalyticsService? analytics; + + /// Builds the Firebase backend. Injected so tests can supply a fake, or + /// null to assert the pre-migration GitHub-only path still works. + final Future Function()? firebaseFactory; + + /// Revision cache. Injected so tests need no application-support directory. + final SyncStateStore? stateStore; + + /// Injectable HTTP client for the sync path. Production leaves this null + /// (the GitHubClient creates its own); tests pass a mock so the configured + /// sync flow can be exercised without real network access. + final http.Client? httpClient; + + /// Injectable local-disk backup. Production leaves this null (a platform + /// file-backed instance is created); tests pass a fake with in-memory IO. + final LocalBackup? localBackup; + + /// How long focus must stay lost before a background sync fires. Public so + /// tests can pump exactly past it. + static const autoSyncDebounce = Duration(seconds: 5); + + @override + State createState() => _CaptureScreenState(); +} diff --git a/lib/ui/capture_status.dart b/lib/ui/capture_status.dart new file mode 100644 index 0000000..cd7cc1d --- /dev/null +++ b/lib/ui/capture_status.dart @@ -0,0 +1,108 @@ +/// The capture screen's small shared pieces: its sync-status record and the +/// metadata dropdown used for priority and status. +/// +/// Split out of `capture_screen.dart` for file size. [SyncStatus] was already +/// public; [MetaDropdown] is public only because Dart privacy is +/// library-scoped. +library; + +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:todo/sync/sync_service.dart'; + +/// Formats [t] as HH:MM:SS for the status line. +String formatSyncTime(DateTime t) { + String two(int n) => n.toString().padLeft(2, '0'); + return '${two(t.hour)}:${two(t.minute)}:${two(t.second)}'; +} + +/// Outcome of a sync attempt, shown in the capture screen's status line. +class SyncStatus { + /// Creates a [SyncStatus] from a completed sync attempt's outcome. + const SyncStatus({ + required this.time, + required this.ok, + required this.detail, + }); + + /// When the attempt finished. + final DateTime time; + + /// Whether the sync succeeded. + final bool ok; + + /// One-line summary: merged/skipped counts on success, the error otherwise. + final String detail; +} + +/// The last sync outcome, in a notifier and in shared preferences. +/// +/// Persisted because the status line has to survive a restart: a failure +/// recorded just before the app closed must still be visible next launch, +/// which is the whole point of showing it. +class SyncStatusStore { + static const _kTime = 'sync.lastTime'; + static const _kOk = 'sync.lastOk'; + static const _kDetail = 'sync.lastDetail'; + + /// The latest outcome, or null before the first sync on this install. + final ValueNotifier latest = ValueNotifier(null); + + /// Reloads the persisted outcome into [latest]. + /// + /// Does nothing when [latest] already holds something: a sync that + /// completed while this was in flight is newer than what is on disk. + Future restore({bool Function()? stillLive}) async { + final prefs = await SharedPreferences.getInstance(); + final time = DateTime.tryParse(prefs.getString(_kTime) ?? ''); + // Checked after the await, not before: the screen can go away while the + // read is in flight, and writing the notifier then is a use-after-dispose. + if (stillLive != null && !stillLive()) return; + if (time == null || latest.value != null) return; + latest.value = SyncStatus( + time: time, + ok: prefs.getBool(_kOk) ?? true, + detail: prefs.getString(_kDetail) ?? '', + ); + } + + /// Records an outcome in [latest] and persists it. + /// + /// [live] is false once the screen is gone: the notifier must not be + /// written after dispose, but the outcome is still worth persisting. + Future record({ + required bool ok, + required String detail, + bool live = true, + }) async { + final status = SyncStatus(time: DateTime.now(), ok: ok, detail: detail); + if (live) latest.value = status; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kTime, status.time.toIso8601String()); + await prefs.setBool(_kOk, ok); + await prefs.setString(_kDetail, detail); + } + + /// Releases the notifier. + void dispose() => latest.dispose(); +} + +/// Human-readable one-liner for a sync outcome, shared by the snackbar and +/// the status line. Skipped files are called out so a peer device silently +/// dropping out of the merge is visible. +/// +/// Firebase-vs-GitHub-only is called out too, and first, because it used +/// to read identically either way: "Synced ... merged N device(s))" while +/// disconnected from Firebase looked exactly like success, which is +/// precisely how a desktop stuck syncing over the GitHub mirror alone +/// went unnoticed for days. +String describeSyncResult(SyncResult result) { + final skipped = result.skippedFiles.length; + final backend = result.firebaseConnected + ? '' + : 'GitHub-only (Firebase not connected) — '; + return '$backend' + 'merged ${result.mergedDevices} device(s)' + '${skipped == 0 ? '' : ', skipped $skipped unreadable file(s)'}'; +} diff --git a/lib/ui/capture_status_footer.dart b/lib/ui/capture_status_footer.dart new file mode 100644 index 0000000..6008b36 --- /dev/null +++ b/lib/ui/capture_status_footer.dart @@ -0,0 +1,77 @@ +/// The two status lines under the capture screen's editor. +/// +/// Split out of `capture_screen.dart` for file size. Both are listeners over +/// notifiers the screen owns, so this rebuilds without the screen having to. +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:todo/ui/capture_status.dart'; + +/// A sync-failure line and a local-save line, each rebuilt independently. +class CaptureStatusFooter extends StatelessWidget { + /// Creates the footer over the screen's two status notifiers. + const CaptureStatusFooter({ + required this.syncStatus, + required this.lastSavedAt, + required this.showSavedAt, + super.key, + }); + + /// The latest sync outcome; only failures are rendered. + final ValueListenable syncStatus; + + /// When the draft was last written locally, or null before the first save. + final ValueListenable lastSavedAt; + + /// Whether to show the local-save line at all (advanced mode only). + final bool showSavedAt; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Sync is automatic; routine "saved"/"synced" chatter is not worth + // showing. Only a sync failure is surfaced, since that is the one + // state the user might need to act on. + ValueListenableBuilder( + valueListenable: syncStatus, + builder: (context, status, _) { + if (status == null || status.ok) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + 'Sync failed at ${formatSyncTime(status.time)} · ' + '${status.detail}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ); + }, + ), + if (showSavedAt) + Padding( + padding: const EdgeInsets.only(top: 4), + child: ValueListenableBuilder( + valueListenable: lastSavedAt, + builder: (context, savedAt, _) => Text( + savedAt == null + ? 'Autosaves as you type' + : 'Saved locally at ${formatSyncTime(savedAt)}', + style: theme.textTheme.bodySmall, + ), + ), + ), + ], + ); + } +} diff --git a/lib/ui/capture_sync_runner.dart b/lib/ui/capture_sync_runner.dart new file mode 100644 index 0000000..284cf82 --- /dev/null +++ b/lib/ui/capture_sync_runner.dart @@ -0,0 +1,103 @@ +/// Runs a sync pass for the capture screen and reports its outcome. +/// +/// Split out of `capture_screen.dart` for file size, and it de-duplicates the +/// two callers at the same time: the manual button and the background tick +/// ran byte-identical `runSync` blocks that differed only in whether they +/// show a snackbar and what they log. +/// +/// Deliberately has no `BuildContext` and no `setState`: the screen decides +/// what to show, this decides what to do. +library; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:http/http.dart' as http; + +import 'package:todo/analytics/analytics_service.dart'; +import 'package:todo/data/app_settings.dart'; +import 'package:todo/data/note_repository.dart'; +import 'package:todo/sync/run_sync.dart'; +import 'package:todo/sync/sync_settings.dart'; +import 'package:todo/ui/capture_status.dart'; + +/// What a sync attempt did, in the terms the capture screen cares about. +class CaptureSyncOutcome { + /// Creates an outcome. + const CaptureSyncOutcome({ + required this.ok, + required this.detail, + this.reconciled, + }); + + /// Whether the pass completed without throwing. + final bool ok; + + /// One-line summary: merged/skipped counts on success, the error otherwise. + final String detail; + + /// Settings reconciled during the pass, when the pass succeeded. + final AppSettings? reconciled; +} + +/// Runs sync passes against one repository and settings pair. +class CaptureSyncRunner { + /// Creates a runner over the screen's injected dependencies. + const CaptureSyncRunner({ + required this.repository, + required this.status, + this.analytics, + this.httpClient, + this.firebaseFactory, + this.stateStore, + }); + + /// The store being synced. + final NoteRepository repository; + + /// Where the outcome is recorded and persisted. + final SyncStatusStore status; + + /// Optional analytics sink, passed straight through to [runSync]. + final AnalyticsService? analytics; + + /// Injected HTTP client, so tests run the real path without network. + final http.Client? httpClient; + + /// Injected Firebase client factory, for the same reason. + final Future Function()? firebaseFactory; + + /// Injected sync-state store. + final SyncStateStore? stateStore; + + /// Runs one pass and records the outcome. + /// + /// [live] is the screen's `mounted` at the moment of recording -- the pass + /// still finishes and still persists its outcome after the screen is gone, + /// it just must not write the notifier. + Future run({ + required SyncSettings settings, + required AppSettings appSettings, + required bool Function() live, + }) async { + try { + final result = await runSync( + repository, + settings, + appSettings: appSettings, + analytics: analytics, + httpClient: httpClient, + firebaseFactory: firebaseFactory, + stateStore: stateStore, + ); + final detail = describeSyncResult(result.syncResult); + await status.record(ok: true, detail: detail, live: live()); + return CaptureSyncOutcome( + ok: true, + detail: detail, + reconciled: result.appSettings, + ); + } on Exception catch (e) { + await status.record(ok: false, detail: '$e', live: live()); + return CaptureSyncOutcome(ok: false, detail: '$e'); + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index f5caf24..29788be 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.12+1 +version: 1.0.13+1 environment: sdk: ^3.12.2