feat(train): Halkyon Train's shell, Todo's copied and renamed (ADR 0030, D12)
apps/train with halkyon_train_server, _client and _flutter, as Todo's: halkyon_base configured by trainBase(), its own register alone and a member of the instance's where the operator names one, the app on halkyon_app's gates with Train's frame, account sheet and About, texts in British English and German that speak of workouts, app.halkyon.train on Android, iOS, Linux, macOS and Windows. Where it differs from Todo: development ports of its own (8180 to 8190), so it runs beside Learn's server; one initial migration with the schema Todo's migrations end at (the same tables, compared line by line), not Todo's seventeen; and Todo's icon as a placeholder until the icon tool of ADR 0031 draws Train's bike. The workspace, the licences, the READMEs and AGENTS.md name it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
2fd39352fc
commit
7b5fbee913
16 changed files with 22363 additions and 7 deletions
14
apps/train/halkyon_train_flutter/lib/driver.dart
Normal file
14
apps/train/halkyon_train_flutter/lib/driver.dart
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// The app with the Flutter driver extension, as `serverpod start` launches it (the
|
||||
// server's pubspec, `flutter_apps`); never meant for real use, just development.
|
||||
// ignore: depend_on_referenced_packages
|
||||
import 'package:flutter_driver/driver_extension.dart';
|
||||
import 'package:halkyon_train_flutter/main.dart' as app;
|
||||
|
||||
void main() {
|
||||
enableFlutterDriverExtension(
|
||||
// Off by default, so the app stays usable by hand; agents typing through
|
||||
// the app set it to true.
|
||||
enableTextEntryEmulation: false,
|
||||
);
|
||||
app.main();
|
||||
}
|
||||
16
apps/train/halkyon_train_flutter/lib/l10n/app_de.arb
Normal file
16
apps/train/halkyon_train_flutter/lib/l10n/app_de.arb
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"@@locale": "de",
|
||||
|
||||
"appTitle": "Halkyon Train",
|
||||
"launchProduct": "Train",
|
||||
|
||||
"instanceFirstStartSubtitle": "Wähle die Instanz, auf der deine Trainings liegen sollen. Jede betreibt jemand; weitere kannst du später hinzufügen.",
|
||||
|
||||
"homeEmptyTitle": "Hier ist noch nichts",
|
||||
"homeEmptyHint": "Trainings kommen mit einer späteren Version von Halkyon Train.",
|
||||
|
||||
"accountTitle": "Konto",
|
||||
"accountDeleteMessage": "Dein Konto wird auf dem Server und auf all deinen Geräten gelöscht. Dies kann nicht rückgängig gemacht werden.",
|
||||
|
||||
"aboutTitle": "Über"
|
||||
}
|
||||
18
apps/train/halkyon_train_flutter/lib/l10n/app_en.arb
Normal file
18
apps/train/halkyon_train_flutter/lib/l10n/app_en.arb
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"@@locale": "en",
|
||||
|
||||
"appTitle": "Halkyon Train",
|
||||
"@appTitle": {"description": "The app's name: the window's title, and where halkyon_app's texts name the app."},
|
||||
"launchProduct": "Train",
|
||||
"@launchProduct": {"description": "The app's word in its wordmark, under \"Halkyon\"."},
|
||||
|
||||
"instanceFirstStartSubtitle": "Choose the instance to keep your workouts on. Someone runs each one; you can add others later.",
|
||||
|
||||
"homeEmptyTitle": "Nothing here yet",
|
||||
"homeEmptyHint": "Workouts come with a later version of Halkyon Train.",
|
||||
|
||||
"accountTitle": "Account",
|
||||
"accountDeleteMessage": "Your account is deleted on the server and on all your devices. This can't be undone.",
|
||||
|
||||
"aboutTitle": "About"
|
||||
}
|
||||
3
apps/train/halkyon_train_flutter/lib/l10n/app_en_GB.arb
Normal file
3
apps/train/halkyon_train_flutter/lib/l10n/app_en_GB.arb
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"@@locale": "en_GB"
|
||||
}
|
||||
3
apps/train/halkyon_train_flutter/lib/l10n/app_en_US.arb
Normal file
3
apps/train/halkyon_train_flutter/lib/l10n/app_en_US.arb
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"@@locale": "en_US"
|
||||
}
|
||||
198
apps/train/halkyon_train_flutter/lib/l10n/app_localizations.dart
Normal file
198
apps/train/halkyon_train_flutter/lib/l10n/app_localizations.dart
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations_de.dart';
|
||||
import 'app_localizations_en.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
/// Callers can lookup localized strings with an instance of AppLocalizations
|
||||
/// returned by `AppLocalizations.of(context)`.
|
||||
///
|
||||
/// Applications need to include `AppLocalizations.delegate()` in their app's
|
||||
/// `localizationDelegates` list, and the locales they support in the app's
|
||||
/// `supportedLocales` list. For example:
|
||||
///
|
||||
/// ```dart
|
||||
/// import 'l10n/app_localizations.dart';
|
||||
///
|
||||
/// return MaterialApp(
|
||||
/// localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
/// supportedLocales: AppLocalizations.supportedLocales,
|
||||
/// home: MyApplicationHome(),
|
||||
/// );
|
||||
/// ```
|
||||
///
|
||||
/// ## Update pubspec.yaml
|
||||
///
|
||||
/// Please make sure to update your pubspec.yaml to include the following
|
||||
/// packages:
|
||||
///
|
||||
/// ```yaml
|
||||
/// dependencies:
|
||||
/// # Internationalization support.
|
||||
/// flutter_localizations:
|
||||
/// sdk: flutter
|
||||
/// intl: any # Use the pinned version from flutter_localizations
|
||||
///
|
||||
/// # Rest of dependencies
|
||||
/// ```
|
||||
///
|
||||
/// ## iOS Applications
|
||||
///
|
||||
/// iOS applications define key application metadata, including supported
|
||||
/// locales, in an Info.plist file that is built into the application bundle.
|
||||
/// To configure the locales supported by your app, you’ll need to edit this
|
||||
/// file.
|
||||
///
|
||||
/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file.
|
||||
/// Then, in the Project Navigator, open the Info.plist file under the Runner
|
||||
/// project’s Runner folder.
|
||||
///
|
||||
/// Next, select the Information Property List item, select Add Item from the
|
||||
/// Editor menu, then select Localizations from the pop-up menu.
|
||||
///
|
||||
/// Select and expand the newly-created Localizations item then, for each
|
||||
/// locale your application supports, add a new item and select the locale
|
||||
/// you wish to add from the pop-up menu in the Value field. This list should
|
||||
/// be consistent with the languages listed in the AppLocalizations.supportedLocales
|
||||
/// property.
|
||||
abstract class AppLocalizations {
|
||||
AppLocalizations(String locale)
|
||||
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
|
||||
|
||||
final String localeName;
|
||||
|
||||
static AppLocalizations of(BuildContext context) {
|
||||
return Localizations.of<AppLocalizations>(context, AppLocalizations)!;
|
||||
}
|
||||
|
||||
static const LocalizationsDelegate<AppLocalizations> delegate =
|
||||
_AppLocalizationsDelegate();
|
||||
|
||||
/// A list of this localizations delegate along with the default localizations
|
||||
/// delegates.
|
||||
///
|
||||
/// Returns a list of localizations delegates containing this delegate along with
|
||||
/// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
|
||||
/// and GlobalWidgetsLocalizations.delegate.
|
||||
///
|
||||
/// Additional delegates can be added by appending to this list in
|
||||
/// MaterialApp. This list does not have to be used at all if a custom list
|
||||
/// of delegates is preferred or required.
|
||||
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
|
||||
<LocalizationsDelegate<dynamic>>[
|
||||
delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
];
|
||||
|
||||
/// A list of this localizations delegate's supported locales.
|
||||
static const List<Locale> supportedLocales = <Locale>[
|
||||
Locale('de'),
|
||||
Locale('en'),
|
||||
Locale('en', 'GB'),
|
||||
Locale('en', 'US'),
|
||||
];
|
||||
|
||||
/// The app's name: the window's title, and where halkyon_app's texts name the app.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Halkyon Train'**
|
||||
String get appTitle;
|
||||
|
||||
/// The app's word in its wordmark, under "Halkyon".
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Train'**
|
||||
String get launchProduct;
|
||||
|
||||
/// No description provided for @instanceFirstStartSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose the instance to keep your workouts on. Someone runs each one; you can add others later.'**
|
||||
String get instanceFirstStartSubtitle;
|
||||
|
||||
/// No description provided for @homeEmptyTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Nothing here yet'**
|
||||
String get homeEmptyTitle;
|
||||
|
||||
/// No description provided for @homeEmptyHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Workouts come with a later version of Halkyon Train.'**
|
||||
String get homeEmptyHint;
|
||||
|
||||
/// No description provided for @accountTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Account'**
|
||||
String get accountTitle;
|
||||
|
||||
/// No description provided for @accountDeleteMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your account is deleted on the server and on all your devices. This can\'t be undone.'**
|
||||
String get accountDeleteMessage;
|
||||
|
||||
/// No description provided for @aboutTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'About'**
|
||||
String get aboutTitle;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
extends LocalizationsDelegate<AppLocalizations> {
|
||||
const _AppLocalizationsDelegate();
|
||||
|
||||
@override
|
||||
Future<AppLocalizations> load(Locale locale) {
|
||||
return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
|
||||
}
|
||||
|
||||
@override
|
||||
bool isSupported(Locale locale) =>
|
||||
<String>['de', 'en'].contains(locale.languageCode);
|
||||
|
||||
@override
|
||||
bool shouldReload(_AppLocalizationsDelegate old) => false;
|
||||
}
|
||||
|
||||
AppLocalizations lookupAppLocalizations(Locale locale) {
|
||||
// Lookup logic when language+country codes are specified.
|
||||
switch (locale.languageCode) {
|
||||
case 'en':
|
||||
{
|
||||
switch (locale.countryCode) {
|
||||
case 'GB':
|
||||
return AppLocalizationsEnGb();
|
||||
case 'US':
|
||||
return AppLocalizationsEnUs();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup logic when only language code is specified.
|
||||
switch (locale.languageCode) {
|
||||
case 'de':
|
||||
return AppLocalizationsDe();
|
||||
case 'en':
|
||||
return AppLocalizationsEn();
|
||||
}
|
||||
|
||||
throw FlutterError(
|
||||
'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
|
||||
'an issue with the localizations generation tool. Please file an issue '
|
||||
'on GitHub with a reproducible sample app and the gen-l10n configuration '
|
||||
'that was used.',
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
/// The translations for German (`de`).
|
||||
class AppLocalizationsDe extends AppLocalizations {
|
||||
AppLocalizationsDe([String locale = 'de']) : super(locale);
|
||||
|
||||
@override
|
||||
String get appTitle => 'Halkyon Train';
|
||||
|
||||
@override
|
||||
String get launchProduct => 'Train';
|
||||
|
||||
@override
|
||||
String get instanceFirstStartSubtitle =>
|
||||
'Wähle die Instanz, auf der deine Trainings liegen sollen. Jede betreibt jemand; weitere kannst du später hinzufügen.';
|
||||
|
||||
@override
|
||||
String get homeEmptyTitle => 'Hier ist noch nichts';
|
||||
|
||||
@override
|
||||
String get homeEmptyHint =>
|
||||
'Trainings kommen mit einer späteren Version von Halkyon Train.';
|
||||
|
||||
@override
|
||||
String get accountTitle => 'Konto';
|
||||
|
||||
@override
|
||||
String get accountDeleteMessage =>
|
||||
'Dein Konto wird auf dem Server und auf all deinen Geräten gelöscht. Dies kann nicht rückgängig gemacht werden.';
|
||||
|
||||
@override
|
||||
String get aboutTitle => 'Über';
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
/// The translations for English (`en`).
|
||||
class AppLocalizationsEn extends AppLocalizations {
|
||||
AppLocalizationsEn([String locale = 'en']) : super(locale);
|
||||
|
||||
@override
|
||||
String get appTitle => 'Halkyon Train';
|
||||
|
||||
@override
|
||||
String get launchProduct => 'Train';
|
||||
|
||||
@override
|
||||
String get instanceFirstStartSubtitle =>
|
||||
'Choose the instance to keep your workouts on. Someone runs each one; you can add others later.';
|
||||
|
||||
@override
|
||||
String get homeEmptyTitle => 'Nothing here yet';
|
||||
|
||||
@override
|
||||
String get homeEmptyHint =>
|
||||
'Workouts come with a later version of Halkyon Train.';
|
||||
|
||||
@override
|
||||
String get accountTitle => 'Account';
|
||||
|
||||
@override
|
||||
String get accountDeleteMessage =>
|
||||
'Your account is deleted on the server and on all your devices. This can\'t be undone.';
|
||||
|
||||
@override
|
||||
String get aboutTitle => 'About';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
class AppLocalizationsEnGb extends AppLocalizationsEn {
|
||||
AppLocalizationsEnGb() : super('en_GB');
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United States (`en_US`).
|
||||
class AppLocalizationsEnUs extends AppLocalizationsEn {
|
||||
AppLocalizationsEnUs() : super('en_US');
|
||||
}
|
||||
39
apps/train/halkyon_train_flutter/lib/main.dart
Normal file
39
apps/train/halkyon_train_flutter/lib/main.dart
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_flutter/src/app/licences.dart';
|
||||
import 'package:halkyon_train_flutter/src/app/train_app.dart';
|
||||
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:serverpod_flutter/serverpod_flutter.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// Noto Sans' notice (halkyon_ui), the flags' and Unicode CLDR's
|
||||
// (halkyon_app), and Lucide's for the app's icon.
|
||||
registerFontLicenses();
|
||||
registerHalkyonAppLicenses();
|
||||
registerLucideLicense();
|
||||
// The instance the build names (`--dart-define=SERVER_URL=…`, or
|
||||
// `assets/config.json`): the first offered at the first start.
|
||||
final builtIn = Instance.parse(await getServerUrl())!;
|
||||
final device = await TrainDevice.create(builtIn: builtIn);
|
||||
final instances = InstanceChoice(
|
||||
FileInstanceStore(File(p.join(device.support, 'instances.json'))),
|
||||
builtIn: builtIn,
|
||||
forget: device.storage.forget,
|
||||
sharesStorage: device.storage.sharesWith,
|
||||
);
|
||||
await instances.load(usedBefore: device.storage.usedBefore());
|
||||
runApp(
|
||||
TrainApp(
|
||||
choice: instances,
|
||||
// Only an instance of Halkyon Train: one of another Halkyon app's
|
||||
// answers too, but holds nothing of this app's.
|
||||
checker: ServerpodInstanceChecker(app: 'train'),
|
||||
open: (instance) => TrainDependencies.create(instance, device),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_train_flutter/src/app/train_frame.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The account: halkyon_app's sections (whose it is, on which instance,
|
||||
/// switching to the account on another, signing out and deleting it;
|
||||
/// SimpleLogin; its password and authenticators) in Train's words where only
|
||||
/// Train can say it; and About.
|
||||
Future<void> showAccountSheet(BuildContext context) => showAdaptiveSheet(
|
||||
context,
|
||||
builder: (context) => const _AccountSheet(),
|
||||
);
|
||||
|
||||
class const _AccountSheet() extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return SheetScaffold(
|
||||
title: l10n.accountTitle,
|
||||
// What failed (signing out, deleting the account, reading it) shows
|
||||
// on the sheet, not behind its barrier.
|
||||
hasSnackBars: true,
|
||||
body: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
AccountTiles(deleteMessage: l10n.accountDeleteMessage),
|
||||
// Accounts waiting for approval, for whoever operates the
|
||||
// instance alone (Halkyon Learn's ADR 0021, D34).
|
||||
const InstanceOperatorTiles(),
|
||||
// SimpleLogin keeps the account where the instance offers it
|
||||
// (ADR 0020, D21), and a password and an authenticator, signing
|
||||
// in on other devices and in the instance's other apps (ADR 0017).
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [SimpleLoginSection(), AccountSecuritySection()],
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.info),
|
||||
title: Text(l10n.aboutTitle),
|
||||
// halkyon_app's About: the app's name and icon, the source of
|
||||
// what the instance runs, which both licences ask to offer, and
|
||||
// the licence list.
|
||||
onTap: () => showAppAbout(
|
||||
context,
|
||||
icon: const Image(image: trainIcon, width: 48, height: 48),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
14
apps/train/halkyon_train_flutter/lib/src/app/licences.dart
Normal file
14
apps/train/halkyon_train_flutter/lib/src/app/licences.dart
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Where Lucide's notice is: its licence, verbatim from the release the app's
|
||||
/// icon was drawn from (ADR 0031).
|
||||
const lucideLicenseAsset = 'assets/icon/LICENSE-lucide.txt';
|
||||
|
||||
/// Adds Lucide's notice (ISC) to the app's licence list: the app's icon is
|
||||
/// Lucide's (ADR 0031), and its licence asks that the notice go with it.
|
||||
void registerLucideLicense() => LicenseRegistry.addLicense(() async* {
|
||||
yield LicenseEntryWithLineBreaks(const [
|
||||
'Lucide (the app icon)',
|
||||
], await rootBundle.loadString(lucideLicenseAsset));
|
||||
});
|
||||
78
apps/train/halkyon_train_flutter/lib/src/app/train_app.dart
Normal file
78
apps/train/halkyon_train_flutter/lib/src/app/train_app.dart
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
|
||||
import 'package:halkyon_train_flutter/src/app/train_frame.dart';
|
||||
import 'package:halkyon_train_flutter/src/app/train_languages.dart';
|
||||
import 'package:halkyon_train_flutter/src/home/home_screen.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The device's look until Train has settings: the system's theme mode and
|
||||
/// language.
|
||||
const _systemLook = (themeMode: ThemeMode.system, locale: null);
|
||||
|
||||
/// Halkyon Train: halkyon_app's [InstanceGate] (the first start, the account
|
||||
/// on each instance) with Train's [trainFrame], and on the instance in use
|
||||
/// halkyon_app's [SessionGate], which shows each signed-in person their own
|
||||
/// [TrainPersonApp].
|
||||
class const TrainApp({
|
||||
/// The instances the device keeps an account on, one in use.
|
||||
required final InstanceChoice choice,
|
||||
|
||||
/// Checks an address before the app moves there: only an instance of
|
||||
/// Train's.
|
||||
required final InstanceChecker checker,
|
||||
|
||||
/// Names the countries instances keep their data in: Unicode CLDR's,
|
||||
/// halkyon_app's, unless a test gives others.
|
||||
final CountryNames countryNames = const AssetCountryNames(),
|
||||
|
||||
/// Train's dependencies on an instance.
|
||||
required final Future<TrainDependencies> Function(Instance instance) open,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => InstanceGate(
|
||||
frame: trainFrame,
|
||||
choice: choice,
|
||||
checker: checker,
|
||||
countryNames: countryNames,
|
||||
open: open,
|
||||
readLook: () async => _systemLook,
|
||||
app: SessionGate(
|
||||
look: (context) => _systemLook,
|
||||
app: (person) => TrainPersonApp(person: person),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A signed-in person's app, built anew for every person ([SessionGate]):
|
||||
/// their [PersonScope] around its navigator, so every screen and sheet is
|
||||
/// inside it, and the home.
|
||||
class const TrainPersonApp({
|
||||
/// Whose app it is.
|
||||
required final SignedIn person,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => MaterialApp(
|
||||
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
|
||||
theme: appTheme(Brightness.light),
|
||||
darkTheme: appTheme(Brightness.dark),
|
||||
localizationsDelegates: trainLocalizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
localeListResolutionCallback: resolveAppLocale,
|
||||
// How many accounts wait, for whoever operates the instance: the
|
||||
// account sheet's tiles and the badge on its button.
|
||||
builder: (context, child) => PersonScope(
|
||||
person: person,
|
||||
child: BlocProvider(
|
||||
create: (context) =>
|
||||
OperatingCubit(AppScope.of(context).instanceOperators),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
home: const HomeScreen(),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
import 'dart:async';
|
||||
|
||||
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:path_provider/path_provider.dart';
|
||||
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
|
||||
import 'package:serverpod_flutter/serverpod_flutter.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.
|
||||
class TrainPerson(
|
||||
/// Whose they are.
|
||||
final SignedIn person,
|
||||
) implements Disposable {
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
|
||||
/// Opens a signed-in person's dependencies ([TrainDependencies.openPerson]).
|
||||
typedef OpenTrainPerson = Future<TrainPerson> Function(SignedIn person);
|
||||
|
||||
/// The app's composition root on one instance, built as the instance opens and
|
||||
/// handed down through halkyon_app's [AppScope] (`InstanceGate`), whose parts
|
||||
/// it provides ([AppDependencies]). Tests build it with halkyon_app's fakes.
|
||||
class TrainDependencies({
|
||||
/// The server's name as people know it (its host), for messages about it.
|
||||
@override required final String serverName,
|
||||
|
||||
/// Who is signed in, and signing in and out.
|
||||
@override required final AuthRepository auth,
|
||||
|
||||
/// Signing in with a password and TOTP, and what keeps an account (ADR
|
||||
/// 0017).
|
||||
@override required final PasswordRepository passwords,
|
||||
|
||||
/// Signing in with SimpleLogin, and the keys of the accounts that do (ADR
|
||||
/// 0020).
|
||||
@override required final SimpleLoginRepository simpleLogin,
|
||||
|
||||
/// What the instance's operators do there (Halkyon Learn's ADR 0021,
|
||||
/// D21 to D24). Train's server is a member of the instance, which leaves
|
||||
/// the operators' work to the register's service, so it shows nothing
|
||||
/// until Train's server holds the register.
|
||||
@override required final InstanceOperatorRepository instanceOperators,
|
||||
|
||||
/// The assistants the operator granted the person (Halkyon Learn's ADR
|
||||
/// 0021, step 2). A member of the instance offers none for now, so Train
|
||||
/// shows none; it asks all the same, as an instance whose register Train's
|
||||
/// server holds would answer.
|
||||
@override required final AssistantRepository assistants,
|
||||
|
||||
/// Granting assistants, as the instance's operators do (ADR 0021); as
|
||||
/// [instanceOperators], nothing on a member.
|
||||
@override required final AssistantOperatorRepository assistantOperators,
|
||||
|
||||
/// Keeps a file the app made where the person chooses (a recovery phrase,
|
||||
/// recovery codes).
|
||||
@override required final FileSaver fileSaver,
|
||||
|
||||
/// Where the source of what the instance runs is, as its basics name it
|
||||
/// (halkyon_base): both licences ask to offer it, which About does
|
||||
/// ([sourceCodeUrl]).
|
||||
required final Future<String> Function() _sourceCodeUrl,
|
||||
|
||||
/// Opens a signed-in person's dependencies ([openPerson]).
|
||||
required final OpenTrainPerson _openPerson,
|
||||
final Future<void> Function()? _onDispose,
|
||||
}) implements AppDependencies<TrainPerson> {
|
||||
/// The dependencies above [context] ([AppScope]).
|
||||
static TrainDependencies of(BuildContext context) =>
|
||||
AppScope.of<TrainDependencies>(context);
|
||||
|
||||
@override
|
||||
Future<TrainPerson> openPerson(SignedIn person) => _openPerson(person);
|
||||
|
||||
@override
|
||||
Future<String> sourceCodeUrl() => _sourceCodeUrl();
|
||||
|
||||
/// Builds the production graph on [instance], around one Serverpod client
|
||||
/// of its own, with what [device] shares across instances. Its session and
|
||||
/// its people's folders are the instance's own (`InstanceStorage`); the
|
||||
/// client holds no secrets.
|
||||
static Future<TrainDependencies> create(
|
||||
Instance instance,
|
||||
TrainDevice device,
|
||||
) async {
|
||||
final client = Client('${instance.url}')
|
||||
..connectivityMonitor = FlutterConnectivityMonitor()
|
||||
..authSessionManager = FlutterAuthSessionManager(
|
||||
storage: SecureClientAuthSuccessStorage(
|
||||
authSuccessStorageKey: device.storage.sessionKeyOf(instance),
|
||||
),
|
||||
);
|
||||
// Restores a stored session before anything asks who is signed in, so
|
||||
// the app doesn't ask how to start while someone is.
|
||||
await client.auth.initialize();
|
||||
|
||||
final authGateway = ServerpodAuthGateway(
|
||||
client,
|
||||
base: client.modules.base,
|
||||
waits: SecureAccountWaitStore(device.storage.waitOf(instance)),
|
||||
);
|
||||
// As the session above: an account waiting for the operator shows its
|
||||
// wait, not the start screen.
|
||||
await authGateway.initialize();
|
||||
final people = device.storage.peopleOf(instance);
|
||||
final folders = PersonFolders(people);
|
||||
final addresses = FileSignInAddresses(people);
|
||||
final accountCrypto = SodiumAccountCrypto();
|
||||
final accountKeys = SecureAccountKeyStore(device.storage.keysOf(instance));
|
||||
final passwords = PasswordRepository(
|
||||
ServerpodPasswordGateway(client, base: client.modules.base),
|
||||
accountCrypto,
|
||||
accountKeys,
|
||||
authGateway,
|
||||
addresses,
|
||||
);
|
||||
final simpleLogin = SimpleLoginRepository(
|
||||
ServerpodSimpleLoginGateway(client, base: client.modules.base),
|
||||
OidcBrowser(loopback: LoopbackWebAuth(appName: 'Halkyon Train')),
|
||||
accountCrypto,
|
||||
accountKeys,
|
||||
authGateway,
|
||||
// The ways back the instance's forwarding page sends Train's sign-ins
|
||||
// on by (halkyon_base's `simplelogin_callback.dart`): the scheme each
|
||||
// platform's project registers for the app, and Train's loopback port.
|
||||
callbackScheme: 'app.halkyon.train',
|
||||
loopbackPort: 43824,
|
||||
);
|
||||
final instanceOperators = InstanceOperatorRepository(
|
||||
ServerpodInstanceOperatorGateway(client.modules.base),
|
||||
);
|
||||
final assistants = AssistantRepository(
|
||||
ServerpodAssistantGateway(client.modules.base),
|
||||
);
|
||||
final assistantOperators = AssistantOperatorRepository(
|
||||
ServerpodAssistantOperatorGateway(client.modules.base),
|
||||
ServerpodInstanceOperatorGateway(client.modules.base),
|
||||
);
|
||||
return TrainDependencies(
|
||||
serverName: instance.name,
|
||||
auth: AuthRepository(authGateway, addresses),
|
||||
passwords: passwords,
|
||||
simpleLogin: simpleLogin,
|
||||
instanceOperators: instanceOperators,
|
||||
assistants: assistants,
|
||||
assistantOperators: assistantOperators,
|
||||
fileSaver: PlatformFileSaver(),
|
||||
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);
|
||||
},
|
||||
onDispose: () async {
|
||||
await passwords.dispose();
|
||||
await simpleLogin.dispose();
|
||||
await instanceOperators.dispose();
|
||||
await assistants.dispose();
|
||||
await assistantOperators.dispose();
|
||||
await authGateway.dispose();
|
||||
client.close();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Releases what [create] opened.
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
await _onDispose?.call();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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({
|
||||
/// The app's support folder, where the device keeps its files.
|
||||
required final String support,
|
||||
|
||||
/// Where the device keeps what it holds of the account on each instance.
|
||||
required final InstanceStorage storage,
|
||||
}) {
|
||||
/// Makes them, [builtIn] being the instance the build names.
|
||||
static Future<TrainDevice> create({required Instance builtIn}) async {
|
||||
final support = (await getApplicationSupportDirectory()).path;
|
||||
return TrainDevice(
|
||||
support: support,
|
||||
storage: InstanceStorage(support, builtIn: builtIn),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_train_flutter/src/app/train_languages.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The app's icon: Lucide's `bike` on white (ADR 0031; still Todo's icon until
|
||||
/// Train's is drawn).
|
||||
const trainIcon = AssetImage('assets/icon/halkyon_train_icon.png');
|
||||
|
||||
/// Halkyon Train as halkyon_app's own screens know it (the first start, the
|
||||
/// start screen, the account switcher, signing in): its name and word, its
|
||||
/// icon, its look and languages, and the sentence only it can say.
|
||||
const trainFrame = AppFrame(
|
||||
name: _name,
|
||||
product: _product,
|
||||
icon: trainIcon,
|
||||
exampleAddress: 'train.example.org',
|
||||
firstStartSubtitle: _firstStartSubtitle,
|
||||
theme: appTheme,
|
||||
localizationsDelegates: trainLocalizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
localeListResolutionCallback: resolveAppLocale,
|
||||
launchScreen: TrainLaunchScreen(),
|
||||
);
|
||||
|
||||
String _name(BuildContext context) => AppLocalizations.of(context).appTitle;
|
||||
|
||||
String _product(BuildContext context) =>
|
||||
AppLocalizations.of(context).launchProduct;
|
||||
|
||||
String _firstStartSubtitle(BuildContext context) =>
|
||||
AppLocalizations.of(context).instanceFirstStartSubtitle;
|
||||
|
||||
/// What shows while the app opens: halkyon_ui's [LaunchScreen] with Train's
|
||||
/// icon, "Train" beneath "Halkyon", read out as the app's name.
|
||||
class const TrainLaunchScreen({super.key}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return LaunchScreen(
|
||||
icon: trainIcon,
|
||||
product: l10n.launchProduct,
|
||||
name: l10n.appTitle,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The app's own texts, halkyon_app's and halkyon_ui's, and Material's,
|
||||
/// Cupertino's and the widgets library's, in every language the app speaks:
|
||||
/// material_ui's, not flutter_localizations', which serve only the SDK's
|
||||
/// Material.
|
||||
const trainLocalizationsDelegates = <LocalizationsDelegate<Object?>>[
|
||||
AppLocalizations.delegate,
|
||||
HalkyonAppLocalizations.delegate,
|
||||
HalkyonUiLocalizations.delegate,
|
||||
...GlobalMaterialLocalizations.delegates,
|
||||
];
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_train_flutter/src/account/account_sheet.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A signed-in person's home: nothing yet, as Train keeps nothing until its
|
||||
/// first model, and the way to the account; a reset of the person's keys
|
||||
/// waiting above it (`KeysResetBanner`).
|
||||
class const HomeScreen({super.key}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.appTitle),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: l10n.accountTitle,
|
||||
icon: const WaitingAccountsBadge(
|
||||
child: Icon(LucideIcons.circleUserRound),
|
||||
),
|
||||
onPressed: () => showAccountSheet(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
const KeysResetBanner(),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: EmptyListMessage(
|
||||
icon: LucideIcons.listChecks,
|
||||
title: l10n.homeEmptyTitle,
|
||||
hint: l10n.homeEmptyHint,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue