mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-13 15:20:48 +01:00
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
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>
226 lines
7.3 KiB
Dart
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.
|