Compare commits

...

4 Commits

Author SHA1 Message Date
6bd2923f0a Instant page routes; coalesce watch re-emits
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXdsB7CQPNXVN829G1
2026-11-10 15:57:03 +01:00
4c77dd5d2e Bulk update: diff generated fields only
planBulkUpdate compared fresh field values with the stored text, so a
peer's "...00Z" created_at (or a web build's millisecond date) read as a
change and got a fresh clock, erasing the created_by node signal; a
record without a priority field got one written. Compare the note's own
generated fields before and after instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXdsB7CQPNXVN829G1
2026-11-10 15:41:35 +01:00
757c54beda 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
2026-11-10 15:39:13 +01:00
5c1bf75bf9 Bulk note writes and assign-to-repo helper
NoteRepository.bulkUpdate sets status/priority on many notes in one
store write (mergeLogs + replaceAll; only changed fields plus updated_at
get a clock). saveReplacing saves one note and tombstones others in one
write. assignToRepo moves plain notes into the repo's open note as
"## title" sections via addRepoTask, demoting body headings below "##"
so a template note stays one section; notes already in a repo are
refused. The seeded watch stream moved to log_watch.dart for the
250-line cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXdsB7CQPNXVN829G1
2026-11-10 15:38:54 +01:00
24 changed files with 1486 additions and 110 deletions

38
lib/data/log_watch.dart Normal file
View File

@ -0,0 +1,38 @@
/// A seeded, coalesced stream over the note log's change events.
library;
import 'dart:async';
/// A stream that emits `compute()` when first listened to (the seed), then
/// again after [changes] fires. Seeding in `onListen` -- rather than lazily in
/// an `async*` body -- avoids a race where a write between `listen` and the
/// first generator tick would swallow the initial snapshot.
///
/// Re-emits are coalesced: the first change of a burst arms one
/// zero-duration timer and every further change before it fires is absorbed,
/// so N back-to-back changes cost ONE `compute()` carrying the latest state
/// instead of N full re-queries (each of which repaints the whole list).
/// The timer fires on the next event-loop turn, so tests stay deterministic
/// under `pumpEventQueue` and the widget tester's `pump`.
Stream<T> watchLog<T>(Stream<void> changes, T Function() compute) {
StreamSubscription<void>? sub;
Timer? pending;
late final StreamController<T> controller;
controller = StreamController<T>(
onListen: () {
controller.add(compute());
sub = changes.listen((_) {
pending ??= Timer(Duration.zero, () {
pending = null;
controller.add(compute());
});
});
},
onCancel: () {
pending?.cancel();
pending = null;
return sub?.cancel();
},
);
return controller.stream;
}

63
lib/data/note_bulk.dart Normal file
View File

@ -0,0 +1,63 @@
/// Partial logs for the multi-note writes: merged in one store write.
///
/// Each write re-serialises the whole log (over a megabyte on a real
/// device), so a bulk action must be one partial log merged once, never one
/// upsert per note. Merging is per field, so a field left out keeps its value
/// and its clock (`created_by` included).
library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_record.dart';
/// Partial records setting [priority] and/or [status] on the live notes of
/// [log] among [ids], all at [hlc].
///
/// Only fields whose value changes are written, plus `updated_at` = [now]
/// (so "last updated" sorting and newer-wins import see the edit). A note
/// with nothing to change, an unknown id and a deleted note are left out.
Log planBulkUpdate(
Log log,
Set<String> ids, {
required Hlc hlc,
required DateTime now,
Priority? priority,
Status? status,
}) {
final plan = <String, Record>{};
for (final id in ids) {
final current = log[id];
if (current == null || current.deleted) continue;
final note = noteFromRecord(current);
final next = note.copyWith(priority: priority, status: status);
if (next.priority == note.priority && next.status == note.status) continue;
// Generated against generated, never against the stored text: a peer's
// `...00Z` (or a web build's millisecond date) would otherwise read as
// a change and get a fresh clock, erasing the `created_at` node signal.
final before = noteFields(note, hlc);
plan[id] = Record(
id: id,
fields: {
for (final MapEntry(:key, :value) in noteFields(
next.copyWith(updatedAt: now),
hlc,
).entries)
if (before[key]?.$1 != value.$1) key: value,
},
);
}
return plan;
}
/// [note] at [hlc] plus a tombstone for each live note of [log] in
/// [removed] (other than [note] itself).
///
/// A merge, not a replace: a field [note] leaves unset keeps its stored
/// value, so this suits notes that only gain content (the repo note an
/// assign appends to), not edits that clear a field.
Log planReplace(Log log, Note note, Iterable<String> removed, Hlc hlc) => {
for (final id in removed)
if (log[id] case final r? when !r.deleted && id != note.id)
id: Record(id: id, fields: const {}, deleted: true, deletedHlc: hlc),
note.id: Record(id: note.id, fields: noteFields(note, hlc)),
};

