feat(todo): the app on halkyon_app, with its own icon

Todo's app is halkyon_app's instance and session gates with Todo's
frame ("Todo" under "Halkyon", its icon, its own sentences) and an
instance check accepting only `todo`; TodoDependencies on an instance,
an empty TodoPerson per person, nothing kept or synced yet. A person
sees an empty home and their account: whose it is on which instance,
switching account, signing out and deleting it (each asking first where
it deletes), and About with the source of what the instance runs and
the licence list. Texts in British English and German. App ID
app.halkyon.todo on every platform, GitHub's callback scheme registered
on Android, no web build.

Its icon is Lucide's list-checks (lucide-static 1.50.0) in the brand's
blues on white: tool/app_icons.py draws its strokes with Pillow and
writes every platform's launcher icon; Lucide's ISC notice joins the
licence list.

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-19 21:56:12 +02:00
commit 6871e3763b
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
19 changed files with 1718 additions and 693 deletions

View file

@ -1,32 +0,0 @@
import 'dart:async';
import 'package:halkyon_todo_client/halkyon_todo_client.dart';
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
import 'package:serverpod_flutter/serverpod_flutter.dart';
// When you are running the app on a physical device, you need to set the
// server URL to the IP address of your computer. You can find the IP
// address by running `ipconfig` on Windows or `ifconfig` on Mac/Linux.
//
// You can set the variable when running or building your app like this:
// E.g. `flutter run --dart-define=SERVER_URL=https://api.example.com/`.
//
// Otherwise, the server URL is fetched from the assets/config.json file or
// defaults to http://$localhost:8080/ if not found.
final serverUrl = getServerUrl();
/// Sets up a global client object that can be used to talk to the server from
/// anywhere in our app. The client is generated from your server code
/// and is set up to connect to a Serverpod running on a local server on
/// the default port. You will need to modify this to connect to staging or
/// production servers.
/// In a larger app, you may want to use the dependency injection of your choice
/// instead of using a global client object. This is just a simple example.
late final Client client;
Future<void> initializeClient() async {
client = Client(await serverUrl)
..connectivityMonitor = FlutterConnectivityMonitor()
..authSessionManager = FlutterAuthSessionManager();
unawaited(client.auth.initialize());
}

View file

