feat(app): the UI foundation: theme, tabs and routes, strings

The app gets its look and navigation before more features land on them.

- Theme (core/theme/): hand-written light and dark Material 3 colour schemes around
  cobalt and copper; ThemeExtras for the colours Material has no role for (states, the
  four FSRS ratings); the 20 colours a person can give content. Tests hold text to WCAG
  AA contrast on each palette colour and on the dark theme's primary colour and surface.
- Layout and shared widgets: Material 3's window-size classes (core/layout/);
  SectionCard, AppLoadingIndicator, confirmDestructiveAction and FoundById
  (core/widget/). Lucide icons.
- Navigation with go_router: four tabs (Home, Library, Explore, Profile), each keeping
  its own stack of screens, as a navigation rail with the app's icon from 600 dp and as
  a navigation bar below; choosing the open tab again returns to its first screen. A
  studyset's screen sits in the library; studying, cards and settings cover the tabs.
- Routes carry ids: FoundById finds the item in the repository's live list, so a
  studyset deleted on another device, or an old link, says it no longer exists. A
  screen that deletes its own item keeps showing it while it closes, rather than
  flashing that message.
- Every person gets an app of their own (PersonApp, lib/src/app/): a
  MaterialApp.router with its own GoRouter and PersonScope around the navigator, built
  by SessionGate inside the person's keyed subtree. Screens read the person's
  repositories wherever they are built, so the static route() helpers and the listener
  that closed every pushed screen on a change of person are gone: a change of person
  now disposes the whole router and starts again at home.
- The account dialog becomes the Profile tab, with Settings (sign out, delete the
  account, About) behind a gear. A waiting GitHub sign-in now keeps waiting while
  another tab shows; a change of person still cancels it.
- The new screens' strings come from Flutter's gen-l10n (English for now). The
  generated files are committed, left out of analysis and formatting, and written
  anew by `flutter pub get`, so CI's check of generated code also catches stale ones.

Widget tests now start the whole app (test/support/pump_app.dart) at the screen's path
and move through it as a person would, by tabs and taps.

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-01 00:18:31 +02:00
commit c21ce6232b
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
49 changed files with 2426 additions and 605 deletions

View file

