Add list filters/sort, status, priority rework, export/import, structured template
Notes list & filtering:
- Text-search filter plus independent date-range filters for both created
and last-updated (AND-combined), a priority filter, and a new status
filter. Default view hides Done/Abandoned and renders as "unfiltered"
(no badge for the default state); fixed badge clipping.
- NoteSort options wired into the list UI; watchCount() for the "N saved".
Status & priority:
- New Status enum (toDo/inProgress/Done/Abandoned) as a settable + filterable
attribute on every note, with capture-screen dropdown.
- Removed "None" priority: every note is Low/Medium/High, default Medium.
Schema migration v2->v3 rewrites legacy priority 0 -> Medium.
Export / import:
- NotesMarkdown round-trippable single-file format with HTML-comment markers.
- Settings "Export notes" (mobile share sheet / desktop writes ~/todo/BACKLOG.md)
and "Import notes" (file picker + safe newer-wins merge by id).
Structured template:
- Every new note pre-fills the richer what/where/must/nice/out/done/depends/
estimate/refs scaffold.
Tests:
- New fast (~5s), deterministic suite via FakeNoteRepository (no DB timers) and
injected http/file-selector/url-launcher fakes. 86 tests, 96.2% line coverage
(note.dart & sync_service.dart at 100%, settings 98.7%). Mobile-only share
branch excluded via coverage:ignore (unreachable on the Linux test host).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 16:52:59 +02:00
|
|
|
import 'dart:async';
|
|
|
|
|
|
2026-06-14 14:20:19 +02:00
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
|
|
|
|
|
import '../data/note.dart';
|
|
|
|
|
import '../data/note_repository.dart';
|
|
|
|
|
|
Add list filters/sort, status, priority rework, export/import, structured template
Notes list & filtering:
- Text-search filter plus independent date-range filters for both created
and last-updated (AND-combined), a priority filter, and a new status
filter. Default view hides Done/Abandoned and renders as "unfiltered"
(no badge for the default state); fixed badge clipping.
- NoteSort options wired into the list UI; watchCount() for the "N saved".
Status & priority:
- New Status enum (toDo/inProgress/Done/Abandoned) as a settable + filterable
attribute on every note, with capture-screen dropdown.
- Removed "None" priority: every note is Low/Medium/High, default Medium.
Schema migration v2->v3 rewrites legacy priority 0 -> Medium.
Export / import:
- NotesMarkdown round-trippable single-file format with HTML-comment markers.
- Settings "Export notes" (mobile share sheet / desktop writes ~/todo/BACKLOG.md)
and "Import notes" (file picker + safe newer-wins merge by id).
Structured template:
- Every new note pre-fills the richer what/where/must/nice/out/done/depends/
estimate/refs scaffold.
Tests:
- New fast (~5s), deterministic suite via FakeNoteRepository (no DB timers) and
injected http/file-selector/url-launcher fakes. 86 tests, 96.2% line coverage
(note.dart & sync_service.dart at 100%, settings 98.7%). Mobile-only share
branch excluded via coverage:ignore (unreachable on the Linux test host).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 16:52:59 +02:00
|
|
|
/// The default status selection: hide completed/dropped work. This is the
|
|
|
|
|
/// app's notion of "unfiltered", so it does not count towards the filter
|
|
|
|
|
/// badge and is what "Clear all" resets to.
|
|
|
|
|
const Set<Status> kDefaultStatuses = {Status.todo, Status.inProgress};
|
|
|
|
|
|
|
|
|
|
/// Searchable, filterable, sortable list of stored/synced notes.
|
2026-06-14 14:20:19 +02:00
|
|
|
///
|
Add list filters/sort, status, priority rework, export/import, structured template
Notes list & filtering:
- Text-search filter plus independent date-range filters for both created
and last-updated (AND-combined), a priority filter, and a new status
filter. Default view hides Done/Abandoned and renders as "unfiltered"
(no badge for the default state); fixed badge clipping.
- NoteSort options wired into the list UI; watchCount() for the "N saved".
Status & priority:
- New Status enum (toDo/inProgress/Done/Abandoned) as a settable + filterable
attribute on every note, with capture-screen dropdown.
- Removed "None" priority: every note is Low/Medium/High, default Medium.
Schema migration v2->v3 rewrites legacy priority 0 -> Medium.
Export / import:
- NotesMarkdown round-trippable single-file format with HTML-comment markers.
- Settings "Export notes" (mobile share sheet / desktop writes ~/todo/BACKLOG.md)
and "Import notes" (file picker + safe newer-wins merge by id).
Structured template:
- Every new note pre-fills the richer what/where/must/nice/out/done/depends/
estimate/refs scaffold.
Tests:
- New fast (~5s), deterministic suite via FakeNoteRepository (no DB timers) and
injected http/file-selector/url-launcher fakes. 86 tests, 96.2% line coverage
(note.dart & sync_service.dart at 100%, settings 98.7%). Mobile-only share
branch excluded via coverage:ignore (unreachable on the Linux test host).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 16:52:59 +02:00
|
|
|
/// The heavy lifting (WHERE/ORDER BY) lives in [NoteRepository]; this screen
|
|
|
|
|
/// only owns transient view state ([NoteSort] + [NoteFilter]) and rebuilds
|
|
|
|
|
/// the watch stream when that state changes. The stream is memoised so a
|
|
|
|
|
/// rebuild (e.g. a search keystroke) does not churn a new DB subscription.
|
|
|
|
|
class NotesListScreen extends StatefulWidget {
|
2026-06-14 14:20:19 +02:00
|
|
|
const NotesListScreen({required this.repository, super.key});
|
|
|
|
|
|
|
|
|
|
final NoteRepository repository;
|
|
|
|
|
|
Add list filters/sort, status, priority rework, export/import, structured template
Notes list & filtering:
- Text-search filter plus independent date-range filters for both created
and last-updated (AND-combined), a priority filter, and a new status
filter. Default view hides Done/Abandoned and renders as "unfiltered"
(no badge for the default state); fixed badge clipping.
- NoteSort options wired into the list UI; watchCount() for the "N saved".
Status & priority:
- New Status enum (toDo/inProgress/Done/Abandoned) as a settable + filterable
attribute on every note, with capture-screen dropdown.
- Removed "None" priority: every note is Low/Medium/High, default Medium.
Schema migration v2->v3 rewrites legacy priority 0 -> Medium.
Export / import:
- NotesMarkdown round-trippable single-file format with HTML-comment markers.
- Settings "Export notes" (mobile share sheet / desktop writes ~/todo/BACKLOG.md)
and "Import notes" (file picker + safe newer-wins merge by id).
Structured template:
- Every new note pre-fills the richer what/where/must/nice/out/done/depends/
estimate/refs scaffold.
Tests:
- New fast (~5s), deterministic suite via FakeNoteRepository (no DB timers) and
injected http/file-selector/url-launcher fakes. 86 tests, 96.2% line coverage
(note.dart & sync_service.dart at 100%, settings 98.7%). Mobile-only share
branch excluded via coverage:ignore (unreachable on the Linux test host).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 16:52:59 +02:00
|
|
|
@override
|
|
|
|
|
State<NotesListScreen> createState() => _NotesListScreenState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _NotesListScreenState extends State<NotesListScreen> {
|
|
|
|
|
/// How long to wait after the last keystroke before re-querying, so we
|
|
|
|
|
/// don't spin up a new subscription on every character typed.
|
|
|
|
|
static const _searchDebounce = Duration(milliseconds: 250);
|
|
|
|
|
|
|
|
|
|
final TextEditingController _searchController = TextEditingController();
|
|
|
|
|
Timer? _debounce;
|
|
|
|
|
|
|
|
|
|
NoteSort _sort = NoteSort.modifiedDesc;
|
|
|
|
|
|
|
|
|
|
/// Default view hides completed/dropped work: only To do + In progress.
|
|
|
|
|
/// The user can widen this (or clear it) via the filter sheet.
|
|
|
|
|
NoteFilter _filter = const NoteFilter(statuses: kDefaultStatuses);
|
|
|
|
|
|
|
|
|
|
/// Whether [statuses] is exactly the default selection (so the badge can
|
|
|
|
|
/// treat the default view as "unfiltered").
|
|
|
|
|
static bool _statusesAreDefault(Set<Status> statuses) =>
|
|
|
|
|
statuses.length == kDefaultStatuses.length &&
|
|
|
|
|
statuses.containsAll(kDefaultStatuses);
|
|
|
|
|
|
|
|
|
|
/// Number of *user-applied* filter facets, for the badge. Excludes the
|
|
|
|
|
/// default status selection so a fresh list shows no badge.
|
|
|
|
|
int get _badgeCount {
|
|
|
|
|
var count = _filter.activeCount;
|
|
|
|
|
if (_statusesAreDefault(_filter.statuses)) count -= 1;
|
|
|
|
|
return count;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Memoised stream; only replaced when [_sort]/[_filter] actually change.
|
|
|
|
|
late Stream<List<Note>> _stream;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
_stream = widget.repository.watchNotes(sort: _sort, filter: _filter);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
_debounce?.cancel();
|
|
|
|
|
_searchController.dispose();
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Rebuilds the watch stream for the current sort + filter. Call only
|
|
|
|
|
/// from handlers that change those, never from [build].
|
|
|
|
|
void _applyState() {
|
|
|
|
|
setState(() {
|
|
|
|
|
_stream = widget.repository.watchNotes(sort: _sort, filter: _filter);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _onSearchChanged(String value) {
|
|
|
|
|
_debounce?.cancel();
|
|
|
|
|
_debounce = Timer(_searchDebounce, () {
|
|
|
|
|
_filter = _filter.copyWith(query: value);
|
|
|
|
|
_applyState();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _setSort(NoteSort sort) {
|
|
|
|
|
if (sort == _sort) return;
|
|
|
|
|
_sort = sort;
|
|
|
|
|
_applyState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Opens the filter sheet and adopts the edited filter (text query is
|
|
|
|
|
/// owned by the search box, so it is preserved across the round-trip).
|
|
|
|
|
Future<void> _openFilters() async {
|
|
|
|
|
final edited = await showModalBottomSheet<NoteFilter>(
|
|
|
|
|
context: context,
|
|
|
|
|
isScrollControlled: true,
|
|
|
|
|
showDragHandle: true,
|
|
|
|
|
builder: (_) => _FilterSheet(initial: _filter),
|
|
|
|
|
);
|
|
|
|
|
if (edited != null) {
|
|
|
|
|
_filter = edited.copyWith(query: _searchController.text);
|
|
|
|
|
_applyState();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Opens the per-note actions sheet (priority, status, delete).
|
|
|
|
|
Future<void> _openNoteActions(Note note) async {
|
|
|
|
|
await showModalBottomSheet<void>(
|
|
|
|
|
context: context,
|
|
|
|
|
showDragHandle: true,
|
|
|
|
|
builder: (_) => _NoteActionsSheet(
|
|
|
|
|
note: note,
|
|
|
|
|
onChanged: (updated) async {
|
|
|
|
|
await widget.repository.upsert(updated);
|
|
|
|
|
},
|
|
|
|
|
onDelete: () async {
|
|
|
|
|
await widget.repository.delete(note.id);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 14:20:19 +02:00
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
Add list filters/sort, status, priority rework, export/import, structured template
Notes list & filtering:
- Text-search filter plus independent date-range filters for both created
and last-updated (AND-combined), a priority filter, and a new status
filter. Default view hides Done/Abandoned and renders as "unfiltered"
(no badge for the default state); fixed badge clipping.
- NoteSort options wired into the list UI; watchCount() for the "N saved".
Status & priority:
- New Status enum (toDo/inProgress/Done/Abandoned) as a settable + filterable
attribute on every note, with capture-screen dropdown.
- Removed "None" priority: every note is Low/Medium/High, default Medium.
Schema migration v2->v3 rewrites legacy priority 0 -> Medium.
Export / import:
- NotesMarkdown round-trippable single-file format with HTML-comment markers.
- Settings "Export notes" (mobile share sheet / desktop writes ~/todo/BACKLOG.md)
and "Import notes" (file picker + safe newer-wins merge by id).
Structured template:
- Every new note pre-fills the richer what/where/must/nice/out/done/depends/
estimate/refs scaffold.
Tests:
- New fast (~5s), deterministic suite via FakeNoteRepository (no DB timers) and
injected http/file-selector/url-launcher fakes. 86 tests, 96.2% line coverage
(note.dart & sync_service.dart at 100%, settings 98.7%). Mobile-only share
branch excluded via coverage:ignore (unreachable on the Linux test host).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 16:52:59 +02:00
|
|
|
final badgeCount = _badgeCount;
|
2026-06-14 14:20:19 +02:00
|
|
|
return Scaffold(
|
Add list filters/sort, status, priority rework, export/import, structured template
Notes list & filtering:
- Text-search filter plus independent date-range filters for both created
and last-updated (AND-combined), a priority filter, and a new status
filter. Default view hides Done/Abandoned and renders as "unfiltered"
(no badge for the default state); fixed badge clipping.
- NoteSort options wired into the list UI; watchCount() for the "N saved".
Status & priority:
- New Status enum (toDo/inProgress/Done/Abandoned) as a settable + filterable
attribute on every note, with capture-screen dropdown.
- Removed "None" priority: every note is Low/Medium/High, default Medium.
Schema migration v2->v3 rewrites legacy priority 0 -> Medium.
Export / import:
- NotesMarkdown round-trippable single-file format with HTML-comment markers.
- Settings "Export notes" (mobile share sheet / desktop writes ~/todo/BACKLOG.md)
and "Import notes" (file picker + safe newer-wins merge by id).
Structured template:
- Every new note pre-fills the richer what/where/must/nice/out/done/depends/
estimate/refs scaffold.
Tests:
- New fast (~5s), deterministic suite via FakeNoteRepository (no DB timers) and
injected http/file-selector/url-launcher fakes. 86 tests, 96.2% line coverage
(note.dart & sync_service.dart at 100%, settings 98.7%). Mobile-only share
branch excluded via coverage:ignore (unreachable on the Linux test host).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 16:52:59 +02:00
|
|
|
appBar: AppBar(
|
|
|
|
|
title: const Text('Notes'),
|
|
|
|
|
actions: [
|
|
|
|
|
PopupMenuButton<NoteSort>(
|
|
|
|
|
tooltip: 'Sort',
|
|
|
|
|
icon: const Icon(Icons.sort),
|
|
|
|
|
initialValue: _sort,
|
|
|
|
|
onSelected: _setSort,
|
|
|
|
|
itemBuilder: (_) => const [
|
|
|
|
|
PopupMenuItem(
|
|
|
|
|
value: NoteSort.modifiedDesc,
|
|
|
|
|
child: Text('Last updated'),
|
|
|
|
|
),
|
|
|
|
|
PopupMenuItem(
|
|
|
|
|
value: NoteSort.createdDesc,
|
|
|
|
|
child: Text('Newest created'),
|
|
|
|
|
),
|
|
|
|
|
PopupMenuItem(
|
|
|
|
|
value: NoteSort.alphabetical,
|
|
|
|
|
child: Text('Alphabetical'),
|
|
|
|
|
),
|
|
|
|
|
PopupMenuItem(
|
|
|
|
|
value: NoteSort.priorityDesc,
|
|
|
|
|
child: Text('Priority'),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
// Filter icon with a badge counting user-applied facets. The
|
|
|
|
|
// trailing padding + inward offset keep the badge from being
|
|
|
|
|
// clipped at the screen edge.
|
|
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.only(right: 8),
|
|
|
|
|
child: Badge(
|
|
|
|
|
isLabelVisible: badgeCount > 0,
|
|
|
|
|
label: Text('$badgeCount'),
|
|
|
|
|
offset: const Offset(-8, 4),
|
|
|
|
|
child: IconButton(
|
|
|
|
|
tooltip: 'Filter',
|
|
|
|
|
icon: const Icon(Icons.filter_list),
|
|
|
|
|
onPressed: _openFilters,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
body: Column(
|
|
|
|
|
children: [
|
|
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
|
|
|
|
child: TextField(
|
|
|
|
|
controller: _searchController,
|
|
|
|
|
onChanged: _onSearchChanged,
|
|
|
|
|
textInputAction: TextInputAction.search,
|
|
|
|
|
decoration: InputDecoration(
|
|
|
|
|
hintText: 'Search notes…',
|
|
|
|
|
prefixIcon: const Icon(Icons.search),
|
|
|
|
|
isDense: true,
|
|
|
|
|
border: const OutlineInputBorder(),
|
|
|
|
|
suffixIcon: _searchController.text.isEmpty
|
|
|
|
|
? null
|
|
|
|
|
: IconButton(
|
|
|
|
|
tooltip: 'Clear search',
|
|
|
|
|
icon: const Icon(Icons.clear),
|
|
|
|
|
onPressed: () {
|
|
|
|
|
_searchController.clear();
|
|
|
|
|
_onSearchChanged('');
|
|
|
|
|
setState(() {});
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: StreamBuilder<List<Note>>(
|
|
|
|
|
stream: _stream,
|
|
|
|
|
builder: (context, snapshot) {
|
|
|
|
|
final notes = snapshot.data ?? const <Note>[];
|
|
|
|
|
if (notes.isEmpty) {
|
|
|
|
|
return Center(
|
|
|
|
|
child: Text(
|
|
|
|
|
_filter.isEmpty
|
|
|
|
|
? 'No notes yet'
|
|
|
|
|
: 'No notes match these filters',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return ListView.separated(
|
|
|
|
|
itemCount: notes.length,
|
|
|
|
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
|
|
|
|
itemBuilder: (context, i) => _NoteTile(
|
|
|
|
|
note: notes[i],
|
|
|
|
|
onTap: () => _openNoteActions(notes[i]),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One row in the notes list: first line, then a metadata subtitle.
|
|
|
|
|
class _NoteTile extends StatelessWidget {
|
|
|
|
|
const _NoteTile({required this.note, required this.onTap});
|
|
|
|
|
|
|
|
|
|
final Note note;
|
|
|
|
|
final VoidCallback onTap;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final firstLine = note.text.split('\n').first;
|
|
|
|
|
// Every note has a status and a priority now, so both are always shown.
|
|
|
|
|
final meta = <String>[
|
|
|
|
|
note.status.label,
|
|
|
|
|
note.priority.label,
|
|
|
|
|
'edited ${_relative(note.updatedAt)}',
|
|
|
|
|
].join(' · ');
|
|
|
|
|
return ListTile(
|
|
|
|
|
title: Text(
|
|
|
|
|
firstLine.isEmpty ? '(empty)' : firstLine,
|
|
|
|
|
maxLines: 2,
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
2026-06-14 14:20:19 +02:00
|
|
|
),
|
Add list filters/sort, status, priority rework, export/import, structured template
Notes list & filtering:
- Text-search filter plus independent date-range filters for both created
and last-updated (AND-combined), a priority filter, and a new status
filter. Default view hides Done/Abandoned and renders as "unfiltered"
(no badge for the default state); fixed badge clipping.
- NoteSort options wired into the list UI; watchCount() for the "N saved".
Status & priority:
- New Status enum (toDo/inProgress/Done/Abandoned) as a settable + filterable
attribute on every note, with capture-screen dropdown.
- Removed "None" priority: every note is Low/Medium/High, default Medium.
Schema migration v2->v3 rewrites legacy priority 0 -> Medium.
Export / import:
- NotesMarkdown round-trippable single-file format with HTML-comment markers.
- Settings "Export notes" (mobile share sheet / desktop writes ~/todo/BACKLOG.md)
and "Import notes" (file picker + safe newer-wins merge by id).
Structured template:
- Every new note pre-fills the richer what/where/must/nice/out/done/depends/
estimate/refs scaffold.
Tests:
- New fast (~5s), deterministic suite via FakeNoteRepository (no DB timers) and
injected http/file-selector/url-launcher fakes. 86 tests, 96.2% line coverage
(note.dart & sync_service.dart at 100%, settings 98.7%). Mobile-only share
branch excluded via coverage:ignore (unreachable on the Linux test host).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 16:52:59 +02:00
|
|
|
subtitle: Text(meta),
|
|
|
|
|
trailing: const Icon(Icons.more_vert),
|
|
|
|
|
onTap: onTap,
|
2026-06-14 14:20:19 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Compact relative time like "2m ago" for the list subtitle.
|
|
|
|
|
String _relative(DateTime t) {
|
|
|
|
|
final d = DateTime.now().difference(t);
|
|
|
|
|
if (d.inMinutes < 1) return 'just now';
|
|
|
|
|
if (d.inHours < 1) return '${d.inMinutes}m ago';
|
|
|
|
|
if (d.inDays < 1) return '${d.inHours}h ago';
|
|
|
|
|
return '${d.inDays}d ago';
|
|
|
|
|
}
|
|
|
|
|
}
|
Add list filters/sort, status, priority rework, export/import, structured template
Notes list & filtering:
- Text-search filter plus independent date-range filters for both created
and last-updated (AND-combined), a priority filter, and a new status
filter. Default view hides Done/Abandoned and renders as "unfiltered"
(no badge for the default state); fixed badge clipping.
- NoteSort options wired into the list UI; watchCount() for the "N saved".
Status & priority:
- New Status enum (toDo/inProgress/Done/Abandoned) as a settable + filterable
attribute on every note, with capture-screen dropdown.
- Removed "None" priority: every note is Low/Medium/High, default Medium.
Schema migration v2->v3 rewrites legacy priority 0 -> Medium.
Export / import:
- NotesMarkdown round-trippable single-file format with HTML-comment markers.
- Settings "Export notes" (mobile share sheet / desktop writes ~/todo/BACKLOG.md)
and "Import notes" (file picker + safe newer-wins merge by id).
Structured template:
- Every new note pre-fills the richer what/where/must/nice/out/done/depends/
estimate/refs scaffold.
Tests:
- New fast (~5s), deterministic suite via FakeNoteRepository (no DB timers) and
injected http/file-selector/url-launcher fakes. 86 tests, 96.2% line coverage
(note.dart & sync_service.dart at 100%, settings 98.7%). Mobile-only share
branch excluded via coverage:ignore (unreachable on the Linux test host).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 16:52:59 +02:00
|
|
|
|
|
|
|
|
/// Bottom sheet for editing one note's priority/status or deleting it.
|
|
|
|
|
class _NoteActionsSheet extends StatefulWidget {
|
|
|
|
|
const _NoteActionsSheet({
|
|
|
|
|
required this.note,
|
|
|
|
|
required this.onChanged,
|
|
|
|
|
required this.onDelete,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final Note note;
|
|
|
|
|
final Future<void> Function(Note) onChanged;
|
|
|
|
|
final Future<void> Function() onDelete;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
State<_NoteActionsSheet> createState() => _NoteActionsSheetState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _NoteActionsSheetState extends State<_NoteActionsSheet> {
|
|
|
|
|
late Priority _priority = widget.note.priority;
|
|
|
|
|
late Status _status = widget.note.status;
|
|
|
|
|
|
|
|
|
|
Future<void> _persist() async {
|
|
|
|
|
await widget.onChanged(
|
|
|
|
|
widget.note.copyWith(
|
|
|
|
|
priority: _priority,
|
|
|
|
|
status: _status,
|
|
|
|
|
updatedAt: DateTime.now(),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final firstLine = widget.note.text.split('\n').first;
|
|
|
|
|
return SafeArea(
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
|
|
|
|
firstLine.isEmpty ? '(empty)' : firstLine,
|
|
|
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
|
|
|
maxLines: 2,
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
_EnumChips<Status>(
|
|
|
|
|
label: 'Status',
|
|
|
|
|
values: Status.values,
|
|
|
|
|
selected: {_status},
|
|
|
|
|
labelOf: (s) => s.label,
|
|
|
|
|
onSelected: (s) {
|
|
|
|
|
setState(() => _status = s);
|
|
|
|
|
_persist();
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
_EnumChips<Priority>(
|
|
|
|
|
label: 'Priority',
|
|
|
|
|
values: Priority.values,
|
|
|
|
|
selected: {_priority},
|
|
|
|
|
labelOf: (p) => p.label,
|
|
|
|
|
onSelected: (p) {
|
|
|
|
|
setState(() => _priority = p);
|
|
|
|
|
_persist();
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
OutlinedButton.icon(
|
|
|
|
|
icon: const Icon(Icons.delete_outline),
|
|
|
|
|
label: const Text('Delete note'),
|
|
|
|
|
style: OutlinedButton.styleFrom(
|
|
|
|
|
foregroundColor: Theme.of(context).colorScheme.error,
|
|
|
|
|
),
|
|
|
|
|
onPressed: () async {
|
|
|
|
|
await widget.onDelete();
|
|
|
|
|
if (context.mounted) Navigator.of(context).pop();
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Single-select chip row for an enum (used for per-note priority/status).
|
|
|
|
|
class _EnumChips<T> extends StatelessWidget {
|
|
|
|
|
const _EnumChips({
|
|
|
|
|
required this.label,
|
|
|
|
|
required this.values,
|
|
|
|
|
required this.selected,
|
|
|
|
|
required this.labelOf,
|
|
|
|
|
required this.onSelected,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final String label;
|
|
|
|
|
final List<T> values;
|
|
|
|
|
final Set<T> selected;
|
|
|
|
|
final String Function(T) labelOf;
|
|
|
|
|
final ValueChanged<T> onSelected;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
return Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Text(label, style: Theme.of(context).textTheme.labelLarge),
|
|
|
|
|
const SizedBox(height: 6),
|
|
|
|
|
Wrap(
|
|
|
|
|
spacing: 8,
|
|
|
|
|
children: [
|
|
|
|
|
for (final v in values)
|
|
|
|
|
ChoiceChip(
|
|
|
|
|
label: Text(labelOf(v)),
|
|
|
|
|
selected: selected.contains(v),
|
|
|
|
|
onSelected: (_) => onSelected(v),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The filter editing sheet: priority + status multi-select and Created /
|
|
|
|
|
/// Last-updated date ranges (presets + a custom range picker). Edits a
|
|
|
|
|
/// working copy and returns it via [Navigator.pop] on "Apply".
|
|
|
|
|
class _FilterSheet extends StatefulWidget {
|
|
|
|
|
const _FilterSheet({required this.initial});
|
|
|
|
|
|
|
|
|
|
final NoteFilter initial;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
State<_FilterSheet> createState() => _FilterSheetState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _FilterSheetState extends State<_FilterSheet> {
|
|
|
|
|
late Set<Priority> _priorities = {...widget.initial.priorities};
|
|
|
|
|
late Set<Status> _statuses = {...widget.initial.statuses};
|
|
|
|
|
late DateTime? _createdFrom = widget.initial.createdFrom;
|
|
|
|
|
late DateTime? _createdTo = widget.initial.createdTo;
|
|
|
|
|
late DateTime? _updatedFrom = widget.initial.updatedFrom;
|
|
|
|
|
late DateTime? _updatedTo = widget.initial.updatedTo;
|
|
|
|
|
|
|
|
|
|
void _toggle<T>(Set<T> set, T value) {
|
|
|
|
|
setState(() => set.contains(value) ? set.remove(value) : set.add(value));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _clearAll() {
|
|
|
|
|
setState(() {
|
|
|
|
|
_priorities = {};
|
|
|
|
|
// Reset to the default view (hide Done/Abandoned), not an empty set,
|
|
|
|
|
// so "Clear all" matches the app's unfiltered baseline.
|
|
|
|
|
_statuses = {...kDefaultStatuses};
|
|
|
|
|
_createdFrom = null;
|
|
|
|
|
_createdTo = null;
|
|
|
|
|
_updatedFrom = null;
|
|
|
|
|
_updatedTo = null;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
NoteFilter _build() {
|
|
|
|
|
// query is owned by the search box and re-applied by the caller.
|
|
|
|
|
return NoteFilter(
|
|
|
|
|
priorities: _priorities,
|
|
|
|
|
statuses: _statuses,
|
|
|
|
|
createdFrom: _createdFrom,
|
|
|
|
|
createdTo: _createdTo,
|
|
|
|
|
updatedFrom: _updatedFrom,
|
|
|
|
|
updatedTo: _updatedTo,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final theme = Theme.of(context);
|
|
|
|
|
return SafeArea(
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: EdgeInsets.fromLTRB(
|
|
|
|
|
16,
|
|
|
|
|
0,
|
|
|
|
|
16,
|
|
|
|
|
16 + MediaQuery.of(context).viewInsets.bottom,
|
|
|
|
|
),
|
|
|
|
|
child: SingleChildScrollView(
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Row(
|
|
|
|
|
children: [
|
|
|
|
|
Text('Filters', style: theme.textTheme.titleLarge),
|
|
|
|
|
const Spacer(),
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: _clearAll,
|
|
|
|
|
child: const Text('Clear all'),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
_MultiChips<Status>(
|
|
|
|
|
label: 'Status',
|
|
|
|
|
values: Status.values,
|
|
|
|
|
selected: _statuses,
|
|
|
|
|
labelOf: (s) => s.label,
|
|
|
|
|
onToggle: (s) => _toggle(_statuses, s),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
_MultiChips<Priority>(
|
|
|
|
|
label: 'Priority',
|
|
|
|
|
values: Priority.values,
|
|
|
|
|
selected: _priorities,
|
|
|
|
|
labelOf: (p) => p.label,
|
|
|
|
|
onToggle: (p) => _toggle(_priorities, p),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
_DateRangeField(
|
|
|
|
|
label: 'Created',
|
|
|
|
|
from: _createdFrom,
|
|
|
|
|
to: _createdTo,
|
|
|
|
|
onChanged: (from, to) => setState(() {
|
|
|
|
|
_createdFrom = from;
|
|
|
|
|
_createdTo = to;
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
_DateRangeField(
|
|
|
|
|
label: 'Last updated',
|
|
|
|
|
from: _updatedFrom,
|
|
|
|
|
to: _updatedTo,
|
|
|
|
|
onChanged: (from, to) => setState(() {
|
|
|
|
|
_updatedFrom = from;
|
|
|
|
|
_updatedTo = to;
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 20),
|
|
|
|
|
FilledButton(
|
|
|
|
|
onPressed: () => Navigator.of(context).pop(_build()),
|
|
|
|
|
child: const Text('Apply'),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Multi-select chip group for an enum (used by the filter sheet).
|
|
|
|
|
class _MultiChips<T> extends StatelessWidget {
|
|
|
|
|
const _MultiChips({
|
|
|
|
|
required this.label,
|
|
|
|
|
required this.values,
|
|
|
|
|
required this.selected,
|
|
|
|
|
required this.labelOf,
|
|
|
|
|
required this.onToggle,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final String label;
|
|
|
|
|
final List<T> values;
|
|
|
|
|
final Set<T> selected;
|
|
|
|
|
final String Function(T) labelOf;
|
|
|
|
|
final ValueChanged<T> onToggle;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
return Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Text(label, style: Theme.of(context).textTheme.labelLarge),
|
|
|
|
|
const SizedBox(height: 6),
|
|
|
|
|
Wrap(
|
|
|
|
|
spacing: 8,
|
|
|
|
|
children: [
|
|
|
|
|
for (final v in values)
|
|
|
|
|
FilterChip(
|
|
|
|
|
label: Text(labelOf(v)),
|
|
|
|
|
selected: selected.contains(v),
|
|
|
|
|
onSelected: (_) => onToggle(v),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A date-range control offering quick presets plus a custom range picker.
|
|
|
|
|
///
|
|
|
|
|
/// Reports the chosen [from]/[to] (day granularity, both inclusive) back to
|
|
|
|
|
/// the parent; `null`/`null` means "any date" for this field.
|
|
|
|
|
class _DateRangeField extends StatelessWidget {
|
|
|
|
|
const _DateRangeField({
|
|
|
|
|
required this.label,
|
|
|
|
|
required this.from,
|
|
|
|
|
required this.to,
|
|
|
|
|
required this.onChanged,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final String label;
|
|
|
|
|
final DateTime? from;
|
|
|
|
|
final DateTime? to;
|
|
|
|
|
|
|
|
|
|
/// Called with the new (from, to); either may be null to clear.
|
|
|
|
|
final void Function(DateTime? from, DateTime? to) onChanged;
|
|
|
|
|
|
|
|
|
|
bool get _hasRange => from != null || to != null;
|
|
|
|
|
|
|
|
|
|
/// Sets a preset range of the last [days] days ending today.
|
|
|
|
|
void _applyDays(int days) {
|
|
|
|
|
final now = DateTime.now();
|
|
|
|
|
final today = DateTime(now.year, now.month, now.day);
|
|
|
|
|
onChanged(today.subtract(Duration(days: days - 1)), today);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _pickCustom(BuildContext context) async {
|
|
|
|
|
final now = DateTime.now();
|
|
|
|
|
final picked = await showDateRangePicker(
|
|
|
|
|
context: context,
|
|
|
|
|
firstDate: DateTime(2020),
|
|
|
|
|
lastDate: DateTime(now.year + 1, 12, 31),
|
|
|
|
|
initialDateRange: (from != null && to != null)
|
|
|
|
|
? DateTimeRange(start: from!, end: to!)
|
|
|
|
|
: null,
|
|
|
|
|
);
|
|
|
|
|
if (picked != null) onChanged(picked.start, picked.end);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final theme = Theme.of(context);
|
|
|
|
|
return Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Row(
|
|
|
|
|
children: [
|
|
|
|
|
Text(label, style: theme.textTheme.labelLarge),
|
|
|
|
|
const Spacer(),
|
|
|
|
|
if (_hasRange)
|
|
|
|
|
Text(_rangeLabel(), style: theme.textTheme.bodySmall),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 6),
|
|
|
|
|
Wrap(
|
|
|
|
|
spacing: 8,
|
|
|
|
|
children: [
|
|
|
|
|
ActionChip(
|
|
|
|
|
label: const Text('Today'),
|
|
|
|
|
onPressed: () => _applyDays(1),
|
|
|
|
|
),
|
|
|
|
|
ActionChip(
|
|
|
|
|
label: const Text('7 days'),
|
|
|
|
|
onPressed: () => _applyDays(7),
|
|
|
|
|
),
|
|
|
|
|
ActionChip(
|
|
|
|
|
label: const Text('30 days'),
|
|
|
|
|
onPressed: () => _applyDays(30),
|
|
|
|
|
),
|
|
|
|
|
ActionChip(
|
|
|
|
|
label: const Text('Custom…'),
|
|
|
|
|
onPressed: () => _pickCustom(context),
|
|
|
|
|
),
|
|
|
|
|
if (_hasRange)
|
|
|
|
|
ActionChip(
|
|
|
|
|
avatar: const Icon(Icons.clear, size: 16),
|
|
|
|
|
label: const Text('Any'),
|
|
|
|
|
onPressed: () => onChanged(null, null),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Compact "YYYY-MM-DD → YYYY-MM-DD" (or one-sided) summary of the range.
|
|
|
|
|
String _rangeLabel() {
|
|
|
|
|
String d(DateTime? t) =>
|
|
|
|
|
t == null ? '…' : '${t.year}-${_two(t.month)}-${_two(t.day)}';
|
|
|
|
|
return '${d(from)} → ${d(to)}';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String _two(int n) => n.toString().padLeft(2, '0');
|
|
|
|
|
}
|