View File

@ -1,8 +1,8 @@
import 'dart:async';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:todo/data/created_by_backfill.dart';
import 'package:todo/data/log_watch.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_bulk.dart';
import 'package:todo/data/note_import.dart';
import 'package:todo/data/note_query.dart';
import 'package:todo/data/note_record.dart';
@ -134,15 +134,40 @@ class NoteRepository {
final plan = planCreatedByBackfill(_store.snapshot(), claudeNodes);
if (dryRun || plan.isEmpty) return plan;
final hlc = _store.nextHlc();
await _store.replaceAll(
mergeLogs(_store.snapshot(), {
for (final e in plan.entries)
e.key: Record(id: e.key, fields: {kCreatedByField: (e.value, hlc)}),
}),
);
await _patch({
for (final e in plan.entries)
e.key: Record(id: e.key, fields: {kCreatedByField: (e.value, hlc)}),
});
return plan;
}
/// Sets [priority] and/or [status] on the live notes [ids] in ONE store
/// write (see [planBulkUpdate]); a no-op when nothing changes.
Future<void> bulkUpdate(
Set<String> ids, {
Priority? priority,
Status? status,
}) => _patch(
planBulkUpdate(
_store.snapshot(),
ids,
hlc: _store.nextHlc(),
now: DateTime.now(),
priority: priority,
status: status,
),
);
/// Saves [note] and deletes [removed] in ONE store write ([planReplace]).
Future<void> saveReplacing(Note note, Iterable<String> removed) =>
_patch(planReplace(_store.snapshot(), note, removed, _store.nextHlc()));
/// Merges a partial log in one write: untouched fields keep their clocks.
Future<void> _patch(Log partial) async {
if (partial.isEmpty) return;
await _store.replaceAll(mergeLogs(_store.snapshot(), partial));
}
/// Soft-deletes a note. The log keeps a sticky tombstone so the deletion
/// propagates on the next sync instead of resurrecting the record.
Future<void> delete(String id) => _store.delete(id);
@ -189,27 +214,10 @@ class NoteRepository {
Stream<List<Note>> watchNotes({
NoteSort sort = NoteSort.priorityDesc,
NoteFilter filter = const NoteFilter(),
}) => _watch(() => _query(sort, filter));
}) => watchLog(_store.changes, () => _query(sort, filter));
/// Emits the live count of non-deleted notes immediately and on each change.
Stream<int> watchCount() => _watch(_liveCount);
/// A stream that emits `compute()` synchronously when first listened to (the
/// seed), then again on every log change. Seeding in `onListen` — rather than
/// lazily in an `async*` body — avoids a race where a write between `listen`
/// and the first generator tick would swallow the initial snapshot.
Stream<T> _watch<T>(T Function() compute) {
StreamSubscription<void>? sub;
late final StreamController<T> controller;
controller = StreamController<T>(
onListen: () {
controller.add(compute());
sub = _store.changes.listen((_) => controller.add(compute()));
},
onCancel: () => sub?.cancel(),
);
return controller.stream;
}
Stream<int> watchCount() => watchLog(_store.changes, _liveCount);
/// This device's stable node id. Names its file in the sync repo so two
/// devices never write the same path.

75
lib/data/repo_assign.dart Normal file
View File