@ -1,14 +1,13 @@
// This file is never meant to be used on real apps, just for development.
// 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 'main.dart' as app;
import 'package:halkyon_todo_flutter/main.dart' as app;
void main() {
enableFlutterDriverExtension(
// Text entry emulation is disabled by default to allow manual usage of the
// app, but will be required for agents to type through the app. Set this
// parameter to true to switch from manual to automated usage.
// Off by default, so the app stays usable by hand; agents typing through
// the app set it to true.
enableTextEntryEmulation: false,
);
app.main();

View file

@ -0,0 +1,27 @@
{
"@@locale": "de",
"appTitle": "Halkyon Todo",
"launchProduct": "Todo",
"instanceFirstStartSubtitle": "Wähle die Instanz, auf der deine Aufgaben liegen sollen. Jede betreibt jemand; weitere kannst du später hinzufügen.",
"emailSignInSwitchMessage": "Diese Adresse hat bereits ein Konto. Die Aufgaben auf diesem Gerät lassen sich noch nicht damit zusammenführen: Wenn du wechselst, werden sie gelöscht.",
"startOtherWays": "Starte ohne Konto oder fahre mit deiner E-Mail-Adresse fort.",
"homeEmptyTitle": "Hier ist noch nichts",
"homeEmptyHint": "Aufgaben kommen mit einer späteren Version von Halkyon Todo.",
"accountTitle": "Konto",
"accountSignedIn": "Angemeldet auf {instance}",
"accountAnonymous": "Anonym auf {instance}",
"accountSwitch": "Konto wechseln",
"accountSignOut": "Abmelden",
"accountSignOutTitle": "Abmelden?",
"accountSignOutAnonymousMessage": "Bei diesem Konto kannst du dich nicht wieder anmelden, deshalb wird es beim Abmelden gelöscht.",
"accountDelete": "Konto löschen",
"accountDeleteTitle": "Dein Konto löschen?",
"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",
"aboutSource": "Der Quellcode dessen, was diese Instanz betreibt:"
}

View file

@ -0,0 +1,32 @@
{
"@@locale": "en",
"appTitle": "Halkyon Todo",
"@appTitle": {"description": "The app's name: the window's title, and where halkyon_app's texts name the app."},
"launchProduct": "Todo",
"@launchProduct": {"description": "The app's word in its wordmark, under \"Halkyon\"."},
"instanceFirstStartSubtitle": "Choose the instance to keep your tasks on. Someone runs each one; you can add others later.",
"emailSignInSwitchMessage": "This address already has an account. The tasks on this device can't be combined with it yet: if you switch, they are deleted.",
"startOtherWays": "Start without an account, or continue with your email address.",
"@startOtherWays": {"description": "Said after a GitHub account nobody on the instance signs in with: the ways in the start screen offers instead."},
"homeEmptyTitle": "Nothing here yet",
"homeEmptyHint": "Tasks come with a later version of Halkyon Todo.",
"accountTitle": "Account",
"accountSignedIn": "Signed in on {instance}",
"@accountSignedIn": {"placeholders": {"instance": {"type": "String"}}},
"accountAnonymous": "Anonymous on {instance}",
"@accountAnonymous": {"placeholders": {"instance": {"type": "String"}}},
"accountSwitch": "Switch account",
"accountSignOut": "Sign out",
"accountSignOutTitle": "Sign out?",
"accountSignOutAnonymousMessage": "This account can't be signed into again, so signing out deletes it.",
"accountDelete": "Delete account",
"accountDeleteTitle": "Delete your account?",
"accountDeleteMessage": "Your account is deleted on the server and on all your devices. This can't be undone.",
"aboutTitle": "About",
"aboutSource": "The source of what this instance runs:"
}

View file

@ -0,0 +1,3 @@
{
"@@locale": "en_GB"
}

View file

@ -0,0 +1,3 @@
{
"@@locale": "en_US"
}

View file

@ -0,0 +1,264 @@
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 Todo'**
String get appTitle;
/// The app's word in its wordmark, under "Halkyon".
///
/// In en, this message translates to:
/// **'Todo'**
String get launchProduct;
/// No description provided for @instanceFirstStartSubtitle.
///
/// In en, this message translates to:
/// **'Choose the instance to keep your tasks on. Someone runs each one; you can add others later.'**
String get instanceFirstStartSubtitle;
/// No description provided for @emailSignInSwitchMessage.
///
/// In en, this message translates to:
/// **'This address already has an account. The tasks on this device can\'t be combined with it yet: if you switch, they are deleted.'**
String get emailSignInSwitchMessage;
/// Said after a GitHub account nobody on the instance signs in with: the ways in the start screen offers instead.
///
/// In en, this message translates to:
/// **'Start without an account, or continue with your email address.'**
String get startOtherWays;
/// 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:
/// **'Tasks come with a later version of Halkyon Todo.'**
String get homeEmptyHint;
/// No description provided for @accountTitle.
///
/// In en, this message translates to:
/// **'Account'**
String get accountTitle;
/// No description provided for @accountSignedIn.
///
/// In en, this message translates to:
/// **'Signed in on {instance}'**
String accountSignedIn(String instance);
/// No description provided for @accountAnonymous.
///
/// In en, this message translates to:
/// **'Anonymous on {instance}'**
String accountAnonymous(String instance);
/// No description provided for @accountSwitch.
///
/// In en, this message translates to:
/// **'Switch account'**
String get accountSwitch;
/// No description provided for @accountSignOut.
///
/// In en, this message translates to:
/// **'Sign out'**
String get accountSignOut;
/// No description provided for @accountSignOutTitle.
///
/// In en, this message translates to:
/// **'Sign out?'**
String get accountSignOutTitle;
/// No description provided for @accountSignOutAnonymousMessage.
///
/// In en, this message translates to:
/// **'This account can\'t be signed into again, so signing out deletes it.'**
String get accountSignOutAnonymousMessage;
/// No description provided for @accountDelete.
///
/// In en, this message translates to:
/// **'Delete account'**
String get accountDelete;
/// No description provided for @accountDeleteTitle.
///
/// In en, this message translates to:
/// **'Delete your account?'**
String get accountDeleteTitle;
/// 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;
/// No description provided for @aboutSource.
///
/// In en, this message translates to:
/// **'The source of what this instance runs:'**
String get aboutSource;
}
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.',
);
}

