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>
This commit is contained in:
Krzysztof Rudnicki 2026-08-14 16:25:51 +02:00
parent 63725e726f
commit d4c8162e0e
10 changed files with 967 additions and 561 deletions

121
lib/ui/capture_app_bar.dart Normal file
View File

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

111
lib/ui/capture_draft.dart Normal file
View File

@ -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<DateTime?> lastSavedAt = ValueNotifier<DateTime?>(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<bool> 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<void> persistMetadata({required bool Function() live}) async {
if (_id == null) return;
await _save(live: live);
}
Future<void> _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

View File

@ -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<T> 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<T> values;
/// Maps a value to its display string.
final String Function(T) labelOf;
/// Called with the newly picked value.
final ValueChanged<T> 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<T>(
value: value,
isDense: true,
isExpanded: true,
items: [
for (final v in values)
DropdownMenuItem<T>(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<Priority> onPriorityChanged;
/// Called with a newly picked status.
final ValueChanged<Status> onStatusChanged;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: MetaDropdown<Priority>(
label: 'Priority',
value: priority,
values: Priority.values,
labelOf: (p) => p.label,
onChanged: onPriorityChanged,
),
),
const SizedBox(width: 12),
Expanded(
child: MetaDropdown<Status>(
label: 'Status',
value: status,
values: Status.values,
labelOf: (s) => s.label,
onChanged: onStatusChanged,
),
),
],
);
}
}

View File

@ -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> 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<FirebaseRestClient?> 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<CaptureScreen> createState() => _CaptureScreenState();
}
part 'capture_screen_sync.dart';
part 'capture_screen_widget.dart';
class _CaptureScreenState extends State<CaptureScreen>
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<CaptureScreen>
/// 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<DateTime?> _lastSavedAt = ValueNotifier<DateTime?>(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<CaptureScreen>
/// 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<SyncStatus?> _lastSync = ValueNotifier<SyncStatus?>(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<CaptureScreen>
/// 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<String, Object?> 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<CaptureScreen>
_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<void> _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<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),
);
},
);
}
/// Records a sync outcome in the UI notifier and persists it.
Future<void> _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<void> _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<CaptureScreen>
}
}
/// 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<void> _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<void> _openSettings() async {
_logEvent('screen_view', params: {'screen': 'settings'});
@ -353,17 +198,6 @@ class _CaptureScreenState extends State<CaptureScreen>
setState(() => _settings = fresh);
}
void _openList() {
_logEvent('screen_view', params: {'screen': 'notes_list'});
unawaited(
Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => NotesListScreen(repository: widget.repository),
),
),
);
}
/// Runs a full sync, routing to settings first if not yet configured.
Future<void> _sync() async {
final settings = _settings ?? await SyncSettings.load();
@ -374,366 +208,39 @@ class _CaptureScreenState extends State<CaptureScreen>
}
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<void> _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<void> _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<void> _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<void> _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<AppSettings>(
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<int>(
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<Priority>(
label: 'Priority',
value: _draftPriority,
values: Priority.values,
labelOf: (p) => p.label,
onChanged: _setPriority,
),
),
const SizedBox(width: 12),
Expanded(
child: _MetaDropdown<Status>(
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<SyncStatus?>(
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<DateTime?>(
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<T> 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<T> values;
final String Function(T) labelOf;
final ValueChanged<T> 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<T>(
value: value,
isDense: true,
isExpanded: true,
items: [
for (final v in values)
DropdownMenuItem<T>(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);
}

View File

@ -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<void> _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<void>(
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<void> _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<void> _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<void> _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<void> _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<void> _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<String, Object?> params = const {}}) {
final analytics = widget.analytics;
if (analytics == null) return;
unawaited(
analytics.logEvent(
AnalyticsEvent(name: name, timestamp: DateTime.now(), params: params),
),
);
}
}

View File

@ -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> 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<FirebaseRestClient?> 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<CaptureScreen> createState() => _CaptureScreenState();
}

108
lib/ui/capture_status.dart Normal file
View File

@ -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<SyncStatus?> latest = ValueNotifier<SyncStatus?>(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<void> 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<void> 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)'}';
}

View File

@ -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?> syncStatus;
/// When the draft was last written locally, or null before the first save.
final ValueListenable<DateTime?> 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<SyncStatus?>(
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<DateTime?>(
valueListenable: lastSavedAt,
builder: (context, savedAt, _) => Text(
savedAt == null
? 'Autosaves as you type'
: 'Saved locally at ${formatSyncTime(savedAt)}',
style: theme.textTheme.bodySmall,
),
),
),
],
);
}
}

View File

@ -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<FirebaseRestClient?> 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<CaptureSyncOutcome> 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');
}
}
}

View File

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