@ -0,0 +1,75 @@
/// Assigning plain notes to a repo from the UI: each becomes a `## title`
/// section of the repo's one open note ([addRepoTask]), and the original goes.
///
/// Only the data layer: nothing here talks to the board or starts a task.
library;
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/data/repo_note.dart';
final _repoShape = RegExp(r'^[\w.-]+/[\w.-]+$');
final _heading = RegExp(r'^(#+)(\s.*)$');
/// Whether [value] looks like `owner/name`.
bool isRepoName(String value) => _repoShape.hasMatch(value.trim());
/// [body] with every heading outside code fences pushed below `##`.
///
/// A repo note splits into tasks at `##` headings, so a template note's own
/// `## what` / `## must` would otherwise turn into tasks of their own.
String demoteHeadings(String body) {
var fenced = false;
final out = <String>[];
for (final line in body.split('\n')) {
if (line.trimLeft().startsWith('```')) fenced = !fenced;
final m = fenced ? null : _heading.firstMatch(line);
if (m == null) {
out.add(line);
continue;
}
final level = m.group(1)!.length;
out.add('${'#' * (level < 2 ? 3 : level + 1)}${m.group(2)}');
}
return out.join('\n');
}
/// Moves the plain [notes] into [repo]'s open note in one write, creating
/// that note when the repo has none; returns the notes refused because
/// they already belong to a repo (left untouched).
Future<List<Note>> assignToRepo(
NoteRepository repository,
List<Note> notes,
String repo,
) async {
final refused = [
for (final n in notes)
if (n.repo != null) n,
];
final plain = [
for (final n in notes)
if (n.repo == null) n,
];
if (plain.isEmpty) return refused;
final first = splitNote(plain.first.text);
var target = (await addRepoTask(
repository,
repo: repo,
title: first.title,
body: demoteHeadings(first.body),
priority: plain.first.priority,
createdBy: 'kuhy',
)).note;
for (final n in plain.skip(1)) {
final (:title, :body) = splitNote(n.text);
target = target.copyWith(
text: '${target.text}\n\n${repoSection(title, demoteHeadings(body))}',
priority: maxPriority(target.priority, n.priority),
);
}
await repository.saveReplacing(
target.copyWith(updatedAt: DateTime.now()),
plain.map((n) => n.id),
);
return refused;
}

View File

@ -1,28 +1,26 @@
/// The one page route every screen is pushed with, on the shared motion tokens.
/// The one page route every screen is pushed with.
library;
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:todo/ui/motion.dart';
/// A route for [builder]: fade plus a short rise over [AppDuration.slow],
/// instant under the OS "remove animations" setting.
Route<T> appRoute<T>(BuildContext context, WidgetBuilder builder) =>
_AppPageRoute<T>(
builder: builder,
duration: motionDuration(context, AppDuration.slow),
);
/// A route for [builder] that switches screens instantly: no fade, no slide.
///
/// Without a GPU the desktop web build repaints the whole 4K canvas in
/// software on every frame, so a 320 ms transition cost ~600 ms of main
/// thread and a 260-300 ms frame stall on every tab switch and note open.
/// The same instant switch applies on the phone. The context is unused but
/// kept so every call site stays as it was.
Route<T> appRoute<T>(BuildContext _, WidgetBuilder builder) =>
_AppPageRoute<T>(builder: builder);
class _AppPageRoute<T> extends MaterialPageRoute<T> {
_AppPageRoute({required super.builder, required this.duration});
final Duration duration;
_AppPageRoute({required super.builder});
@override
Duration get transitionDuration => duration;
Duration get transitionDuration => Duration.zero;
@override
Duration get reverseTransitionDuration => duration;
Duration get reverseTransitionDuration => Duration.zero;
@override
Widget buildTransitions(
@ -30,21 +28,5 @@ class _AppPageRoute<T> extends MaterialPageRoute<T> {
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
final enter = CurvedAnimation(
parent: animation,
curve: AppCurve.decelerate,
reverseCurve: AppCurve.accelerate,
);
return FadeTransition(
opacity: enter,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.04),
end: Offset.zero,
).animate(enter),
child: child,
),
);
}
) => child;
}

View File

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

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.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,

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.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),
);

View File

@ -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]),
),

View File

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

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
# 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.66+1
version: 1.0.70+1
environment:
sdk: ^3.12.2

View File

