todo-app/lib/ui/notes_list_screen.dart
Krzysztof kuhy Rudnicki 7f84414c87 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

656 lines
20 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import '../data/note.dart';
import '../data/note_repository.dart';
/// 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.
///
/// 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 {
const NotesListScreen({required this.repository, super.key});
final NoteRepository repository;
@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);
},
),
);
}
@override
Widget build(BuildContext context) {
final badgeCount = _badgeCount;
return Scaffold(
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,
),
subtitle: Text(meta),
trailing: const Icon(Icons.more_vert),
onTap: onTap,
);
}
/// 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';
}
}
/// 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');
}