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

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

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

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

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

109 lines
4.0 KiB
Dart

/// 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)'}';
}