View file

@ -0,0 +1,78 @@
// 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 Todo';
@override
String get launchProduct => 'Todo';
@override
String get instanceFirstStartSubtitle =>
'Wähle die Instanz, auf der deine Aufgaben liegen sollen. Jede betreibt jemand; weitere kannst du später hinzufügen.';
@override
String get emailSignInSwitchMessage =>
'Diese Adresse hat bereits ein Konto. Die Aufgaben auf diesem Gerät lassen sich noch nicht damit zusammenführen: Wenn du wechselst, werden sie gelöscht.';
@override
String get startOtherWays =>
'Starte ohne Konto oder fahre mit deiner E-Mail-Adresse fort.';
@override
String get homeEmptyTitle => 'Hier ist noch nichts';
@override
String get homeEmptyHint =>
'Aufgaben kommen mit einer späteren Version von Halkyon Todo.';
@override
String get accountTitle => 'Konto';
@override
String accountSignedIn(String instance) {
return 'Angemeldet auf $instance';
}
@override
String accountAnonymous(String instance) {
return 'Anonym auf $instance';
}
@override
String get accountSwitch => 'Konto wechseln';
@override
String get accountSignOut => 'Abmelden';
@override
String get accountSignOutTitle => 'Abmelden?';
@override
String get accountSignOutAnonymousMessage =>
'Bei diesem Konto kannst du dich nicht wieder anmelden, deshalb wird es beim Abmelden gelöscht.';
@override
String get accountDelete => 'Konto löschen';
@override
String get accountDeleteTitle => 'Dein Konto löschen?';
@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';
@override
String get aboutSource => 'Der Quellcode dessen, was diese Instanz betreibt:';
}

View file

@ -0,0 +1,88 @@
// 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 Todo';
@override
String get launchProduct => 'Todo';
@override
String get instanceFirstStartSubtitle =>
'Choose the instance to keep your tasks on. Someone runs each one; you can add others later.';
@override
String get emailSignInSwitchMessage =>
'This address already has an account. The tasks on this device can\'t be combined with it yet: if you switch, they are deleted.';
@override
String get startOtherWays =>
'Start without an account, or continue with your email address.';
@override
String get homeEmptyTitle => 'Nothing here yet';
@override
String get homeEmptyHint =>
'Tasks come with a later version of Halkyon Todo.';
@override
String get accountTitle => 'Account';
@override
String accountSignedIn(String instance) {
return 'Signed in on $instance';
}
@override
String accountAnonymous(String instance) {
return 'Anonymous on $instance';
}
@override
String get accountSwitch => 'Switch account';
@override
String get accountSignOut => 'Sign out';
@override
String get accountSignOutTitle => 'Sign out?';
@override
String get accountSignOutAnonymousMessage =>
'This account can\'t be signed into again, so signing out deletes it.';
@override
String get accountDelete => 'Delete account';
@override
String get accountDeleteTitle => 'Delete your 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';
@override
String get aboutSource => 'The source of what this instance runs:';
}
/// 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');
}

View file