@ -73,7 +73,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
## App architecture ## App architecture
- **Feature-first.** App: `lib/src/core/` holds infrastructure shared by features, `lib/src/feature/<name>/` everything a user would name. Server: `lib/src/<feature>/` holds that feature's `.spy.yaml` models and endpoints. Code moves into `core/` only once a second feature needs it. Why: a feature is added, changed or removed in one place. - **Feature-first.** App: `lib/src/core/` holds infrastructure shared by features, `lib/src/feature/<name>/` everything a user would name, and `lib/src/app/` puts the features together (the person's app and its router, the only code that knows them all). Server: `lib/src/<feature>/` holds that feature's `.spy.yaml` models and endpoints. Code moves into `core/` only once a second feature needs it. Why: a feature is added, changed or removed in one place.
- **Four layers per app feature**, dependencies pointing strictly downward: `widget/` (UI; reads bloc state, sends events) → `bloc/` (UI state; orchestrates repositories) → `data/` (repositories and gateways) → `model/` (immutable domain types; pure Dart, no Flutter, no I/O). A widget never calls a gateway; a model never imports a bloc. - **Four layers per app feature**, dependencies pointing strictly downward: `widget/` (UI; reads bloc state, sends events) → `bloc/` (UI state; orchestrates repositories) → `data/` (repositories and gateways) → `model/` (immutable domain types; pure Dart, no Flutter, no I/O). A widget never calls a gateway; a model never imports a bloc.
- **Generated protocol classes are not domain models.** They carry the envelope (`payload` as bytes); `model/` holds the decoded content. Mapping between them happens in `data/`. - **Generated protocol classes are not domain models.** They carry the envelope (`payload` as bytes); `model/` holds the decoded content. Mapping between them happens in `data/`.
- **Import `halkyon_learn_client` with a prefix (`as protocol`) or a `show` list.** The generator emits a `<Model>Repository` for every table, and its names clash with the app's own repositories. - **Import `halkyon_learn_client` with a prefix (`as protocol`) or a `show` list.** The generator emits a `<Model>Repository` for every table, and its names clash with the app's own repositories.
@ -81,7 +81,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Manual dependency injection.** One `Dependencies` object (`lib/src/core/dependencies/`) is the composition root, built at startup (`Dependencies.create()`) and exposed through the `DependenciesScope` InheritedWidget. It hands features repositories only. No service locator, no DI framework, no globals (the scaffold's global `client` moved into it). Why: every dependency is a typed, greppable field, and nothing is resolved by magic. - **Manual dependency injection.** One `Dependencies` object (`lib/src/core/dependencies/`) is the composition root, built at startup (`Dependencies.create()`) and exposed through the `DependenciesScope` InheritedWidget. It hands features repositories only. No service locator, no DI framework, no globals (the scaffold's global `client` moved into it). Why: every dependency is a typed, greppable field, and nothing is resolved by magic.
- **One graph per context.** The dependency graph belongs to one instance and one signed-in person; when either changes, a `KeyedSubtree` disposes the whole subtree and rebuilds it. Why: nothing from one person's session (blocs, streams, caches) can leak into another's. - **One graph per context.** The dependency graph belongs to one instance and one signed-in person; when either changes, a `KeyedSubtree` disposes the whole subtree and rebuilds it. Why: nothing from one person's session (blocs, streams, caches) can leak into another's.
- **App-wide vs. per person.** `Dependencies` holds what outlives a person (the client, auth). Everything of the signed-in person (their local database, its sync, their repositories) is `PersonDependencies`, opened by `PersonScope` inside `SessionGate`'s keyed subtree (`Dependencies.openPerson`) and disposed with it; widgets reach it through `PersonScope.of(context)`. - **App-wide vs. per person.** `Dependencies` holds what outlives a person (the client, auth). Everything of the signed-in person (their local database, its sync, their repositories) is `PersonDependencies`, opened by `PersonScope` inside `SessionGate`'s keyed subtree (`Dependencies.openPerson`) and disposed with it; widgets reach it through `PersonScope.of(context)`.
- **Pushed screens live above that subtree** (the `Navigator` belongs to `MaterialApp`). A screen's static `route(context, …)` reads what it needs from `PersonScope.of(context)` when it is pushed and hands it to the screen's bloc, and `SessionGate` closes every pushed screen when the person changes. A screen pushed from a pushed screen gets the repositories handed along as `route()` parameters (e.g. `CardScreen.route(cards, …)`). - **Every person has an app of their own** (`PersonApp`, `lib/src/app/`), which `SessionGate` builds inside the keyed subtree: a `MaterialApp.router` with its own `GoRouter`, and `PersonScope` around the router's navigator (`MaterialApp.builder`). So every screen and dialog is inside the person's scope and reads their repositories from `PersonScope.of(context)` wherever it is built, and a change of person disposes the router with every screen on it and starts again at home. Until someone is signed in, `SessionGate` shows a plain `MaterialApp`: the spinner, or the failure with a way to try again.
- **Material's `Card` widget clashes with the domain's `Card`**: files that need both import `material_ui` with `hide Card`. - **Material's `Card` widget clashes with the domain's `Card`**: files that need both import `material_ui` with `hide Card`.
- **State: `flutter_bloc`, uniformly.** Blocs fold repository streams into immutable states with value equality, are provided as close to their consumer as possible, and log through one global `BlocObserver`. Why: the data layer is stream-shaped, event → state machines test without widgets, and one pattern everywhere is itself the point. - **State: `flutter_bloc`, uniformly.** Blocs fold repository streams into immutable states with value equality, are provided as close to their consumer as possible, and log through one global `BlocObserver`. Why: the data layer is stream-shaped, event → state machines test without widgets, and one pattern everywhere is itself the point.
- **Value equality comes from `equatable`**, mixed in (`with Equatable`), for models and states alike; `leancode_lint`'s `missing_equatable_props` catches a field left out of `props`. - **Value equality comes from `equatable`**, mixed in (`with Equatable`), for models and states alike; `leancode_lint`'s `missing_equatable_props` catches a field left out of `props`.
@ -90,6 +90,10 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **UTC for storing and sending, local time for display and day boundaries.** Serverpod sends `DateTime` as UTC and stores it in `timestamp without time zone` columns. Compare and compute in UTC; call `.toLocal()` only to display. "Due today", streaks and daily goals use the learner's **local** calendar day, computed on the client; the server never needs day boundaries. - **UTC for storing and sending, local time for display and day boundaries.** Serverpod sends `DateTime` as UTC and stores it in `timestamp without time zone` columns. Compare and compute in UTC; call `.toLocal()` only to display. "Due today", streaks and daily goals use the learner's **local** calendar day, computed on the client; the server never needs day boundaries.
- **Build configuration comes from `--dart-define`.** The client holds no secrets, ever. - **Build configuration comes from `--dart-define`.** The client holds no secrets, ever.
- **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved. - **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved.
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute.indexedStack`, each keeping its own stack of screens; `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below, and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library); what needs the whole window (studying, a card, settings) is a top-level route over the tabs, reached with `context.push`. Explore stays empty until something is shared.
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings); `PaletteColor` for the 20 colours a person can give content. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`); the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`).
- **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/app_en.arb`; English only for now): no string a person reads is written into a widget, except in older screens until they are restyled. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` file.
## Testing ## Testing
@ -102,7 +106,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Text inside the editor's views** (`RichContentView`, `RichContentEditor`) is rich text: find it with `findRichText: true`. - **Text inside the editor's views** (`RichContentView`, `RichContentEditor`) is rich text: find it with `findRichText: true`.
- **Layout-sensitive widget tests** set `tester.view.physicalSize` explicitly, so a responsive branch isn't missed for the wrong reason. - **Layout-sensitive widget tests** set `tester.view.physicalSize` explicitly, so a responsive branch isn't missed for the wrong reason.
- **Sync tests run against a running server** (ADR 0001, rule 9: the client's and the server's sync engines can't share one process): `halkyon_learn_flutter/test/sync/`, tagged `requires-server`, skipped by `tool/check.sh`; run them with `flutter test -t requires-server` while `serverpod start` runs. Every device is a client with in-memory session storage and its own local database, with the app's real gateway and repository on top; tests sync explicitly. What only the operator could see (a blanked payload, a row that must not exist) is checked with SQL against the development database, through the embedded PostgreSQL's Unix socket where there is one. An honest device only syncs the spaces the server announces, so a forged write never leaves it; the server's own check against modified clients (`_assertCanMergeInboundSpace` in the sync engine) has no test yet. They live in the app package because the server's Docker build resolves the server's dependencies without the client package. They must not initialize a Flutter test binding (no `testWidgets`), which would replace real HTTP with a mock. - **Sync tests run against a running server** (ADR 0001, rule 9: the client's and the server's sync engines can't share one process): `halkyon_learn_flutter/test/sync/`, tagged `requires-server`, skipped by `tool/check.sh`; run them with `flutter test -t requires-server` while `serverpod start` runs. Every device is a client with in-memory session storage and its own local database, with the app's real gateway and repository on top; tests sync explicitly. What only the operator could see (a blanked payload, a row that must not exist) is checked with SQL against the development database, through the embedded PostgreSQL's Unix socket where there is one. An honest device only syncs the spaces the server announces, so a forged write never leaves it; the server's own check against modified clients (`_assertCanMergeInboundSpace` in the sync engine) has no test yet. They live in the app package because the server's Docker build resolves the server's dependencies without the client package. They must not initialize a Flutter test binding (no `testWidgets`), which would replace real HTTP with a mock.
- **Widget tests of the signed-in app** pump `SessionGate` with `testDependencies(...)` and `pumpAndSettle()` until `PersonScope` has opened the person's data; the fake gateways work in memory (`FakeStudysetGateway`, with `failReads` for the error path). - **Widget tests of screens start the whole app**: `pumpApp(tester, testDependencies(...), location: …)` (`test/support/pump_app.dart`) pumps `SessionGate` with the real `PersonApp`, opened at the screen's path, and settles once `PersonScope` has opened the person's data. From there a test moves as a person would: `openTab` (the tab's label alone may also be a screen's title), taps, `pageBack`. Data goes into the fake gateways before pumping; they work in memory (`FakeStudysetGateway`, with `failReads` for the error path), and a repository of the test's own on the same fake gateway writes what the app then reads. `pumpAndSettle` never ends while a spinner shows (`AppLoadingIndicator` animates without end): pump frames instead where one is expected. A widget pumped on its own that reads `AppLocalizations` needs `AppLocalizations.localizationsDelegates` in its `MaterialApp`.
## CI and deploys ## CI and deploys
@ -116,7 +120,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **GitHub keeps only the numeric GitHub ID**: our own slim login (`lib/src/auth/github.dart`) on Serverpod's GitHub helpers; Serverpod's stock GitHub login endpoint, which stores name, email and avatar, is never exposed. GitHub sign-in is on only where the passwords hold `githubClientId` and `githubClientSecret` (in production `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in `deploy/.env`); the app asks `instance.info()`. - **GitHub keeps only the numeric GitHub ID**: our own slim login (`lib/src/auth/github.dart`) on Serverpod's GitHub helpers; Serverpod's stock GitHub login endpoint, which stores name, email and avatar, is never exposed. GitHub sign-in is on only where the passwords hold `githubClientId` and `githubClientSecret` (in production `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in `deploy/.env`); the app asks `instance.info()`.
- **Linking keeps the person** (`GitHubUpgradeEndpoint`, `requireLogin`): the GitHub ID joins the signed-in anonymous person, who becomes identified and loses the anonymous identity, in one transaction. **Signing in** (`GitHubSignInEndpoint`) only reaches people who have a GitHub account already, and only without a session: Serverpod refuses to issue another person's session inside a signed-in call. Refusals are `GitHubSignInRefusedException` with a reason. - **Linking keeps the person** (`GitHubUpgradeEndpoint`, `requireLogin`): the GitHub ID joins the signed-in anonymous person, who becomes identified and loses the anonymous identity, in one transaction. **Signing in** (`GitHubSignInEndpoint`) only reaches people who have a GitHub account already, and only without a session: Serverpod refuses to issue another person's session inside a signed-in call. Refusals are `GitHubSignInRefusedException` with a reason.
- **Switching devices' person** (the app's `switchToGitHub`): swap the session directly, never through "signed out" (which would start another anonymous account), then end the anonymous session left behind. - **Switching devices' person** (the app's `switchToGitHub`): swap the session directly, never through "signed out" (which would start another anonymous account), then end the anonymous session left behind.
- **On Linux and Windows the browser half is the app's own** (`LoopbackWebAuth`, `feature/auth/data/`): the system browser and a loopback address (RFC 8252), put in place of flutter_web_auth_2's own implementation (checked with v5.1.0) before each sign-in. The package's front ends every pending sign-in when the app is resumed, and on the desktop the window regaining focus resumes it, so its loopback server closed before GitHub answered. Ours waits for GitHub, the dialog's Cancel (shown only there), or 5 minutes; the regression test drives the package's front through a focus change. Remove it once the package stops ending sign-ins on focus there. Serverpod (v4.0.3) reports every cancelled browser round trip as an unknown error, so the gateway knows a cancel only by having asked for it; a cancel on GitHub's own page, or leaving the browser on Android, still shows "Couldn't sign in with GitHub." - **On Linux and Windows the browser half is the app's own** (`LoopbackWebAuth`, `feature/auth/data/`): the system browser and a loopback address (RFC 8252), put in place of flutter_web_auth_2's own implementation (checked with v5.1.0) before each sign-in. The package's front ends every pending sign-in when the app is resumed, and on the desktop the window regaining focus resumes it, so its loopback server closed before GitHub answered. Ours waits for GitHub, the profile's Cancel (shown only there), a change of person, or 5 minutes, also while another tab is open; the regression test drives the package's front through a focus change. Remove it once the package stops ending sign-ins on focus there. Serverpod (v4.0.3) reports every cancelled browser round trip as an unknown error, so the gateway knows a cancel only by having asked for it; a cancel on GitHub's own page, or leaving the browser on Android, still shows "Couldn't sign in with GitHub."
- **Authorization lives in endpoint code**; Serverpod has no row-level security. Load content filtered by the session's `authUserId`, never by an owner ID the client sends. A row another person owns must be indistinguishable from a missing one. Features involving other people require the `identified` scope, declared via `requiredScopes`, never checked ad hoc. - **Authorization lives in endpoint code**; Serverpod has no row-level security. Load content filtered by the session's `authUserId`, never by an owner ID the client sends. A row another person owns must be indistinguishable from a missing one. Features involving other people require the `identified` scope, declared via `requiredScopes`, never checked ad hoc.
- **The reference is `DevicesEndpoint`** (`halkyon_learn_server/lib/src/auth/`) with its tests: the owner check sits in the same query (`WHERE "id" = $1 AND "authUserId" = $2`), zero rows means `NotFoundException` (shared, `lib/src/common/`), and every rule has a test that asserts the refusal. Each query carries its SQL as a comment. - **The reference is `DevicesEndpoint`** (`halkyon_learn_server/lib/src/auth/`) with its tests: the owner check sits in the same query (`WHERE "id" = $1 AND "authUserId" = $2`), zero rows means `NotFoundException` (shared, `lib/src/common/`), and every rule has a test that asserts the refusal. Each query carries its SQL as a comment.
- **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent. - **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent.

