mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-07-04 15:03:01 +02:00
Rework guided-mode layout so the text field fills the full available screen height. The previous approach nested _buildStepPage (a Column with its own Expanded) inside NoteEditor's outer Expanded — a nested-Column/Expanded chain that collapses silently in release builds. Fix: return _buildStepPage directly from build() when in guided mode, making the TextField a first-level Expanded child of the same Column as _buildRaw uses. This is the same depth at which expands:true works. Also hide the Save FAB (_chromeVisible gate in CaptureScreen) while guided mode is active so it cannot overlap the Next/Done button. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Cb7oE5Xsc8zSBHHtT6qwa
485 lines
16 KiB
Dart
485 lines
16 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
import '../data/note.dart';
|
|
import '../data/note_repository.dart';
|
|
import '../data/note_template.dart';
|
|
import '../sync/github_client.dart';
|
|
import '../sync/local_backup.dart';
|
|
import '../sync/sync_service.dart';
|
|
import '../sync/sync_settings.dart';
|
|
import 'note_editor.dart';
|
|
import 'notes_list_screen.dart';
|
|
import 'settings_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 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 {
|
|
const CaptureScreen({
|
|
required this.repository,
|
|
this.httpClient,
|
|
this.localBackup,
|
|
super.key,
|
|
});
|
|
|
|
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;
|
|
|
|
@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<List<Note>>? _notesSub;
|
|
|
|
/// 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;
|
|
DateTime? _lastSavedAt;
|
|
|
|
/// 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;
|
|
|
|
/// 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();
|
|
// Recover from the local backup first (covers an empty DB after a wipe),
|
|
// then keep the backup current as notes change.
|
|
_recoverFromBackup();
|
|
_notesSub = widget.repository.watchNotes().listen(
|
|
_localBackup.scheduleExport,
|
|
);
|
|
SyncSettings.load().then((s) {
|
|
if (!mounted) return;
|
|
setState(() => _settings = s);
|
|
_autoSync(); // pull on launch so a reinstalled device recovers its notes
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
_notesSub?.cancel();
|
|
_localBackup.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// 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() {
|
|
Future<File> backupFile() async {
|
|
if (Platform.isAndroid || Platform.isIOS) {
|
|
final dir = await getApplicationDocumentsDirectory();
|
|
return File('${dir.path}/todo-backlog.md');
|
|
}
|
|
final home = Platform.environment['HOME'] ?? Directory.current.path;
|
|
final dir = Directory('$home/todo')..createSync(recursive: true);
|
|
return File('${dir.path}/BACKLOG.md');
|
|
}
|
|
|
|
return LocalBackup(
|
|
reader: () async {
|
|
final file = await backupFile();
|
|
return file.existsSync() ? file.readAsString() : null;
|
|
},
|
|
writer: (markdown) async => (await backupFile()).writeAsString(markdown),
|
|
);
|
|
}
|
|
// coverage:ignore-end
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
// Push on background so the remote (the durable store) stays near-current.
|
|
if (state == AppLifecycleState.paused) _autoSync();
|
|
}
|
|
|
|
/// Best-effort background sync: silent, skips when unconfigured, and never
|
|
/// overlaps itself. Failures are swallowed — the manual Sync button is the
|
|
/// place that surfaces errors.
|
|
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 {
|
|
await _syncService.sync(widget.repository, client);
|
|
} catch (_) {
|
|
// Best-effort: ignore (offline, transient GitHub errors, etc.).
|
|
} 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() {
|
|
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);
|
|
_showSnack('Synced: merged ${result.mergedDevices} device(s)');
|
|
} catch (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) setState(() => _lastSavedAt = 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) setState(() => _lastSavedAt = 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;
|
|
_lastSavedAt = null;
|
|
_draftPriority = Priority.defaultValue;
|
|
_draftStatus = Status.todo;
|
|
_chromeVisible = true;
|
|
});
|
|
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: [
|
|
// 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 save indicator.
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 96),
|
|
child: Text(
|
|
_lastSavedAt == null
|
|
? 'Autosaves as you type'
|
|
: 'Saved locally at ${_formatTime(_lastSavedAt!)}',
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
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)}';
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|