feat(todo): the person's projects, sealed and synced, the Inbox made once (ADR 0033, D5, D6)

TodoPerson opens the person's own sync database in their folder,
halkyon_app's SyncService behind AppCompatibility, their key (TodoKeys,
Train's copied with Todo's own key IDs) and their projects, and closes
them in that order, the key last. A project is sealed for its row; a
readable, foreign or moved row shows locked, and a device without the
account's keys reads and writes nothing. The Inbox is the row under an
ID keyed by the account, so every device makes the same one and the
instance can't tell which it is; it can't be deleted. Settings shows
Sync's card first and the cog its dot while sync fails, as Learn's.

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-01 01:05:31 +01:00
commit 60a8f5b617
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
12 changed files with 1285 additions and 36 deletions

View file

@ -79,13 +79,23 @@ class _TodoPersonAppState() extends State<TodoPersonApp> {
localizationsDelegates: todoLocalizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
localeListResolutionCallback: resolveAppLocale,
// How many accounts wait, for whoever operates the instance: Settings'
// operator card and the badge on Browse and its cog.
// How many accounts wait, for whoever operates the instance (Settings'
// operator card and the badge on Browse and its cog), and where sync
// stands.
builder: (context, child) => PersonScope(
person: widget.person,
child: BlocProvider(
create: (context) =>
OperatingCubit(AppScope.of(context).instanceOperators),
child: MultiBlocProvider(
providers: [
BlocProvider(
create: (context) =>
OperatingCubit(AppScope.of(context).instanceOperators),
),
// Where sync stands: Settings' card and the dot on the cog.
BlocProvider(
create: (context) =>
SyncStatusBloc(PersonScope.of<TodoPerson>(context).sync),
),
],
child: ScreenColor(
color: PaletteColor.strawberry.background,
child: child!,

View file

@ -1,24 +1,44 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart';
import 'package:halkyon_todo_client/halkyon_todo_client.dart' show Client;
import 'package:halkyon_todo_client/halkyon_todo_client.dart'
show Client, UuidValue;
import 'package:halkyon_todo_flutter/src/core/privacy/sync_content_key_gateway.dart';
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/sync_project_gateway.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
import 'package:serverpod_flutter/serverpod_flutter.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// A signed-in person's dependencies: who they are, and nothing else yet, as
/// Todo keeps nothing of a person's until its first model (no local
/// database, no sync). halkyon_app's `PersonScope` opens and disposes them
/// with the person.
/// A signed-in person's dependencies: who they are and their projects, on
/// their own local database in their folder, which the sync engine keeps in
/// step with their other devices, every row sealed with their key (ADR 0033,
/// D5, D6). halkyon_app's `PersonScope` opens and disposes them with the
/// person.
class TodoPerson(
/// Whose they are.
final SignedIn person,
) implements Disposable {
final SignedIn person, {
/// Their sync with the instance: where it stands, for Settings' card.
required final SyncService sync,
/// Their projects, the Inbox among them, sealed and synced.
required final ProjectRepository projects,
final Future<void> Function()? _onDispose,
}) implements Disposable {
/// Closes what was opened for the person.
@override
Future<void> dispose() async {}
Future<void> dispose() async {
await _onDispose?.call();
}
}
/// Opens a signed-in person's dependencies ([TodoDependencies.openPerson]).
@ -112,6 +132,13 @@ class TodoDependencies({
// As the session above: an account waiting for the operator shows its
// wait, not the start screen.
await authGateway.initialize();
// Whether the server still works with this build: asked before the
// first sync of each launch.
final compatibility = AppCompatibility(
appBuild: device.appBuild,
minimumAppBuild: () async =>
(await client.modules.base.instance.basics()).minimumAppBuild,
);
final people = device.storage.peopleOf(instance);
final folders = PersonFolders(people);
final addresses = FileSignInAddresses(people);
@ -146,6 +173,8 @@ class TodoDependencies({
ServerpodAssistantOperatorGateway(client.modules.base),
ServerpodInstanceOperatorGateway(client.modules.base),
);
// The last person's data closing, before the next one's opens.
var previousClosed = Future<void>.value();
return TodoDependencies(
serverName: instance.name,
auth: AuthRepository(authGateway, addresses),
@ -159,9 +188,29 @@ class TodoDependencies({
sourceCodeUrl: () async =>
(await client.modules.base.instance.basics()).sourceCodeUrl,
openPerson: (person) async {
// One person's folder on the device at a time, as Learn keeps it.
await folders.openOnly(person.authUserId);
return TodoPerson(person);
// The person before may still be closing their database, which may
// be this one's (signed in again): waited for, but not for ever.
await previousClosed.timeout(
const Duration(seconds: 10),
onTimeout: () {},
);
final closed = Completer<void>();
previousClosed = closed.future;
try {
return await _openPersonData(
client,
compatibility,
person,
// One person's folder on the device at a time, as Learn keeps it.
await folders.openOnly(person.authUserId),
accountKeys: () => accountKeys.read(person.authUserId),
onClosed: closed.complete,
);
} on Object {
// Nothing was opened, so there is nothing to wait for.
closed.complete();
rethrow;
}
},
onDispose: () async {
await passwords.dispose();
@ -182,6 +231,73 @@ class TodoDependencies({
}
}
/// Opens [person]'s own local database in [folder] (see [PersonFolders]), its
/// sync, their key and their projects, the Inbox made where it isn't yet;
/// [onClosed] runs once all is closed again.
Future<TodoPerson> _openPersonData(
Client client,
AppCompatibility compatibility,
SignedIn person,
Directory folder, {
required Future<UnlockedAccountKeys?> Function() accountKeys,
required void Function() onClosed,
}) async {
final db = await client.createSyncSession(
p.join(folder.path, 'sync.sqlite'),
persistentUserId: UuidValue.withValidation(person.authUserId),
);
final kept = FilePendingChangesStore(
File(p.join(folder.path, 'sync_pending.json')),
);
final sync = SyncService(
compatibleSync(
({required onMerge}) => client.offlineSync.syncOnce(
db,
// Only when changes from other devices came in: a sync that only
// sent this device's changes has nothing new to read.
onInboundMerge: (space) => onMerge(),
),
compatibility,
),
pendingChanges: kept,
syncedBefore: (await kept.read()).synced,
)..start();
// The person's key, with which everything of theirs is sealed (D5); read
// again whenever the projects are, as a sync may have brought it.
final keys = TodoKeys(
SyncContentKeyGateway(db, sync),
SodiumContentCipher(),
accountKeys,
);
await keys.refresh();
final projects = ProjectRepository(SyncProjectGateway(db, sync), keys);
// The Inbox, made once by whichever device is first; nothing where this
// device holds none of the account's keys.
unawaited(
projects.ensureInbox().then<void>(
(_) {},
onError: (Object error) =>
log('The Inbox not made: $error', name: 'Projects'),
),
);
return TodoPerson(
person,
sync: sync,
projects: projects,
onDispose: () async {
try {
await projects.dispose();
await sync.dispose();
await db.close();
} finally {
// The key opened here goes once nothing can write any more.
keys.dispose();
onClosed();
}
},
);
}
/// What the dependencies on every instance share: the device's own parts,
/// made once when the app starts, outliving every switch of instance.
class TodoDevice({

View file

@ -0,0 +1,17 @@
import 'dart:typed_data';
/// A `content_key` row (ADR 0033, D5; Train's ADR 0030, D15): the key's ID
/// as the row says it, which is never trusted, and the key sealed to the account.
typedef ContentKeyRow = ({String dataKeyId, Uint8List sealedKey});
/// Where the person's data keys are kept: their synced `content_key` rows.
abstract interface class ContentKeyGateway() {
/// Every key row of the person's.
Future<List<ContentKeyRow>> all();
/// Keeps a new key.
Future<void> insert({
required String dataKeyId,
required Uint8List sealedKey,
});
}

View file

@ -0,0 +1,40 @@
import 'dart:typed_data';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_client/halkyon_todo_client.dart' as protocol;
import 'package:halkyon_todo_flutter/src/core/privacy/content_key_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// [ContentKeyGateway] on the person's local database, which the sync engine
/// keeps in step with their other devices.
class SyncContentKeyGateway(
final OfflineSyncDatabaseSession _db,
final SyncService _sync,
) implements ContentKeyGateway {
@override
Future<List<ContentKeyRow>> all() async => [
for (final key in await protocol.ContentKey.db.find(
_db,
orderBy: (t) => t.id,
))
(
dataKeyId: key.dataKeyId,
sealedKey: Uint8List.sublistView(key.sealedKey),
),
];
@override
Future<void> insert({
required String dataKeyId,
required Uint8List sealedKey,
}) async {
await protocol.ContentKey.db.insertRow(
_db,
protocol.ContentKey(
dataKeyId: dataKeyId,
sealedKey: ByteData.sublistView(sealedKey),
),
);
_sync.requestSync();
}
}

View file

@ -0,0 +1,115 @@
import 'dart:convert';
import 'dart:math';
import 'package:clock/clock.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/src/core/privacy/content_key_gateway.dart';
/// Refused: this device holds none of the account's keys (signed in without
/// them), so it opens none of the person's data and writes none (ADR 0033,
/// D5; as Train's ADR 0030, D15, and Learn's ADR 0019 have it).
final class KeysMissing() implements Exception {
@override
String toString() => "KeysMissing: the account's keys aren't on this device";
}
/// The person's key on this device (ADR 0033, D5; Train's `TrainKeys`, copied
/// with Todo's own IDs, ADR 0030, D15): one key, with which
/// everything of Todo's is sealed (ADR 0033, D5), kept in their synced `content_key`
/// rows sealed to the account, so their other devices open it.
///
/// A key's ID is sealed with it and signed by the account (halkyon_keys'
/// `sealDataKey` opens a key only under the ID it was sealed with), so the
/// instance can't relabel one, and only IDs of Todo's own form are opened:
/// a key the account sealed for another app (Learn's personal key) never
/// counts here. Two devices that each made a key while offline both write
/// with the older once they see both; every key that opens still reads.
class TodoKeys(
final ContentKeyGateway _gateway,
/// Seals and opens with the keys; [TodoKeys] owns it.
final ContentCipher cipher,
/// The account's keys on this device; null where none are.
final Future<UnlockedAccountKeys?> Function() _accountKeys,
) {
var _unlocked = false;
String? _writeKey;
/// Reads the key rows again and opens the person's keys among them: as
/// the person's data opens, and after a sync brought others' rows.
Future<void> refresh() async {
final account = await _accountKeys();
if (account == null) {
return;
}
if (!_unlocked) {
await cipher.unlock(account);
_unlocked = true;
}
for (final row in await _gateway.all()) {
final id = row.dataKeyId;
if (_madeAt(id) != null &&
(cipher.has(id) || cipher.openKey(id, row.sealedKey)) &&
_older(id, _writeKey)) {
_writeKey = id;
}
}
}
/// Whether the account's keys are open here: [refresh] found them.
bool get unlocked => _unlocked;
/// The ID of the row named [name], keyed by the account (D57): the same
/// on each of its devices, nothing of [name] read from it by the
/// instance. Throws [KeysMissing] where [refresh] found no keys.
String rowId(String name) =>
_unlocked ? cipher.rowId(name) : throw KeysMissing();
/// Whether what is sealed with the key [keyId] opens here.
bool canOpen(String keyId) =>
_unlocked && _madeAt(keyId) != null && cipher.has(keyId);
/// The key to seal with, made and kept the first time; throws
/// [KeysMissing] without the account's keys. The key rows are read first
/// where none was chosen yet, so that no second key is made beside one
/// already there.
Future<String> keyToWrite() async {
if (_writeKey == null) {
await refresh();
}
if (_writeKey case final key?) {
return key;
}
if (!_unlocked) {
throw KeysMissing();
}
final random = Random.secure();
final nonce = base64Url
.encode([for (var i = 0; i < 12; i++) random.nextInt(256)])
.replaceAll('=', '');
final id = 'todo1.${clock.now().toUtc().millisecondsSinceEpoch}.$nonce';
await _gateway.insert(dataKeyId: id, sealedKey: cipher.newKey(id));
return _writeKey = id;
}
/// Wipes the keys kept open here.
void dispose() => cipher.dispose();
/// When the key [id] was made, as its ID says (`todo1.<ms>.<nonce>`);
/// null for an ID of another form, which is never Todo's.
static int? _madeAt(String id) => switch (id.split('.')) {
['todo1', final ms, final nonce] when nonce.isNotEmpty => int.tryParse(ms),
_ => null,
};
/// Whether [id] was made before [other] (none at all: yes), ties going to
/// the smaller ID, so every device picks the same.
static bool _older(String id, String? other) {
if (other == null) {
return true;
}
final (a, b) = (_madeAt(id)!, _madeAt(other)!);
return a < b || (a == b && id.compareTo(other) < 0);
}
}

View file

@ -0,0 +1,21 @@
import 'package:halkyon_app/halkyon_app.dart';
/// A `project` row as kept: its id and its envelope, sealed (ADR 0033, D5).
typedef ProjectRow = ({String id, Envelope envelope});
/// Where the person's projects are kept: their synced `project` rows
/// (D6). The repository seals and opens; this only keeps.
abstract interface class ProjectGateway() {
Future<List<ProjectRow>> projects();
Future<void> insert(ProjectRow project);
/// Replaces the project [id]'s envelope (an edit); nothing for one gone.
Future<void> update(String id, Envelope envelope);
/// Deletes the project [id], its payload blanked first (ADR 0002, rule 2).
Future<void> delete(String id);
/// Fires when a sync brought changes from the person's other devices.
Stream<void> get remoteChanges;
}

View file

@ -0,0 +1,48 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:typed_data';
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
/// The schema of a project's payload this app writes and reads.
const projectSchemaVersion = 1;
/// [content] as its row's payload, before sealing: UTF-8 JSON, the colour by
/// its palette name.
ByteData encodeProject(ProjectContent content) => ByteData.sublistView(
Uint8List.fromList(
utf8.encode(
jsonEncode({
'schemaVersion': projectSchemaVersion,
'name': content.name,
'color': ?content.color?.name,
'position': content.position,
}),
),
),
);
/// The project [payload] holds, opened; null for one of a newer schema or one
/// it can't read. A colour it doesn't know is none.
ProjectContent? decodeProject(ByteData payload) {
try {
final json = jsonDecode(
utf8.decode(Uint8List.sublistView(payload)),
) as Map<String, Object?>;
final version = json['schemaVersion']! as int;
if (version > projectSchemaVersion) {
log('A project of schema $version skipped', name: 'Project');
return null;
}
final color = json['color'] as String?;
return ProjectContent(
name: json['name']! as String,
color: PaletteColor.values.where((c) => c.name == color).firstOrNull,
position: (json['position'] as num?)?.toDouble() ?? 0,
);
} on Object catch (error) {
log("A project's payload not read: $error", name: 'Project');
return null;
}
}

View file

@ -0,0 +1,125 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_client/halkyon_todo_client.dart' show Uuid;
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/project_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/project_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
/// Refused: the Inbox stays as long as the person does (ADR 0033, D6).
final class InboxKept() implements Exception {
@override
String toString() => "InboxKept: the Inbox can't be deleted";
}
/// The person's projects (ADR 0033, D6), each sealed with their key for its
/// row (D5); a readable row is one the instance made up, and shows locked.
///
/// The Inbox is the row under an ID keyed by the account
/// ([TodoKeys.rowId], Train's ADR 0030, D57): every device of the person's
/// makes the same one, so two that make it offline write one row, and the
/// instance can tell neither which row it is nor whose.
class ProjectRepository(final ProjectGateway _gateway, final TodoKeys _keys) {
StreamSubscription<void>? _remote;
/// This device's changes, and those a sync brings while anyone listens.
late final StreamController<void> _changes = StreamController.broadcast(
onListen: () => _remote = _gateway.remoteChanges.listen(_changes.add),
onCancel: () => _remote?.cancel(),
);
late final _envelope = PayloadEnvelope(_keys.cipher);
/// Fires when the projects changed, here or on the person's other devices.
Stream<void> get changes => _changes.stream;
/// The Inbox's row ID; throws [KeysMissing] without the account's keys.
String get inboxId => _keys.rowId('todo/project/inbox');
/// Every project, the Inbox first, then by their position, ties by when
/// they were made; one that doesn't open here with its content null.
Future<List<StoredProject>> all() async {
await _keys.refresh();
final inbox = _keys.unlocked ? inboxId : null;
final projects = [
for (final row in await _gateway.projects())
StoredProject(row.id, switch (_open(row.envelope, row.id)) {
final payload? => decodeProject(payload),
null => null,
}, inbox: row.id == inbox),
];
return projects..sort(_order);
}
/// Makes the Inbox where it isn't yet and returns its ID; throws
/// [KeysMissing] where this device can't seal.
Future<String> ensureInbox() async {
await _keys.refresh();
final id = inboxId;
if (!(await _gateway.projects()).any((row) => row.id == id)) {
await _gateway.insert((
id: id,
envelope: await _sealed(id, ProjectContent.inbox),
));
_changes.add(null);
}
return id;
}
/// Keeps a new project and returns its ID; throws [KeysMissing] where this
/// device can't seal.
Future<String> save(ProjectContent content) async {
final id = const Uuid().v7();
await _gateway.insert((id: id, envelope: await _sealed(id, content)));
_changes.add(null);
return id;
}
Future<void> update(String id, ProjectContent content) async {
await _gateway.update(id, await _sealed(id, content));
_changes.add(null);
}
/// Deletes the project [id]; throws [InboxKept] for the Inbox.
Future<void> delete(String id) async {
if (_keys.unlocked && id == inboxId) {
throw InboxKept();
}
await _gateway.delete(id);
_changes.add(null);
}
/// Closes what it holds open.
Future<void> dispose() => _changes.close();
Future<Envelope> _sealed(String id, ProjectContent content) async =>
_envelope.seal(
encodeProject(content),
keyId: await _keys.keyToWrite(),
table: 'project',
id: id,
);
ByteData? _open(Envelope envelope, String id) {
if (envelope.keyId == null) {
return null;
}
return switch (_envelope.open(envelope, table: 'project', id: id)) {
Readable(:final payload) => payload,
Locked() => null,
};
}
static int _order(StoredProject a, StoredProject b) {
if (a.inbox != b.inbox) {
return a.inbox ? -1 : 1;
}
final byPosition = (a.content?.position ?? double.infinity).compareTo(
b.content?.position ?? double.infinity,
);
return byPosition != 0 ? byPosition : a.id.compareTo(b.id);
}
}

View file

@ -0,0 +1,99 @@
import 'dart:typed_data';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_client/halkyon_todo_client.dart' as protocol;
import 'package:halkyon_todo_flutter/src/feature/project/data/project_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// [ProjectGateway] on the person's local database, which the sync engine
/// keeps in step with their other devices.
class SyncProjectGateway(
final OfflineSyncDatabaseSession _db,
final SyncService _sync,
) implements ProjectGateway {
@override
Future<List<ProjectRow>> projects() async => [
for (final project in await protocol.Project.db.find(
_db,
orderBy: (t) => t.id,
))
(
id: project.id!.uuid,
envelope: (
keyId: project.keyId,
nonce: project.nonce,
payload: project.payload,
),
),
];
@override
Future<void> insert(ProjectRow project) async {
await protocol.Project.db.insertRow(
_db,
protocol.Project(
id: protocol.UuidValue.withValidation(project.id),
keyId: project.envelope.keyId,
nonce: project.envelope.nonce,
payload: project.envelope.payload,
),
);
_sync.requestSync();
}
@override
Future<void> update(String id, Envelope envelope) async {
if (await protocol.Project.db.findById(
_db,
protocol.UuidValue.withValidation(id),
)
case final project?) {
// Only the named columns change (ADR 0001, rule 1).
await protocol.Project.db.updateRow(
_db,
project.copyWith(
keyId: envelope.keyId,
nonce: envelope.nonce,
payload: envelope.payload,
),
columns: (t) => [t.keyId, t.nonce, t.payload],
);
_sync.requestSync();
}
}
@override
Future<void> delete(String id) async {
final uuid = protocol.UuidValue.withValidation(id);
await _db.db.transaction((transaction) async {
// Blanked first (ADR 0002, rule 2): the instance keeps deleted rows as
// they last were, until the space goes.
if (await protocol.Project.db.findById(
_db,
uuid,
transaction: transaction,
)
case final project?) {
await protocol.Project.db.updateRow(
_db,
protocol.Project(
id: project.id,
spaceId: project.spaceId,
payload: ByteData(0),
),
columns: (t) => [t.keyId, t.nonce, t.payload],
transaction: transaction,
);
}
await protocol.Project.db.deleteWhere(
_db,
where: (t) => t.id.equals(uuid),
transaction: transaction,
);
});
_sync.requestSync();
}
@override
Stream<void> get remoteChanges => _sync.merges;
}

View file

@ -0,0 +1,29 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
/// What a project is (ADR 0033, D6), as its sealed payload holds it: its
/// [name], its [color] from halkyon_ui's palette (D9; none: the theme's),
/// and its [position] among the person's projects, smaller first. The Inbox
/// is told by its row's ID, not by anything here (`ProjectRepository`), and
/// shows under the app's own word for it, whatever [name] holds.
final class const ProjectContent({
required final String name,
final PaletteColor? color,
final double position = 0,
}) with Equatable {
/// The Inbox's content, as the first device writes it.
static const inbox = ProjectContent(name: '');
ProjectContent copyWith({
String? name,
PaletteColor? Function()? color,
double? position,
}) => ProjectContent(
name: name ?? this.name,
color: color == null ? this.color : color(),
position: position ?? this.position,
);
@override
List<Object?> get props => [name, color, position];
}

View file

@ -0,0 +1,14 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
/// A project as the person's devices keep it: its [id], whether it is their
/// [inbox], and what it is, or [content] null where it doesn't open here:
/// shown as locked, never half.
final class const StoredProject(
final String id,
final ProjectContent? content, {
final bool inbox = false,
}) with Equatable {
@override
List<Object?> get props => [id, content, inbox];
}

View file

@ -14,13 +14,16 @@ class const SettingsButton({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) => IconButton(
tooltip: AppLocalizations.of(context).settingsTitle,
icon: const WaitingAccountsBadge(child: Icon(LucideIcons.settings)),
// The dot of a failing sync, which the Sync card explains, as Learn's.
icon: const SyncTroubleBadge(
child: WaitingAccountsBadge(child: Icon(LucideIcons.settings)),
),
onPressed: () => context.push(TodoPaths.settings),
);
}
/// Settings, over the tabs (ADR 0033, D4), grouped in cards as Learn's and
/// Train's: the operator's card for whoever operates the instance; Todo's
/// Train's: where sync stands; the operator's card for whoever operates the instance; Todo's
/// own groups as their settings come (none is shown empty); the account,
/// near the foot (halkyon_app's sections: whose it is on which instance,
/// switching, signing out and deleting it, SimpleLogin, its password and
@ -51,6 +54,10 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Where sync stands, first, as Learn's: the cog's dot leads
// here.
const SyncStatusCard(),
const SizedBox(height: 12),
// Accounts waiting for approval, for whoever operates
// the instance alone (Halkyon Learn's ADR 0021, D34),
// first, as the cog's count leads here.