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:
Tizian 「ティツィアーン」 2026-11-07 22:57:29 +01:00
commit c954e844f7
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
17 changed files with 757 additions and 204 deletions

View file

@ -31,6 +31,7 @@
"deadlineOn": "Frist {day}", "deadlineOn": "Frist {day}",
"deadlineNone": "Keine Frist", "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.", "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", "filtersLabelsTitle": "Filter & Labels",
"filtersLabelsHint": "Gespeicherte Ansichten deiner Aufgaben", "filtersLabelsHint": "Gespeicherte Ansichten deiner Aufgaben",
"filtersTitle": "Meine Filter", "filtersTitle": "Meine Filter",

View file

@ -72,6 +72,10 @@
}, },
"deadlineNone": "No deadline", "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.", "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", "filtersLabelsTitle": "Filters & labels",
"filtersLabelsHint": "Views of your tasks, kept", "filtersLabelsHint": "Views of your tasks, kept",
"filtersTitle": "My filters", "filtersTitle": "My filters",

View file

@ -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.'** /// **'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; 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. /// No description provided for @filtersLabelsTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -120,6 +120,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get keysMissing => 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.'; '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 @override
String get filtersLabelsTitle => 'Filter & Labels'; String get filtersLabelsTitle => 'Filter & Labels';

View file

@ -120,6 +120,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get keysMissing => 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.'; '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 @override
String get filtersLabelsTitle => 'Filters & labels'; String get filtersLabelsTitle => 'Filters & labels';

View file

