mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 20:07:02 +01:00
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:
parent
63725e726f
commit
d4c8162e0e
121
lib/ui/capture_app_bar.dart
Normal file
121
lib/ui/capture_app_bar.dart
Normal 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
111
lib/ui/capture_draft.dart
Normal 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
|
||||
122
lib/ui/capture_meta_row.dart
Normal file
122
lib/ui/capture_meta_row.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
193
lib/ui/capture_screen_sync.dart
Normal file
193
lib/ui/capture_screen_sync.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
64
lib/ui/capture_screen_widget.dart
Normal file
64
lib/ui/capture_screen_widget.dart
Normal 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
108
lib/ui/capture_status.dart
Normal 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)'}';
|
||||
}
|
||||
77
lib/ui/capture_status_footer.dart
Normal file
77
lib/ui/capture_status_footer.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
103
lib/ui/capture_sync_runner.dart
Normal file
103
lib/ui/capture_sync_runner.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user