todo-app/lib/ui/capture_screen.dart
Krzysztof kuhy Rudnicki 66143bdfe3 Make the app compile and run for web, behind platform seams
First step of moving the desktop app off Flutter's Linux embedder, which tops
out at ~20fps at 4K (see docs/desktop-performance-findings.md). Android is
unaffected and keeps every existing code path.

The blocker was that `dart:io` was imported by note_repository, capture_screen
and settings_screen. On web it compiles to a stub and then throws at runtime --
the first web build rendered a blank white window. So each `dart:io` use now
sits behind a conditional export, with the shared logic hoisted into pure Dart:

- note_repository loses `dart:io`/sqlite entirely. Platform wiring moves to
  repository_factory{,_io,_web}, and the legacy sqlite import moves to its own
  io-only library. The HLC-from-updated_at seeding is preserved verbatim, so
  independently-migrating devices still resolve pre-cutover edits by real edit
  time.
- Notes persist to IndexedDB on web, not localStorage: the log is the primary
  copy of the backlog and localStorage caps at ~5-10MB and is evicted eagerly.
- BACKLOG.md and the explicit Export keep working through a local wrapper
  (added next). A browser cannot write `~/todo/BACKLOG.md`, and the user's
  tooling plus the todo MCP server read that path, so leaving it to rot was not
  an option. Writes are best-effort so the app still works with no wrapper.
- Notes are also mirrored to the wrapper's on-disk copy, so a wiped Chrome
  profile is not a total-loss event for notes that have not reached GitHub yet.

`openRepository` is split so `openRepositoryIn` keeps the migration tests; the
suite stays at 174 green and 100% coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 13:14:03 +02:00

616 lines
22 KiB
Dart

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/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/local_backup.dart';
import 'package:todo/sync/local_backup_factory.dart';
import 'package:todo/sync/sync_service.dart';
import 'package:todo/sync/sync_settings.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 explicit "Save"
/// action finalises the current idea and clears the field for the next
/// one (remote sync will hook in here later).
class CaptureScreen extends StatefulWidget {
/// Creates a [CaptureScreen] backed by [repository].
const CaptureScreen({
required this.repository,
this.httpClient,
this.localBackup,
super.key,
});
/// The store the captured note is persisted to on every keystroke.
final NoteRepository repository;
/// 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();
}
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;
/// Keeps an always-current Markdown backup on local disk and recovers from
/// it on launch (third durability layer beside sync + Android Auto Backup).
late final LocalBackup _localBackup;
StreamSubscription<void>? _changesSub;
/// 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;
final SyncService _syncService = const SyncService();
SyncSettings? _settings;
bool _syncing = false;
/// 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);
/// 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;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_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.
unawaited(_recoverFromBackup());
_changesSub = widget.repository.changes.listen(
(_) => _localBackup.schedule(),
);
// Also refresh the on-disk backup once on launch (the change tick only
// fires on writes), so a deleted/stale BACKLOG.md is regenerated even if
// the user makes no edits this session.
_localBackup.schedule();
unawaited(_restoreSyncStatus());
unawaited(
SyncSettings.load().then((s) {
if (!mounted) return;
setState(() => _settings = s);
// Pull on launch so a reinstalled device recovers its notes.
unawaited(_autoSync());
}),
);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_autoSyncDebounce?.cancel();
unawaited(_changesSub?.cancel());
_localBackup.dispose();
_lastSavedAt.dispose();
_lastSync.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) ?? '',
);
}
/// 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.
static String _describe(SyncResult result) {
final skipped = result.skippedFiles.length;
return '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.
if (state == AppLifecycleState.paused) {
// Immediate: Android may kill the app before a debounce timer fires.
_autoSyncDebounce?.cancel();
unawaited(_autoSync());
} else if (state == AppLifecycleState.inactive ||
state == AppLifecycleState.hidden) {
// Focus loss — the only background signal Flutter Linux delivers
// reliably (`paused` rarely arrives on desktop). Debounced so alt-tab
// flicker doesn't fire a request per focus change.
_autoSyncDebounce?.cancel();
_autoSyncDebounce = Timer(CaptureScreen.autoSyncDebounce, _autoSync);
}
}
/// 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 || !settings.isConfigured) return;
_autoSyncing = true;
final client = GitHubClient(
owner: settings.owner,
repo: settings.repo,
token: settings.token,
httpClient: widget.httpClient,
);
try {
final result = await _syncService.sync(widget.repository, client);
await _recordSyncStatus(ok: true, detail: _describe(result));
} 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');
} finally {
client.close();
_autoSyncing = false;
}
}
/// Opens the settings screen and adopts any saved configuration.
Future<void> _openSettings() async {
final current = _settings ?? await SyncSettings.load();
if (!mounted) return;
await Navigator.of(context).push<SyncSettings>(
MaterialPageRoute(
builder: (_) => SettingsScreen(
initial: current,
repository: widget.repository,
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();
setState(() => _settings = fresh);
}
void _openList() {
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();
if (!settings.isConfigured) {
_showSnack('Add a GitHub token in settings to enable sync');
await _openSettings();
return;
}
setState(() => _syncing = true);
final client = GitHubClient(
owner: settings.owner,
repo: settings.repo,
token: settings.token,
httpClient: widget.httpClient,
);
try {
final result = await _syncService.sync(widget.repository, client);
final detail = _describe(result);
await _recordSyncStatus(ok: true, detail: detail);
_showSnack('Synced: $detail');
} on Exception catch (e) {
await _recordSyncStatus(ok: false, detail: '$e');
_showSnack('Sync failed: $e');
} finally {
client.close();
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)),
);
}
/// 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();
}
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 {
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 {
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.
void _saveAndReset() {
// A note was actually persisted only if a draft row was created.
final saved = _draftId != null;
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;
if (saved) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Idea saved locally'),
duration: Duration(seconds: 1),
),
);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Capture'),
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: Text('$count saved')),
);
},
),
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: 'Sync settings',
onPressed: _openSettings,
icon: const Icon(Icons.settings),
),
],
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Pickers sit above the editor so the bottom-right Save FAB
// never overlaps them. 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.
if (_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(
child: NoteEditor(
key: ValueKey(_editorGeneration),
initialTemplate: NoteTemplate.defaultTemplate,
initialMode: NoteEditorMode.raw,
priority: _draftPriority,
onPriorityChanged: _setPriority,
onChromeVisibleChanged: (visible) =>
setState(() => _chromeVisible = visible),
autofocus: true,
onChanged: _onChanged,
),
),
const SizedBox(height: 8),
// Leave room so the Save FAB doesn't cover the indicators.
Padding(
padding: const EdgeInsets.only(right: 96),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ValueListenableBuilder<DateTime?>(
valueListenable: _lastSavedAt,
builder: (context, savedAt, _) => Text(
savedAt == null
? 'Autosaves as you type'
: 'Saved locally at ${_formatTime(savedAt)}',
style: theme.textTheme.bodySmall,
),
),
ValueListenableBuilder<SyncStatus?>(
valueListenable: _lastSync,
builder: (context, status, _) {
if (status == null) return const SizedBox.shrink();
return Text(
'${status.ok ? 'Synced' : 'Sync failed'} at '
'${_formatTime(status.time)} · ${status.detail}',
style: theme.textTheme.bodySmall?.copyWith(
color: status.ok ? null : theme.colorScheme.error,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
},
),
],
),
),
],
),
),
floatingActionButton: _chromeVisible
? FloatingActionButton.extended(
onPressed: _saveAndReset,
icon: const Icon(Icons.check),
label: const Text('Save'),
)
: null,
);
}
/// 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);
},
),
),
);
}
}