@ -1,61 +1,41 @@
import 'package:flutter/material.dart';
import 'dart:io';
import 'client.dart';
import 'screens/greetings_screen.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/src/app/licences.dart';
import 'package:halkyon_todo_flutter/src/app/todo_app.dart';
import 'package:halkyon_todo_flutter/src/app/todo_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';
void main() async {
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await initializeClient();
runApp(const MyApp());
}
/// Builds a theme for the given [brightness].
ThemeData _buildTheme(Brightness brightness) {
return ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: brightness,
// 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 TodoDevice.create(builtIn: builtIn);
final instances = InstanceChoice(
FileInstanceStore(File(p.join(device.support, 'instances.json'))),
builtIn: builtIn,
forget: device.storage.forget,
);
await instances.load(usedBefore: device.storage.usedBefore());
runApp(
TodoApp(
choice: instances,
// Only an instance of Halkyon Todo: one of another Halkyon app's
// answers too, but holds nothing of this app's.
checker: ServerpodInstanceChecker(app: 'todo'),
// No country names yet: where an instance keeps people's data shows
// its flag and the country's code.
countryNames: MemoryCountryNames(const {}),
open: (instance) => TodoDependencies.create(instance, device),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Serverpod Demo',
theme: _buildTheme(Brightness.light),
darkTheme: _buildTheme(Brightness.dark),
themeMode: ThemeMode.system,
home: const MyHomePage(title: 'Serverpod Example'),
);
}
}
class MyHomePage extends StatelessWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(title)),
body: const GreetingsScreen(),
// To test authentication in this example app, uncomment the line below
// and comment out the line above. This wraps the GreetingsScreen with a
// SignInScreen, which automatically shows a sign-in UI when the user is
// not authenticated and displays the GreetingsScreen once they sign in.
//
// body: SignInScreen(
// child: GreetingsScreen(
// onSignOut: () async {
// await client.auth.signOutDevice();
// },
// ),
// ),
);
}
}

View file

@ -1,122 +0,0 @@
import 'package:flutter/material.dart';
import '../client.dart';
class GreetingsScreen extends StatefulWidget {
final Future<void> Function()? onSignOut;
const GreetingsScreen({super.key, this.onSignOut});
@override
State<GreetingsScreen> createState() => _GreetingsScreenState();
}
class _GreetingsScreenState extends State<GreetingsScreen> {
/// Holds the last result or null if no result exists yet.
String? _resultMessage;
/// Holds the last error message that we've received from the server or null
/// if no error exists yet.
String? _errorMessage;
final _textEditingController = TextEditingController();
/// Calls the `hello` method of the `greeting` endpoint. Will set either the
/// `_resultMessage` or `_errorMessage` field, depending on if the call
/// is successful.
void _callHello() async {
try {
final result = await client.greeting.hello(_textEditingController.text);
setState(() {
_errorMessage = null;
_resultMessage = result.message;
});
} catch (e) {
setState(() {
_errorMessage = '$e';
});
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
if (widget.onSignOut != null) ...[
const Text('You are connected'),
ElevatedButton(
onPressed: widget.onSignOut,
child: const Text('Sign out'),
),
],
const SizedBox(height: 32),
TextField(
controller: _textEditingController,
onSubmitted: (_) => _callHello(),
decoration: InputDecoration(
hintText: 'Enter your name',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
onPressed: _callHello,
icon: const Icon(Icons.send),
),
),
),
const SizedBox(height: 16),
ResultDisplay(
resultMessage: _resultMessage,
errorMessage: _errorMessage,
),
],
),
);
}
}
/// ResultDisplays shows the result of the call. Either the returned result
/// from the `example.greeting` endpoint method or an error message.
class ResultDisplay extends StatelessWidget {
final String? resultMessage;
final String? errorMessage;
const ResultDisplay({super.key, this.resultMessage, this.errorMessage});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
String text;
Color backgroundColor;
Color foregroundColor;
if (errorMessage != null) {
backgroundColor = colors.errorContainer;
foregroundColor = colors.onErrorContainer;
text = errorMessage!;
} else if (resultMessage != null) {
backgroundColor = colors.primaryContainer;
foregroundColor = colors.onPrimaryContainer;
text = resultMessage!;
} else {
backgroundColor = colors.surfaceContainerHighest;
foregroundColor = colors.onSurfaceVariant;
text = 'No server response yet.';
}
return Container(
constraints: const BoxConstraints(minHeight: 60),
width: double.infinity,
padding: const EdgeInsets.all(16),
// Reuses the text field's border so the shape and width always match.
decoration: ShapeDecoration(
color: backgroundColor,
shape: OutlineInputBorder(
borderSide: BorderSide(color: foregroundColor),
),
),
child: Center(
child: Text(text, style: TextStyle(color: foregroundColor)),
),
);
}
}