@ -82,6 +82,28 @@ class SealedRows<T>(
_changes.add(null); _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. /// Deletes the rows [ids] together.
Future<void> delete(Iterable<String> ids) async { Future<void> delete(Iterable<String> ids) async {
await _gateway.delete(ids); await _gateway.delete(ids);

View file

@ -61,6 +61,19 @@ final class const TodoState({
if (section.content?.projectId == projectId) section, if (section.content?.projectId == projectId) section,
]..sort((a, b) => a.content!.position.compareTo(b.content!.position)); ]..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. /// The task [id]; null for one gone or locked.
OpenTask? taskOf(String id) => OpenTask? taskOf(String id) =>
tasks.where((task) => task.id == id).firstOrNull; 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 /// Reads the person's data into [TodoState], and again whenever it changes
/// here or a sync brings changes from their other devices. /// 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> { class TodoCubit(final TodoPerson _todo) extends Cubit<TodoState> {
this : super(const 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>>[]; final _subscriptions = <StreamSubscription<void>>[];
late final _reads = CoalescingRunner( late final _reads = CoalescingRunner(
_read, _readAll,
onError: (error, stackTrace) => onError: (error, stackTrace) =>
log("The person's tasks not read: $error", name: 'Todo'), 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()); 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 projects = await _todo.projects.all();
final sections = await _todo.sections.all(); final sections = await _todo.sections.all();
final tasks = await _todo.tasks.all(); final tasks = await _todo.tasks.all();
@ -118,41 +175,40 @@ class TodoCubit(final TodoPerson _todo) extends Cubit<TodoState> {
if (isClosed) { if (isClosed) {
return; return;
} }
emit( _read = TodoState(
TodoState( loaded: true,
loaded: true, projects: projects,
projects: projects, sections: [
sections: [ for (final section in sections)
for (final section in sections) if (section.content != null) section,
if (section.content != null) section, ],
], tasks: openTasks(tasks),
tasks: openTasks(tasks), labels: [
labels: [ for (final label in labels)
for (final label in labels) if (label.content != null) label,
if (label.content != null) label, ],
], filters: [
filters: [ for (final filter in filters)
for (final filter in filters) if (filter.content != null) filter,
if (filter.content != null) filter, ]..sort((a, b) => a.content!.position.compareTo(b.content!.position)),
]..sort((a, b) => a.content!.position.compareTo(b.content!.position)), comments:
comments: [
[ for (final comment in comments)
for (final comment in comments) if (comment.content != null) comment,
if (comment.content != null) comment, ]..sort(
]..sort( (a, b) => a.content!.writtenAt.compareTo(b.content!.writtenAt),
(a, b) => a.content!.writtenAt.compareTo(b.content!.writtenAt), ),
), settings: settings,
settings: settings, locked: [
locked: [ ...projects.map((row) => row.content),
...projects.map((row) => row.content), ...sections.map((row) => row.content),
...sections.map((row) => row.content), ...tasks.map((row) => row.content),
...tasks.map((row) => row.content), ...labels.map((row) => row.content),
...labels.map((row) => row.content), ...filters.map((row) => row.content),
...filters.map((row) => row.content), ...comments.map((row) => row.content),
...comments.map((row) => row.content), ].where((content) => content == null).length,
].where((content) => content == null).length,
),
); );
_show();
} }
@override @override

View file

@ -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/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/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.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 /// 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 /// (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 => Future<String> add(TaskContent content) async =>
await _rows.insert(await _reminded(content, before: null)); await _rows.insert(await _reminded(content, before: null));
Future<void> update(String id, TaskContent content) async { Future<void> update(String id, TaskContent content) =>
final needs = _mayRemind(content); updateAll({id: content});
final before = needs
? (await all()).where((task) => task.id == id).firstOrNull?.content /// Replaces the content of each task [contents] names, read once and
: null; /// written together (the tasks chosen, D14), so that a hundred tasks
await _rows.update( /// aren't opened a hundred times; throws [KeysMissing] where this device
id, /// can't seal, with none written.
needs ? await _reminded(content, before: before) : content, 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 /// Whether [content] may get the automatic reminder: it has a time and
/// no reminder of its own. /// no reminder of its own.
bool _mayRemind(TaskContent content) => bool _mayRemind(TaskContent content) =>
@ -82,87 +115,65 @@ class TaskRepository(
/// Marks [task] done now, or not done where [done] is false, and returns /// 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. /// 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 /// A recurring task (ADR 0033, D7) done moves on to its next day
/// its next day, its deadline as far, its time kept, with when it was done /// ([completedContent]), its sub-tasks open again, as Todoist's do; where
/// kept in its [TaskContent.completions], and its sub-tasks open again, /// not [reopenSubTasks] (the person's setting, D4), they stay as they are.
/// 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.
Future<List<StoredTask>> complete( Future<List<StoredTask>> complete(
StoredTask task, { StoredTask task, {
bool done = true, bool done = true,
bool reopenSubTasks = 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 { }) async {
final content = task.content;
if (content == null) {
return const [];
}
final now = clock.now(); 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 tasks = await all();
final below = _withDescendants({task.id}, tasks)..remove(task.id); final open = openTasks(tasks);
final reopened = [ final writes = <String, TaskContent>{};
for (final sub in tasks) final before = <StoredTask>[];
if (reopenSubTasks && for (final task in chosen) {
below.contains(sub.id) && final content = task.content;
(sub.content?.completed ?? false)) if (content == null || writes.containsKey(task.id)) {
sub, continue;
]; }
await update( final next = completedContent(content, now: now, done: done);
task.id, writes[task.id] = next;
content.copyWith( before.add(task);
date: () => date.on(next).repeating(repeat.counted()), final movedOn = done && next.date != content.date;
deadline: () => content.deadline?.plus(moved), if (!movedOn || !reopenSubTasks) {
completions: [...content.completions, now], continue;
), }
); final below = withSubTasks({task.id}, open)..remove(task.id);
for (final sub in reopened) { for (final sub in open) {
await update(sub.id, sub.content!.copyWith(completedAt: () => null)); 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. /// Writes [tasks] back as they were, undoing what [complete] changed.
Future<void> restore(List<StoredTask> tasks) async { Future<void> restore(List<StoredTask> tasks) => updateAll({
for (final task in tasks) { for (final task in tasks) task.id: ?task.content,
if (task.content case final content?) { });
await update(task.id, content);
}
}
}
/// Moves the tasks [ids] to the project [projectId] with their sub-tasks, /// Moves the tasks [ids] to the project [projectId] with their sub-tasks,
/// theirs too, out of any section; one whose parent stays behind becomes /// 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 { Future<void> move(Iterable<String> ids, String projectId) async {
final tasks = await all(); final tasks = await all();
final moving = _withDescendants({...ids}, tasks); await _write(movedTo(openTasks(tasks), ids, projectId), 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,
),
);
}
}
} }
/// Places the task [id] at [position] in [sectionId] of its project, /// Places the task [id] at [position] in [sectionId] of its project,
@ -177,29 +188,22 @@ class TaskRepository(
String? parentId, String? parentId,
}) async { }) async {
final tasks = await all(); final tasks = await all();
final moving = _withDescendants({id}, tasks); final open = openTasks(tasks);
if (parentId != null && moving.contains(parentId)) { if (parentId != null && withSubTasks({id}, open).contains(parentId)) {
throw ArgumentError.value(parentId, 'parentId', 'is $id or under it'); throw ArgumentError.value(parentId, 'parentId', 'is $id or under it');
} }
final before = <StoredTask>[]; final writes = placedAt(
for (final task in tasks) { open,
final content = task.content; id,
if (content == null || !moving.contains(task.id)) { sectionId: sectionId,
continue; parentId: parentId,
} position: position,
final placed = task.id == id );
? content.copyWith( await _write(writes, tasks);
sectionId: () => sectionId, return [
parentId: () => parentId, for (final task in tasks)
position: position, if (writes.containsKey(task.id)) task,
) ];
: content.copyWith(sectionId: () => sectionId);
if (placed != content) {
before.add(task);
await update(task.id, placed);
}
}
return before;
} }
/// Copies the task [id] with its sub-tasks, theirs too, each as it is, /// 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. /// 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(); 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 /// Deletes every task in the project [projectId] (or, given, only those

View file

@ -183,3 +183,103 @@ DateTime? addedAt(String id) {
null => null, 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],
);
}

View file

@ -6,13 +6,13 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart'; import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.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/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/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.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.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/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_selection.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.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:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -41,7 +41,6 @@ Future<void> moveTask(
bool allDay = false, bool allDay = false,
}) async { }) async {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final tasks = PersonScope.of<TodoPerson>(context).tasks; final tasks = PersonScope.of<TodoPerson>(context).tasks;
final task = context final task = context
.read<TodoCubit>() .read<TodoCubit>()
@ -67,13 +66,17 @@ Future<void> moveTask(
if (to == date) { if (to == date) {
return; return;
} }
try { final moved = content.copyWith(date: () => to);
await tasks.update(id, content.copyWith(date: () => to)); // There at once; back, with a snack bar, where not kept.
} on KeysMissing { final written = await writeTasks(
messenger.showSnackBar(SnackBar(content: Text(l10n.keysMissing))); context,
return; change: (all) => withChanges(all, {id: moved}),
} write: () async {
if (context.mounted) { await tasks.update(id, moved);
return true;
},
);
if (written != null && context.mounted) {
final dayName = dayText(context, day, Day.of(clock.now())); final dayName = dayText(context, day, Day.of(clock.now()));
showUndoSnackBar( showUndoSnackBar(
context, context,
@ -132,24 +135,38 @@ Future<void> dropAt(
); );
case TaskDrag(:final id): case TaskDrag(:final id):
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final tasks = PersonScope.of<TodoPerson>(context).tasks; final tasks = PersonScope.of<TodoPerson>(context).tasks;
try { final open = context.read<TodoCubit>().state.tasks;
final before = await tasks.place( 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, id,
sectionId: spot.sectionId, sectionId: spot.sectionId,
parentId: spot.parentId, parentId: spot.parentId,
position: spot.position, 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)));
} }
} }
} }

