feat(train): a person's workouts opened with them (ADR 0030, D14, D15)
TrainPerson now opens the person's own sync database in their folder, halkyon_app's SyncService behind AppCompatibility (the module's basics' minimumAppBuild against the build package_info_plus reads), their key and their workouts, and closes them in that order, the key last. As in Learn, a person's data waits for the one before to close, since signing in again opens the same database. The pubspec's dependencies are sorted again (clock had gone before flutter). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
d1daec9c0b
commit
6171ff10a0
5 changed files with 143 additions and 17 deletions
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
What holds for every app and package of this repository is in the [root AGENTS.md](../../AGENTS.md); it applies here in full, and Learn's patterns (`apps/learn/AGENTS.md`) wherever Train needs the same thing.
|
||||
|
||||
- **A shell on the shared packages**, Todo's copied and renamed (decided 2026-10-28, ADR 0030, D12; what Train is to do: ADR 0030): signing in, the instances, the account and the look work; nothing of a person's is kept or synced yet, which waits for Train's first model (ADR 0030: synced with `serverpod_offline_sync`, every row sealed, D7). Packages: `halkyon_train_server`, `halkyon_train_client` (generated, never edited), `halkyon_train_flutter`; app ID `app.halkyon.train` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`).
|
||||
- **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`trainBase()`, `lib/src/train_base.dart`): its basics say it serves `train` (never change it once apps use it), sign-in mails name Halkyon Train, the source is this repository, `minimumAppBuild` 1, no data of its own to delete with a person yet (add a `deletePersonData` hook with Train's first table). Authentication through the module's builders: a password, SimpleLogin on the register, email codes proving an address, server-side sessions; no anonymous accounts (ADR 0020, D25), nor GitHub (D36). **Accounts** (ADR 0016): Train holds its own register when it runs alone, and is a member of the instance's register (Learn's server on learn.halkyon.eu) where the operator sets `ACCOUNT_REGISTER_URL` (`trainBase(accounts: AccountsMode.fromEnvironment())`) with the instance's shared `INSTANCE_SECRET` (`instanceSecret`); then sign-in, handles and profiles are the register's, the email settings are the register's alone, and an identified person's deletion goes to the register first. `addInstanceRoutes` serves the calls between the services on its web server, and the server doesn't start without the secret where it needs it. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled, one entry each however often the server starts (and cancels the one under the pruning's name before ADR 0020, D25): the anonymous accounts left from before daily, sign-up attempts and codes hourly, as the register the deletions its members haven't confirmed hourly, as a member the people the register no longer knows daily (each a no-op in the other mode). Session logs are never persisted, in any run mode. Migrations (2026-10-28): one initial one with Serverpod's tables, its auth modules' and the module's as they stand, nothing of Train's own (Todo's grew one by one with the module; Train starts where they ended). Tests (`test/integration/train_server_test.dart`) call the module's endpoints through the server's own dispatch (`halkyon_base.<endpoint>`), since the generated test tools expose the project's endpoints alone.
|
||||
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Train's frame (`trainFrame`: "Train" under "Halkyon", its icon, `train.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'train')`; `TrainDependencies` (`AppDependencies<TrainPerson>`) on one instance, `TrainPerson` (empty) per person; a person's app with an empty home and the account sheet: halkyon_app's `AccountTiles` (whose account on which instance, Switch account, Sign out, Delete account, which asks first) in Train's words, and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TrainDependencies.sourceCodeUrl`, and the licence list). Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Train has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Train's whole tree with halkyon_app's fakes (`test/app/train_app_test.dart`).
|
||||
- **A shell on the shared packages**, Todo's copied and renamed (decided 2026-10-28, ADR 0030, D12; what Train is to do: ADR 0030): signing in, the instances, the account and the look work, and since step 5a (28 Oct 2026) a person's workouts are kept and synced with `serverpod_offline_sync`, every row sealed (ADR 0030, D7, D14, D15), though no screen records or shows them yet (5b, 5c). Packages: `halkyon_train_server`, `halkyon_train_client` (generated, never edited), `halkyon_train_flutter`; app ID `app.halkyon.train` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`).
|
||||
- **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`trainBase()`, `lib/src/train_base.dart`): its basics say it serves `train` (never change it once apps use it), sign-in mails name Halkyon Train, the source is this repository, `minimumAppBuild` 1, and `deleteTrainData` (`lib/src/train_data.dart`) as its `deletePersonData` hook, which deletes the person's sync spaces and so, by the sync engine's cascades, every row of theirs (`test/integration/workout/person_deletion_test.dart`). Authentication through the module's builders: a password, SimpleLogin on the register, email codes proving an address, server-side sessions; no anonymous accounts (ADR 0020, D25), nor GitHub (D36). **Accounts** (ADR 0016): Train holds its own register when it runs alone, and is a member of the instance's register (Learn's server on learn.halkyon.eu) where the operator sets `ACCOUNT_REGISTER_URL` (`trainBase(accounts: AccountsMode.fromEnvironment())`) with the instance's shared `INSTANCE_SECRET` (`instanceSecret`); then sign-in, handles and profiles are the register's, the email settings are the register's alone, and an identified person's deletion goes to the register first. `addInstanceRoutes` serves the calls between the services on its web server, and the server doesn't start without the secret where it needs it. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled, one entry each however often the server starts (and cancels the one under the pruning's name before ADR 0020, D25): the anonymous accounts left from before daily, sign-up attempts and codes hourly, as the register the deletions its members haven't confirmed hourly, as a member the people the register no longer knows daily (each a no-op in the other mode). Session logs are never persisted, in any run mode. Migrations (2026-10-28): an initial one with Serverpod's tables, its auth modules' and the module's as they stand (Todo's grew one by one with the module; Train starts where they ended), then `workouts`, purely additive: the sync engine's tables and Train's first three. **Synced models** (`database: sync`, the module `offline_sync`, `experimental_features: databaseSync` in `config/generator.yaml`): `content_key` (the person's key sealed to the account), `workout` and `workout_series` (a workout's series in chunks of at most ten minutes, deleted with it), each a sealed `payload` with its `keyId` and `nonce`, nothing else readable. Tests (`test/integration/train_server_test.dart`) call the module's endpoints through the server's own dispatch (`halkyon_base.<endpoint>`), since the generated test tools expose the project's endpoints alone.
|
||||
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Train's frame (`trainFrame`: "Train" under "Halkyon", its icon, `train.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'train')`; `TrainDependencies` (`AppDependencies<TrainPerson>`) on one instance, `TrainPerson` per person: their own sync database in their folder (`sync.sqlite`, halkyon_app's `PersonFolders`, one person's at a time), halkyon_app's `SyncService` behind `AppCompatibility` (the module's basics' `minimumAppBuild`, against the build `package_info_plus` reads), their key (`TrainKeys`, `lib/src/core/privacy/`: one key per person, IDs `train1.<ms>.<nonce>`, the oldest-made writing, kept in `content_key` and opened only in Train's form, ADR 0030, D15) and their workouts (`WorkoutRepository`, `lib/src/feature/workout/`: halkyon_app's `PayloadEnvelope` seals a workout for its row and a series chunk for its row and its workout; payloads are versioned JSON, a series' columns delta-coded, gzipped before sealing; a deletion blanks every payload first); the gateways' tests run on a sync database opened without a server, the repository's on memory gateways and real libsodium; a person's app with an empty home and the account sheet: halkyon_app's `AccountTiles` (whose account on which instance, Switch account, Sign out, Delete account, which asks first) in Train's words, and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TrainDependencies.sourceCodeUrl`, and the licence list). Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Train has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Train's whole tree with halkyon_app's fakes (`test/app/train_app_test.dart`).
|
||||
- **The icon** is Lucide's `bike` fading from cobalt through violet and strawberry into carrot (ADR 0031), drawn with the other apps' by the repository's `tool/app_icons.py` (`python3 -I tool/app_icons.py train` from the root): `assets/icon/halkyon_train_icon.png`, Lucide's ISC licence beside it, and every platform's launcher icon through flutter_launcher_icons. `registerLucideLicense` puts Lucide's notice in the licence list.
|
||||
- **`serverpod start`** runs in `apps/train/halkyon_train_server` (it launches the app from `lib/driver.dart`); `.mcp.json`'s `--server-dir` names it when a session works on Train. Its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them. Train's development server has ports of its own (API 8180, Insights 8181, web 8182, PostgreSQL 8190; the app's `assets/config.json`), so it runs beside Learn's, as a member of Learn's register; its tests use `config/test.yaml`'s ports, as Learn's and Todo's do, which Melos runs one package at a time.
|
||||
|
|
|
|||
|
|
@ -1,23 +1,39 @@
|
|||
import 'dart:async';
|
||||
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_train_client/halkyon_train_client.dart' show Client;
|
||||
import 'package:halkyon_train_client/halkyon_train_client.dart'
|
||||
show Client, UuidValue;
|
||||
import 'package:halkyon_train_flutter/src/core/privacy/sync_content_key_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/sync_workout_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.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
|
||||
/// Train 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 workouts, on
|
||||
/// their own local database in their folder, which the sync engine keeps in
|
||||
/// step with their other devices (ADR 0030, D7 and D14). halkyon_app's
|
||||
/// `PersonScope` opens and disposes them with the person.
|
||||
class TrainPerson(
|
||||
/// Whose they are.
|
||||
final SignedIn person,
|
||||
) implements Disposable {
|
||||
final SignedIn person, {
|
||||
|
||||
/// Their workouts, sealed and synced.
|
||||
required final WorkoutRepository workouts,
|
||||
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 ([TrainDependencies.openPerson]).
|
||||
|
|
@ -107,6 +123,13 @@ class TrainDependencies({
|
|||
// 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);
|
||||
|
|
@ -141,6 +164,8 @@ class TrainDependencies({
|
|||
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 TrainDependencies(
|
||||
serverName: instance.name,
|
||||
auth: AuthRepository(authGateway, addresses),
|
||||
|
|
@ -153,9 +178,29 @@ class TrainDependencies({
|
|||
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 TrainPerson(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();
|
||||
|
|
@ -176,6 +221,63 @@ class TrainDependencies({
|
|||
}
|
||||
}
|
||||
|
||||
/// Opens [person]'s own local database in [folder] (see [PersonFolders]), its
|
||||
/// sync, their key and their workouts; [onClosed] runs once all is closed
|
||||
/// again.
|
||||
Future<TrainPerson> _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 (D15); read
|
||||
// again whenever the workouts are, as a sync may have brought it.
|
||||
final keys = TrainKeys(
|
||||
SyncContentKeyGateway(db, sync),
|
||||
SodiumContentCipher(),
|
||||
accountKeys,
|
||||
);
|
||||
await keys.refresh();
|
||||
final workouts = WorkoutRepository(SyncWorkoutGateway(db, sync), keys);
|
||||
return TrainPerson(
|
||||
person,
|
||||
workouts: workouts,
|
||||
onDispose: () async {
|
||||
try {
|
||||
await workouts.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 TrainDevice({
|
||||
|
|
@ -184,13 +286,19 @@ class TrainDevice({
|
|||
|
||||
/// Where the device keeps what it holds of the account on each instance.
|
||||
required final InstanceStorage storage,
|
||||
|
||||
/// This build's number (after the `+` in the pubspec's version), which the
|
||||
/// server knows apps by (`AppCompatibility`); null where it has none.
|
||||
required final int? appBuild,
|
||||
}) {
|
||||
/// Makes them, [builtIn] being the instance the build names.
|
||||
static Future<TrainDevice> create({required Instance builtIn}) async {
|
||||
final package = await PackageInfo.fromPlatform();
|
||||
final support = (await getApplicationSupportDirectory()).path;
|
||||
return TrainDevice(
|
||||
support: support,
|
||||
storage: InstanceStorage(support, builtIn: builtIn),
|
||||
appBuild: int.tryParse(package.buildNumber),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import file_selector_macos
|
|||
import flutter_secure_storage_darwin
|
||||
import flutter_web_auth_2
|
||||
import google_sign_in_ios
|
||||
import package_info_plus
|
||||
import sign_in_with_apple
|
||||
import url_launcher_macos
|
||||
import window_to_front
|
||||
|
|
@ -24,6 +25,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||
FlutterWebAuth2Plugin.register(with: registry.registrar(forPlugin: "FlutterWebAuth2Plugin"))
|
||||
GoogleSignInPlugin.register(with: registry.registrar(forPlugin: "GoogleSignInPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
WindowToFrontPlugin.register(with: registry.registrar(forPlugin: "WindowToFrontPlugin"))
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ environment:
|
|||
resolution: workspace
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
clock: ^1.1.3
|
||||
equatable: ^3.0.0
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_bloc: ^9.1.1
|
||||
# What every Halkyon app is built around (a shared package): the instances a
|
||||
# device keeps accounts on, signing in through halkyon_base, each person's
|
||||
|
|
@ -38,6 +38,7 @@ dependencies:
|
|||
intl: ^0.20.3
|
||||
lucide_icons_flutter: ^3.1.20
|
||||
material_ui: ^1.5.0
|
||||
package_info_plus: ^10.2.2
|
||||
path: ^1.9.1
|
||||
path_provider: ^2.1.6
|
||||
serverpod_auth_idp_flutter: 4.0.4
|
||||
|
|
|
|||
|
|
@ -3,9 +3,14 @@ import 'package:halkyon_app/halkyon_app.dart';
|
|||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_train_flutter/src/app/train_app.dart';
|
||||
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
|
||||
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../support/memory_content_key_gateway.dart';
|
||||
import '../support/memory_workout_gateway.dart';
|
||||
|
||||
void main() {
|
||||
final builtIn = Instance.parse('train.example.org')!;
|
||||
late MemoryInstanceStore store;
|
||||
|
|
@ -85,7 +90,17 @@ void main() {
|
|||
'https://code.halkyon.app/project-halkyon/halkyon',
|
||||
openPerson: (person) async {
|
||||
opened.add(person);
|
||||
return TrainPerson(person);
|
||||
return TrainPerson(
|
||||
person,
|
||||
workouts: WorkoutRepository(
|
||||
MemoryWorkoutGateway(),
|
||||
TrainKeys(
|
||||
MemoryContentKeyGateway(),
|
||||
FakeContentCipher(),
|
||||
() async => null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in a new issue