View file

@ -1,78 +0,0 @@
import 'package:flutter/material.dart';
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
import '../client.dart';
class SignInScreen extends StatefulWidget {
final Widget child;
const SignInScreen({super.key, required this.child});
@override
State<SignInScreen> createState() => _SignInScreenState();
}
class _SignInScreenState extends State<SignInScreen> {
bool _isSignedIn = false;
@override
void initState() {
super.initState();
client.auth.authInfoListenable.addListener(_updateSignedInState);
_isSignedIn = client.auth.isAuthenticated;
}
@override
void dispose() {
client.auth.authInfoListenable.removeListener(_updateSignedInState);
super.dispose();
}
void _updateSignedInState() {
setState(() {
_isSignedIn = client.auth.isAuthenticated;
});
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return _isSignedIn
? widget.child
: Center(
child: SignInWidget(
client: client,
onAuthenticated: () {
context.showSnackBar(
message: 'User authenticated.',
backgroundColor: colors.primaryContainer,
foregroundColor: colors.onPrimaryContainer,
);
},
onError: (error) {
context.showSnackBar(
message: 'Authentication failed: $error',
backgroundColor: colors.errorContainer,
foregroundColor: colors.onErrorContainer,
);
},
),
);
}
}
extension on BuildContext {
void showSnackBar({
required String message,
required Color backgroundColor,
required Color foregroundColor,
}) {
ScaffoldMessenger.of(this).showSnackBar(
SnackBar(
content: Text(message, style: TextStyle(color: foregroundColor)),
backgroundColor: backgroundColor,
duration: const Duration(seconds: 5),
),
);
}
}

View file

@ -0,0 +1,127 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/app/todo_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, as Learn's Settings shows it: whose it is, on which instance,
/// switching to the account on another, signing out and deleting 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);
final theme = Theme.of(context);
final instance = TodoDependencies.of(context).serverName;
final identified = switch (context.watch<SessionBloc>().state) {
SessionStateActive(:final person) => person.isIdentified,
_ => false,
};
return SheetScaffold(
title: l10n.accountTitle,
body: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
leading: Icon(
identified ? LucideIcons.userCheck : LucideIcons.userRound,
),
title: Text(
identified
? l10n.accountSignedIn(instance)
: l10n.accountAnonymous(instance),
),
),
// The accounts on this device, one per instance.
ListTile(
leading: const Icon(LucideIcons.users),
title: Text(l10n.accountSwitch),
onTap: () => showAccountSwitcher(context),
),
ListTile(
leading: const Icon(LucideIcons.logOut),
title: Text(l10n.accountSignOut),
onTap: () => _signOut(context, identified: identified),
),
ListTile(
leading: Icon(LucideIcons.trash2, color: theme.colorScheme.error),
title: Text(
l10n.accountDelete,
style: TextStyle(color: theme.colorScheme.error),
),
onTap: () => _delete(context),
),
ListTile(
leading: const Icon(LucideIcons.info),
title: Text(l10n.aboutTitle),
onTap: () => unawaited(_showAbout(context)),
),
],
),
);
}
}
/// Signs out; an anonymous account can't be signed into again, so signing out
/// of one deletes it, and asks first. The app then asks how to start.
Future<void> _signOut(BuildContext context, {required bool identified}) async {
final l10n = AppLocalizations.of(context);
final session = context.read<SessionBloc>();
if (!identified &&
!await confirmDestructiveAction(
context,
title: l10n.accountSignOutTitle,
message: l10n.accountSignOutAnonymousMessage,
confirmLabel: l10n.accountSignOut,
)) {
return;
}
session.add(const SessionEventSignOutRequested());
}
/// Deletes the account with everything in it, after asking.
Future<void> _delete(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final session = context.read<SessionBloc>();
if (await confirmDestructiveAction(
context,
title: l10n.accountDeleteTitle,
message: l10n.accountDeleteMessage,
confirmLabel: l10n.accountDelete,
)) {
session.add(const SessionEventDeleteRequested());
}
}
/// The app's About: its name and icon, the source of what the instance runs
/// (as its basics name it, which both licences ask to offer), and the licence
/// list.
Future<void> _showAbout(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final source = await TodoDependencies.of(context).sourceCode();
if (!context.mounted) {
return;
}
showAboutDialog(
context: context,
applicationName: l10n.appTitle,
applicationIcon: const Image(image: todoIcon, width: 48, height: 48),
children: [
if (source != null) ...[
Text(l10n.aboutSource),
SelectableText('$source'),
],
],
);
}

