todo-app/lib/ui/notes_list_screen.dart
Krzysztof kuhy Rudnicki 9349747629
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Repo tasks: repo/done_check/by/evidence fields, filter chips, CLI close/list/search
Four optional note fields (repo, done_check, by, evidence) in the crdt_sync
record, written only when set, so editing a plain idea stays wire-identical
to v3 (note_repository_sync_compat_test). The log is schemaless: no stored
schema version; the legacy sqlite reader stays at v3.

NoteFilter gains a sources facet (ideas / every repo task / one repo);
search also matches the repo. The notes list shows only open ideas by
default (no badge); the filter sheet adds Ideas, Repo tasks and one chip
per repo when repo tasks exist. The per-note sheet shows the task fields
read-only. BACKLOG.md markers carry the new attributes, entity-escaped.

CLI: --repo --done-check --by on add, --close <id> --evidence (refused
without), --list [--repo] [--status], --search, and every id flag takes a
unique 8-character prefix. --list prints id8, status, priority, repo, title.

install_hooks.sh: the wrapper finds pre-commit's generated hook next to
itself, so commits from a git worktree (where .git is a file) work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 08:26:26 +01:00

226 lines
7.3 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/ui/note_tile.dart';
import 'package:todo/ui/notes_filter_sheet.dart';
import 'package:todo/ui/notes_list_defaults.dart';
import 'package:todo/ui/notes_list_navigation.dart';
export 'package:todo/ui/notes_list_defaults.dart';
/// 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 {
/// Creates a [NotesListScreen] backed by [repository].
const NotesListScreen({
required this.repository,
required this.appSettings,
super.key,
});
/// The store this screen searches/filters/sorts.
final NoteRepository repository;
/// Passed through to the detail screen so an opened note is edited with the
/// same chrome capture uses.
final ValueNotifier<AppSettings> appSettings;
@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.priorityDesc;
/// Default view: open ideas only (no Done/Abandoned, no repo tasks).
NoteFilter _filter = kDefaultNoteFilter;
/// 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 repos = {
for (final n in await widget.repository.listNotes()) ?n.repo,
}.toList()..sort();
if (!mounted) return;
final edited = await showModalBottomSheet<NoteFilter>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => FilterSheet(initial: _filter, repos: repos),
);
if (edited != null) {
_filter = edited.copyWith(query: _searchController.text);
_applyState();
}
}
@override
Widget build(BuildContext context) {
final badgeCount = userFacetCount(_filter);
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(
key: ValueKey(notes[i].id),
note: notes[i],
onTap: () => openNote(
context,
notes[i],
widget.repository,
widget.appSettings,
),
onActions: () =>
openNoteActions(context, notes[i], widget.repository),
),
);
},
),
),
],
),
);
}
}
/// One row in the notes list: first line, then a metadata subtitle.