feat: the rail opens into a sidebar with labels from expanded windows on

Decided by the developer on 9 and 10 Nov 2026: phones keep the bar,
medium windows (600 to 840 dp) the rail alone, and from 840 dp a toggle
at the rail's foot opens it into a sidebar 256 dp wide, as Proton
Drive's: the app named beside its icon, each tab its label beside its
icon, the icons where the rail had them, so nothing moves under the
pointer. Its labels come in one after another, top to bottom, all at once
where animations are off. Open or closed is kept per device, in
halkyon_app's Look (look.json, `sidebar`), which Learn keeps in its
DeviceSettings (`sidebarOpen`), as it does the appearance and language.

NavigationFrame takes sidebarOpen and onSidebarChanged (no toggle without
it), and sidebarSections, what the app lists below the tabs, which Todo's
favourites and projects will be. The closed rail keeps Flutter's widget,
its toggle pinned at the foot (trailingAtBottom); the open one is the
frame's own, as the staggered labels need. Learn, Todo, Train and
Meditate keep the choice.

Whats-New: In wide windows, the button at the foot of the side rail opens it into a sidebar with names, and it stays open.
Whats-New-De: In breiten Fenstern öffnet der Knopf unten an der Seitenleiste sie mit Namen, und sie bleibt offen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-10 16:44:08 +01:00
commit 3f441cbc98
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 766 additions and 31 deletions

View file

