mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 19:07:02 +01:00
- Committing a captured note (New note tap with a persisted draft) fires HapticFeedback.lightImpact and plays a ~600 ms checkmark beside the counter; empty drafts and keystrokes confirm nothing. - appRoute: one shared page route (fade + rise, AppDuration.slow) replaces all seven MaterialPageRoute sites; guard test keeps it that way. - Wizard progress bar animates on AppDuration.base / AppCurve.standard. - Everything honours disableAnimations; the haptic and the (static) checkmark still fire. Removes the finished dopamine-ux prompt (sound step rejected). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
241 lines
9.1 KiB
Dart
241 lines
9.1 KiB
Dart
/// 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 {
|
|
/// Background sync, then an image reconcile over the notes it produced.
|
|
/// The reconcile runs even when sync is skipped (unconfigured, offline):
|
|
/// images have their own login and must still upload and prefetch.
|
|
Future<void> _autoSync() async {
|
|
await _autoSyncPass();
|
|
await _reconcileImages();
|
|
}
|
|
|
|
/// 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> _autoSyncPass() 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);
|
|
}
|
|
|
|
/// Opens the settings screen and adopts any saved configuration.
|
|
Future<void> _openSettings() async {
|
|
_logEvent('screen_view', params: {'screen': 'settings'});
|
|
final current = _settings ?? await SyncSettings.load();
|
|
if (!mounted) return;
|
|
await Navigator.of(context).push<SyncSettings>(
|
|
appRoute(
|
|
context,
|
|
(_) => SettingsScreen(
|
|
initial: current,
|
|
repository: widget.repository,
|
|
appSettings: widget.appSettings,
|
|
analytics: widget.analytics,
|
|
httpClient: widget.httpClient,
|
|
),
|
|
),
|
|
);
|
|
if (!mounted) return;
|
|
// Always reload from storage: a device-flow "Connect" saves the token
|
|
// without popping a result, so relying on the pop value would miss it and
|
|
// leave us syncing with stale (token-less) settings.
|
|
final fresh = await SyncSettings.load();
|
|
_rebuild(() => _settings = fresh);
|
|
}
|
|
|
|
void _openList() {
|
|
_logEvent('screen_view', params: {'screen': 'notes_list'});
|
|
Navigator.of(context).push<void>(
|
|
appRoute(
|
|
context,
|
|
(_) => NotesListScreen(
|
|
repository: widget.repository,
|
|
appSettings: widget.appSettings,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Shows transient feedback, if this screen is still mounted.
|
|
///
|
|
/// Delegates to the shared helpers so the styling and the
|
|
/// replace-don't-queue behaviour match every other app; [isError] picks
|
|
/// the danger hue and the longer duration, because an error the user
|
|
/// missed is an error they will hit again.
|
|
void _showSnack(String message, {bool isError = false}) {
|
|
if (!mounted) return;
|
|
if (isError) {
|
|
showError(context, message);
|
|
} else {
|
|
showToast(context, message);
|
|
}
|
|
}
|
|
|
|
/// 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. Images
|
|
/// pasted and files dropped anywhere on the (desktop) page attach to the
|
|
/// draft.
|
|
Widget _captureBody({required bool advanced}) {
|
|
return ImageInputListener(
|
|
onImages: (images, rejected) =>
|
|
unawaited(_attachImages(images, rejected: rejected)),
|
|
onFiles: (files) => unawaited(_attachFiles(files)),
|
|
child: _composer(advanced: advanced),
|
|
);
|
|
}
|
|
|
|
Widget _composer({required bool advanced}) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Expanded(
|
|
// Bumping this key is how "new note" gets a fresh editor: it
|
|
// remounts NoteForm, which also resets the chrome gate to
|
|
// visible. Keep it here rather than inside NoteForm — a key
|
|
// below the conditional metadata row can't be matched after
|
|
// advancedMode toggles that row in or out, so Flutter tears
|
|
// the editor down and remounts it with an empty draft,
|
|
// discarding whatever the user was typing (fixed in ce2ea5b).
|
|
key: ValueKey(_editorGeneration),
|
|
child: NoteForm(
|
|
advancedMode: advanced,
|
|
initialTemplate: NoteTemplate.defaultTemplate,
|
|
priority: _draft.priority,
|
|
status: _draft.status,
|
|
onPriorityChanged: _setPriority,
|
|
onStatusChanged: _setStatus,
|
|
autofocus: true,
|
|
onChanged: _onChanged,
|
|
attachController: _attach,
|
|
),
|
|
),
|
|
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.
|
|
/// Autosave already persisted every keystroke, so the only thing left to
|
|
/// give is the confirmation -- and only when a note was really captured.
|
|
/// The haptic fires here, on the tap, before anything touches the network.
|
|
void _saveAndReset() {
|
|
_logEvent('new_note_reset');
|
|
if (_draft.hasCapture) {
|
|
unawaited(HapticFeedback.lightImpact());
|
|
_captures.value++;
|
|
}
|
|
_rebuild(() {
|
|
// Bumping the generation remounts NoteForm, which resets the chrome
|
|
// gate to visible for free -- no separate flag to keep in step.
|
|
_editorGeneration++; // recreate the editor with a fresh template
|
|
_draft.reset();
|
|
});
|
|
}
|
|
|
|
/// 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),
|
|
),
|
|
);
|
|
}
|
|
}
|