mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 19:27:01 +01:00
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:
parent
5c1bf75bf9
commit
757c54beda
@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
86
lib/ui/note_selection.dart
Normal file
86
lib/ui/note_selection.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
37
lib/ui/notes_assign_flow.dart
Normal file
37
lib/ui/notes_assign_flow.dart
Normal 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;
|
||||||
|
}
|
||||||
@ -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),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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]),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
lib/ui/notes_list_screen_select.dart
Normal file
43
lib/ui/notes_list_screen_select.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
128
lib/ui/notes_selection_bar.dart
Normal file
128
lib/ui/notes_selection_bar.dart
Normal 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',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
97
lib/ui/repo_picker_sheet.dart
Normal file
97
lib/ui/repo_picker_sheet.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
102
test/note_selection_test.dart
Normal file
102
test/note_selection_test.dart
Normal 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'});
|
||||||
|
});
|
||||||
|
}
|
||||||
126
test/notes_list_assign_test.dart
Normal file
126
test/notes_list_assign_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
177
test/notes_list_select_test.dart
Normal file
177
test/notes_list_select_test.dart
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user