View 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 (`../tool/app_icons.py`).
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 `list-checks`, 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));
});

View file

@ -0,0 +1,67 @@
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/app/todo_frame.dart';
import 'package:halkyon_todo_flutter/src/app/todo_languages.dart';
import 'package:halkyon_todo_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 Todo has settings: the system's theme mode and
/// language.
const _systemLook = (themeMode: ThemeMode.system, locale: null);
/// Halkyon Todo: halkyon_app's [InstanceGate] (the first start, the account
/// on each instance) with Todo's [todoFrame], and on the instance in use
/// halkyon_app's [SessionGate], which shows each signed-in person their own
/// [TodoPersonApp].
class const TodoApp({
/// 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
/// Todo's.
required final InstanceChecker checker,
/// Names the countries instances keep their data in.
required final CountryNames countryNames,
/// Todo's dependencies on an instance.
required final Future<TodoDependencies> Function(Instance instance) open,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => InstanceGate(
frame: todoFrame,
choice: choice,
checker: checker,
countryNames: countryNames,
open: open,
readLook: () async => _systemLook,
app: SessionGate(
look: (context) => _systemLook,
app: (person) => TodoPersonApp(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 TodoPersonApp({
/// 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: todoLocalizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
localeListResolutionCallback: resolveTodoLocale,
builder: (context, child) => PersonScope(person: person, child: child!),
home: const HomeScreen(),
);
}

View file

@ -0,0 +1,125 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_client/halkyon_todo_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
/// 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.
class TodoPerson(
/// Whose they are.
final SignedIn person,
) implements Disposable {
@override
Future<void> dispose() async {}
}
/// Opens a signed-in person's dependencies ([TodoDependencies.openPerson]).
typedef OpenTodoPerson = Future<TodoPerson> 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 TodoDependencies({
/// 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,
/// Whether the person here signed out, so the app asks how to start.
@override required final SignedOutMark signedOut,
/// 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.
required final Future<Uri?> Function() sourceCode,
/// Opens a signed-in person's dependencies ([openPerson]).
required final OpenTodoPerson _openPerson,
final Future<void> Function()? _onDispose,
}) implements AppDependencies<TodoPerson> {
/// The dependencies above [context] ([AppScope]).
static TodoDependencies of(BuildContext context) =>
AppScope.of<TodoDependencies>(context);
@override
Future<TodoPerson> openPerson(SignedIn person) => _openPerson(person);
/// 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<TodoDependencies> create(
Instance instance,
TodoDevice 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;
// otherwise the first launch logic would sign in a second anonymous person.
await client.auth.initialize();
final authGateway = ServerpodAuthGateway(
client,
base: client.modules.base,
// The scheme GitHub's sign-in returns to on Android, iOS and macOS, as
// each platform's project registers it for the app.
callbackScheme: 'app.halkyon.todo',
appName: 'Halkyon Todo',
);
final people = device.storage.peopleOf(instance);
final folders = PersonFolders(people);
return TodoDependencies(
serverName: instance.name,
auth: AuthRepository(authGateway, FileSignInAddresses(people)),
signedOut: FileSignedOutMark(people),
sourceCode: () async => Uri.tryParse(
(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);
},
onDispose: () async {
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 TodoDevice({
/// 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<TodoDevice> create({required Instance builtIn}) async {
final support = (await getApplicationSupportDirectory()).path;
return TodoDevice(
support: support,
storage: InstanceStorage(support, builtIn: builtIn),
);
}
}

View file

@ -0,0 +1,54 @@
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_languages.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// The app's icon: Lucide's `list-checks` in the brand's blues on white.
const todoIcon = AssetImage('assets/icon/halkyon_todo_icon.png');
/// Halkyon Todo 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 three sentences only it can say.
const todoFrame = AppFrame(
name: _name,
product: _product,
icon: todoIcon,
exampleAddress: 'todo.example.org',
firstStartSubtitle: _firstStartSubtitle,
switchLeavesBehind: _switchLeavesBehind,
thenAddGitHub: _startOtherWays,
theme: appTheme,
localizationsDelegates: todoLocalizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
localeListResolutionCallback: resolveTodoLocale,
launchScreen: TodoLaunchScreen(),
);
String _name(BuildContext context) => AppLocalizations.of(context).appTitle;
String _product(BuildContext context) =>
AppLocalizations.of(context).launchProduct;
String _firstStartSubtitle(BuildContext context) =>
AppLocalizations.of(context).instanceFirstStartSubtitle;
String _switchLeavesBehind(BuildContext context) =>
AppLocalizations.of(context).emailSignInSwitchMessage;
String _startOtherWays(BuildContext context) =>
AppLocalizations.of(context).startOtherWays;
/// What shows while the app opens: halkyon_ui's [LaunchScreen] with Todo's
/// icon, "Todo" beneath "Halkyon", read out as the app's name.
class const TodoLaunchScreen({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return LaunchScreen(
icon: todoIcon,
product: l10n.launchProduct,
name: l10n.appTitle,
);
}
}

View file

@ -0,0 +1,52 @@
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_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 todoLocalizationsDelegates = <LocalizationsDelegate<Object?>>[
AppLocalizations.delegate,
HalkyonAppLocalizations.delegate,
HalkyonUiLocalizations.delegate,
...GlobalMaterialLocalizations.delegates,
];
/// The English the app speaks wherever it can't speak the language asked for:
/// British English, which `app_en.arb` holds.
const _fallback = Locale('en', 'GB');
/// Which of the [supported] locales the app speaks, for a device preferring
/// the [preferred] ones in this order, as Learn picks (`resolveAppLocale`):
/// the first language it speaks wins, in its country's variant where there is
/// one, English of any other country as British English, and a country alone
/// never picks a language.
Locale resolveTodoLocale(
List<Locale>? preferred,
Iterable<Locale> supported,
) {
for (final locale in preferred ?? const <Locale>[]) {
final sameLanguage = supported.where(
(candidate) => candidate.languageCode == locale.languageCode,
);
if (sameLanguage.isEmpty) {
continue;
}
for (final candidate in sameLanguage) {
if (candidate.countryCode != null &&
candidate.countryCode == locale.countryCode) {
return candidate;
}
}
if (locale.languageCode == _fallback.languageCode) {
return _fallback;
}
return sameLanguage.firstWhere(
(candidate) => candidate.countryCode == null,
orElse: () => sameLanguage.first,
);
}
return _fallback;
}

View file

@ -0,0 +1,33 @@
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_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 Todo keeps nothing until its
/// first model, and the way to the account.
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 Icon(LucideIcons.circleUserRound),
onPressed: () => showAccountSheet(context),
),
],
),
body: Center(
child: EmptyListMessage(
icon: LucideIcons.listChecks,
title: l10n.homeEmptyTitle,
hint: l10n.homeEmptyHint,
),
),
);
}
}