From 757c54beda7ac2aee1d004ce4c490532d0d7d004 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 10 Nov 2026 15:39:13 +0100 Subject: [PATCH] Multi-select notes with an icon action bar An app-bar toggle enters selection mode: a tap toggles a note (tint + check on grid tiles, ListTile.selected + check in the list), Ctrl/Cmd- click toggles and Shift-click range-selects on desktop, long-press outside the mode still opens the per-note sheet. While anything is selected an icon-only bottom bar sets status or priority for the whole selection in one bulkUpdate, assigns plain notes to a repo picked from the repos on notes or typed as owner/name, or clears the selection. A refused assign (note already in a repo) flashes a block icon. Labels are semantic only; menus pass an empty tooltip. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWXdsB7CQPNXVN829G1 --- lib/ui/note_grid_tile.dart | 68 ++++++---- lib/ui/note_selection.dart | 86 +++++++++++++ lib/ui/note_tile.dart | 8 ++ lib/ui/notes_assign_flow.dart | 37 ++++++ lib/ui/notes_grid.dart | 5 + lib/ui/notes_list_body.dart | 12 +- lib/ui/notes_list_screen.dart | 20 ++- lib/ui/notes_list_screen_select.dart | 43 +++++++ lib/ui/notes_selection_bar.dart | 128 +++++++++++++++++++ lib/ui/repo_picker_sheet.dart | 97 +++++++++++++++ pubspec.yaml | 2 +- test/note_selection_test.dart | 102 +++++++++++++++ test/notes_list_assign_test.dart | 126 +++++++++++++++++++ test/notes_list_select_test.dart | 177 +++++++++++++++++++++++++++ 14 files changed, 881 insertions(+), 30 deletions(-) create mode 100644 lib/ui/note_selection.dart create mode 100644 lib/ui/notes_assign_flow.dart create mode 100644 lib/ui/notes_list_screen_select.dart create mode 100644 lib/ui/notes_selection_bar.dart create mode 100644 lib/ui/repo_picker_sheet.dart create mode 100644 test/note_selection_test.dart create mode 100644 test/notes_list_assign_test.dart create mode 100644 test/notes_list_select_test.dart diff --git a/lib/ui/note_grid_tile.dart b/lib/ui/note_grid_tile.dart index d6444b0..ecb0d3b 100644 --- a/lib/ui/note_grid_tile.dart +++ b/lib/ui/note_grid_tile.dart @@ -7,6 +7,21 @@ import 'package:flutter/material.dart'; import 'package:todo/data/note.dart'; import 'package:todo/data/note_template.dart'; +/// Danger for high, the accent for medium, muted for low. +Color priorityColor(Priority p, ColorScheme scheme) => switch (p) { + Priority.high => scheme.error, + Priority.medium => scheme.primary, + Priority.low => scheme.outline, +}; + +/// The glyph standing for [s] wherever a status is shown without words. +IconData statusIcon(Status s) => switch (s) { + Status.todo => Icons.radio_button_unchecked, + Status.inProgress => Icons.timelapse, + Status.done => Icons.check_circle_outline, + Status.abandoned => Icons.cancel_outlined, +}; + /// A note as a grid cell. It fills whatever cell it is given. class NoteGridTile extends StatelessWidget { /// Creates a tile for [note]. @@ -14,9 +29,13 @@ class NoteGridTile extends StatelessWidget { required this.note, required this.onTap, required this.onActions, + this.selected = false, super.key, }); + /// Whether the note is part of the multi-selection: a tint and a check. + final bool selected; + /// The note this tile displays. final Note note; @@ -27,18 +46,7 @@ class NoteGridTile extends StatelessWidget { final VoidCallback onActions; /// Danger for high, the accent for medium, muted for low. - Color accentFor(ColorScheme scheme) => switch (note.priority) { - Priority.high => scheme.error, - Priority.medium => scheme.primary, - Priority.low => scheme.outline, - }; - - static IconData _statusIcon(Status s) => switch (s) { - Status.todo => Icons.radio_button_unchecked, - Status.inProgress => Icons.timelapse, - Status.done => Icons.check_circle_outline, - Status.abandoned => Icons.cancel_outlined, - }; + Color accentFor(ColorScheme scheme) => priorityColor(note.priority, scheme); @override Widget build(BuildContext context) { @@ -49,7 +57,7 @@ class NoteGridTile extends StatelessWidget { final closed = note.status == Status.done || note.status == Status.abandoned; return Material( - color: scheme.surfaceContainerHigh, + color: selected ? scheme.secondaryContainer : scheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(AppRadius.md), clipBehavior: Clip.antiAlias, child: InkWell( @@ -72,14 +80,30 @@ class NoteGridTile extends StatelessWidget { children: [ // Expanded: a big text scale shortens the title, never overflows. Expanded( - child: Text( - title.isEmpty ? '(empty)' : title, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith( - color: closed ? scheme.onSurfaceVariant : null, - decoration: closed ? TextDecoration.lineThrough : null, - ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + title.isEmpty ? '(empty)' : title, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: closed ? scheme.onSurfaceVariant : null, + decoration: closed + ? TextDecoration.lineThrough + : null, + ), + ), + ), + if (selected) + Icon( + Icons.check_circle, + size: 20, + color: scheme.primary, + semanticLabel: 'Selected', + ), + ], ), ), if (note.repoName case final repo?) @@ -92,7 +116,7 @@ class NoteGridTile extends StatelessWidget { Row( children: [ Icon( - _statusIcon(note.status), + statusIcon(note.status), size: 14, color: scheme.onSurfaceVariant, ), diff --git a/lib/ui/note_selection.dart b/lib/ui/note_selection.dart new file mode 100644 index 0000000..9cb2db1 --- /dev/null +++ b/lib/ui/note_selection.dart @@ -0,0 +1,86 @@ +/// Which notes of the list are selected, and whether a tap selects. +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'package:todo/data/note.dart'; + +/// The notes list's multi-select state. +/// +/// Holds ids, not notes, so a selection survives the list re-emitting +/// (a bulk status change rebuilds every tile). Selection order is kept, so +/// an assign appends the notes in the order they were picked. +class NoteSelection extends ChangeNotifier { + final Set _ids = {}; + bool _active = false; + String? _anchor; + + /// Whether a plain tap selects rather than opens. + bool get active => _active; + + /// The selected ids, in the order they were picked. + Set get ids => Set.unmodifiable(_ids); + + /// Whether [id] is selected. + bool contains(String id) => _ids.contains(id); + + /// Enters selection mode, or leaves it and drops the selection. + void toggleMode() { + if (_active) return clear(); + _active = true; + notifyListeners(); + } + + /// Flips [id] in or out of the selection; enters the mode. + void toggle(String id) { + _active = true; + if (!_ids.remove(id)) _ids.add(id); + _anchor = id; + notifyListeners(); + } + + /// Selects every note of [order] from the last toggled one to [id] + /// (Shift-click); without an anchor in [order] it is a [toggle]. + void extendTo(String id, List order) { + final from = order.indexWhere((n) => n.id == _anchor); + final to = order.indexWhere((n) => n.id == id); + if (from < 0 || to < 0) return toggle(id); + final (lo, hi) = from < to ? (from, to) : (to, from); + _ids.addAll(order.sublist(lo, hi + 1).map((n) => n.id)); + _anchor = id; + notifyListeners(); + } + + /// Keeps only the selected ids in [keep] (e.g. the notes an assign + /// refused); an empty result is a [clear]. + void retain(Set keep) { + _ids.retainWhere(keep.contains); + if (_ids.isEmpty) return clear(); + notifyListeners(); + } + + /// Drops the selection and leaves the mode. + void clear() { + _ids.clear(); + _active = false; + _anchor = null; + notifyListeners(); + } + + /// Handles a tap on [note] of the list [order]; false means "open it". + /// + /// Shift extends, Ctrl/Cmd toggles even outside the mode (desktop), and in + /// the mode a plain tap toggles. + bool handleTap(Note note, List order) { + final keys = HardwareKeyboard.instance; + if (keys.isShiftPressed) { + extendTo(note.id, order); + } else if (_active || keys.isControlPressed || keys.isMetaPressed) { + toggle(note.id); + } else { + return false; + } + return true; + } +} diff --git a/lib/ui/note_tile.dart b/lib/ui/note_tile.dart index 884b3be..82796f8 100644 --- a/lib/ui/note_tile.dart +++ b/lib/ui/note_tile.dart @@ -16,9 +16,13 @@ class NoteTile extends StatelessWidget { required this.note, required this.onTap, required this.onActions, + this.selected = false, super.key, }); + /// Whether the row is part of the multi-selection: highlighted + a check. + final bool selected; + /// The note this row displays. final Note note; @@ -41,6 +45,10 @@ class NoteTile extends StatelessWidget { ?note.repoName, ].join(' ยท '); return ListTile( + selected: selected, + leading: selected + ? const Icon(Icons.check_circle, semanticLabel: 'Selected') + : null, title: Text( firstLine.isEmpty ? '(empty)' : firstLine, maxLines: 2, diff --git a/lib/ui/notes_assign_flow.dart b/lib/ui/notes_assign_flow.dart new file mode 100644 index 0000000..78041b1 --- /dev/null +++ b/lib/ui/notes_assign_flow.dart @@ -0,0 +1,37 @@ +/// The selection bar's assign flow: pick a repo, move the notes into it. +/// +/// Data-layer only ([assignToRepo]); the UI never reaches the board, so an +/// assign never starts a task. +library; + +import 'package:flutter/material.dart'; + +import 'package:todo/data/note_repository.dart'; +import 'package:todo/data/repo_assign.dart'; +import 'package:todo/ui/note_selection.dart'; +import 'package:todo/ui/repo_picker_sheet.dart'; + +/// Asks for a repo and assigns the selected plain notes to it, in +/// selection order. True when some notes were refused (already in a repo); +/// those stay selected, the moved ones leave the selection. +Future assignSelection( + BuildContext context, + NoteRepository repository, + NoteSelection selection, +) async { + final all = await repository.listNotes(); + final repos = {for (final n in all) ?n.repo}.toList()..sort(); + if (!context.mounted) return false; + final repo = await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (_) => RepoPickerSheet(repos: repos), + ); + if (repo == null) return false; + final byId = {for (final n in all) n.id: n}; + final chosen = [for (final id in selection.ids) ?byId[id]]; + final refused = await assignToRepo(repository, chosen, repo); + selection.retain({for (final n in refused) n.id}); + return refused.isNotEmpty; +} diff --git a/lib/ui/notes_grid.dart b/lib/ui/notes_grid.dart index fdd155f..c9be91b 100644 --- a/lib/ui/notes_grid.dart +++ b/lib/ui/notes_grid.dart @@ -26,9 +26,13 @@ class NotesGrid extends StatelessWidget { required this.notes, required this.onOpen, required this.onActions, + required this.isSelected, super.key, }); + /// Whether the note with this id is in the multi-selection. + final bool Function(String id) isSelected; + /// Notes in display order. final List notes; @@ -42,6 +46,7 @@ class NotesGrid extends StatelessWidget { return NoteGridTile( key: ValueKey(note.id), note: note, + selected: isSelected(note.id), onTap: () => onOpen(note), onActions: () => onActions(note), ); diff --git a/lib/ui/notes_list_body.dart b/lib/ui/notes_list_body.dart index e3bfd5f..1f21afe 100644 --- a/lib/ui/notes_list_body.dart +++ b/lib/ui/notes_list_body.dart @@ -16,9 +16,13 @@ class NotesListBody extends StatelessWidget { required this.notes, required this.onOpen, required this.onActions, + required this.isSelected, super.key, }); + /// Whether the note with this id is in the multi-selection. + final bool Function(String id) isSelected; + /// Notes in display order. final List notes; @@ -32,13 +36,19 @@ class NotesListBody extends StatelessWidget { Widget build(BuildContext context) { return LayoutBuilder( builder: (context, box) => box.maxWidth < kNotesGridMaxWidth - ? NotesGrid(notes: notes, onOpen: onOpen, onActions: onActions) + ? NotesGrid( + notes: notes, + onOpen: onOpen, + onActions: onActions, + isSelected: isSelected, + ) : ListView.separated( itemCount: notes.length, separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, i) => NoteTile( key: ValueKey(notes[i].id), note: notes[i], + selected: isSelected(notes[i].id), onTap: () => onOpen(notes[i]), onActions: () => onActions(notes[i]), ), diff --git a/lib/ui/notes_list_screen.dart b/lib/ui/notes_list_screen.dart index 4565629..a23d71a 100644 --- a/lib/ui/notes_list_screen.dart +++ b/lib/ui/notes_list_screen.dart @@ -5,13 +5,18 @@ 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_selection.dart'; +import 'package:todo/ui/notes_assign_flow.dart'; import 'package:todo/ui/notes_filter_sheet.dart'; import 'package:todo/ui/notes_list_body.dart'; import 'package:todo/ui/notes_list_defaults.dart'; import 'package:todo/ui/notes_list_navigation.dart'; +import 'package:todo/ui/notes_selection_bar.dart'; export 'package:todo/ui/notes_list_defaults.dart'; +part 'notes_list_screen_select.dart'; + /// Searchable, filterable, sortable list of stored/synced notes. /// /// The heavy lifting (WHERE/ORDER BY) lives in [NoteRepository]; this screen @@ -53,6 +58,9 @@ class _NotesListScreenState extends State { /// Memoised stream; only replaced when [_sort]/[_filter] actually change. late Stream> _stream; + /// Multi-select; any change rebuilds the screen (tiles + bottom bar). + late final _selection = NoteSelection()..addListener(() => setState(() {})); + @override void initState() { super.initState(); @@ -63,6 +71,7 @@ class _NotesListScreenState extends State { void dispose() { _debounce?.cancel(); _searchController.dispose(); + _selection.dispose(); super.dispose(); } @@ -127,6 +136,7 @@ class _NotesListScreenState extends State { appBar: AppBar( title: const Text('Notes'), actions: [ + _selectButton(), // Eye, no label: open = Claude-made notes shown, slashed = hidden. IconButton( icon: Icon( @@ -220,20 +230,18 @@ class _NotesListScreenState extends State { ), ); } - void open(Note n) => - openNote(context, n, widget.repository, widget.appSettings); - void actions(Note n) => - openNoteActions(context, n, widget.repository); return NotesListBody( notes: notes, - onOpen: open, - onActions: actions, + onOpen: (n) => _tap(n, notes), + onActions: _longPress, + isSelected: _selection.contains, ); }, ), ), ], ), + bottomNavigationBar: _selectionBar(), ); } } diff --git a/lib/ui/notes_list_screen_select.dart b/lib/ui/notes_list_screen_select.dart new file mode 100644 index 0000000..7cb3888 --- /dev/null +++ b/lib/ui/notes_list_screen_select.dart @@ -0,0 +1,43 @@ +/// The notes list's multi-select wiring: the app-bar toggle, taps, and the +/// bottom action bar. +/// +/// A `part` of `notes_list_screen.dart` because it reads the screen's +/// private selection and repository; split out for file size. +part of 'notes_list_screen.dart'; + +extension _Selecting on _NotesListScreenState { + /// App-bar toggle into and out of selection mode (tinted while on). + Widget _selectButton() => IconButton( + key: const ValueKey('select-mode'), + isSelected: _selection.active, + onPressed: _selection.toggleMode, + icon: const Icon(Icons.checklist, semanticLabel: 'Select'), + ); + + /// A tap on [note] of [notes]: selects in the mode or with Shift/Ctrl, + /// else opens it. + void _tap(Note note, List notes) { + if (_selection.handleTap(note, notes)) return; + unawaited(openNote(context, note, widget.repository, widget.appSettings)); + } + + /// A long-press: toggles in the mode, else the per-note sheet. + void _longPress(Note note) { + if (_selection.active) return _selection.toggle(note.id); + unawaited(openNoteActions(context, note, widget.repository)); + } + + /// The bar, only while something is selected. Status and priority are + /// ONE store write for the whole selection ([NoteRepository.bulkUpdate]). + Widget? _selectionBar() { + if (_selection.ids.isEmpty) return null; + final repo = widget.repository; + return NotesSelectionBar( + onStatus: (s) => unawaited(repo.bulkUpdate(_selection.ids, status: s)), + onPriority: (p) => + unawaited(repo.bulkUpdate(_selection.ids, priority: p)), + onAssign: () => assignSelection(context, repo, _selection), + onClear: _selection.clear, + ); + } +} diff --git a/lib/ui/notes_selection_bar.dart b/lib/ui/notes_selection_bar.dart new file mode 100644 index 0000000..db9de24 --- /dev/null +++ b/lib/ui/notes_selection_bar.dart @@ -0,0 +1,128 @@ +/// The icon-only action bar shown while notes are selected. +library; + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:todo/data/note.dart'; +import 'package:todo/ui/note_grid_tile.dart'; + +/// How long the assign icon shows the refusal block. +const kRefusalFlash = Duration(milliseconds: 1500); + +/// Set status, set priority, assign to repo, clear: icons only. +/// +/// Menus pass `tooltip: ''` because a tooltip is visible text; the icons +/// carry semantic labels instead. +class NotesSelectionBar extends StatefulWidget { + /// Creates the bar; every callback acts on the whole selection. + const NotesSelectionBar({ + required this.onStatus, + required this.onPriority, + required this.onAssign, + required this.onClear, + super.key, + }); + + /// Sets every selected note's status. + final ValueChanged onStatus; + + /// Sets every selected note's priority. + final ValueChanged onPriority; + + /// Runs the assign flow; true when some notes were refused. + final Future Function() onAssign; + + /// Drops the selection. + final VoidCallback onClear; + + @override + State createState() => _NotesSelectionBarState(); +} + +class _NotesSelectionBarState extends State { + Timer? _flash; + + @override + void dispose() { + _flash?.cancel(); + super.dispose(); + } + + Future _assign() async { + if (!await widget.onAssign() || !mounted) return; + _flash?.cancel(); + setState(() => _flash = Timer(kRefusalFlash, () => setState(() {}))); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final refused = _flash?.isActive ?? false; + return Material( + key: const ValueKey('selection-bar'), + color: scheme.surfaceContainer, + child: SafeArea( + top: false, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + PopupMenuButton( + key: const ValueKey('bulk-status'), + tooltip: '', + icon: const Icon(Icons.task_alt, semanticLabel: 'Set status'), + onSelected: widget.onStatus, + itemBuilder: (_) => [ + for (final s in Status.values) + PopupMenuItem( + key: ValueKey('status-${s.name}'), + value: s, + child: Icon(statusIcon(s), semanticLabel: s.label), + ), + ], + ), + PopupMenuButton( + key: const ValueKey('bulk-priority'), + tooltip: '', + icon: const Icon( + Icons.flag_outlined, + semanticLabel: 'Set priority', + ), + onSelected: widget.onPriority, + itemBuilder: (_) => [ + for (final p in Priority.values.reversed) + PopupMenuItem( + key: ValueKey('priority-${p.name}'), + value: p, + child: Icon( + Icons.flag, + color: priorityColor(p, scheme), + semanticLabel: p.label, + ), + ), + ], + ), + IconButton( + key: const ValueKey('bulk-assign'), + onPressed: _assign, + icon: Icon( + refused ? Icons.block : Icons.drive_file_move_outline, + color: refused ? scheme.error : null, + semanticLabel: 'Assign to repo', + ), + ), + IconButton( + key: const ValueKey('bulk-clear'), + onPressed: widget.onClear, + icon: const Icon( + Icons.deselect, + semanticLabel: 'Clear selection', + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui/repo_picker_sheet.dart b/lib/ui/repo_picker_sheet.dart new file mode 100644 index 0000000..bdc153d --- /dev/null +++ b/lib/ui/repo_picker_sheet.dart @@ -0,0 +1,97 @@ +/// Bottom sheet picking the repo selected notes are assigned to. +library; + +import 'package:flutter/material.dart'; + +import 'package:todo/data/repo_assign.dart'; + +/// The repos already on notes, plus a field for a new `kuhyx/`. +/// +/// Pops with the chosen `owner/name`; dismissing it pops null. The field +/// starts at `kuhyx/` (content, not a label) and refuses anything that is +/// not `owner/name` with an error icon rather than words. +class RepoPickerSheet extends StatefulWidget { + /// Creates the picker listing [repos]. + const RepoPickerSheet({required this.repos, super.key}); + + /// Distinct repos already present on notes, in display order. + final List repos; + + @override + State createState() => _RepoPickerSheetState(); +} + +class _RepoPickerSheetState extends State { + final _field = TextEditingController(text: 'kuhyx/'); + bool _bad = false; + + @override + void dispose() { + _field.dispose(); + super.dispose(); + } + + void _submit() { + final value = _field.text.trim(); + if (!isRepoName(value)) return setState(() => _bad = true); + Navigator.of(context).pop(value); + } + + @override + Widget build(BuildContext context) { + return Padding( + key: const ValueKey('repo-picker'), + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: ListView( + shrinkWrap: true, + children: [ + for (final repo in widget.repos) + ListTile( + key: ValueKey('repo-$repo'), + leading: const Icon(Icons.folder_outlined), + title: Text(repo), + onTap: () => Navigator.of(context).pop(repo), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(12), + child: TextField( + key: const ValueKey('repo-new'), + controller: _field, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _submit(), + onChanged: (_) { + if (_bad) setState(() => _bad = false); + }, + decoration: InputDecoration( + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.create_new_folder_outlined), + error: _bad + ? const Icon( + Icons.error_outline, + size: 16, + semanticLabel: 'Not owner/name', + ) + : null, + suffixIcon: IconButton( + key: const ValueKey('repo-new-submit'), + onPressed: _submit, + icon: const Icon(Icons.check, semanticLabel: 'Use repo'), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 882df80..2f5554e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.67+1 +version: 1.0.68+1 environment: sdk: ^3.12.2 diff --git a/test/note_selection_test.dart b/test/note_selection_test.dart new file mode 100644 index 0000000..dfea825 --- /dev/null +++ b/test/note_selection_test.dart @@ -0,0 +1,102 @@ +/// The notes list's selection state, apart from any widget. +library; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/ui/note_selection.dart'; + +import 'notes_list_harness.dart'; + +void main() { + final order = [ + note('a', 'A'), + note('b', 'B'), + note('c', 'C'), + note('d', 'D'), + ]; + + test('mode toggles; leaving it clears the selection', () { + final s = NoteSelection(); + addTearDown(s.dispose); + expect(s.active, isFalse); + s.toggleMode(); + expect(s.active, isTrue); + s.toggle('a'); + expect(s.ids, {'a'}); + s.toggleMode(); + expect(s.active, isFalse); + expect(s.ids, isEmpty); + }); + + test('toggle adds then removes, and enters the mode', () { + final s = NoteSelection(); + addTearDown(s.dispose); + var notified = 0; + s.addListener(() => notified++); + s.toggle('b'); + expect(s.active, isTrue); + expect(s.contains('b'), isTrue); + s.toggle('b'); + expect(s.contains('b'), isFalse); + expect(s.active, isTrue); + expect(notified, 2); + }); + + test('extendTo selects the run from the anchor, either direction', () { + final s = NoteSelection(); + addTearDown(s.dispose); + s + ..toggle('d') + ..extendTo('b', order); + expect(s.ids, {'b', 'c', 'd'}); + s + ..clear() + ..toggle('a') + ..extendTo('c', order); + expect(s.ids, {'a', 'b', 'c'}); + }); + + test('extendTo without a usable anchor just toggles', () { + final s = NoteSelection(); + addTearDown(s.dispose); + s.extendTo('c', order); + expect(s.ids, {'c'}); + s + ..clear() + ..toggle('zz') + ..extendTo('a', order); + expect(s.ids, {'zz', 'a'}); + }); + + test('clear empties and leaves the mode', () { + final s = NoteSelection(); + addTearDown(s.dispose); + s + ..toggle('a') + ..clear(); + expect(s.ids, isEmpty); + expect(s.active, isFalse); + }); + + testWidgets('handleTap: plain tap outside the mode is not consumed', ( + t, + ) async { + final s = NoteSelection(); + addTearDown(s.dispose); + expect(s.handleTap(order[0], order), isFalse); + expect(s.ids, isEmpty); + + await t.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + expect(s.handleTap(order[0], order), isTrue); + await t.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + expect(s.ids, {'a'}); + + await t.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + expect(s.handleTap(order[2], order), isTrue); + await t.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + expect(s.ids, {'a', 'b', 'c'}); + + expect(s.handleTap(order[1], order), isTrue); + expect(s.ids, {'a', 'c'}); + }); +} diff --git a/test/notes_list_assign_test.dart b/test/notes_list_assign_test.dart new file mode 100644 index 0000000..541fab8 --- /dev/null +++ b/test/notes_list_assign_test.dart @@ -0,0 +1,126 @@ +/// Assign-to-repo from the selection bar: picker, move, refusal. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/data/note.dart'; + +import 'fake_note_repository.dart'; +import 'notes_list_harness.dart'; + +void _phone(WidgetTester t) { + t.view + ..physicalSize = const Size(1080, 2400) + ..devicePixelRatio = 2.625; + addTearDown(t.view.reset); +} + +Note _repoNote(String id, String text, String repo) { + final n = note(id, text); + return n.copyWith(repo: repo); +} + +Future _select( + WidgetTester t, + List seed, + List titles, +) async { + final repo = await pumpList(t, seed: seed, applyFilter: true); + await t.tap(find.byKey(const ValueKey('select-mode'))); + await t.pump(); + for (final title in titles) { + await t.tap(find.text(title)); + await t.pump(); + } + await t.tap(find.byKey(const ValueKey('bulk-assign'))); + await t.pump(); + await t.pump(const Duration(milliseconds: 400)); + return repo; +} + +Future _settle(WidgetTester t) async { + await t.pump(); + await t.pump(const Duration(milliseconds: 400)); +} + +void main() { + testWidgets('picker lists known repos; picking one moves the notes', ( + t, + ) async { + _phone(t); + final repo = await _select( + t, + [ + _repoNote('r', 'app todos\n\n## Old', 'kuhyx/app'), + _repoNote('o', 'web todos', 'kuhyx/web'), + note('p1', 'Fix login'), + note('p2', 'Tidy up'), + ], + ['Fix login', 'Tidy up'], + ); + expect(find.byKey(const ValueKey('repo-kuhyx/app')), findsOneWidget); + expect(find.byKey(const ValueKey('repo-kuhyx/web')), findsOneWidget); + + await t.tap(find.byKey(const ValueKey('repo-kuhyx/app'))); + await _settle(t); + + final notes = {for (final n in await repo.listNotes()) n.id: n}; + expect(notes.keys, unorderedEquals(['r', 'o'])); + expect( + notes['r']!.text, + 'app todos\n\n## Old\n\n## Fix login\n\n## Tidy up', + ); + expect(repo.upsertCalls, 0); + expect(find.byKey(const ValueKey('selection-bar')), findsNothing); + }); + + testWidgets('a new kuhyx/ typed in the field creates its note', ( + t, + ) async { + _phone(t); + final repo = await _select(t, [note('p', 'Ship it')], ['Ship it']); + final field = find.byKey(const ValueKey('repo-new')); + expect(t.widget(field).controller!.text, 'kuhyx/'); + + await t.enterText(field, 'kuhyx/'); + await t.tap(find.byKey(const ValueKey('repo-new-submit'))); + await _settle(t); + expect(find.byKey(const ValueKey('repo-picker')), findsOneWidget); + + await t.enterText(field, 'kuhyx/fresh'); + await t.testTextInput.receiveAction(TextInputAction.done); + await _settle(t); + + final made = (await repo.listNotes()).single; + expect(made.repo, 'kuhyx/fresh'); + expect(made.text, 'fresh todos\n\n## Ship it'); + }); + + testWidgets('dismissing the picker changes nothing', (t) async { + _phone(t); + final repo = await _select(t, [note('p', 'Ship it')], ['Ship it']); + await t.tapAt(const Offset(10, 40)); + await _settle(t); + expect((await repo.listNotes()).single.id, 'p'); + expect(find.byKey(const ValueKey('selection-bar')), findsOneWidget); + }); + + testWidgets('a note already in a repo is refused with a block icon', ( + t, + ) async { + _phone(t); + final repo = await _select( + t, + [_repoNote('r', 'app todos\n\n## Old', 'kuhyx/app')], + ['app todos'], + ); + await t.tap(find.byKey(const ValueKey('repo-kuhyx/app'))); + await _settle(t); + + expect((await repo.listNotes()).single.text, 'app todos\n\n## Old'); + expect(find.byIcon(Icons.block), findsOneWidget); + expect(find.byKey(const ValueKey('selection-bar')), findsOneWidget); + await t.pump(const Duration(seconds: 2)); + expect(find.byIcon(Icons.block), findsNothing); + }); +} diff --git a/test/notes_list_select_test.dart b/test/notes_list_select_test.dart new file mode 100644 index 0000000..419f31a --- /dev/null +++ b/test/notes_list_select_test.dart @@ -0,0 +1,177 @@ +/// Multi-select on the notes list: mode, toggling, bulk status/priority. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/data/note.dart'; +import 'package:todo/ui/note_actions_sheet.dart'; +import 'package:todo/ui/note_detail_screen.dart'; + +import 'fake_note_repository.dart'; +import 'notes_list_harness.dart'; + +const _dpr = 2.625; + +/// Pixel 6a: 1080x2400 px at 2.625 with the real system-bar insets. +void _pixel6a(WidgetTester t) { + const bars = FakeViewPadding(top: 132, bottom: 63); + t.view + ..physicalSize = const Size(1080, 2400) + ..devicePixelRatio = _dpr + ..padding = bars + ..viewPadding = bars; + addTearDown(t.view.reset); +} + +void _desktop(WidgetTester t) { + t.view + ..physicalSize = const Size(1600, 1000) + ..devicePixelRatio = 1; + addTearDown(t.view.reset); +} + +final _seed = [note('a', 'Alpha'), note('b', 'Beta'), note('c', 'Gamma')]; + +Finder _checkIn(String id) => find.descendant( + of: find.byKey(ValueKey(id)), + matching: find.byIcon(Icons.check_circle), +); + +Future _pick(WidgetTester t, String button, String item) async { + await t.tap(find.byKey(ValueKey(button))); + await t.pump(); + await t.pump(const Duration(milliseconds: 400)); + await t.tap(find.byKey(ValueKey(item))); + await t.pump(); + await t.pump(const Duration(milliseconds: 400)); +} + +Future _selectAll(WidgetTester t) async { + final repo = await pumpList(t, seed: _seed); + await t.tap(find.byKey(const ValueKey('select-mode'))); + await t.pump(); + for (final title in ['Alpha', 'Beta', 'Gamma']) { + await t.tap(find.text(title)); + await t.pump(); + } + return repo; +} + +void main() { + testWidgets('no bar and no checks until something is selected', (t) async { + _pixel6a(t); + await pumpList(t, seed: _seed); + expect(find.byKey(const ValueKey('selection-bar')), findsNothing); + await t.tap(find.byKey(const ValueKey('select-mode'))); + await t.pump(); + expect(find.byKey(const ValueKey('selection-bar')), findsNothing); + expect(find.byIcon(Icons.check_circle), findsNothing); + }); + + testWidgets('in selection mode a tap toggles instead of opening', (t) async { + _pixel6a(t); + await pumpList(t, seed: _seed); + await t.tap(find.byKey(const ValueKey('select-mode'))); + await t.pump(); + await t.tap(find.text('Alpha')); + await t.pump(); + expect(find.byType(NoteDetailScreen), findsNothing); + expect(_checkIn('a'), findsOneWidget); + expect(_checkIn('b'), findsNothing); + expect(find.byKey(const ValueKey('selection-bar')), findsOneWidget); + + await t.longPress(find.text('Beta')); + await t.pump(); + expect(_checkIn('b'), findsOneWidget); + expect(find.byType(NoteActionsSheet), findsNothing); + + await t.tap(find.text('Alpha')); + await t.pump(); + expect(_checkIn('a'), findsNothing); + }); + + testWidgets('long-press outside selection mode still opens the sheet', ( + t, + ) async { + _pixel6a(t); + await pumpList(t, seed: _seed); + await t.longPress(find.text('Alpha')); + await t.pump(); + await t.pump(const Duration(milliseconds: 400)); + expect(find.byType(NoteActionsSheet), findsOneWidget); + }); + + testWidgets('bulk status then priority: one call each, no upserts', ( + t, + ) async { + _pixel6a(t); + final repo = await _selectAll(t); + await _pick(t, 'bulk-status', 'status-inProgress'); + await _pick(t, 'bulk-priority', 'priority-high'); + + expect(repo.bulkCalls, 2); + expect(repo.upsertCalls, 0); + final notes = await repo.listNotes(); + expect(notes.map((n) => n.status).toSet(), {Status.inProgress}); + expect(notes.map((n) => n.priority).toSet(), {Priority.high}); + // Selection survives an action, so status + priority is one selection. + expect(find.byIcon(Icons.check_circle), findsNWidgets(3)); + }); + + testWidgets('clear empties the selection and leaves the mode', (t) async { + _pixel6a(t); + await _selectAll(t); + await t.tap(find.byKey(const ValueKey('bulk-clear'))); + await t.pump(); + expect(find.byKey(const ValueKey('selection-bar')), findsNothing); + await t.tap(find.text('Alpha')); + await t.pump(); + await t.pump(const Duration(milliseconds: 500)); + expect(find.byType(NoteDetailScreen), findsOneWidget); + }); + + testWidgets('desktop list: ctrl-click toggles, shift-click extends', ( + t, + ) async { + _desktop(t); + await pumpList(t, seed: _seed); + await t.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await t.tap(find.text('Alpha')); + await t.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await t.pump(); + expect(find.byType(NoteDetailScreen), findsNothing); + await t.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await t.tap(find.text('Gamma')); + await t.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await t.pump(); + for (final id in ['a', 'b', 'c']) { + final tile = t.widget( + find.descendant( + of: find.byKey(ValueKey(id)), + matching: find.byType(ListTile), + ), + ); + expect(tile.selected, isTrue, reason: id); + } + expect(_checkIn('b'), findsOneWidget); + }); + + testWidgets('R7: bar icons are hit-testable above the bottom inset', ( + t, + ) async { + _pixel6a(t); + await _selectAll(t); + const bottomEdge = 2400 / _dpr - 63 / _dpr; + for (final key in [ + 'bulk-status', + 'bulk-priority', + 'bulk-assign', + 'bulk-clear', + ]) { + final f = find.byKey(ValueKey(key)); + expect(f.hitTestable(), findsOneWidget, reason: key); + expect(t.getRect(f).bottom, lessThanOrEqualTo(bottomEdge), reason: key); + } + }); +}