View file

@ -13,6 +13,8 @@ analyzer:
- windows/** - windows/**
- macos/** - macos/**
- linux/** - linux/**
# Generated by gen-l10n (see l10n.yaml).
- lib/l10n/app_localizations*.dart
formatter: formatter:
trailing_commas: preserve trailing_commas: preserve

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View file

@ -0,0 +1,9 @@
# Flutter's gen-l10n: the strings in lib/l10n/app_en.arb become the AppLocalizations
# class, generated into lib/l10n/app_localizations*.dart (committed, never edited or
# formatted by hand). English only for now; another language is one more .arb file.
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
# `AppLocalizations.of(context).foo` without a `!`: the delegate is always installed.
nullable-getter: false

View file

@ -0,0 +1,49 @@
{
"@@locale": "en",
"appTitle": "Halkyon Learn",
"@appTitle": {"description": "The app's name, e.g. in the window title and the rail's logo."},
"shellTabHome": "Home",
"shellTabLibrary": "Library",
"shellTabExplore": "Explore",
"shellTabProfile": "Profile",
"homeTitle": "Home",
"homeLibraryIntro": "Your studysets and cards are in your library.",
"homeOpenLibrary": "Open library",
"libraryTitle": "Library",
"exploreTitle": "Explore",
"exploreEmpty": "Studysets that others share will appear here.",
"profileTitle": "Profile",
"profileSettingsTooltip": "Settings",
"profileIdentified": "Your progress is kept with your account.",
"profileAnonymous": "Progress is saved on this device. Sign in to keep it.",
"profileSignInWithGitHub": "Sign in with GitHub",
"profileSignInFailed": "Couldn't sign in with GitHub.",
"profileContinueInBrowser": "Continue in your browser.",
"profileSwitchTitle": "Switch to your GitHub account?",
"profileSwitchMessage": "This GitHub account already has progress from another device. The studysets on this device can't be combined with it yet: if you switch, they are deleted.",
"profileSwitchStay": "Stay anonymous",
"profileSwitchConfirm": "Switch",
"settingsTitle": "Settings",
"settingsAccount": "Account",
"settingsSignOut": "Sign out",
"settingsSignOutTitle": "Sign out?",
"settingsSignOutAnonymousMessage": "This account can't be signed into again, so signing out deletes it with its progress.",
"settingsDeleteAccount": "Delete account",
"settingsDeleteTitle": "Delete your account?",
"settingsDeleteMessage": "Everything you have, your studysets, cards and reviews, is deleted on the server and on all your devices. This can't be undone.",
"settingsAbout": "About Halkyon Learn",
"commonCancel": "Cancel",
"commonClose": "Close",
"commonUndo": "Undo",
"commonTryAgain": "Try again",
"sessionUnreachable": "Couldn't reach the server.",
"notFoundGone": "This no longer exists. It may have been deleted on another device."
}

View file

@ -0,0 +1,350 @@
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_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('en')];
/// The app's name, e.g. in the window title and the rail's logo.
///
/// In en, this message translates to:
/// **'Halkyon Learn'**
String get appTitle;
/// No description provided for @shellTabHome.
///
/// In en, this message translates to:
/// **'Home'**
String get shellTabHome;
/// No description provided for @shellTabLibrary.
///
/// In en, this message translates to:
/// **'Library'**
String get shellTabLibrary;
/// No description provided for @shellTabExplore.
///
/// In en, this message translates to:
/// **'Explore'**
String get shellTabExplore;
/// No description provided for @shellTabProfile.
///
/// In en, this message translates to:
/// **'Profile'**
String get shellTabProfile;
/// No description provided for @homeTitle.
///
/// In en, this message translates to:
/// **'Home'**
String get homeTitle;
/// No description provided for @homeLibraryIntro.
///
/// In en, this message translates to:
/// **'Your studysets and cards are in your library.'**
String get homeLibraryIntro;
/// No description provided for @homeOpenLibrary.
///
/// In en, this message translates to:
/// **'Open library'**
String get homeOpenLibrary;
/// No description provided for @libraryTitle.
///
/// In en, this message translates to:
/// **'Library'**
String get libraryTitle;
/// No description provided for @exploreTitle.
///
/// In en, this message translates to:
/// **'Explore'**
String get exploreTitle;
/// No description provided for @exploreEmpty.
///
/// In en, this message translates to:
/// **'Studysets that others share will appear here.'**
String get exploreEmpty;
/// No description provided for @profileTitle.
///
/// In en, this message translates to:
/// **'Profile'**
String get profileTitle;
/// No description provided for @profileSettingsTooltip.
///
/// In en, this message translates to:
/// **'Settings'**
String get profileSettingsTooltip;
/// No description provided for @profileIdentified.
///
/// In en, this message translates to:
/// **'Your progress is kept with your account.'**
String get profileIdentified;
/// No description provided for @profileAnonymous.
///
/// In en, this message translates to:
/// **'Progress is saved on this device. Sign in to keep it.'**
String get profileAnonymous;
/// No description provided for @profileSignInWithGitHub.
///
/// In en, this message translates to:
/// **'Sign in with GitHub'**
String get profileSignInWithGitHub;
/// No description provided for @profileSignInFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t sign in with GitHub.'**
String get profileSignInFailed;
/// No description provided for @profileContinueInBrowser.
///
/// In en, this message translates to:
/// **'Continue in your browser.'**
String get profileContinueInBrowser;
/// No description provided for @profileSwitchTitle.
///
/// In en, this message translates to:
/// **'Switch to your GitHub account?'**
String get profileSwitchTitle;
/// No description provided for @profileSwitchMessage.
///
/// In en, this message translates to:
/// **'This GitHub account already has progress from another device. The studysets on this device can\'t be combined with it yet: if you switch, they are deleted.'**
String get profileSwitchMessage;
/// No description provided for @profileSwitchStay.
///
/// In en, this message translates to:
/// **'Stay anonymous'**
String get profileSwitchStay;
/// No description provided for @profileSwitchConfirm.
///
/// In en, this message translates to:
/// **'Switch'**
String get profileSwitchConfirm;
/// No description provided for @settingsTitle.
///
/// In en, this message translates to:
/// **'Settings'**
String get settingsTitle;
/// No description provided for @settingsAccount.
///
/// In en, this message translates to:
/// **'Account'**
String get settingsAccount;
/// No description provided for @settingsSignOut.
///
/// In en, this message translates to:
/// **'Sign out'**
String get settingsSignOut;
/// No description provided for @settingsSignOutTitle.
///
/// In en, this message translates to:
/// **'Sign out?'**
String get settingsSignOutTitle;
/// No description provided for @settingsSignOutAnonymousMessage.
///
/// In en, this message translates to:
/// **'This account can\'t be signed into again, so signing out deletes it with its progress.'**
String get settingsSignOutAnonymousMessage;
/// No description provided for @settingsDeleteAccount.
///
/// In en, this message translates to:
/// **'Delete account'**
String get settingsDeleteAccount;
/// No description provided for @settingsDeleteTitle.
///
/// In en, this message translates to:
/// **'Delete your account?'**
String get settingsDeleteTitle;
/// No description provided for @settingsDeleteMessage.
///
/// In en, this message translates to:
/// **'Everything you have, your studysets, cards and reviews, is deleted on the server and on all your devices. This can\'t be undone.'**
String get settingsDeleteMessage;
/// No description provided for @settingsAbout.
///
/// In en, this message translates to:
/// **'About Halkyon Learn'**
String get settingsAbout;
/// No description provided for @commonCancel.
///
/// In en, this message translates to:
/// **'Cancel'**
String get commonCancel;
/// No description provided for @commonClose.
///
/// In en, this message translates to:
/// **'Close'**
String get commonClose;
/// No description provided for @commonUndo.
///
/// In en, this message translates to:
/// **'Undo'**
String get commonUndo;
/// No description provided for @commonTryAgain.
///
/// In en, this message translates to:
/// **'Try again'**
String get commonTryAgain;
/// No description provided for @sessionUnreachable.
///
/// In en, this message translates to:
/// **'Couldn\'t reach the server.'**
String get sessionUnreachable;
/// No description provided for @notFoundGone.
///
/// In en, this message translates to:
/// **'This no longer exists. It may have been deleted on another device.'**
String get notFoundGone;
}
class _AppLocalizationsDelegate
extends LocalizationsDelegate<AppLocalizations> {
const _AppLocalizationsDelegate();
@override
Future<AppLocalizations> load(Locale locale) {
return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
}
@override
bool isSupported(Locale locale) =>
<String>['en'].contains(locale.languageCode);
@override
bool shouldReload(_AppLocalizationsDelegate old) => false;
}
AppLocalizations lookupAppLocalizations(Locale locale) {
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
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,128 @@
// 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 Learn';
@override
String get shellTabHome => 'Home';
@override
String get shellTabLibrary => 'Library';
@override
String get shellTabExplore => 'Explore';
@override
String get shellTabProfile => 'Profile';
@override
String get homeTitle => 'Home';
@override
String get homeLibraryIntro =>
'Your studysets and cards are in your library.';
@override
String get homeOpenLibrary => 'Open library';
@override
String get libraryTitle => 'Library';
@override
String get exploreTitle => 'Explore';
@override
String get exploreEmpty => 'Studysets that others share will appear here.';
@override
String get profileTitle => 'Profile';
@override
String get profileSettingsTooltip => 'Settings';
@override
String get profileIdentified => 'Your progress is kept with your account.';
@override
String get profileAnonymous =>
'Progress is saved on this device. Sign in to keep it.';
@override
String get profileSignInWithGitHub => 'Sign in with GitHub';
@override
String get profileSignInFailed => 'Couldn\'t sign in with GitHub.';
@override
String get profileContinueInBrowser => 'Continue in your browser.';
@override
String get profileSwitchTitle => 'Switch to your GitHub account?';
@override
String get profileSwitchMessage =>
'This GitHub account already has progress from another device. The studysets on this device can\'t be combined with it yet: if you switch, they are deleted.';
@override
String get profileSwitchStay => 'Stay anonymous';
@override
String get profileSwitchConfirm => 'Switch';
@override
String get settingsTitle => 'Settings';
@override
String get settingsAccount => 'Account';
@override
String get settingsSignOut => 'Sign out';
@override
String get settingsSignOutTitle => 'Sign out?';
@override
String get settingsSignOutAnonymousMessage =>
'This account can\'t be signed into again, so signing out deletes it with its progress.';
@override
String get settingsDeleteAccount => 'Delete account';
@override
String get settingsDeleteTitle => 'Delete your account?';
@override
String get settingsDeleteMessage =>
'Everything you have, your studysets, cards and reviews, is deleted on the server and on all your devices. This can\'t be undone.';
@override
String get settingsAbout => 'About Halkyon Learn';
@override
String get commonCancel => 'Cancel';
@override
String get commonClose => 'Close';
@override
String get commonUndo => 'Undo';
@override
String get commonTryAgain => 'Try again';
@override
String get sessionUnreachable => 'Couldn\'t reach the server.';
@override
String get notFoundGone =>
'This no longer exists. It may have been deleted on another device.';
}

View file

@ -1,9 +1,9 @@
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/app/person_app.dart';
import 'package:halkyon_learn_flutter/src/core/bloc/app_bloc_observer.dart'; import 'package:halkyon_learn_flutter/src/core/bloc/app_bloc_observer.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/widget/session_gate.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/widget/session_gate.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_list_screen.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
Future<void> main() async { Future<void> main() async {
@ -13,35 +13,7 @@ Future<void> main() async {
runApp( runApp(
DependenciesScope( DependenciesScope(
dependencies: dependencies, dependencies: dependencies,
child: const HalkyonLearnApp(), child: SessionGate(app: (person) => PersonApp(person: person)),
), ),
); );
} }
/// Builds a theme for the given [brightness].
ThemeData _buildTheme(Brightness brightness) {
return ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: brightness,
),
);
}
class const HalkyonLearnApp({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Halkyon Learn',
theme: _buildTheme(Brightness.light),
darkTheme: _buildTheme(Brightness.dark),
// Packages that still import package:flutter/material.dart (Serverpod's sign-in
// widgets, the rich-text editor) see this app's theme and localizations only
// through the bridge, since material_ui is a separate copy of that library. The
// bridge is deprecated on purpose, as a reminder: remove it once they have moved.
// ignore: deprecated_member_use
builder: (context, child) => MaterialUiCompatibilityBridge(child: child!),
home: const SessionGate(child: StudysetListScreen()),
);
}
}

View file

@ -0,0 +1,147 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/router/app_shell.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/found_by_id.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/widget/profile_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_edit_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/widget/explore_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/home_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/widget/settings_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_session_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_edit_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_list_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
// Material's Card widget would clash with the domain's Card.
import 'package:material_ui/material_ui.dart' hide Card;
/// The router of one person's app: the four tabs in a shell, each with its own
/// stack of screens, and the focused surfaces over them. Every route is built
/// inside the person's scope, so screens read their repositories from
/// [PersonScope]. The app opens at [initialLocation].
GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
initialLocation: initialLocation,
routes: [
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) =>
AppShell(navigationShell: navigationShell),
// In the order of the shell's destinations.
branches: [
StatefulShellBranch(
routes: [
GoRoute(
path: RoutePaths.home,
builder: (context, state) => const HomeScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: RoutePaths.library,
builder: (context, state) => const StudysetListScreen(),
// Nested, so a studyset's screen sits above the library in the
// tab's stack.
routes: [
GoRoute(
path: 'studysets/:id',
// Keyed by the id: several studysets' screens can be on
// the stack at once, and must not share one page.
pageBuilder: (context, state) {
final id = state.pathParameters['id']!;
return MaterialPage<void>(
key: ValueKey('studyset-$id'),
child: _studysetScreen(context, id),
);
},
),
],
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: RoutePaths.explore,
builder: (context, state) => const ExploreScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: RoutePaths.profile,
builder: (context, state) => const ProfileScreen(),
),
],
),
],
),
// Over the tabs: they need the whole window.
GoRoute(
path: '/study/:studysetId',
builder: (context, state) {
final person = PersonScope.of(context);
return BlocProvider(
create: (context) => StudySessionBloc(
person.cards,
person.reviews,
state.pathParameters['studysetId']!,
)..add(const StudySessionEventStarted()),
child: const StudyScreen(),
);
},
),
// Before the card route: its `new` would otherwise read as a card's id.
GoRoute(
path: '/studysets/:studysetId/cards/new',
builder: (context, state) => BlocProvider(
create: (context) => CardEditBloc(
PersonScope.of(context).cards,
state.pathParameters['studysetId']!,
null,
),
child: const CardScreen(card: null),
),
),
GoRoute(
path: '/studysets/:studysetId/cards/:cardId',
builder: (context, state) {
final cards = PersonScope.of(context).cards;
final studysetId = state.pathParameters['studysetId']!;
return FoundById<Card>(
id: state.pathParameters['cardId']!,
items: () => cards.watch(studysetId),
idOf: (card) => card.id,
builder: (card) => BlocProvider(
create: (context) => CardEditBloc(cards, studysetId, card),
child: CardScreen(card: card),
),
);
},
),
GoRoute(
path: RoutePaths.settings,
builder: (context, state) => const SettingsScreen(),
),
],
);
Widget _studysetScreen(BuildContext context, String id) {
final person = PersonScope.of(context);
return FoundById<Studyset>(
id: id,
items: person.studysets.watchAll,
idOf: (studyset) => studyset.id,
builder: (studyset) => BlocProvider(
create: (context) =>
StudysetEditBloc(person.studysets, person.cards, studyset),
child: StudysetScreen(studyset: studyset, cards: person.cards),
),
);
}

View file

@ -0,0 +1,61 @@
import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/app/app_router.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/theme/app_theme.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
import 'package:material_ui/material_ui.dart';
/// The app of one signed-in [person]: its own router, around the person's data
/// ([PersonScope]).
///
/// `SessionGate` builds it anew for every person, keyed by the person, so a
/// change of person disposes all of it, the router with its stacks of screens,
/// every bloc and stream, and starts again at home. Nothing of one person's
/// app can reach another's.
class const PersonApp({
/// Whose app this is.
required final SignedIn person,
/// Where the app opens; home unless a test, say, starts elsewhere.
final String initialLocation = RoutePaths.home,
super.key,
}) extends StatefulWidget {
@override
State<PersonApp> createState() => _PersonAppState();
}
class _PersonAppState() extends State<PersonApp> {
late final GoRouter _router = buildRouter(
initialLocation: widget.initialLocation,
);
@override
void dispose() {
_router.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => MaterialApp.router(
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
theme: appTheme(Brightness.light),
darkTheme: appTheme(Brightness.dark),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
routerConfig: _router,
// The person's data opens around the router's navigator, so every screen
// and dialog is inside it.
//
// Packages that still import package:flutter/material.dart (Serverpod's
// sign-in widgets, the rich-text editor, go_router's pages) see this app's
// theme and localizations only through the bridge, since material_ui is a
// separate copy of that library. The bridge is deprecated on purpose, as a
// reminder: remove it once they have moved.
// ignore: deprecated_member_use
builder: (context, child) => MaterialUiCompatibilityBridge(
child: PersonScope(person: widget.person, child: child!),
),
);
}

View file

@ -0,0 +1,3 @@
/// The widest body text should run: about 720 dp, which keeps lines within
/// the 60 to 80 characters that read most easily.
const double readableContentMaxWidth = 720;

View file

@ -0,0 +1,41 @@
import 'package:material_ui/material_ui.dart';
/// Material 3's window size classes, by width in logical pixels: compact
/// below 600, medium to 840, expanded to 1200, large to 1600, extra large
/// beyond.
///
/// Layouts branch on two of the lines: from medium on, navigation is a rail
/// at the side instead of a bar at the bottom; from expanded on, the layout
/// is a desktop one (sheets open as dialogs, the studyset screen shows two
/// columns). Expanded rather than medium for the latter because medium
/// windows are mostly tablets, used by touch, and expanded ones mostly
/// desktops.
enum WindowSize {
compact,
medium,
expanded,
large,
extraLarge;
/// The class of a window [width] wide.
factory fromWidth(double width) => switch (width) {
< 600 => compact,
< 840 => medium,
< 1200 => expanded,
< 1600 => large,
_ => extraLarge,
};
/// Whether navigation is a rail rather than a bottom bar.
bool get isAtLeastMedium => index >= medium.index;
/// Whether the layout is a desktop one.
bool get isAtLeastExpanded => index >= expanded.index;
}
/// `context.windowSize`: the class of the window [BuildContext] is in.
extension WindowSizeOf on BuildContext {
/// Rebuilds the caller when the window's width changes.
WindowSize get windowSize =>
WindowSize.fromWidth(MediaQuery.sizeOf(this).width);
}

View file

@ -0,0 +1,121 @@
import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/layout/window_size.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The frame around the four tabs: a navigation rail at the side from medium
/// windows on, a navigation bar at the bottom below that. Every tab keeps its
/// own stack of screens, and the navigation stays visible on all of them;
/// screens that need the whole window are routes outside the shell.
///
/// Choosing the tab that is already selected goes back to its first screen,
/// as Material prescribes.
class const AppShell({
/// The tabs' navigators, from `StatefulShellRoute`.
required final StatefulNavigationShell navigationShell,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final destinations = _destinations(AppLocalizations.of(context));
if (context.windowSize.isAtLeastMedium) {
return Scaffold(
body: Row(
children: [
NavigationRail(
selectedIndex: navigationShell.currentIndex,
labelType: NavigationRailLabelType.all,
onDestinationSelected: _select,
leading: const _RailLogo(),
destinations: [
for (final destination in destinations)
NavigationRailDestination(
icon: Icon(destination.icon),
selectedIcon: Icon(destination.selectedIcon),
label: Text(destination.label),
),
],
),
const VerticalDivider(thickness: 1, width: 1),
Expanded(child: navigationShell),
],
),
);
}
return Scaffold(
body: navigationShell,
bottomNavigationBar: NavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: _select,
destinations: [
for (final destination in destinations)
NavigationDestination(
icon: Icon(destination.icon),
selectedIcon: Icon(destination.selectedIcon),
label: destination.label,
),
],
),
);
}
void _select(int index) => navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
);
}
/// The kingfisher atop the rail. The icon has a white background, so it sits
/// in a rounded tile that reads on light and dark rails alike.
class const _RailLogo() extends StatelessWidget {
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: ClipRRect(
borderRadius: BorderRadius.circular(9),
child: Image.asset(
'assets/icon/halkyon_icon.png',
width: 40,
height: 40,
// The source is 1024 px square; a 40 dp logo needs no more than this
// in memory, even at three times the density.
cacheWidth: 120,
semanticLabel: AppLocalizations.of(context).appTitle,
),
),
);
}
/// A tab: its label and icons. Lucide's icons are outlines with few filled
/// companions: the library switches from a closed to an open book, and the
/// other tabs show selection through Material's highlight alone.
typedef _Destination = ({
String label,
IconData icon,
IconData selectedIcon,
});
/// The tabs, in the order of the router's branches.
List<_Destination> _destinations(AppLocalizations l10n) => [
(
label: l10n.shellTabHome,
icon: LucideIcons.house,
selectedIcon: LucideIcons.house,
),
(
label: l10n.shellTabLibrary,
icon: LucideIcons.book,
selectedIcon: LucideIcons.bookOpen,
),
(
label: l10n.shellTabExplore,
icon: LucideIcons.compass,
selectedIcon: LucideIcons.compass,
),
(
label: l10n.shellTabProfile,
icon: LucideIcons.user,
selectedIcon: LucideIcons.user,
),
];

View file

@ -0,0 +1,33 @@
/// The app's locations: the four tabs, a studyset's screen inside the library
/// tab, and the focused surfaces that cover the tabs.
abstract final class RoutePaths() {
/// The home tab, where the app opens.
static const home = '/';
/// The library tab.
static const library = '/library';
/// The explore tab.
static const explore = '/explore';
/// The profile tab.
static const profile = '/profile';
/// Settings, over the tabs.
static const settings = '/settings';
/// A studyset's screen, nested in the library tab: its back button leads
/// to the library.
static String studyset(String id) => '/library/studysets/$id';
/// Studying a studyset, over the tabs.
static String study(String studysetId) => '/study/$studysetId';
/// A new card in a studyset, over the tabs.
static String newCard(String studysetId) =>
'/studysets/$studysetId/cards/new';
/// A card of a studyset, over the tabs.
static String card(String studysetId, String cardId) =>
'/studysets/$studysetId/cards/$cardId';
}

View file

@ -0,0 +1,115 @@
import 'package:halkyon_learn_flutter/src/core/theme/brand.dart';
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
import 'package:material_ui/material_ui.dart';
/// The app's theme for [brightness]: Material 3 with a hand-authored colour
/// scheme, [ThemeExtras], and the current style of progress indicators.
ThemeData appTheme(Brightness brightness) => ThemeData(
useMaterial3: true,
colorScheme: switch (brightness) {
Brightness.light => _lightScheme(),
Brightness.dark => _darkScheme(),
},
// Rounded ends, a gap in the track and a stop indicator, instead of the
// older style. `false` is the only way to opt in so far; the flag is
// deprecated because it will become the default.
// ignore: deprecated_member_use
progressIndicatorTheme: const ProgressIndicatorThemeData(year2023: false),
extensions: [
switch (brightness) {
Brightness.light => ThemeExtras.light,
Brightness.dark => ThemeExtras.dark,
},
],
);
// The colour schemes are written by hand rather than derived from the cobalt
// seed, so that the whole interface, surfaces and neutrals included, reads as
// one family with the picker palette (PaletteColor) instead of Material's
// grey with vivid chips on top. The chromatic roles are palette colours:
// primary cobalt, secondary slate blue (a livelier blue for the rarely used
// role), tertiary copper (the warm accent), error cerise. The neutrals carry a
// cool, cobalt-tinted undertone: near-white blues in light mode, a deep
// navy-slate in dark mode. `fromSeed` still fills the roles the interface
// rarely shows (shadow, scrim, the fixed colours).
const _white = Color(0xFFFFFFFF);
ColorScheme _lightScheme() =>
ColorScheme.fromSeed(
seedColor: Brand.primary,
tertiary: Brand.accent,
).copyWith(
primary: Brand.primary,
onPrimary: _white,
primaryContainer: const Color(0xFFDFE2F9),
onPrimaryContainer: const Color(0xFF0A175C),
secondary: const Color(0xFF415DF0),
onSecondary: _white,
secondaryContainer: const Color(0xFFE1E5FB),
onSecondaryContainer: const Color(0xFF101A66),
tertiary: Brand.accent,
onTertiary: _white,
tertiaryContainer: const Color(0xFFFFDCC9),
onTertiaryContainer: const Color(0xFF481500),
error: const Color(0xFFBA1E55),
onError: _white,
errorContainer: const Color(0xFFFFD9E1),
onErrorContainer: const Color(0xFF45001B),
surface: const Color(0xFFF7F9FF),
onSurface: const Color(0xFF191B24),
onSurfaceVariant: const Color(0xFF474B5C),
surfaceDim: const Color(0xFFD6DDF0),
surfaceBright: const Color(0xFFF8FAFF),
surfaceContainerLowest: _white,
surfaceContainerLow: const Color(0xFFEDF1FC),
surfaceContainer: const Color(0xFFE3E9F8),
surfaceContainerHigh: const Color(0xFFD8E0F3),
surfaceContainerHighest: const Color(0xFFCDD7EF),
outline: const Color(0xFF757A8C),
outlineVariant: const Color(0xFFC4CADB),
inverseSurface: const Color(0xFF2D2F3B),
onInverseSurface: const Color(0xFFEEF1FB),
inversePrimary: const Color(0xFFAEB9FF),
surfaceTint: Brand.primary,
);
ColorScheme _darkScheme() =>
ColorScheme.fromSeed(
seedColor: Brand.primary,
brightness: Brightness.dark,
tertiary: Brand.accent,
).copyWith(
primary: const Color(0xFFAEB9FF),
onPrimary: const Color(0xFF0A1668),
primaryContainer: const Color(0xFF1E2E8C),
onPrimaryContainer: const Color(0xFFDFE2F9),
secondary: const Color(0xFFB7C0F7),
onSecondary: const Color(0xFF14225E),
secondaryContainer: const Color(0xFF2E3C9E),
onSecondaryContainer: const Color(0xFFE3E7FD),
tertiary: const Color(0xFFFFB694),
onTertiary: const Color(0xFF5A1E00),
tertiaryContainer: const Color(0xFF9A3600),
onTertiaryContainer: const Color(0xFFFFDCC9),
error: const Color(0xFFFF9EBB),
onError: const Color(0xFF5E0022),
errorContainer: const Color(0xFF8E1244),
onErrorContainer: const Color(0xFFFFD9E1),
surface: const Color(0xFF131520),
onSurface: const Color(0xFFE5E7F2),
onSurfaceVariant: const Color(0xFFC0C4D6),
surfaceDim: const Color(0xFF131520),
surfaceBright: const Color(0xFF363948),
surfaceContainerLowest: const Color(0xFF0D0F18),
surfaceContainerLow: const Color(0xFF191C2A),
surfaceContainer: const Color(0xFF1D2130),
surfaceContainerHigh: const Color(0xFF272B3C),
surfaceContainerHighest: const Color(0xFF323648),
outline: const Color(0xFF8A8FA3),
outlineVariant: const Color(0xFF434862),
inverseSurface: const Color(0xFFE5E7F2),
onInverseSurface: const Color(0xFF1D2130),
inversePrimary: Brand.primary,
surfaceTint: const Color(0xFFAEB9FF),
);

View file

@ -0,0 +1,12 @@
import 'package:material_ui/material_ui.dart';
/// Halkyon's brand colours, taken from the kingfisher of the app icon.
abstract final class Brand() {
/// Cobalt blue: the primary colour, recurring across the icon's gradients.
static const Color primary = Color(0xFF273EB2);
/// Deep copper orange: the accent, a warm contrast to the cobalt. Set as
/// the colour scheme's tertiary, so it stays this exact orange instead of
/// one derived from the cobalt.
static const Color accent = Color(0xFFC44800);
}

View file

@ -0,0 +1,57 @@
import 'package:material_ui/material_ui.dart';
/// The colours a person can give content, such as a studyset: twenty named
/// colours, the names of Proton's label colours.
///
/// They are not theme colours: a studyset someone made cobalt stays cobalt in
/// light and dark mode alike. Content stores a colour by its [name].
///
/// Each colour is a triple: the [background] fill (bars, chips, dots), the
/// [foreground] for text and icons on that fill (black or white, whichever
/// contrasts more; every pair reaches WCAG AA, which a test guards), and a
/// darker [outline] for borders and focus rings.
enum PaletteColor(
/// The fill.
final Color background,
/// Text and icons on [background].
final Color foreground,
/// Borders and focus rings, where the fill alone lacks definition.
final Color outline,
) {
carrot(Color(0xFFF78400), _black, Color(0xFFBA680A)),
cerise(Color(0xFFBA1E55), _white, Color(0xFF861F43)),
cobalt(Color(0xFF273EB2), _white, Color(0xFF263580)),
copper(Color(0xFFC44800), _white, Color(0xFF8A3707)),
enzian(Color(0xFF5252CC), _white, Color(0xFF4141AA)),
fern(Color(0xFF3CBB3A), _black, Color(0xFF398A38)),
forest(Color(0xFF258723), _white, Color(0xFF20591E)),
ocean(Color(0xFF0A77A6), _white, Color(0xFF0D5270)),
olive(Color(0xFFB4A40E), _black, Color(0xFF7D7311)),
pacific(Color(0xFF179FD9), _black, Color(0xFF1C79A1)),
pickle(Color(0xFF807304), _white, Color(0xFF4A4307)),
pine(Color(0xFF0F735A), _white, Color(0xFF0D4235)),
pink(Color(0xFFDB60D7), _black, Color(0xFFC543C0)),
plum(Color(0xFFA939A4), _white, Color(0xFF793577)),
purple(Color(0xFF8080FF), _black, Color(0xFF5656F6)),
reef(Color(0xFF1DA583), _black, Color(0xFF1D725D)),
sahara(Color(0xFF936D59), _white, Color(0xFF6A584E)),
slateblue(Color(0xFF415DF0), _white, Color(0xFF1F3EDF)),
soil(Color(0xFF54473F), _white, Color(0xFF32302E)),
strawberry(Color(0xFFEC3E7C), _black, Color(0xFFD52263));
/// The colour stored as [name], or null for none or an unknown name, e.g.
/// one a newer app version added.
static PaletteColor? byName(String? name) {
for (final color in values) {
if (color.name == name) {
return color;
}
}
return null;
}
}
const _black = Color(0xFF000000);
const _white = Color(0xFFFFFFFF);

View file

@ -0,0 +1,106 @@
import 'package:material_ui/material_ui.dart';
/// The colours Material's colour scheme has no role for: status colours for
/// feedback (success, warning, info; error is in the scheme), and the four
/// FSRS ratings, which colour the study screen's rating buttons.
final class const ThemeExtras({
required final Color success,
required final Color onSuccess,
required final Color warning,
required final Color onWarning,
required final Color info,
required final Color onInfo,
/// "Again": the card was forgotten.
required final Color fsrsAgain,
/// "Hard".
required final Color fsrsHard,
/// "Good".
required final Color fsrsGood,
/// "Easy".
required final Color fsrsEasy,
}) extends ThemeExtension<ThemeExtras> {
/// On light surfaces.
static const light = ThemeExtras(
success: Color(0xFF1EA885),
onSuccess: Color(0xFFFFFFFF),
warning: Color(0xFFFF9900),
onWarning: Color(0xFF000000),
info: Color(0xFF239ECE),
onInfo: Color(0xFFFFFFFF),
fsrsAgain: Color(0xFFEC3E7C),
fsrsHard: Color(0xFFF78400),
fsrsGood: Color(0xFF3CBB3A),
fsrsEasy: Color(0xFF179FD9),
);
/// On dark surfaces: the status colours a little brighter, for contrast.
/// The rating colours stay: they fill large buttons, where they read well
/// in both modes.
static const dark = ThemeExtras(
success: Color(0xFF35B191),
onSuccess: Color(0xFFFFFFFF),
warning: Color(0xFFFFA31A),
onWarning: Color(0xFF000000),
info: Color(0xFF39A8D3),
onInfo: Color(0xFFFFFFFF),
fsrsAgain: Color(0xFFEC3E7C),
fsrsHard: Color(0xFFF78400),
fsrsGood: Color(0xFF3CBB3A),
fsrsEasy: Color(0xFF179FD9),
);
@override
ThemeExtras copyWith({
Color? success,
Color? onSuccess,
Color? warning,
Color? onWarning,
Color? info,
Color? onInfo,
Color? fsrsAgain,
Color? fsrsHard,
Color? fsrsGood,
Color? fsrsEasy,
}) => ThemeExtras(
success: success ?? this.success,
onSuccess: onSuccess ?? this.onSuccess,
warning: warning ?? this.warning,
onWarning: onWarning ?? this.onWarning,
info: info ?? this.info,
onInfo: onInfo ?? this.onInfo,
fsrsAgain: fsrsAgain ?? this.fsrsAgain,
fsrsHard: fsrsHard ?? this.fsrsHard,
fsrsGood: fsrsGood ?? this.fsrsGood,
fsrsEasy: fsrsEasy ?? this.fsrsEasy,
);
@override
ThemeExtras lerp(ThemeExtras? other, double t) {
if (other == null) {
return this;
}
Color mix(Color a, Color b) => Color.lerp(a, b, t)!;
return ThemeExtras(
success: mix(success, other.success),
onSuccess: mix(onSuccess, other.onSuccess),
warning: mix(warning, other.warning),
onWarning: mix(onWarning, other.onWarning),
info: mix(info, other.info),
onInfo: mix(onInfo, other.onInfo),
fsrsAgain: mix(fsrsAgain, other.fsrsAgain),
fsrsHard: mix(fsrsHard, other.fsrsHard),
fsrsGood: mix(fsrsGood, other.fsrsGood),
fsrsEasy: mix(fsrsEasy, other.fsrsEasy),
);
}
}
/// `Theme.of(context).extras`.
extension ThemeExtrasOf on ThemeData {
/// This theme's [ThemeExtras]; every theme from `appTheme` has them.
ThemeExtras get extras => extension<ThemeExtras>()!;
}

View file

@ -0,0 +1,21 @@
import 'package:expressive_loading_indicator/expressive_loading_indicator.dart';
import 'package:material_ui/material_ui.dart';
/// The app's spinner for anything still loading: Material 3 Expressive's
/// morphing indicator, which Flutter doesn't ship yet. Every loading state
/// shows this widget, so swapping in the SDK's own indicator one day changes
/// one file. Determinate progress (a mastery ring) and small in-button
/// spinners stay [CircularProgressIndicator]s.
class const AppLoadingIndicator({
/// The side of its square; Material's 48 dp when null.
final double? size,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => ExpressiveLoadingIndicator(
constraints: switch (size) {
final size? => BoxConstraints.tightFor(width: size, height: size),
null => null,
},
);
}

View file

@ -0,0 +1,31 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:material_ui/material_ui.dart';
/// Asks before something that can't be taken back: true only when the person
/// confirms; cancelling or dismissing the dialog is false.
Future<bool> confirmDestructiveAction(
BuildContext context, {
required String title,
required String message,
required String confirmLabel,
}) async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(l10n.commonCancel),
),
FilledButton.tonal(
onPressed: () => Navigator.of(context).pop(true),
child: Text(confirmLabel),
),
],
),
);
return confirmed ?? false;
}

Some files were not shown because too many files have changed in this diff Show more