perf(todo): ticks, drags and the chosen tasks show at once, written in one go
What a person does to their tasks shows before the device has sealed and kept it (Flutter's optimistic state): TodoCubit.showing shows a change on the tasks at once and on every state read while its write runs; where the write fails, the tasks show as they are again at once and a snack bar says so (the keys missing, or "That wasn't saved"), through writeTasks. A task's check, a task dragged to a day, an hour or a place in its project, Reschedule, and the selection's date, labels, priority, deadline, move, complete and delete go through it; Undo stays where it was, offered once the write is kept. The chosen tasks were written one at a time, each write opening every task again (SealedRows.all decrypts all of them), so deleting a hundred opened them a hundred times. TaskRepository now reads once and writes together: updateAll, completeAll, deleteAll, move, place and restore, the automatic reminder asked once, and SealedRows.updateAll seals all of them before writing any (KeysMissing leaves none written) and fires one change, so the screen reads once after. What a write changes is worked out by pure functions shared by the repository and the screen (movedTo, placedAt, completedContent, withSubTasks in task_views.dart). Whats-New: Ticking, moving and changing tasks, many at once too, shows at once. Whats-New-De: Abhaken, Verschieben und Ändern von Aufgaben, auch vieler auf einmal, zeigt sich sofort. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
56f40d4d65
commit
c954e844f7
17 changed files with 757 additions and 204 deletions
|
|
@ -31,6 +31,7 @@
|
|||
"deadlineOn": "Frist {day}",
|
||||
"deadlineNone": "Keine Frist",
|
||||
"keysMissing": "Dieses Gerät kann nichts speichern: Es hat die Schlüssel deines Kontos nicht. Melde dich mit deinem Passwort oder deiner Wiederherstellungsphrase an, um sie hinzuzufügen.",
|
||||
"tasksNotSaved": "Das wurde nicht gespeichert, deine Aufgaben sind wie vorher. Versuch es noch mal.",
|
||||
"filtersLabelsTitle": "Filter & Labels",
|
||||
"filtersLabelsHint": "Gespeicherte Ansichten deiner Aufgaben",
|
||||
"filtersTitle": "Meine Filter",
|
||||
|
|
|
|||
|
|
@ -72,6 +72,10 @@
|
|||
},
|
||||
"deadlineNone": "No deadline",
|
||||
"keysMissing": "This device can't save: it doesn't hold your account's keys. Sign in with your password or recovery phrase to add them.",
|
||||
"tasksNotSaved": "That wasn't saved, so your tasks are as they were. Try again.",
|
||||
"@tasksNotSaved": {
|
||||
"description": "A snack bar after a change to tasks, shown at once, could not be kept on the device and was taken back."
|
||||
},
|
||||
"filtersLabelsTitle": "Filters & labels",
|
||||
"filtersLabelsHint": "Views of your tasks, kept",
|
||||
"filtersTitle": "My filters",
|
||||
|
|
|
|||
|
|
@ -286,6 +286,12 @@ abstract class AppLocalizations {
|
|||
/// **'This device can\'t save: it doesn\'t hold your account\'s keys. Sign in with your password or recovery phrase to add them.'**
|
||||
String get keysMissing;
|
||||
|
||||
/// A snack bar after a change to tasks, shown at once, could not be kept on the device and was taken back.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'That wasn\'t saved, so your tasks are as they were. Try again.'**
|
||||
String get tasksNotSaved;
|
||||
|
||||
/// No description provided for @filtersLabelsTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -120,6 +120,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get keysMissing =>
|
||||
'Dieses Gerät kann nichts speichern: Es hat die Schlüssel deines Kontos nicht. Melde dich mit deinem Passwort oder deiner Wiederherstellungsphrase an, um sie hinzuzufügen.';
|
||||
|
||||
@override
|
||||
String get tasksNotSaved =>
|
||||
'Das wurde nicht gespeichert, deine Aufgaben sind wie vorher. Versuch es noch mal.';
|
||||
|
||||
@override
|
||||
String get filtersLabelsTitle => 'Filter & Labels';
|
||||
|
||||
|
|
|
|||
|
|
@ -120,6 +120,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get keysMissing =>
|
||||
'This device can\'t save: it doesn\'t hold your account\'s keys. Sign in with your password or recovery phrase to add them.';
|
||||
|
||||
@override
|
||||
String get tasksNotSaved =>
|
||||
'That wasn\'t saved, so your tasks are as they were. Try again.';
|
||||
|
||||
@override
|
||||
String get filtersLabelsTitle => 'Filters & labels';
|
||||
|
||||
|
|
|
|||
|
|
@ -82,6 +82,28 @@ class SealedRows<T>(
|
|||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// Replaces the content of each row [contents] names, firing [changes]
|
||||
/// once for all of them rather than once a row; seals them all before
|
||||
/// writing any, so it throws [KeysMissing] where this device can't seal
|
||||
/// with nothing written.
|
||||
Future<void> updateAll(Map<String, T> contents) async {
|
||||
if (contents.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final sealed = {
|
||||
for (final MapEntry(:key, :value) in contents.entries)
|
||||
key: await _sealed(key, value),
|
||||
};
|
||||
try {
|
||||
for (final MapEntry(:key, :value) in sealed.entries) {
|
||||
await _gateway.update(key, value);
|
||||
}
|
||||
} finally {
|
||||
// Those written before a failure are changed too.
|
||||
_changes.add(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the rows [ids] together.
|
||||
Future<void> delete(Iterable<String> ids) async {
|
||||
await _gateway.delete(ids);
|
||||
|
|
|
|||
|
|
@ -61,6 +61,19 @@ final class const TodoState({
|
|||
if (section.content?.projectId == projectId) section,
|
||||
]..sort((a, b) => a.content!.position.compareTo(b.content!.position));
|
||||
|
||||
/// This state with [tasks] in place of its tasks.
|
||||
TodoState withTasks(List<OpenTask> tasks) => TodoState(
|
||||
loaded: loaded,
|
||||
projects: projects,
|
||||
sections: sections,
|
||||
tasks: tasks,
|
||||
labels: labels,
|
||||
filters: filters,
|
||||
comments: comments,
|
||||
settings: settings,
|
||||
locked: locked,
|
||||
);
|
||||
|
||||
/// The task [id]; null for one gone or locked.
|
||||
OpenTask? taskOf(String id) =>
|
||||
tasks.where((task) => task.id == id).firstOrNull;
|
||||
|
|
@ -79,14 +92,27 @@ final class const TodoState({
|
|||
];
|
||||
}
|
||||
|
||||
/// A change to the person's tasks as a write is making it.
|
||||
typedef TaskChange = List<OpenTask> Function(List<OpenTask> tasks);
|
||||
|
||||
/// Reads the person's data into [TodoState], and again whenever it changes
|
||||
/// here or a sync brings changes from their other devices.
|
||||
///
|
||||
/// What a write is changing shows at once ([showing], optimistic state): a
|
||||
/// person ticking a task, or a hundred chosen, sees it done before the
|
||||
/// device has sealed and kept it.
|
||||
class TodoCubit(final TodoPerson _todo) extends Cubit<TodoState> {
|
||||
this : super(const TodoState());
|
||||
|
||||
/// The state as last read, before the changes under way.
|
||||
TodoState _read = const TodoState();
|
||||
|
||||
/// The changes under way, shown on every state read until each is done.
|
||||
final _changes = <TaskChange>[];
|
||||
|
||||
final _subscriptions = <StreamSubscription<void>>[];
|
||||
late final _reads = CoalescingRunner(
|
||||
_read,
|
||||
_readAll,
|
||||
onError: (error, stackTrace) =>
|
||||
log("The person's tasks not read: $error", name: 'Todo'),
|
||||
);
|
||||
|
|
@ -107,7 +133,38 @@ class TodoCubit(final TodoPerson _todo) extends Cubit<TodoState> {
|
|||
unawaited(_reads.run());
|
||||
}
|
||||
|
||||
Future<void> _read() async {
|
||||
/// Shows [change] on the tasks at once, while [write] makes it, and on
|
||||
/// every state read meanwhile; returns what [write] returns. Where it
|
||||
/// fails, the tasks show as they are again at once, and it throws as
|
||||
/// [write] did, for the caller to say so.
|
||||
Future<T> showing<T>(TaskChange change, Future<T> Function() write) async {
|
||||
_changes.add(change);
|
||||
_show();
|
||||
try {
|
||||
return await write();
|
||||
} on Object {
|
||||
_changes.remove(change);
|
||||
_show();
|
||||
rethrow;
|
||||
} finally {
|
||||
// Done, the state read next holds what it wrote.
|
||||
_changes.remove(change);
|
||||
unawaited(_reads.run());
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits the state as read with the changes under way.
|
||||
void _show() {
|
||||
if (!isClosed) {
|
||||
emit(
|
||||
_read.withTasks(
|
||||
_changes.fold(_read.tasks, (tasks, change) => change(tasks)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _readAll() async {
|
||||
final projects = await _todo.projects.all();
|
||||
final sections = await _todo.sections.all();
|
||||
final tasks = await _todo.tasks.all();
|
||||
|
|
@ -118,41 +175,40 @@ class TodoCubit(final TodoPerson _todo) extends Cubit<TodoState> {
|
|||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
emit(
|
||||
TodoState(
|
||||
loaded: true,
|
||||
projects: projects,
|
||||
sections: [
|
||||
for (final section in sections)
|
||||
if (section.content != null) section,
|
||||
],
|
||||
tasks: openTasks(tasks),
|
||||
labels: [
|
||||
for (final label in labels)
|
||||
if (label.content != null) label,
|
||||
],
|
||||
filters: [
|
||||
for (final filter in filters)
|
||||
if (filter.content != null) filter,
|
||||
]..sort((a, b) => a.content!.position.compareTo(b.content!.position)),
|
||||
comments:
|
||||
[
|
||||
for (final comment in comments)
|
||||
if (comment.content != null) comment,
|
||||
]..sort(
|
||||
(a, b) => a.content!.writtenAt.compareTo(b.content!.writtenAt),
|
||||
),
|
||||
settings: settings,
|
||||
locked: [
|
||||
...projects.map((row) => row.content),
|
||||
...sections.map((row) => row.content),
|
||||
...tasks.map((row) => row.content),
|
||||
...labels.map((row) => row.content),
|
||||
...filters.map((row) => row.content),
|
||||
...comments.map((row) => row.content),
|
||||
].where((content) => content == null).length,
|
||||
),
|
||||
_read = TodoState(
|
||||
loaded: true,
|
||||
projects: projects,
|
||||
sections: [
|
||||
for (final section in sections)
|
||||
if (section.content != null) section,
|
||||
],
|
||||
tasks: openTasks(tasks),
|
||||
labels: [
|
||||
for (final label in labels)
|
||||
if (label.content != null) label,
|
||||
],
|
||||
filters: [
|
||||
for (final filter in filters)
|
||||
if (filter.content != null) filter,
|
||||
]..sort((a, b) => a.content!.position.compareTo(b.content!.position)),
|
||||
comments:
|
||||
[
|
||||
for (final comment in comments)
|
||||
if (comment.content != null) comment,
|
||||
]..sort(
|
||||
(a, b) => a.content!.writtenAt.compareTo(b.content!.writtenAt),
|
||||
),
|
||||
settings: settings,
|
||||
locked: [
|
||||
...projects.map((row) => row.content),
|
||||
...sections.map((row) => row.content),
|
||||
...tasks.map((row) => row.content),
|
||||
...labels.map((row) => row.content),
|
||||
...filters.map((row) => row.content),
|
||||
...comments.map((row) => row.content),
|
||||
].where((content) => content == null).length,
|
||||
);
|
||||
_show();
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
|
|||
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_rows.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/data/task_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
|
||||
|
||||
/// The person's tasks (ADR 0033, D6), each sealed with their key for its row
|
||||
/// (D5, [SealedRows]): which project a task is in, its date and whether it
|
||||
|
|
@ -46,17 +47,49 @@ class TaskRepository(
|
|||
Future<String> add(TaskContent content) async =>
|
||||
await _rows.insert(await _reminded(content, before: null));
|
||||
|
||||
Future<void> update(String id, TaskContent content) async {
|
||||
final needs = _mayRemind(content);
|
||||
final before = needs
|
||||
? (await all()).where((task) => task.id == id).firstOrNull?.content
|
||||
: null;
|
||||
await _rows.update(
|
||||
id,
|
||||
needs ? await _reminded(content, before: before) : content,
|
||||
Future<void> update(String id, TaskContent content) =>
|
||||
updateAll({id: content});
|
||||
|
||||
/// Replaces the content of each task [contents] names, read once and
|
||||
/// written together (the tasks chosen, D14), so that a hundred tasks
|
||||
/// aren't opened a hundred times; throws [KeysMissing] where this device
|
||||
/// can't seal, with none written.
|
||||
Future<void> updateAll(Map<String, TaskContent> contents) async {
|
||||
await _write(
|
||||
contents,
|
||||
contents.values.any(_mayRemind) ? await all() : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Writes [contents], the automatic reminder added where a task gains a
|
||||
/// time ([tasks]: every task as it is, read where one may need it).
|
||||
Future<void> _write(
|
||||
Map<String, TaskContent> contents,
|
||||
List<StoredTask>? tasks,
|
||||
) async {
|
||||
final was = {
|
||||
for (final task in tasks ?? const <StoredTask>[]) task.id: task.content,
|
||||
};
|
||||
int? minutes;
|
||||
var asked = false;
|
||||
final written = <String, TaskContent>{};
|
||||
for (final MapEntry(key: id, value: content) in contents.entries) {
|
||||
if (!_mayRemind(content) || was[id]?.date?.time != null) {
|
||||
written[id] = content;
|
||||
continue;
|
||||
}
|
||||
if (!asked) {
|
||||
minutes = await automaticReminder!();
|
||||
asked = true;
|
||||
}
|
||||
written[id] = switch (minutes) {
|
||||
final minutes? => content.copyWith(reminders: [minutes]),
|
||||
null => content,
|
||||
};
|
||||
}
|
||||
await _rows.updateAll(written);
|
||||
}
|
||||
|
||||
/// Whether [content] may get the automatic reminder: it has a time and
|
||||
/// no reminder of its own.
|
||||
bool _mayRemind(TaskContent content) =>
|
||||
|
|
@ -82,87 +115,65 @@ class TaskRepository(
|
|||
/// Marks [task] done now, or not done where [done] is false, and returns
|
||||
/// every task it changed as it was before, for [restore] to undo it.
|
||||
///
|
||||
/// A recurring task (ADR 0033, D7) done isn't marked done but moves on to
|
||||
/// its next day, its deadline as far, its time kept, with when it was done
|
||||
/// kept in its [TaskContent.completions], and its sub-tasks open again,
|
||||
/// as Todoist's do; done its last time, it is done as any other.
|
||||
///
|
||||
/// Where not [reopenSubTasks] (the person's setting, D4), its sub-tasks
|
||||
/// stay as they are.
|
||||
/// A recurring task (ADR 0033, D7) done moves on to its next day
|
||||
/// ([completedContent]), its sub-tasks open again, as Todoist's do; where
|
||||
/// not [reopenSubTasks] (the person's setting, D4), they stay as they are.
|
||||
Future<List<StoredTask>> complete(
|
||||
StoredTask task, {
|
||||
bool done = true,
|
||||
bool reopenSubTasks = true,
|
||||
}) => completeAll([task], done: done, reopenSubTasks: reopenSubTasks);
|
||||
|
||||
/// Marks every one of [chosen] done, as [complete] does each, the tasks
|
||||
/// read once and written together (D14); returns every task it changed
|
||||
/// as it was before, for [restore] to undo it.
|
||||
Future<List<StoredTask>> completeAll(
|
||||
Iterable<StoredTask> chosen, {
|
||||
bool done = true,
|
||||
bool reopenSubTasks = true,
|
||||
}) async {
|
||||
final content = task.content;
|
||||
if (content == null) {
|
||||
return const [];
|
||||
}
|
||||
final now = clock.now();
|
||||
final date = content.date;
|
||||
final repeat = date?.repeat;
|
||||
final next = done && date != null && repeat != null
|
||||
? repeat.next(date.day, today: Day.of(now))
|
||||
: null;
|
||||
if (date == null || repeat == null || next == null) {
|
||||
await update(
|
||||
task.id,
|
||||
content.copyWith(completedAt: () => done ? now : null),
|
||||
);
|
||||
return [task];
|
||||
}
|
||||
final moved = date.day.daysUntil(next);
|
||||
final tasks = await all();
|
||||
final below = _withDescendants({task.id}, tasks)..remove(task.id);
|
||||
final reopened = [
|
||||
for (final sub in tasks)
|
||||
if (reopenSubTasks &&
|
||||
below.contains(sub.id) &&
|
||||
(sub.content?.completed ?? false))
|
||||
sub,
|
||||
];
|
||||
await update(
|
||||
task.id,
|
||||
content.copyWith(
|
||||
date: () => date.on(next).repeating(repeat.counted()),
|
||||
deadline: () => content.deadline?.plus(moved),
|
||||
completions: [...content.completions, now],
|
||||
),
|
||||
);
|
||||
for (final sub in reopened) {
|
||||
await update(sub.id, sub.content!.copyWith(completedAt: () => null));
|
||||
final open = openTasks(tasks);
|
||||
final writes = <String, TaskContent>{};
|
||||
final before = <StoredTask>[];
|
||||
for (final task in chosen) {
|
||||
final content = task.content;
|
||||
if (content == null || writes.containsKey(task.id)) {
|
||||
continue;
|
||||
}
|
||||
final next = completedContent(content, now: now, done: done);
|
||||
writes[task.id] = next;
|
||||
before.add(task);
|
||||
final movedOn = done && next.date != content.date;
|
||||
if (!movedOn || !reopenSubTasks) {
|
||||
continue;
|
||||
}
|
||||
final below = withSubTasks({task.id}, open)..remove(task.id);
|
||||
for (final sub in open) {
|
||||
if (below.contains(sub.id) &&
|
||||
sub.content.completed &&
|
||||
!writes.containsKey(sub.id)) {
|
||||
writes[sub.id] = sub.content.copyWith(completedAt: () => null);
|
||||
before.add(StoredTask(sub.id, sub.content));
|
||||
}
|
||||
}
|
||||
}
|
||||
return [task, ...reopened];
|
||||
await _write(writes, tasks);
|
||||
return before;
|
||||
}
|
||||
|
||||
/// Writes [tasks] back as they were, undoing what [complete] changed.
|
||||
Future<void> restore(List<StoredTask> tasks) async {
|
||||
for (final task in tasks) {
|
||||
if (task.content case final content?) {
|
||||
await update(task.id, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
Future<void> restore(List<StoredTask> tasks) => updateAll({
|
||||
for (final task in tasks) task.id: ?task.content,
|
||||
});
|
||||
|
||||
/// Moves the tasks [ids] to the project [projectId] with their sub-tasks,
|
||||
/// theirs too, out of any section; one whose parent stays behind becomes
|
||||
/// a task of its own there.
|
||||
/// a task of its own there ([movedTo]).
|
||||
Future<void> move(Iterable<String> ids, String projectId) async {
|
||||
final tasks = await all();
|
||||
final moving = _withDescendants({...ids}, tasks);
|
||||
for (final task in tasks) {
|
||||
if (task.content case final content? when moving.contains(task.id)) {
|
||||
await update(
|
||||
task.id,
|
||||
content.copyWith(
|
||||
projectId: projectId,
|
||||
sectionId: () => null,
|
||||
parentId: () =>
|
||||
moving.contains(content.parentId) ? content.parentId : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
await _write(movedTo(openTasks(tasks), ids, projectId), tasks);
|
||||
}
|
||||
|
||||
/// Places the task [id] at [position] in [sectionId] of its project,
|
||||
|
|
@ -177,29 +188,22 @@ class TaskRepository(
|
|||
String? parentId,
|
||||
}) async {
|
||||
final tasks = await all();
|
||||
final moving = _withDescendants({id}, tasks);
|
||||
if (parentId != null && moving.contains(parentId)) {
|
||||
final open = openTasks(tasks);
|
||||
if (parentId != null && withSubTasks({id}, open).contains(parentId)) {
|
||||
throw ArgumentError.value(parentId, 'parentId', 'is $id or under it');
|
||||
}
|
||||
final before = <StoredTask>[];
|
||||
for (final task in tasks) {
|
||||
final content = task.content;
|
||||
if (content == null || !moving.contains(task.id)) {
|
||||
continue;
|
||||
}
|
||||
final placed = task.id == id
|
||||
? content.copyWith(
|
||||
sectionId: () => sectionId,
|
||||
parentId: () => parentId,
|
||||
position: position,
|
||||
)
|
||||
: content.copyWith(sectionId: () => sectionId);
|
||||
if (placed != content) {
|
||||
before.add(task);
|
||||
await update(task.id, placed);
|
||||
}
|
||||
}
|
||||
return before;
|
||||
final writes = placedAt(
|
||||
open,
|
||||
id,
|
||||
sectionId: sectionId,
|
||||
parentId: parentId,
|
||||
position: position,
|
||||
);
|
||||
await _write(writes, tasks);
|
||||
return [
|
||||
for (final task in tasks)
|
||||
if (writes.containsKey(task.id)) task,
|
||||
];
|
||||
}
|
||||
|
||||
/// Copies the task [id] with its sub-tasks, theirs too, each as it is,
|
||||
|
|
@ -240,9 +244,13 @@ class TaskRepository(
|
|||
}
|
||||
|
||||
/// Deletes the task [id] with its sub-tasks, theirs too, together.
|
||||
Future<void> delete(String id) async {
|
||||
Future<void> delete(String id) => deleteAll([id]);
|
||||
|
||||
/// Deletes the tasks [ids] with their sub-tasks, theirs too, together,
|
||||
/// the tasks read once (the tasks chosen, D14).
|
||||
Future<void> deleteAll(Iterable<String> ids) async {
|
||||
final tasks = await all();
|
||||
await _gone(_withDescendants({id}, tasks));
|
||||
await _gone(_withDescendants({...ids}, tasks));
|
||||
}
|
||||
|
||||
/// Deletes every task in the project [projectId] (or, given, only those
|
||||
|
|
|
|||
|
|
@ -183,3 +183,103 @@ DateTime? addedAt(String id) {
|
|||
null => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// [ids] and every task of [tasks] below them, sub-tasks of sub-tasks too.
|
||||
Set<String> withSubTasks(Set<String> ids, List<OpenTask> tasks) {
|
||||
final found = {...ids};
|
||||
var grew = true;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
for (final task in tasks) {
|
||||
if (task.content.parentId case final parent?
|
||||
when found.contains(parent) && found.add(task.id)) {
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/// [tasks] with the contents [changed] names in place of theirs: the
|
||||
/// changes a write is making, shown before it is done.
|
||||
List<OpenTask> withChanges(
|
||||
List<OpenTask> tasks,
|
||||
Map<String, TaskContent> changed,
|
||||
) => [
|
||||
for (final task in tasks)
|
||||
(id: task.id, content: changed[task.id] ?? task.content),
|
||||
];
|
||||
|
||||
/// The tasks [ids] moved to the project [projectId] with their sub-tasks,
|
||||
/// theirs too, out of any section, as the contents they get; one whose
|
||||
/// parent stays behind becomes a task of its own there.
|
||||
Map<String, TaskContent> movedTo(
|
||||
List<OpenTask> tasks,
|
||||
Iterable<String> ids,
|
||||
String projectId,
|
||||
) {
|
||||
final moving = withSubTasks({...ids}, tasks);
|
||||
return {
|
||||
for (final task in tasks)
|
||||
if (moving.contains(task.id))
|
||||
task.id: task.content.copyWith(
|
||||
projectId: projectId,
|
||||
sectionId: () => null,
|
||||
parentId: () => moving.contains(task.content.parentId)
|
||||
? task.content.parentId
|
||||
: null,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// The task [id] placed at [position] in [sectionId] of its project, under
|
||||
/// [parentId], its sub-tasks, theirs too, into that section with it (D16),
|
||||
/// as the contents of those it changes.
|
||||
Map<String, TaskContent> placedAt(
|
||||
List<OpenTask> tasks,
|
||||
String id, {
|
||||
required String? sectionId,
|
||||
required double position,
|
||||
String? parentId,
|
||||
}) {
|
||||
final moving = withSubTasks({id}, tasks);
|
||||
return {
|
||||
for (final task in tasks)
|
||||
if (moving.contains(task.id))
|
||||
if (task.id == id
|
||||
? task.content.copyWith(
|
||||
sectionId: () => sectionId,
|
||||
parentId: () => parentId,
|
||||
position: position,
|
||||
)
|
||||
: task.content.copyWith(sectionId: () => sectionId)
|
||||
case final placed when placed != task.content)
|
||||
task.id: placed,
|
||||
};
|
||||
}
|
||||
|
||||
/// [content] marked done at [now], or not done where [done] is false.
|
||||
///
|
||||
/// A recurring task (ADR 0033, D7) done isn't marked done but moves on to
|
||||
/// its next day, its deadline as far, its time kept, with when it was done
|
||||
/// kept in its [TaskContent.completions]; done its last time, it is done as
|
||||
/// any other.
|
||||
TaskContent completedContent(
|
||||
TaskContent content, {
|
||||
required DateTime now,
|
||||
bool done = true,
|
||||
}) {
|
||||
final date = content.date;
|
||||
final repeat = date?.repeat;
|
||||
final next = done && date != null && repeat != null
|
||||
? repeat.next(date.day, today: Day.of(now))
|
||||
: null;
|
||||
if (date == null || repeat == null || next == null) {
|
||||
return content.copyWith(completedAt: () => done ? now : null);
|
||||
}
|
||||
return content.copyWith(
|
||||
date: () => date.on(next).repeating(repeat.counted()),
|
||||
deadline: () => content.deadline?.plus(date.day.daysUntil(next)),
|
||||
completions: [...content.completions, now],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/quick_add_sheet.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_selection.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_writes.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -41,7 +41,6 @@ Future<void> moveTask(
|
|||
bool allDay = false,
|
||||
}) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final tasks = PersonScope.of<TodoPerson>(context).tasks;
|
||||
final task = context
|
||||
.read<TodoCubit>()
|
||||
|
|
@ -67,13 +66,17 @@ Future<void> moveTask(
|
|||
if (to == date) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await tasks.update(id, content.copyWith(date: () => to));
|
||||
} on KeysMissing {
|
||||
messenger.showSnackBar(SnackBar(content: Text(l10n.keysMissing)));
|
||||
return;
|
||||
}
|
||||
if (context.mounted) {
|
||||
final moved = content.copyWith(date: () => to);
|
||||
// There at once; back, with a snack bar, where not kept.
|
||||
final written = await writeTasks(
|
||||
context,
|
||||
change: (all) => withChanges(all, {id: moved}),
|
||||
write: () async {
|
||||
await tasks.update(id, moved);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
if (written != null && context.mounted) {
|
||||
final dayName = dayText(context, day, Day.of(clock.now()));
|
||||
showUndoSnackBar(
|
||||
context,
|
||||
|
|
@ -132,24 +135,38 @@ Future<void> dropAt(
|
|||
);
|
||||
case TaskDrag(:final id):
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final tasks = PersonScope.of<TodoPerson>(context).tasks;
|
||||
try {
|
||||
final before = await tasks.place(
|
||||
final open = context.read<TodoCubit>().state.tasks;
|
||||
if (spot.parentId case final parent?
|
||||
when withSubTasks({id}, open).contains(parent)) {
|
||||
return;
|
||||
}
|
||||
// There at once; back, with a snack bar, where not kept.
|
||||
final before = await writeTasks(
|
||||
context,
|
||||
change: (all) => withChanges(
|
||||
all,
|
||||
placedAt(
|
||||
all,
|
||||
id,
|
||||
sectionId: spot.sectionId,
|
||||
parentId: spot.parentId,
|
||||
position: spot.position,
|
||||
),
|
||||
),
|
||||
write: () => tasks.place(
|
||||
id,
|
||||
sectionId: spot.sectionId,
|
||||
parentId: spot.parentId,
|
||||
position: spot.position,
|
||||
),
|
||||
);
|
||||
if (before != null && before.isNotEmpty && context.mounted) {
|
||||
showUndoSnackBar(
|
||||
context,
|
||||
message: l10n.orderChanged,
|
||||
onUndo: () => unawaited(tasks.restore(before)),
|
||||
);
|
||||
if (before.isNotEmpty && context.mounted) {
|
||||
showUndoSnackBar(
|
||||
context,
|
||||
message: l10n.orderChanged,
|
||||
onUndo: () => unawaited(tasks.restore(before)),
|
||||
);
|
||||
}
|
||||
} on KeysMissing {
|
||||
messenger.showSnackBar(SnackBar(content: Text(l10n.keysMissing)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||
|
|
@ -13,6 +14,7 @@ import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
|||
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/date_picker.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_pickers.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_writes.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -149,8 +151,9 @@ class const TaskSelectionBar({
|
|||
}
|
||||
}
|
||||
|
||||
/// What the bar does to the chosen tasks, each written through the
|
||||
/// person's [TaskRepository]; choosing ends once it is done.
|
||||
/// What the bar does to the chosen tasks, written through the person's
|
||||
/// [TaskRepository] together: shown at once, as choosing ends, and shown
|
||||
/// as they were again, with a snack bar, where writing fails.
|
||||
class _SelectionActions(
|
||||
final BuildContext _context,
|
||||
final TaskSelection _selection,
|
||||
|
|
@ -168,13 +171,24 @@ class _SelectionActions(
|
|||
];
|
||||
}
|
||||
|
||||
/// Writes [change] into each chosen task, then stops choosing.
|
||||
Future<void> _each(TaskContent Function(TaskContent content) change) =>
|
||||
_guard(() async {
|
||||
for (final task in _chosen) {
|
||||
await _tasks.update(task.id, change(task.content));
|
||||
}
|
||||
});
|
||||
/// Writes [change] into each chosen task, shown at once.
|
||||
Future<void> _each(TaskContent Function(TaskContent content) change) {
|
||||
final contents = {
|
||||
for (final task in _chosen) task.id: change(task.content),
|
||||
};
|
||||
return _write(
|
||||
(tasks) => withChanges(tasks, contents),
|
||||
() => _tasks.updateAll(contents),
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows [change] at once while [write] makes it ([writeTasks]), and
|
||||
/// stops choosing.
|
||||
Future<T?> _write<T>(TaskChange change, Future<T> Function() write) {
|
||||
final written = writeTasks(_context, change: change, write: write);
|
||||
_selection.end();
|
||||
return written;
|
||||
}
|
||||
|
||||
Future<void> _guard(Future<void> Function() write) async {
|
||||
final messenger = ScaffoldMessenger.of(_context);
|
||||
|
|
@ -210,7 +224,11 @@ class _SelectionActions(
|
|||
)
|
||||
case final projectId?) {
|
||||
final ids = _selection.ids.toList();
|
||||
await _guard(() => _tasks.move(ids, projectId));
|
||||
final tasks = _tasks;
|
||||
await _write(
|
||||
(all) => withChanges(all, movedTo(all, ids, projectId)),
|
||||
() => tasks.move(ids, projectId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -269,18 +287,17 @@ class _SelectionActions(
|
|||
final tasks = _tasks;
|
||||
final chosen = _chosen.where((task) => !task.content.completed).toList();
|
||||
final reopen = context.read<TodoCubit>().state.settings.tasks.resetSubTasks;
|
||||
final before = <StoredTask>[];
|
||||
await _guard(() async {
|
||||
for (final task in chosen) {
|
||||
before.addAll(
|
||||
await tasks.complete(
|
||||
StoredTask(task.id, task.content),
|
||||
reopenSubTasks: reopen,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
if (before.isNotEmpty && context.mounted) {
|
||||
final now = clock.now();
|
||||
final before = await _write(
|
||||
(all) => withChanges(all, {
|
||||
for (final task in chosen)
|
||||
task.id: completedContent(task.content, now: now),
|
||||
}),
|
||||
() => tasks.completeAll([
|
||||
for (final task in chosen) StoredTask(task.id, task.content),
|
||||
], reopenSubTasks: reopen),
|
||||
);
|
||||
if (before != null && before.isNotEmpty && context.mounted) {
|
||||
showUndoSnackBar(
|
||||
context,
|
||||
message: l10n.tasksCompleted(chosen.length),
|
||||
|
|
@ -299,11 +316,14 @@ class _SelectionActions(
|
|||
confirmLabel: l10n.selectionDelete(count),
|
||||
)) {
|
||||
final ids = _selection.ids.toList();
|
||||
await _guard(() async {
|
||||
for (final id in ids) {
|
||||
await _tasks.delete(id);
|
||||
}
|
||||
});
|
||||
final tasks = _tasks;
|
||||
await _write((all) {
|
||||
final gone = withSubTasks({...ids}, all);
|
||||
return [
|
||||
for (final task in all)
|
||||
if (!gone.contains(task.id)) task,
|
||||
];
|
||||
}, () => tasks.deleteAll(ids));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import 'package:halkyon_todo_flutter/src/feature/task/widget/date_picker.dart';
|
|||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_selection.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_sheet.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_writes.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -372,17 +373,24 @@ class const TaskCheck({required final OpenTask task, super.key})
|
|||
onPressed: () async {
|
||||
final tasks = PersonScope.of<TodoPerson>(context).tasks;
|
||||
final stored = StoredTask(task.id, content);
|
||||
final before = await tasks.complete(
|
||||
stored,
|
||||
done: !content.completed,
|
||||
reopenSubTasks: context
|
||||
.read<TodoCubit>()
|
||||
.state
|
||||
.settings
|
||||
.tasks
|
||||
.resetSubTasks,
|
||||
final done = !content.completed;
|
||||
final reopen = context
|
||||
.read<TodoCubit>()
|
||||
.state
|
||||
.settings
|
||||
.tasks
|
||||
.resetSubTasks;
|
||||
final now = clock.now();
|
||||
// Filled at once; taken back, with a snack bar, where not kept.
|
||||
final before = await writeTasks(
|
||||
context,
|
||||
change: (all) => withChanges(all, {
|
||||
task.id: completedContent(content, now: now, done: done),
|
||||
}),
|
||||
write: () =>
|
||||
tasks.complete(stored, done: done, reopenSubTasks: reopen),
|
||||
);
|
||||
if (!content.completed && context.mounted) {
|
||||
if (before != null && done && context.mounted) {
|
||||
showUndoSnackBar(
|
||||
context,
|
||||
message: l10n.taskCompleted,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Shows [change] on the person's tasks at once while [write] makes it
|
||||
/// (optimistic state, [TodoCubit.showing]), and returns what [write]
|
||||
/// returns. Where it fails, the tasks show as they are again and a snack
|
||||
/// bar says so, the keys missing or not, and it returns null.
|
||||
///
|
||||
/// What it needs of [context] it takes before anything waits, so the
|
||||
/// widget asking may go meanwhile (the selection's bar, as choosing ends).
|
||||
Future<T?> writeTasks<T>(
|
||||
BuildContext context, {
|
||||
required TaskChange change,
|
||||
required Future<T> Function() write,
|
||||
}) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final todo = context.read<TodoCubit>();
|
||||
try {
|
||||
return await todo.showing(change, write);
|
||||
} on KeysMissing {
|
||||
messenger.showSnackBar(SnackBar(content: Text(l10n.keysMissing)));
|
||||
} on Object catch (error, st) {
|
||||
log(
|
||||
'A change to tasks not saved',
|
||||
name: 'Todo',
|
||||
error: error,
|
||||
stackTrace: st,
|
||||
);
|
||||
messenger.showSnackBar(SnackBar(content: Text(l10n.tasksNotSaved)));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import 'package:halkyon_todo_flutter/src/feature/task/widget/quick_add_sheet.dar
|
|||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_refresh.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_writes.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/hour_grid.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/plan_host.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart';
|
||||
|
|
@ -252,18 +253,22 @@ String dayHeading(BuildContext context, Day day, Day today) {
|
|||
Future<void> reschedule(BuildContext context, List<OpenTask> tasks) async {
|
||||
final todo = PersonScope.of<TodoPerson>(context);
|
||||
if (await pickTaskDate(context) case (date: final date?)) {
|
||||
for (final task in tasks) {
|
||||
final old = task.content.date;
|
||||
await todo.tasks.update(
|
||||
task.id,
|
||||
task.content.copyWith(
|
||||
final contents = {
|
||||
for (final task in tasks)
|
||||
task.id: task.content.copyWith(
|
||||
date: () => TaskDate(
|
||||
date.day,
|
||||
time: date.time ?? old?.time,
|
||||
duration: old?.duration,
|
||||
repeat: old?.repeat,
|
||||
time: date.time ?? task.content.date?.time,
|
||||
duration: task.content.date?.duration,
|
||||
repeat: task.content.date?.repeat,
|
||||
),
|
||||
),
|
||||
};
|
||||
if (context.mounted) {
|
||||
await writeTasks(
|
||||
context,
|
||||
change: (all) => withChanges(all, contents),
|
||||
write: () => todo.tasks.updateAll(contents),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart'
|
||||
show PointerDeviceKind, kLongPressTimeout;
|
||||
|
|
@ -4789,6 +4791,130 @@ void main() {
|
|||
expect(find.text('Post letter'), findsOneWidget);
|
||||
});
|
||||
|
||||
/// Chooses [titles], one swiped and the others tapped.
|
||||
Future<void> choose(WidgetTester tester, List<String> titles) async {
|
||||
await tester.drag(row(titles.first), const Offset(500, 0));
|
||||
await tester.pumpAndSettle();
|
||||
for (final title in titles.skip(1)) {
|
||||
await tester.tap(row(title));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
}
|
||||
|
||||
/// Picks [item] from the bar's ⋮ menu.
|
||||
Future<void> more(WidgetTester tester, String item) async {
|
||||
await tester.tap(find.byTooltip('More'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(item));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('Delete takes them off at once, before the device has '
|
||||
'written it, and together', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await seedToday(tester, ['Water plants', 'Post letter', 'Cook']);
|
||||
await choose(tester, ['Water plants', 'Post letter']);
|
||||
final hold = taskRows.holdWrites = Completer<void>();
|
||||
taskRows.reads = 0;
|
||||
|
||||
await more(tester, 'Delete 2 tasks');
|
||||
await tester.tap(find.text('Delete 2 tasks').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Water plants'), findsNothing);
|
||||
expect(find.text('Post letter'), findsNothing);
|
||||
expect(find.text('Cook'), findsOneWidget);
|
||||
expect(taskRows.byId, hasLength(3), reason: 'not written yet');
|
||||
|
||||
hold.complete();
|
||||
await tester.pumpAndSettle();
|
||||
expect(taskRows.byId, hasLength(1));
|
||||
expect(find.text('Water plants'), findsNothing);
|
||||
expect(
|
||||
taskRows.reads,
|
||||
lessThanOrEqualTo(3),
|
||||
reason: 'read once to delete, and by the screen after',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('Complete shows them done at once; where the device '
|
||||
"can't keep it, they are open again and a snack bar says so, "
|
||||
'with no Undo', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await seedToday(tester, ['Water plants', 'Post letter']);
|
||||
await choose(tester, ['Water plants', 'Post letter']);
|
||||
final hold = taskRows.holdWrites = Completer<void>();
|
||||
taskRows.failWrites = Exception('The disk is full');
|
||||
|
||||
await more(tester, 'Complete 2 tasks');
|
||||
expect(find.text('Water plants'), findsNothing, reason: 'at once');
|
||||
expect(find.text('2 tasks selected'), findsNothing);
|
||||
|
||||
hold.complete();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Water plants'), findsOneWidget);
|
||||
expect(find.text('Post letter'), findsOneWidget);
|
||||
expect(
|
||||
find.text(
|
||||
"That wasn't saved, so your tasks are as they were. Try again.",
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Undo'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets("a task's check fills at once; where the device can't "
|
||||
'keep it, the task is open again, with a snack bar', (
|
||||
tester,
|
||||
) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await seedToday(tester, ['Water plants']);
|
||||
final hold = taskRows.holdWrites = Completer<void>();
|
||||
|
||||
await tester.tap(find.byTooltip('Mark as done'));
|
||||
await tester.pump();
|
||||
expect(find.text('Water plants'), findsNothing, reason: 'at once');
|
||||
expect(
|
||||
(await tester.runAsync(() => tasks().all()))!
|
||||
.single
|
||||
.content!
|
||||
.completed,
|
||||
isFalse,
|
||||
reason: 'not written yet',
|
||||
);
|
||||
|
||||
taskRows.failWrites = Exception('The disk is full');
|
||||
hold.complete();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Water plants'), findsOneWidget);
|
||||
expect(
|
||||
find.text(
|
||||
"That wasn't saved, so your tasks are as they were. Try again.",
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Task done'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets("a task's check kept offers Undo, as before", (
|
||||
tester,
|
||||
) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await seedToday(tester, ['Water plants']);
|
||||
|
||||
await tester.tap(find.byTooltip('Mark as done'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Water plants'), findsNothing);
|
||||
expect(find.text('Task done'), findsOneWidget);
|
||||
expect(
|
||||
(await tester.runAsync(() => tasks().all()))!
|
||||
.single
|
||||
.content!
|
||||
.completed,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("a row swiped is chosen in the Inbox too, which a project's "
|
||||
'menu also starts', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
|
|
|
|||
|
|
@ -570,4 +570,111 @@ void main() {
|
|||
[other],
|
||||
);
|
||||
});
|
||||
|
||||
group('the tasks chosen together (D14) are read once and written in one '
|
||||
'go', () {
|
||||
late TaskRepository tasks;
|
||||
late List<String> ids;
|
||||
var changes = 0;
|
||||
|
||||
setUp(() async {
|
||||
tasks = TaskRepository(
|
||||
rows,
|
||||
await keysOf(alice),
|
||||
automaticReminder: () async => 15,
|
||||
);
|
||||
ids = [
|
||||
for (final title in ['Water', 'Post', 'Cook'])
|
||||
await tasks.add(TaskContent(title: title, projectId: 'inbox')),
|
||||
];
|
||||
await tasks.add(
|
||||
TaskContent(title: 'Pot', projectId: 'inbox', parentId: ids.first),
|
||||
);
|
||||
changes = 0;
|
||||
tasks.changes.listen((_) => changes++);
|
||||
rows.reads = 0;
|
||||
});
|
||||
|
||||
test('deleting takes them and their sub-tasks', () async {
|
||||
await tasks.deleteAll(ids.take(2));
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(rows.reads, 1);
|
||||
expect(changes, 1);
|
||||
expect([for (final t in await tasks.all()) t.content?.title], ['Cook']);
|
||||
});
|
||||
|
||||
test('changing each, a time gained bringing the automatic reminder; '
|
||||
'where this device holds no keys, none is written', () async {
|
||||
const timed = TaskDate(Day(2026, 11, 2), time: 540);
|
||||
final contents = {
|
||||
for (final t in await tasks.all())
|
||||
if (ids.take(2).contains(t.id))
|
||||
t.id: t.content!.copyWith(date: () => timed),
|
||||
};
|
||||
rows.reads = 0;
|
||||
|
||||
await tasks.updateAll(contents);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(rows.reads, 1);
|
||||
expect(changes, 1);
|
||||
final after = {for (final t in await tasks.all()) t.id: t.content!};
|
||||
expect(after[ids[0]]?.date, timed);
|
||||
expect(after[ids[0]]?.reminders, [15]);
|
||||
expect(after[ids[1]]?.reminders, [15]);
|
||||
expect(after[ids[2]]?.date, isNull);
|
||||
|
||||
final locked = TaskRepository(rows, await keysOf(null));
|
||||
final before = Map.of(rows.byId);
|
||||
await expectLater(
|
||||
locked.updateAll({
|
||||
for (final id in ids)
|
||||
id: const TaskContent(title: 'X', projectId: 'p'),
|
||||
}),
|
||||
throwsA(isA<KeysMissing>()),
|
||||
);
|
||||
expect(rows.byId, before);
|
||||
});
|
||||
|
||||
test('completing them, undone together', () async {
|
||||
final at = DateTime(2026, 11, 2, 9);
|
||||
final chosen = (await tasks.all()).where((t) => ids.contains(t.id));
|
||||
rows.reads = 0;
|
||||
|
||||
final before = await withClock(
|
||||
Clock.fixed(at),
|
||||
() => tasks.completeAll(chosen),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(rows.reads, 1);
|
||||
expect(changes, 1);
|
||||
expect(
|
||||
{
|
||||
for (final t in await tasks.all())
|
||||
t.content!.title: t.content!.completedAt,
|
||||
},
|
||||
{'Water': at, 'Post': at, 'Cook': at, 'Pot': null},
|
||||
);
|
||||
|
||||
await tasks.restore(before);
|
||||
expect(
|
||||
[for (final t in await tasks.all()) t.content!.completed],
|
||||
everyElement(isFalse),
|
||||
);
|
||||
});
|
||||
|
||||
test('moving them', () async {
|
||||
await tasks.move(ids, 'travel');
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(rows.reads, 1);
|
||||
expect(changes, 1);
|
||||
expect(
|
||||
[for (final t in await tasks.all()) t.content!.projectId],
|
||||
everyElement('travel'),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,27 +10,54 @@ class MemorySealedRowGateway() implements SealedRowGateway {
|
|||
final byId = <String, Envelope>{};
|
||||
final _remote = StreamController<void>.broadcast();
|
||||
|
||||
/// How often every row was read.
|
||||
int reads = 0;
|
||||
|
||||
/// Thrown by every write while set, as a local database failing would.
|
||||
Exception? failWrites;
|
||||
|
||||
/// While set, every write waits for it to complete, so that a test sees
|
||||
/// what shows before the write is done.
|
||||
Completer<void>? holdWrites;
|
||||
|
||||
/// As a sync bringing others' changes.
|
||||
void bringRemoteChanges() => _remote.add(null);
|
||||
|
||||
@override
|
||||
Future<List<SealedRow>> rows() async => [
|
||||
for (final MapEntry(:key, :value) in byId.entries)
|
||||
(id: key, envelope: value),
|
||||
];
|
||||
Future<void> _write() async {
|
||||
await holdWrites?.future;
|
||||
if (failWrites case final error?) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> insert(SealedRow row) async => byId[row.id] = row.envelope;
|
||||
Future<List<SealedRow>> rows() async {
|
||||
reads++;
|
||||
return [
|
||||
for (final MapEntry(:key, :value) in byId.entries)
|
||||
(id: key, envelope: value),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> insert(SealedRow row) async {
|
||||
await _write();
|
||||
byId[row.id] = row.envelope;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> update(String id, Envelope envelope) async {
|
||||
await _write();
|
||||
if (byId.containsKey(id)) {
|
||||
byId[id] = envelope;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(Iterable<String> ids) async => ids.forEach(byId.remove);
|
||||
Future<void> delete(Iterable<String> ids) async {
|
||||
await _write();
|
||||
ids.forEach(byId.remove);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _remote.stream;
|
||||
|
|
|
|||
Loading…
Reference in a new issue