View file

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'package:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart'; import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.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/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/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_pickers.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_writes.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.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 /// What the bar does to the chosen tasks, written through the person's
/// person's [TaskRepository]; choosing ends once it is done. /// [TaskRepository] together: shown at once, as choosing ends, and shown
/// as they were again, with a snack bar, where writing fails.
class _SelectionActions( class _SelectionActions(
final BuildContext _context, final BuildContext _context,
final TaskSelection _selection, final TaskSelection _selection,
@ -168,13 +171,24 @@ class _SelectionActions(
]; ];
} }
/// Writes [change] into each chosen task, then stops choosing. /// Writes [change] into each chosen task, shown at once.
Future<void> _each(TaskContent Function(TaskContent content) change) => Future<void> _each(TaskContent Function(TaskContent content) change) {
_guard(() async { final contents = {
for (final task in _chosen) { for (final task in _chosen) task.id: change(task.content),
await _tasks.update(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 { Future<void> _guard(Future<void> Function() write) async {
final messenger = ScaffoldMessenger.of(_context); final messenger = ScaffoldMessenger.of(_context);
@ -210,7 +224,11 @@ class _SelectionActions(
) )
case final projectId?) { case final projectId?) {
final ids = _selection.ids.toList(); 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 tasks = _tasks;
final chosen = _chosen.where((task) => !task.content.completed).toList(); final chosen = _chosen.where((task) => !task.content.completed).toList();
final reopen = context.read<TodoCubit>().state.settings.tasks.resetSubTasks; final reopen = context.read<TodoCubit>().state.settings.tasks.resetSubTasks;
final before = <StoredTask>[]; final now = clock.now();
await _guard(() async { final before = await _write(
for (final task in chosen) { (all) => withChanges(all, {
before.addAll( for (final task in chosen)
await tasks.complete( task.id: completedContent(task.content, now: now),
StoredTask(task.id, task.content), }),
reopenSubTasks: reopen, () => tasks.completeAll([
), for (final task in chosen) StoredTask(task.id, task.content),
); ], reopenSubTasks: reopen),
} );
}); if (before != null && before.isNotEmpty && context.mounted) {
if (before.isNotEmpty && context.mounted) {
showUndoSnackBar( showUndoSnackBar(
context, context,
message: l10n.tasksCompleted(chosen.length), message: l10n.tasksCompleted(chosen.length),
@ -299,11 +316,14 @@ class _SelectionActions(
confirmLabel: l10n.selectionDelete(count), confirmLabel: l10n.selectionDelete(count),
)) { )) {
final ids = _selection.ids.toList(); final ids = _selection.ids.toList();
await _guard(() async { final tasks = _tasks;
for (final id in ids) { await _write((all) {
await _tasks.delete(id); final gone = withSubTasks({...ids}, all);
} return [
}); for (final task in all)
if (!gone.contains(task.id)) task,
];
}, () => tasks.deleteAll(ids));
} }
} }
} }

View file

@ -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_selection.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_sheet.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_texts.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_writes.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -372,17 +373,24 @@ class const TaskCheck({required final OpenTask task, super.key})
onPressed: () async { onPressed: () async {
final tasks = PersonScope.of<TodoPerson>(context).tasks; final tasks = PersonScope.of<TodoPerson>(context).tasks;
final stored = StoredTask(task.id, content); final stored = StoredTask(task.id, content);
final before = await tasks.complete( final done = !content.completed;
stored, final reopen = context
done: !content.completed, .read<TodoCubit>()
reopenSubTasks: context .state
.read<TodoCubit>() .settings
.state .tasks
.settings .resetSubTasks;
.tasks final now = clock.now();
.resetSubTasks, // 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( showUndoSnackBar(
context, context,
message: l10n.taskCompleted, message: l10n.taskCompleted,

View file

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

View file

@ -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_drag.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_refresh.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_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/hour_grid.dart';
import 'package:halkyon_todo_flutter/src/tabs/plan_host.dart'; import 'package:halkyon_todo_flutter/src/tabs/plan_host.dart';
import 'package:halkyon_todo_flutter/src/tabs/tab_screen.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 { Future<void> reschedule(BuildContext context, List<OpenTask> tasks) async {
final todo = PersonScope.of<TodoPerson>(context); final todo = PersonScope.of<TodoPerson>(context);
if (await pickTaskDate(context) case (date: final date?)) { if (await pickTaskDate(context) case (date: final date?)) {
for (final task in tasks) { final contents = {
final old = task.content.date; for (final task in tasks)
await todo.tasks.update( task.id: task.content.copyWith(
task.id,
task.content.copyWith(
date: () => TaskDate( date: () => TaskDate(
date.day, date.day,
time: date.time ?? old?.time, time: date.time ?? task.content.date?.time,
duration: old?.duration, duration: task.content.date?.duration,
repeat: old?.repeat, repeat: task.content.date?.repeat,
), ),
), ),
};
if (context.mounted) {
await writeTasks(
context,
change: (all) => withChanges(all, contents),
write: () => todo.tasks.updateAll(contents),
); );
} }
} }

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart' import 'package:flutter/gestures.dart'
show PointerDeviceKind, kLongPressTimeout; show PointerDeviceKind, kLongPressTimeout;
@ -4789,6 +4791,130 @@ void main() {
expect(find.text('Post letter'), findsOneWidget); 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 " testWidgets("a row swiped is chosen in the Inbox too, which a project's "
'menu also starts', (tester) async { 'menu also starts', (tester) async {
await start(tester, usedBefore: true); await start(tester, usedBefore: true);

View file

@ -570,4 +570,111 @@ void main() {
[other], [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'),
);
});
});
} }

View file

@ -10,27 +10,54 @@ class MemorySealedRowGateway() implements SealedRowGateway {
final byId = <String, Envelope>{}; final byId = <String, Envelope>{};
final _remote = StreamController<void>.broadcast(); 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. /// As a sync bringing others' changes.
void bringRemoteChanges() => _remote.add(null); void bringRemoteChanges() => _remote.add(null);
@override Future<void> _write() async {
Future<List<SealedRow>> rows() async => [ await holdWrites?.future;
for (final MapEntry(:key, :value) in byId.entries) if (failWrites case final error?) {
(id: key, envelope: value), throw error;
]; }
}
@override @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 @override
Future<void> update(String id, Envelope envelope) async { Future<void> update(String id, Envelope envelope) async {
await _write();
if (byId.containsKey(id)) { if (byId.containsKey(id)) {
byId[id] = envelope; byId[id] = envelope;
} }
} }
@override @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 @override
Stream<void> get remoteChanges => _remote.stream; Stream<void> get remoteChanges => _remote.stream;