mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 18:07:03 +01:00
Compare commits
4 Commits
57019eec30
...
6bd2923f0a
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bd2923f0a | |||
| 4c77dd5d2e | |||
| 757c54beda | |||
| 5c1bf75bf9 |
38
lib/data/log_watch.dart
Normal file
38
lib/data/log_watch.dart
Normal 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
63
lib/data/note_bulk.dart
Normal 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)),
|
||||
};
|
||||
@ -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
75
lib/data/repo_assign.dart
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
|
||||
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.66+1
|
||||
version: 1.0.70+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
@ -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
83
test/log_watch_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
139
test/note_repository_bulk_test.dart
Normal file
139
test/note_repository_bulk_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
109
test/repo_assign_test.dart
Normal file
109
test/repo_assign_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user