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_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(
|
||||
@ -71,6 +79,10 @@ class NoteGridTile extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Expanded: a big text scale shortens the title, never overflows.
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title.isEmpty ? '(empty)' : title,
|
||||
@ -78,10 +90,22 @@ class NoteGridTile extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: closed ? scheme.onSurfaceVariant : null,
|
||||
decoration: closed ? TextDecoration.lineThrough : 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?)
|
||||
Text(
|
||||
repo,
|
||||
@ -92,7 +116,7 @@ class NoteGridTile extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
_statusIcon(note.status),
|
||||
statusIcon(note.status),
|
||||
size: 14,
|
||||
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.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,
|
||||
|
||||
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.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<Note> 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),
|
||||
);
|
||||
|
||||
@ -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<Note> 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]),
|
||||
),
|
||||
|
||||
@ -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<NotesListScreen> {
|
||||
/// Memoised stream; only replaced when [_sort]/[_filter] actually change.
|
||||
late Stream<List<Note>> _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<NotesListScreen> {
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_searchController.dispose();
|
||||
_selection.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -127,6 +136,7 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
||||
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<NotesListScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
# 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
|
||||
|
||||
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