todo-app/lib/ui/capture_screen.dart
Krzysztof kuhy Rudnicki d48bcd24f7 Initial commit: offline-first CRDT notes app (capture + GitHub sync)
Flutter app for Android + Linux desktop. Captures ideas with per-keystroke local autosave to a CRDT-backed SQLite store (sqlite_crdt), and syncs through a private GitHub repo using per-device changeset files (conflict-free last-writer-wins merge). Includes GitHub OAuth device-flow sign-in with PAT fallback, a barebones notes list, and sync settings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:20:19 +02:00

239 lines
7.1 KiB
Dart

import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import '../data/note.dart';
import '../data/note_repository.dart';
import '../sync/github_client.dart';
import '../sync/sync_service.dart';
import '../sync/sync_settings.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, super.key});
final NoteRepository repository;
@override
State<CaptureScreen> createState() => _CaptureScreenState();
}
class _CaptureScreenState extends State<CaptureScreen> {
static const _uuid = Uuid();
final TextEditingController _controller = TextEditingController();
final FocusNode _focusNode = FocusNode();
/// Id of the note currently being edited, or null before the first
/// keystroke of a fresh draft.
String? _draftId;
DateTime? _draftCreatedAt;
DateTime? _lastSavedAt;
final SyncService _syncService = const SyncService();
SyncSettings? _settings;
bool _syncing = false;
@override
void initState() {
super.initState();
SyncSettings.load().then((s) {
if (mounted) setState(() => _settings = s);
});
}
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
/// Opens the settings screen and adopts any saved configuration.
Future<void> _openSettings() async {
final current = _settings ?? await SyncSettings.load();
if (!mounted) return;
final result = await Navigator.of(context).push<SyncSettings>(
MaterialPageRoute(
builder: (_) => SettingsScreen(initial: current),
),
);
if (result != null && mounted) setState(() => _settings = result);
}
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,
);
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 {
if (_draftId == null) {
if (text.isEmpty) return;
_draftId = _uuid.v4();
_draftCreatedAt = DateTime.now();
}
final now = DateTime.now();
await widget.repository.upsert(
Note(
id: _draftId!,
text: text,
priority: Priority.none,
createdAt: _draftCreatedAt!,
updatedAt: now,
),
);
if (mounted) setState(() => _lastSavedAt = now);
}
/// Finalises the current idea and resets the field for the next one.
void _saveAndReset() {
final hadText = _controller.text.trim().isNotEmpty;
setState(() {
_controller.clear();
_draftId = null;
_draftCreatedAt = null;
_lastSavedAt = null;
});
_focusNode.requestFocus();
if (hadText) {
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<List<Note>>(
stream: widget.repository.watchNotes(),
builder: (context, snapshot) {
final count = snapshot.data?.length ?? 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: [
Expanded(
child: TextField(
controller: _controller,
focusNode: _focusNode,
autofocus: true,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
keyboardType: TextInputType.multiline,
style: theme.textTheme.bodyLarge,
decoration: const InputDecoration(
border: InputBorder.none,
hintText: 'Write your idea…',
),
onChanged: _onChanged,
),
),
const SizedBox(height: 8),
Text(
_lastSavedAt == null
? 'Autosaves as you type'
: 'Saved locally at ${_formatTime(_lastSavedAt!)}',
style: theme.textTheme.bodySmall,
),
],
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _saveAndReset,
icon: const Icon(Icons.check),
label: const Text('Save'),
),
);
}
/// 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)}';
}
}