@ -29,6 +29,11 @@ class FakeNoteRepository implements NoteRepository {
NoteSort? lastSort;
NoteFilter? lastFilter;
/// Calls to [upsert] and to the one-write [bulkUpdate], so a bulk action
/// can be shown to be one call rather than one write per note.
int upsertCalls = 0;
int bulkCalls = 0;
/// Emits the current snapshot to a new subscriber first (so late-binding
/// [StreamBuilder]s get the seed), then forwards subsequent changes.
Stream<List<Note>> _snapshots() async* {
@ -46,12 +51,37 @@ class FakeNoteRepository implements NoteRepository {
@override
Future<void> upsert(Note note) async {
upsertCalls++;
_notes
..removeWhere((n) => n.id == note.id)
..add(note);
_emit();
}
@override
Future<void> bulkUpdate(
Set<String> ids, {
Priority? priority,
Status? status,
}) async {
bulkCalls++;
for (final (i, n) in _notes.indexed.toList()) {
if (ids.contains(n.id)) {
_notes[i] = n.copyWith(priority: priority, status: status);
}
}
_emit();
}
@override
Future<void> saveReplacing(Note note, Iterable<String> removed) async {
final gone = {...removed, note.id};
_notes
..removeWhere((n) => gone.contains(n.id))
..add(note);
_emit();
}
@override
Future<void> delete(String id) async {
_notes.removeWhere((n) => n.id == id);
@ -83,7 +113,9 @@ class FakeNoteRepository implements NoteRepository {
Future<List<Note>> listNotes({
NoteSort sort = NoteSort.modifiedDesc,
NoteFilter filter = const NoteFilter(),
}) async => List.unmodifiable(_notes);
}) async => List.unmodifiable(
applyFilter ? _notes.where((n) => matchesFilter(n, filter)) : _notes,
);
@override
Stream<List<Note>> watchNotes({

83
test/log_watch_test.dart Normal file
View File

@ -0,0 +1,83 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/data/log_watch.dart';
void main() {
late StreamController<void> changes;
late int computes;
setUp(() {
changes = StreamController<void>.broadcast(sync: true);
computes = 0;
});
tearDown(() => changes.close());
Stream<int> watch() => watchLog(changes.stream, () => ++computes);
test('the seed is emitted at once on listen', () async {
final seen = <int>[];
final sub = watch().listen(seen.add);
expect(computes, 1, reason: 'seeded inside listen, not on a timer');
await pumpEventQueue();
expect(seen, [1]);
await sub.cancel();
});
test(
'a burst of 5 changes yields one recompute carrying the latest',
() async {
final seen = <int>[];
final sub = watch().listen(seen.add);
for (var i = 0; i < 5; i++) {
changes.add(null);
}
expect(computes, 1, reason: 'nothing recomputed inside the burst');
await pumpEventQueue();
await sub.cancel();
expect(computes, 2);
expect(seen, [1, 2]);
},
);
test('changes after a flush start a new coalescing window', () async {
final seen = <int>[];
final sub = watch().listen(seen.add);
changes.add(null);
await pumpEventQueue();
changes.add(null);
changes.add(null);
await pumpEventQueue();
await sub.cancel();
expect(seen, [1, 2, 3]);
});
test(
'cancel drops a pending recompute and the change subscription',
() async {
final sub = watch().listen((_) {});
changes.add(null);
await sub.cancel();
await pumpEventQueue();
expect(computes, 1);
expect(changes.hasListener, isFalse);
},
);
test('each listener gets its own stream state', () async {
final a = <int>[];
final b = <int>[];
final subA = watch().listen(a.add);
final subB = watch().listen(b.add);
changes.add(null);
await pumpEventQueue();
await subA.cancel();
await subB.cancel();
expect(a.length, 2);
expect(b.length, 2);
});
}

View File

@ -13,7 +13,7 @@ double _barValue(WidgetTester tester) => tester
.widget<LinearProgressIndicator>(find.byType(LinearProgressIndicator))
.value!;
Future<void> _pushPage(WidgetTester tester) async {
Future<Route<void>> _pushPage(WidgetTester tester) async {
late BuildContext ctx;
await tester.pumpWidget(
MaterialApp(
@ -25,12 +25,10 @@ Future<void> _pushPage(WidgetTester tester) async {
),
),
);
unawaited(
Navigator.of(
ctx,
).push<void>(appRoute(ctx, (_) => const Scaffold(body: Text('page')))),
);
final route = appRoute<void>(ctx, (_) => const Scaffold(body: Text('page')));
unawaited(Navigator.of(ctx).push<void>(route));
await tester.pump();
return route;
}
void main() {
@ -46,22 +44,20 @@ void main() {
expect(offenders, isEmpty);
});
testWidgets('a pushed page eases in over AppDuration.slow', (tester) async {
await _pushPage(tester);
await tester.pump(AppDuration.slow ~/ 4);
testWidgets('a pushed page is instant: no fade or slide', (tester) async {
final route = await _pushPage(tester) as PageRoute<void>;
expect(route.transitionDuration, Duration.zero);
expect(route.reverseTransitionDuration, Duration.zero);
// Fully visible and covering the old page after a single pump.
expect(find.text('home'), findsNothing);
expect(find.text('page'), findsOneWidget);
final fade = tester.widget<FadeTransition>(
find
.ancestor(
of: find.text('page'),
matching: find.byType(FadeTransition),
)
.first,
final page = find.ancestor(
of: find.text('page'),
matching: find.byType(FadeTransition),
);
expect(fade.opacity.value, inExclusiveRange(0, 1));
await tester.pump(AppDuration.slow);
expect(find.text('home'), findsNothing); // fully covered, route settled
for (final f in tester.widgetList<FadeTransition>(page)) {
expect(f.opacity.value, 1);
}
});
testWidgets('remove-animations: the page is there at once', (tester) async {

View File

@ -0,0 +1,139 @@
/// Bulk status/priority and save-replacing: one store write each.
library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'note_repository_helpers.dart';
/// Counts log writes, so "one write" is measured, not assumed.
class _CountingPersistence implements LogPersistence {
final _inner = MemoryPersistence();
int writes = 0;
@override
Future<String?> read() => _inner.read();
@override
Future<void> write(String text) {
writes++;
return _inner.write(text);
}
}
Future<(NoteRepository, _CountingPersistence)> _open() async {
final p = _CountingPersistence();
final repo = await NoteRepository.openWith(persistence: p, nodeId: 'dev');
addTearDown(repo.close);
return (repo, p);
}
Hlc _clock(NoteRepository repo, String id, String field) =>
repo.exportLog()[id]!.fields[field]!.$2;
void main() {
test('sets status and priority on every id in ONE write', () async {
final (repo, p) = await _open();
for (final id in ['a', 'b', 'c']) {
await repo.upsert(
noteFixture(id, 'note $id').copyWith(createdBy: 'kuhy'),
);
}
final textClock = _clock(repo, 'a', 'text');
final createdClock = _clock(repo, 'a', 'created_at');
p.writes = 0;
await repo.bulkUpdate(
{'a', 'b', 'c'},
status: Status.inProgress,
priority: Priority.high,
);
expect(p.writes, 1);
final notes = {for (final n in await repo.listNotes()) n.id: n};
for (final n in notes.values) {
expect(n.status, Status.inProgress);
expect(n.priority, Priority.high);
expect(n.createdBy, 'kuhy');
}
expect(_clock(repo, 'a', 'text'), textClock);
expect(_clock(repo, 'a', 'created_at'), createdClock);
expect(_clock(repo, 'a', 'status') > textClock, isTrue);
expect(_clock(repo, 'a', 'updated_at') > textClock, isTrue);
});
test('touches only fields whose value changes', () async {
final (repo, _) = await _open();
await repo.upsert(noteFixture('a', 'A', priority: Priority.high));
final priorityClock = _clock(repo, 'a', 'priority');
await repo.bulkUpdate({'a'}, priority: Priority.high, status: Status.done);
expect(_clock(repo, 'a', 'priority'), priorityClock);
expect((await repo.listNotes()).single.status, Status.done);
});
test(
'a stored value Dart would format differently keeps its clock',
() async {
final (repo, _) = await _open();
final peer = Hlc(wallTimeMs: 1000, counter: 0, nodeId: 'mcp');
// A peer's (or a web build's) timestamp text, and no priority field.
await repo.importLog({
'a': Record(
id: 'a',
fields: {
'text': ('A', peer),
'status': (0, peer),
'created_at': ('2026-01-01T00:00:00Z', peer),
'updated_at': ('2026-01-01T00:00:00Z', peer),
},
),
});
await repo.bulkUpdate({'a'}, status: Status.done);
final fields = repo.exportLog()['a']!.fields;
expect(fields['created_at'], ('2026-01-01T00:00:00Z', peer));
expect(fields.containsKey('priority'), isFalse);
expect(fields['status']!.$2.nodeId, 'dev');
},
);
test('no write when nothing changes or ids are unknown/deleted', () async {
final (repo, p) = await _open();
await repo.upsert(noteFixture('a', 'A'));
await repo.upsert(noteFixture('gone', 'G'));
await repo.delete('gone');
p.writes = 0;
await repo.bulkUpdate({'a'}, priority: Priority.medium);
await repo.bulkUpdate({'missing', 'gone'}, status: Status.done);
await repo.bulkUpdate({'a'});
expect(p.writes, 0);
expect(repo.exportLog().containsKey('missing'), isFalse);
expect(repo.exportLog()['gone']!.fields.containsKey('status'), isTrue);
});
test('saveReplacing saves one note and tombstones the rest', () async {
final (repo, p) = await _open();
await repo.upsert(noteFixture('a', 'A').copyWith(createdBy: 'kuhy'));
await repo.upsert(noteFixture('b', 'B'));
await repo.upsert(noteFixture('keep', 'K'));
p.writes = 0;
final saved = noteFixture('a', 'A2', status: Status.inProgress);
await repo.saveReplacing(saved, ['b', 'missing']);
expect(p.writes, 1);
final notes = {for (final n in await repo.listNotes()) n.id: n};
expect(notes.keys, unorderedEquals(['a', 'keep']));
expect(notes['a']!.text, 'A2');
expect(notes['a']!.createdBy, 'kuhy');
expect(repo.exportLog()['b']!.deleted, isTrue);
expect(repo.exportLog().containsKey('missing'), isFalse);
});
}

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

109
test/repo_assign_test.dart Normal file
View File

@ -0,0 +1,109 @@
/// Assigning plain notes to a repo: sections in its one open note.
library;
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/data/repo_assign.dart';
import 'package:todo/data/repo_note_split.dart';
import 'note_repository_helpers.dart';
Future<NoteRepository> _repo(List<Note> seed) async {
final repo = await NoteRepository.openInMemory();
addTearDown(repo.close);
for (final n in seed) {
await repo.upsert(n);
}
return repo;
}
Note _task(String id, String text, String repo, {Status? status}) =>
noteFixture(
id,
text,
status: status ?? Status.todo,
createdAt: DateTime(2026),
).copyWith(repo: repo, doneCheck: 'true');
void main() {
test('appends each plain note as one section and removes it', () async {
final repo = await _repo([
_task('r', 'app todos\n\n## Old\nbody', 'kuhyx/app'),
_task('closed', 'app todos\n\n## x', 'kuhyx/app', status: Status.done),
noteFixture('p1', 'Fix login\n\n## what\n_why_\nit 500s'),
noteFixture('p2', 'Tidy', priority: Priority.high),
]);
final plain = (await repo.listNotes()).where((n) => n.repo == null).toList()
..sort((a, b) => a.id.compareTo(b.id));
// Sections land in the order given (the UI passes selection order).
final refused = await assignToRepo(repo, plain, 'kuhyx/app');
expect(refused, isEmpty);
final notes = {for (final n in await repo.listNotes()) n.id: n};
expect(notes.keys, unorderedEquals(['r', 'closed']));
final text = notes['r']!.text;
expect(openSections(text), ['Old', 'Fix login', 'Tidy']);
expect(text, contains('### what\n_why_\nit 500s'));
expect(notes['r']!.priority, Priority.high);
expect(notes['r']!.doneCheck, 'true');
});
test('creates the repo note when the repo has none open', () async {
final repo = await _repo([noteFixture('p', 'Ship it\nnow')]);
await assignToRepo(repo, await repo.listNotes(), 'kuhyx/new-app');
final made = (await repo.listNotes()).single;
expect(made.id, isNot('p'));
expect(made.repo, 'kuhyx/new-app');
expect(made.status, Status.todo);
expect(made.createdBy, 'kuhy');
expect(made.text, 'new-app todos\n\n## Ship it\nnow');
});
test('refuses notes that already have a repo, moves the rest', () async {
final repo = await _repo([
_task('t', 'other todos\n\n## A', 'kuhyx/other'),
noteFixture('p', 'Plain'),
]);
final refused = await assignToRepo(
repo,
await repo.listNotes(),
'kuhyx/app',
);
expect(refused.map((n) => n.id), ['t']);
final notes = {for (final n in await repo.listNotes()) n.id: n};
expect(notes['t']!.text, 'other todos\n\n## A');
expect(notes.containsKey('p'), isFalse);
});
test('only repo notes: a no-op that returns them all', () async {
final repo = await _repo([_task('t', 'other todos', 'kuhyx/other')]);
final before = repo.exportLog();
final refused = await assignToRepo(
repo,
await repo.listNotes(),
'kuhyx/app',
);
expect(refused, hasLength(1));
expect(repo.exportLog(), before);
});
test('demoteHeadings keeps a body inside its section', () {
expect(
demoteHeadings('# a\n## b\n### c\n```\n## code\n```\ntext #x'),
'### a\n### b\n#### c\n```\n## code\n```\ntext #x',
);
});
test('isRepoName accepts owner/name only', () {
expect(isRepoName('kuhyx/todo-app'), isTrue);
expect(isRepoName('kuhyx/'), isFalse);
expect(isRepoName('todo'), isFalse);
expect(isRepoName('a/b/c'), isFalse);
});
}