todo-app/lib/ui/notes_list_screen.dart
Krzysztof kuhy Rudnicki 85a45daaee
Some checks are pending
file-length / file length <= 250 lines (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Share one note form between capture and the detail screen
Editing an existing note looked nothing like capturing a new one: it
always showed the template dropdown, the View/Guided/Raw pills and the
priority/status row. The cause was one missing argument -- both screens
already embedded the same NoteEditor, but NoteDetailScreen never passed
advancedMode, which defaults to true in the widget while the app-wide
AppSettings.advancedMode defaults to false.

Extract the shared form as three single-purpose components rather than
one widget owning several jobs:

- note_meta_row.dart renders the priority/status row (was
  capture_meta_row.dart; drops the private _MetaDropdown duplicate the
  detail screen carried, which used DropdownButtonFormField where the
  shared one uses DropdownButton).
- note_form_chrome_gate.dart owns chrome visibility and nothing else,
  replacing the _chromeVisible flag both screens copy-pasted.
- note_form.dart composes those two with NoteEditor.

appSettings is now a required parameter through NotesListScreen and
NoteDetailScreen. Required, not defaulted: a silent default is exactly
what caused this, so it must not be reintroducible.

Also in the capture app bar: drop the decorative Icons.list_alt_outlined
beside the note count, since the adjacent Icons.list button already names
what is counted, and move the count to the right of that button. The
finalize-and-clear action becomes Icons.check instead of Icons.add -- it
never saved anything (every keystroke is already persisted), so a plus
sign misdescribed it.

314 tests, 100% line coverage. Verified in a running web build that edit
and capture render identically with advanced mode both off and on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 17:48:39 +02:00

239 lines
7.9 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_navigation.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 {
/// 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 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();
}
}
@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(
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.