@ -35,7 +35,7 @@ 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.
- **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.
- **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`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, halkyon_ui's `TabsStack`: go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them through halkyon_ui's `NavigationFrame`, every Halkyon app's (decided 2026-10-30), as a navigation rail from 600 dp on and as a navigation bar below (Material 3 Expressive's shorter one, 64 dp, set in the theme: Flutter's Material library has none yet), flush with the bottom edge (`FlushNavigationBar`, decided 7 Nov 2026 by the developer in place of a bar floating above it: Material 3 Expressive keeps the bar flush, 64 dp, and a fade replaces the floating card; its top corners rounded, 20 dp, a tap on another tab sending a wave over it in the colour of the screen in view (`ScreenTint.color`: cobalt on Learn's tabs, a studyset's own inside it; the frame's `accent`, Learn's purple, only before a screen claims one; Learn's tabs wash in cobalt, so its bar is cobalt there, not its icon's purple, by the developer's choice, 8 Nov 2026), the tab chosen in that colour made legible on the bar (`readableTone`, 4.5:1 in both themes), without Material's ripple or the indicator growing, the indicator kept, on the bar's own neutral surface: decided by the developer on 8 Nov 2026 after trying the variants on the phone, see ADR 0030's amendment of D60), with a fade 32 dp high above it into the background of the screen in view (`ScreenTint`), which taps pass through: the tab screens run on beneath both (the shell's `extendBody`), every list ending with room for them (the body's bottom padding, `FlushNavigationBar.coverOf`: the bar, its fade and the system's bar) and a screen with a floating button painting the fade itself beneath it (`FrameFade` in its Scaffold's bottom slot, with `extendBody`, the button placed by `FrameFade.buttonLocation`: 16 dp above the bar, over the fade, as Material places it; the frame paints no fade of its own while such a screen is in view, a page pushed over it ending that, a menu or dialog not; decided by the developer on 9 Nov 2026, the button having stood 48 dp above the bar, clear of a fade the frame painted over it); it steps aside inside a studyset (its screen, notes, paths and dictionary: `RoutePaths.isInsideStudyset`), whose own bar at the bottom takes its place, leaving a strip as high as the system's bar (decided 2026-10-11, as Material 3 puts toolbars below the top level and Vaia does), 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), and **the screens behind a studyset are its path** (decided 2026-10-11; `feature/studyset/widget/studyset_navigation.dart`): a topic, at any depth, opens with the screens of the levels between pushed beneath it (`openStudysetWithin`; the same route, keyed by id and named by its location, so several stack), the path's steps and the levels a long press on back lists go up by popping to that page (`goUpTo`), and a studyset opened from elsewhere gets its levels beneath it (`openStudyset`). So back, Android's predictive back, iOS's swipe from the edge and the path all lead one level up. Below expanded windows the topics open in a sheet from the end of the path's line, never a drawer, whose swipe from the edge is iOS's way back. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore lists what is published (see *Explore and copies*).
- **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`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, halkyon_ui's `TabsStack`: go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them through halkyon_ui's `NavigationFrame`, every Halkyon app's (decided 2026-10-30), as a navigation rail from 600 dp on (from 840 dp a toggle at its foot opens it into a sidebar 256 dp wide with labels, kept open or closed per device in `DeviceSettings.sidebarOpen`, halkyon_app's `Look`; decided by the developer, 9 and 10 Nov 2026) and as a navigation bar below (Material 3 Expressive's shorter one, 64 dp, set in the theme: Flutter's Material library has none yet), flush with the bottom edge (`FlushNavigationBar`, decided 7 Nov 2026 by the developer in place of a bar floating above it: Material 3 Expressive keeps the bar flush, 64 dp, and a fade replaces the floating card; its top corners rounded, 20 dp, a tap on another tab sending a wave over it in the colour of the screen in view (`ScreenTint.color`: cobalt on Learn's tabs, a studyset's own inside it; the frame's `accent`, Learn's purple, only before a screen claims one; Learn's tabs wash in cobalt, so its bar is cobalt there, not its icon's purple, by the developer's choice, 8 Nov 2026), the tab chosen in that colour made legible on the bar (`readableTone`, 4.5:1 in both themes), without Material's ripple or the indicator growing, the indicator kept, on the bar's own neutral surface: decided by the developer on 8 Nov 2026 after trying the variants on the phone, see ADR 0030's amendment of D60), with a fade 32 dp high above it into the background of the screen in view (`ScreenTint`), which taps pass through: the tab screens run on beneath both (the shell's `extendBody`), every list ending with room for them (the body's bottom padding, `FlushNavigationBar.coverOf`: the bar, its fade and the system's bar) and a screen with a floating button painting the fade itself beneath it (`FrameFade` in its Scaffold's bottom slot, with `extendBody`, the button placed by `FrameFade.buttonLocation`: 16 dp above the bar, over the fade, as Material places it; the frame paints no fade of its own while such a screen is in view, a page pushed over it ending that, a menu or dialog not; decided by the developer on 9 Nov 2026, the button having stood 48 dp above the bar, clear of a fade the frame painted over it); it steps aside inside a studyset (its screen, notes, paths and dictionary: `RoutePaths.isInsideStudyset`), whose own bar at the bottom takes its place, leaving a strip as high as the system's bar (decided 2026-10-11, as Material 3 puts toolbars below the top level and Vaia does), 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), and **the screens behind a studyset are its path** (decided 2026-10-11; `feature/studyset/widget/studyset_navigation.dart`): a topic, at any depth, opens with the screens of the levels between pushed beneath it (`openStudysetWithin`; the same route, keyed by id and named by its location, so several stack), the path's steps and the levels a long press on back lists go up by popping to that page (`goUpTo`), and a studyset opened from elsewhere gets its levels beneath it (`openStudyset`). So back, Android's predictive back, iOS's swipe from the edge and the path all lead one level up. Below expanded windows the topics open in a sheet from the end of the path's line, never a drawer, whose swipe from the edge is iOS's way back. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore lists what is published (see *Explore and copies*).
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById`: 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, every Halkyon app's**: the theme, its fonts, the window sizes and the shared widgets come from `halkyon_ui` (`packages/halkyon_ui`, decided 2026-10-18). Change them there, not here. `appTheme(brightness, extensions:)` with hand-written light and dark colour schemes around the brand's cobalt and copper, which Learn calls as `learnTheme` (`core/theme/`) with its own colours beside; `Theme.of(context).extras` (`ThemeExtras`) for the states Material has no role for, and `Theme.of(context).learnColors` (`LearnColors`) for Learn's own (the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content; snack bars floating above the navigation bar as Material 3 places them, in its inverse colours (dark in the light theme), 16 in from the sides and 8 above the bar's fade, 560 wide and centred from medium windows on (`FittedSnackBars`, decided 2026-10-12; Flutter's default is Material 2's fixed bar across the foot); `TopGradient` for a colour washing down from the top of every tab's first screen and Settings (cobalt) and of every screen of a studyset (its colour, own or inherited, which `_inStudyset` in the router hands down as `ScreenColor`): the studyset, studying it, its cards, writing them, its notes, paths and dictionary; behind a clear app bar, in dark mode a deep shade (42 % of the colour over black), in light mode a tint, held to WCAG AA for the theme's text. From medium windows on, a `TintFrame` takes the colour of the screen in view (`ScreenTint`, claimed by the `TopGradient` whose tab shows and whose route is on top), fading out by 60 % of the height: the rail sits on it, transparent, and the screen is a rounded panel inset beside it, its own wash half as strong; screens over the tabs (studying, the card browser and composer, Settings) sit in the same frame without the rail (`FramedScreen`). As Vaia does, decided 2026-10-10. The PDF reader and the reading timeline stay plain. halkyon_ui's `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, and Learn's `learn_theme_test.dart` a starred star to 3:1 on the cards. **Fonts are bundled, never fetched** (halkyon_ui's `app_fonts.dart`; the CJK fonts are the app's own, `assets/fonts/ATTRIBUTION.md`, their licence registered by `core/theme/cjk_fonts.dart`, under the families halkyon_ui's fallback names): Noto Sans, from halkyon_ui, is the app's font on every device (Latin, Greek with polytonic, Cyrillic); Noto Sans JP, SC, TC and KR fill in Japanese, Chinese and Korean, Japanese first unless a studyset's language puts its own first (`StudysetLanguageScope`, which every studyset route wraps its screen in through `_inStudyset`, and the library's and Explore's cards each around their studyset; Han characters take the first listed font's shapes); each is one variable font holding every weight, and their OFL licences join the licence list. Scripts none covers (Arabic, Hebrew, Thai, …) fall back to the device's own fonts. A studyset's languages (`frontLanguage`, `backLanguage`, BCP 47) are given together or inherited together from the nearest studyset above (`languagesOf`). Never add `google_fonts` or anything else that downloads fonts. Layouts follow Material 3's window-size classes (`context.windowSize`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets come from halkyon_ui (`SectionCard`, `AppLoadingIndicator` (every spinner is Material 3 Expressive's, in buttons `ButtonLoadingIndicator`, pulling a list down `AppRefreshIndicator`, never Flutter's older circular one), `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `requiredDecoration` (every required field: while empty its outline stronger, in the primary colour, and "Required" below it; never an asterisk) and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `CollapsingRow`, `CountBadge`, `liftWhileDragged`, `LaunchScreen` and `BrandWordmark` ("Halkyon" over an app's word), `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), `Shimmer` and `SkeletonBox` (what a list shows while it loads in place of a spinner, the Library's cards since 1 Nov 2026), and the sheets below), with texts of their own (`HalkyonUiLocalizations`); Learn's own are in `core/widget/` (`ConflictVersions`, the versions of a conflict each editor shows, with Keep, its save status beside it `SaveStatusLabel` in `core/editor/`; `ContentCountsLine`; `AppLaunchScreen`, what shows while the app opens: the kingfisher, "Halkyon" over "Learn", whose wordmark the first start shows too).
- **A studyset's options are the same wherever a menu of it opens** (`studysetOptions` and `doStudysetOption` in `studyset_actions.dart`, decided 2026-10-12): its card in the library, its own screen, a topic beside it. Import content runs where the studyset's blocs are, on its screen: from elsewhere it opens the screen, which asks what to import as it opens (`?import=content`).

View file

@ -47,6 +47,9 @@ class DeviceSettingsBloc(
on<DeviceSettingsEventCheckGrammarChosen>(
(event, emit) => _apply(emit, state.withCheckGrammar(event.on)),
);
on<DeviceSettingsEventSidebarChosen>(
(event, emit) => _apply(emit, state.withSidebar(open: event.open)),
);
on<DeviceSettingsEventPdfSidebarDragged>(
(event, emit) => _apply(emit, state.withPdfSidebarShare(event.share)),
);
@ -148,6 +151,13 @@ final class const DeviceSettingsEventCheckGrammarChosen(final bool on)
List<Object?> get props => [on];
}
/// The person opens the rail into the sidebar, or closes it ([open]).
final class const DeviceSettingsEventSidebarChosen(final bool open)
extends DeviceSettingsEvent {
@override
List<Object?> get props => [open];
}
/// The person shows the card browser of [studysetId] one card at a time
/// ([detailed]) or as a grid.
final class const DeviceSettingsEventBrowserViewChosen(

View file

@ -77,6 +77,7 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
if (value case final String name) key: ?LlmEffort.named(name),
},
checkGrammar: json['checkGrammar'] != false,
sidebarOpen: json['sidebarOpen'] == true,
);
} on FormatException catch (error) {
log('The device settings are unreadable', name: 'Settings', error: error);
@ -105,6 +106,7 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
key: value.key,
},
'checkGrammar': settings.checkGrammar,
if (settings.sidebarOpen) 'sidebarOpen': true,
}),
flush: true,
);

