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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXdsB7CQPNXVN829G1
This commit is contained in:
Krzysztof Rudnicki 2026-11-10 15:39:13 +01:00
parent 5c1bf75bf9
commit 757c54beda
14 changed files with 881 additions and 30 deletions

View File

@ -7,6 +7,21 @@ import 'package:flutter/material.dart';
import 'package:todo/data/note.dart'; import 'package:todo/data/note.dart';
import 'package:todo/data/note_template.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. /// A note as a grid cell. It fills whatever cell it is given.
class NoteGridTile extends StatelessWidget { class NoteGridTile extends StatelessWidget {
/// Creates a tile for [note]. /// Creates a tile for [note].
@ -14,9 +29,13 @@ class NoteGridTile extends StatelessWidget {
required this.note, required this.note,
required this.onTap, required this.onTap,
required this.onActions, required this.onActions,
this.selected = false,
super.key, super.key,
}); });
/// Whether the note is part of the multi-selection: a tint and a check.
final bool selected;
/// The note this tile displays. /// The note this tile displays.
final Note note; final Note note;
@ -27,18 +46,7 @@ class NoteGridTile extends StatelessWidget {
final VoidCallback onActions; final VoidCallback onActions;
/// Danger for high, the accent for medium, muted for low. /// Danger for high, the accent for medium, muted for low.
Color accentFor(ColorScheme scheme) => switch (note.priority) { Color accentFor(ColorScheme scheme) => priorityColor(note.priority, scheme);
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,
};
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -49,7 +57,7 @@ class NoteGridTile extends StatelessWidget {
final closed = final closed =
note.status == Status.done || note.status == Status.abandoned; note.status == Status.done || note.status == Status.abandoned;
return Material( return Material(
color: scheme.surfaceContainerHigh, color: selected ? scheme.secondaryContainer : scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.md), borderRadius: BorderRadius.circular(AppRadius.md),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: InkWell( child: InkWell(
@ -72,14 +80,30 @@ class NoteGridTile extends StatelessWidget {
children: [ children: [
// Expanded: a big text scale shortens the title, never overflows. // Expanded: a big text scale shortens the title, never overflows.
Expanded( Expanded(
child: Text( child: Row(
title.isEmpty ? '(empty)' : title, crossAxisAlignment: CrossAxisAlignment.start,
maxLines: 3, children: [
overflow: TextOverflow.ellipsis, Expanded(
style: theme.textTheme.bodyMedium?.copyWith( child: Text(
color: closed ? scheme.onSurfaceVariant : null, title.isEmpty ? '(empty)' : title,
decoration: closed ? TextDecoration.lineThrough : null, 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?) if (note.repoName case final repo?)
@ -92,7 +116,7 @@ class NoteGridTile extends StatelessWidget {
Row( Row(
children: [ children: [
Icon( Icon(
_statusIcon(note.status), statusIcon(note.status),
size: 14, size: 14,
color: scheme.onSurfaceVariant, color: scheme.onSurfaceVariant,
), ),

View File

@ -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<String> _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<String> 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<Note> 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<String> 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<Note> 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;
}
}

View File

@ -16,9 +16,13 @@ class NoteTile extends StatelessWidget {
required this.note, required this.note,
required this.onTap, required this.onTap,
required this.onActions, required this.onActions,
this.selected = false,
super.key, super.key,
}); });
/// Whether the row is part of the multi-selection: highlighted + a check.
final bool selected;
/// The note this row displays. /// The note this row displays.
final Note note; final Note note;
@ -41,6 +45,10 @@ class NoteTile extends StatelessWidget {
?note.repoName, ?note.repoName,
].join(' · '); ].join(' · ');
return ListTile( return ListTile(
selected: selected,
leading: selected
? const Icon(Icons.check_circle, semanticLabel: 'Selected')
: null,
title: Text( title: Text(
firstLine.isEmpty ? '(empty)' : firstLine, firstLine.isEmpty ? '(empty)' : firstLine,
maxLines: 2, maxLines: 2,

View File

@ -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<bool> 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<String>(
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;
}

View File

@ -26,9 +26,13 @@ class NotesGrid extends StatelessWidget {
required this.notes, required this.notes,
required this.onOpen, required this.onOpen,
required this.onActions, required this.onActions,
required this.isSelected,
super.key, super.key,
}); });
/// Whether the note with this id is in the multi-selection.
final bool Function(String id) isSelected;
/// Notes in display order. /// Notes in display order.
final List<Note> notes; final List<Note> notes;
@ -42,6 +46,7 @@ class NotesGrid extends StatelessWidget {
return NoteGridTile( return NoteGridTile(
key: ValueKey(note.id), key: ValueKey(note.id),
note: note, note: note,
selected: isSelected(note.id),
onTap: () => onOpen(note), onTap: () => onOpen(note),
onActions: () => onActions(note), onActions: () => onActions(note),
); );

View File

@ -16,9 +16,13 @@ class NotesListBody extends StatelessWidget {
required this.notes, required this.notes,
required this.onOpen, required this.onOpen,
required this.onActions, required this.onActions,
required this.isSelected,
super.key, super.key,
}); });
/// Whether the note with this id is in the multi-selection.
final bool Function(String id) isSelected;
/// Notes in display order. /// Notes in display order.
final List<Note> notes; final List<Note> notes;
@ -32,13 +36,19 @@ class NotesListBody extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder( return LayoutBuilder(
builder: (context, box) => box.maxWidth < kNotesGridMaxWidth builder: (context, box) => box.maxWidth < kNotesGridMaxWidth
? NotesGrid(notes: notes, onOpen: onOpen, onActions: onActions) ? NotesGrid(
notes: notes,
onOpen: onOpen,
onActions: onActions,
isSelected: isSelected,
)
: ListView.separated( : ListView.separated(
itemCount: notes.length, itemCount: notes.length,
separatorBuilder: (_, _) => const Divider(height: 1), separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) => NoteTile( itemBuilder: (context, i) => NoteTile(
key: ValueKey(notes[i].id), key: ValueKey(notes[i].id),
note: notes[i], note: notes[i],
selected: isSelected(notes[i].id),
onTap: () => onOpen(notes[i]), onTap: () => onOpen(notes[i]),
onActions: () => onActions(notes[i]), onActions: () => onActions(notes[i]),
), ),

View File

@ -5,13 +5,18 @@ import 'package:flutter/material.dart';
import 'package:todo/data/app_settings.dart'; import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note.dart'; import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.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_filter_sheet.dart';
import 'package:todo/ui/notes_list_body.dart'; import 'package:todo/ui/notes_list_body.dart';
import 'package:todo/ui/notes_list_defaults.dart'; import 'package:todo/ui/notes_list_defaults.dart';
import 'package:todo/ui/notes_list_navigation.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'; export 'package:todo/ui/notes_list_defaults.dart';
part 'notes_list_screen_select.dart';
/// Searchable, filterable, sortable list of stored/synced notes. /// Searchable, filterable, sortable list of stored/synced notes.
/// ///
/// The heavy lifting (WHERE/ORDER BY) lives in [NoteRepository]; this screen /// The heavy lifting (WHERE/ORDER BY) lives in [NoteRepository]; this screen
@ -53,6 +58,9 @@ class _NotesListScreenState extends State<NotesListScreen> {
/// Memoised stream; only replaced when [_sort]/[_filter] actually change. /// Memoised stream; only replaced when [_sort]/[_filter] actually change.
late Stream<List<Note>> _stream; late Stream<List<Note>> _stream;
/// Multi-select; any change rebuilds the screen (tiles + bottom bar).
late final _selection = NoteSelection()..addListener(() => setState(() {}));
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -63,6 +71,7 @@ class _NotesListScreenState extends State<NotesListScreen> {
void dispose() { void dispose() {
_debounce?.cancel(); _debounce?.cancel();
_searchController.dispose(); _searchController.dispose();
_selection.dispose();
super.dispose(); super.dispose();
} }
@ -127,6 +136,7 @@ class _NotesListScreenState extends State<NotesListScreen> {
appBar: AppBar( appBar: AppBar(
title: const Text('Notes'), title: const Text('Notes'),
actions: [ actions: [
_selectButton(),
// Eye, no label: open = Claude-made notes shown, slashed = hidden. // Eye, no label: open = Claude-made notes shown, slashed = hidden.
IconButton( IconButton(
icon: Icon( icon: Icon(
@ -220,20 +230,18 @@ class _NotesListScreenState extends State<NotesListScreen> {
), ),
); );
} }
void open(Note n) =>
openNote(context, n, widget.repository, widget.appSettings);
void actions(Note n) =>
openNoteActions(context, n, widget.repository);
return NotesListBody( return NotesListBody(
notes: notes, notes: notes,
onOpen: open, onOpen: (n) => _tap(n, notes),
onActions: actions, onActions: _longPress,
isSelected: _selection.contains,
); );
}, },
), ),
), ),
], ],
), ),
bottomNavigationBar: _selectionBar(),
); );
} }
} }

View File

@ -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<Note> 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,
);
}
}

View File

@ -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<Status> onStatus;
/// Sets every selected note's priority.
final ValueChanged<Priority> onPriority;
/// Runs the assign flow; true when some notes were refused.
final Future<bool> Function() onAssign;
/// Drops the selection.
final VoidCallback onClear;
@override
State<NotesSelectionBar> createState() => _NotesSelectionBarState();
}
class _NotesSelectionBarState extends State<NotesSelectionBar> {
Timer? _flash;
@override
void dispose() {
_flash?.cancel();
super.dispose();
}
Future<void> _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<Status>(
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<Priority>(
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',
),
),
],
),
),
);
}
}

View File

@ -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/<name>`.
///
/// 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<String> repos;
@override
State<RepoPickerSheet> createState() => _RepoPickerSheetState();
}
class _RepoPickerSheetState extends State<RepoPickerSheet> {
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'),
),
),
),
),
],
),
),
);
}
}

View File

@ -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 # 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 # 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. # 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: environment:
sdk: ^3.12.2 sdk: ^3.12.2

View File

@ -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'});
});
}

View File

@ -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<FakeNoteRepository> _select(
WidgetTester t,
List<Note> seed,
List<String> 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<void> _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/<name> 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<TextField>(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);
});
}

View File

@ -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<void> _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<FakeNoteRepository> _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<ListTile>(
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);
}
});
}