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:
Tizian 「ティツィアーン」 2026-10-28 11:44:40 +01:00
commit 6171ff10a0
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 143 additions and 17 deletions

View file

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

View file

@ -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"))

View file

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

View file

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