View file

@ -12,7 +12,8 @@ export 'package:halkyon_app/halkyon_app.dart' show Appearance;
/// whether studying shows the Pomodoro timer, which studysets' card
/// browsers show one card at a time, whether and how the person searches
/// the web from the app, who answered the field last and at which effort,
/// and whether writing is checked as it is written.
/// whether writing is checked as it is written, and whether the rail is
/// open into the sidebar.
/// Unlike the person's settings, they don't follow anyone to other devices,
/// and they apply before anyone is signed in.
final class const DeviceSettings({
@ -62,6 +63,10 @@ final class const DeviceSettings({
/// allows counts as none chosen.
final Map<String, LlmEffort> askEfforts = const {},
/// Whether the rail is open into the sidebar from expanded windows on
/// (decided 10 Nov 2026), halkyon_app's `Look.sidebarOpen` kept here.
final bool sidebarOpen = false,
/// Whether the editors underline what the instance's LanguageTool finds
/// as the person writes (ADR 0024, Settings › Writing): on unless turned
/// off; nothing is checked where the instance checks nothing.
@ -111,6 +116,9 @@ final class const DeviceSettings({
DeviceSettings withAskEffort(String key, LlmEffort effort) =>
_copy(askEfforts: {...askEfforts, key: effort});
/// These settings with the sidebar [open] or closed.
DeviceSettings withSidebar({required bool open}) => _copy(sidebarOpen: open);
/// These settings with writing checked as it is written [on] or off.
DeviceSettings withCheckGrammar(bool on) => _copy(checkGrammar: on);
@ -127,6 +135,7 @@ final class const DeviceSettings({
Object? askWith = _keep,
Map<String, LlmEffort>? askEfforts,
bool? checkGrammar,
bool? sidebarOpen,
}) => DeviceSettings(
appearance: appearance ?? this.appearance,
language: identical(language, _keep) ? this.language : language as String?,
@ -144,6 +153,7 @@ final class const DeviceSettings({
askWith: identical(askWith, _keep) ? this.askWith : askWith as String?,
askEfforts: askEfforts ?? this.askEfforts,
checkGrammar: checkGrammar ?? this.checkGrammar,
sidebarOpen: sidebarOpen ?? this.sidebarOpen,
);
@override
@ -160,6 +170,7 @@ final class const DeviceSettings({
askWith,
askEfforts,
checkGrammar,
sidebarOpen,
];
}

View file

@ -29,7 +29,11 @@ class const DeviceSettingsScope({required final Widget child, super.key})
/// language, as halkyon_app's [Look] means them for every Halkyon app.
extension DeviceSettingsApp on DeviceSettings {
/// The device's look of the app.
Look get appLook => Look(appearance: appearance, language: language);
Look get appLook => Look(
appearance: appearance,
language: language,
sidebarOpen: sidebarOpen,
);
/// The app's theme mode.
ThemeMode get themeMode => appLook.themeMode;

View file

@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/app/learn_frame.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/bloc/composer_cubit.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -53,6 +54,14 @@ class const AppShell({
icon: learnIcon,
// The bar's wave and chosen tab: Learn's purple, its icon's last colour.
accent: PaletteColor.purple.background,
// Open into the sidebar or not, as the device keeps it (decided
// 10 Nov 2026).
sidebarOpen: context.select<DeviceSettingsBloc, bool>(
(settings) => settings.state.sidebarOpen,
),
onSidebarChanged: (open) => context.read<DeviceSettingsBloc>().add(
DeviceSettingsEventSidebarChosen(open),
),
selectedIndex: navigationShell.currentIndex,
onSelected: (index) {
// Another tab keeps the assistant's field as it is (D69); the

View file

@ -54,6 +54,17 @@ void main() {
expect((await FileDeviceSettingsStore(file).read()).checkGrammar, isFalse);
});
test('keeps the sidebar closed until it is opened (decided 10 Nov 2026), '
'and the look says so', () async {
expect((await store.read()).sidebarOpen, isFalse);
await store.write(const DeviceSettings(sidebarOpen: true));
final read = await FileDeviceSettingsStore(file).read();
expect(read.sidebarOpen, isTrue);
expect(read.withLanguage('de').sidebarOpen, isTrue);
});
test('shows the Pomodoro timer while studying until told not to', () async {
expect((await store.read()).pomodoroWhileStudying, isTrue);

View file

@ -15,6 +15,7 @@ import 'package:material_ui/material_ui.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/memory_device_settings_store.dart';
import '../../support/pump_app.dart';
import '../../support/test_dependencies.dart';
@ -230,6 +231,36 @@ void main() {
expect(find.byType(HomeScreen), findsOneWidget);
});
testWidgets('from expanded windows on, the rail opens into a sidebar '
'kept open on this device (decided 10 Nov 2026)', (tester) async {
final store = MemoryDeviceSettingsStore();
tester.view.physicalSize = const Size(1200, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
studysetGateway: studysets,
cardGateway: cardGateway,
deviceSettingsStore: store,
),
);
await tester.tap(find.byTooltip('Open sidebar'));
await tester.pumpAndSettle();
expect(find.byKey(NavigationFrame.sidebarKey), findsOneWidget);
expect(store.writes.last.sidebarOpen, isTrue);
await tester.tap(
find.descendant(
of: find.byKey(NavigationFrame.sidebarKey),
matching: find.text('Library'),
),
);
await tester.pumpAndSettle();
expect(find.byType(LibraryScreen), findsOneWidget);
});
testWidgets('in wider windows, the rail stays inside a studyset', (
tester,
) async {