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
24 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 {

View file

@ -1,3 +1,4 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_meditate_flutter/l10n/app_localizations.dart';
@ -22,6 +23,13 @@ class const MeditateShell({
final l10n = AppLocalizations.of(context);
return WhatsNewOnUpdate(
child: NavigationFrame(
// Open into the sidebar or not, as the device keeps it (decided
// 10 Nov 2026).
sidebarOpen: context.select<LookCubit, bool>(
(look) => look.state.sidebarOpen,
),
onSidebarChanged: (open) =>
context.read<LookCubit>().chooseSidebar(open: open),
appName: l10n.appTitle,
icon: meditateIcon,
// The bar's wave and chosen tab: Meditate's fern, its screens' wash.

View file

@ -1,3 +1,4 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
@ -50,6 +51,13 @@ class _TodoShellState() extends State<TodoShell> {
listenable: Listenable.merge([_selection, _hold]),
builder: (context, child) => WhatsNewOnUpdate(
child: NavigationFrame(
// Open into the sidebar or not, as the device keeps it (decided
// 10 Nov 2026).
sidebarOpen: context.select<LookCubit, bool>(
(look) => look.state.sidebarOpen,
),
onSidebarChanged: (open) =>
context.read<LookCubit>().chooseSidebar(open: open),
appName: l10n.appTitle,
icon: todoIcon,
// The bar's wave and chosen tab: Todo's strawberry, its screens' wash.

View file

@ -4765,6 +4765,21 @@ void main() {
});
});
group("the sidebar (the developer's decisions of 9 and 10 Nov 2026)", () {
testWidgets('from expanded windows on, the rail opens into it, kept '
'open on this device', (tester) async {
await start(tester, usedBefore: true);
tester.view.physicalSize = const Size(1200, 900);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Open sidebar'));
await tester.pumpAndSettle();
expect(find.byKey(NavigationFrame.sidebarKey), findsOneWidget);
expect(looks.writes.last.sidebarOpen, isTrue);
});
});
group("a project's linked calendar (D39, the developer's decisions of "
'10 Nov 2026)', () {
ProjectRepository projects() => ProjectRepository(projectRows, keys);

View file

@ -49,6 +49,13 @@ class const TrainShell({
(bloc) => showsRecordingBar(bloc.state),
);
final frame = NavigationFrame(
// Open into the sidebar or not, as the device keeps it (decided
// 10 Nov 2026).
sidebarOpen: context.select<LookCubit, bool>(
(look) => look.state.sidebarOpen,
),
onSidebarChanged: (open) =>
context.read<LookCubit>().chooseSidebar(open: open),
status: recording ? const RecordingBar() : null,
appName: l10n.appTitle,
icon: trainIcon,

View file

@ -23,6 +23,10 @@ class LookCubit(final LookStore _store, super.initialState)
Future<void> chooseLanguage(String? language) =>
_apply(state.withLanguage(language));
/// Opens the rail into the sidebar, or closes it.
Future<void> chooseSidebar({required bool open}) =>
_apply(state.withSidebar(open: open));
Future<void> _apply(Look look) async {
emit(look);
try {

View file

@ -39,6 +39,7 @@ class FileLookStore(final File _file) implements LookStore {
final String tag => tag,
_ => null,
},
sidebarOpen: json['sidebar'] == true,
);
} on FileSystemException {
return const Look();
@ -56,6 +57,7 @@ class FileLookStore(final File _file) implements LookStore {
jsonEncode({
'appearance': look.appearance.name,
'language': ?look.language,
if (look.sidebarOpen) 'sidebar': true,
}),
flush: true,
);

View file

@ -16,23 +16,38 @@ enum Appearance() {
typedef AppLanguage = ({String tag, String name});
/// The device's look of the app (Halkyon Learn's since 2026-10-07, every
/// app's since ADR 0014's amendment of 4 Nov 2026): its appearance and the
/// language it speaks. It stays on the device, follows nobody to another,
/// and applies before anyone is signed in.
/// app's since ADR 0014's amendment of 4 Nov 2026): its appearance, the
/// language it speaks, and whether the rail is open into the sidebar from
/// expanded windows on (`NavigationFrame`, decided 10 Nov 2026). It stays
/// on the device, follows nobody to another, and applies before anyone is
/// signed in.
final class const Look({
/// Light, dark, or as the device.
final Appearance appearance = Appearance.device,
/// The language's BCP 47 tag, as `en-GB` or `de`; null for the device's.
final String? language,
/// Whether the rail is open into the sidebar.
final bool sidebarOpen = false,
}) with Equatable {
/// This look with [appearance] instead.
Look withAppearance(Appearance appearance) =>
Look(appearance: appearance, language: language);
Look withAppearance(Appearance appearance) => Look(
appearance: appearance,
language: language,
sidebarOpen: sidebarOpen,
);
/// This look speaking [language] instead; null for the device's.
Look withLanguage(String? language) =>
Look(appearance: appearance, language: language);
Look withLanguage(String? language) => Look(
appearance: appearance,
language: language,
sidebarOpen: sidebarOpen,
);
/// This look with the sidebar [open] or closed.
Look withSidebar({required bool open}) =>
Look(appearance: appearance, language: language, sidebarOpen: open);
/// The app's theme mode.
ThemeMode get themeMode => switch (appearance) {
@ -52,7 +67,7 @@ final class const Look({
LookSettings get settings => (themeMode: themeMode, locale: locale);
@override
List<Object?> get props => [appearance, language];
List<Object?> get props => [appearance, language, sidebarOpen];
}
/// The locale of the BCP 47 [tag], as `en-GB`: its language and, where it

View file

@ -32,6 +32,31 @@ void main() {
);
});
test('keeps the sidebar open or closed (decided 10 Nov 2026), the '
'appearance and language as they were', () async {
const look = Look(appearance: Appearance.dark, sidebarOpen: true);
await FileLookStore(file).write(look);
expect(await FileLookStore(file).read(), look);
final cubit = await LookCubit.open(FileLookStore(file));
addTearDown(cubit.close);
await cubit.chooseSidebar(open: false);
expect(
await FileLookStore(file).read(),
const Look(appearance: Appearance.dark),
);
await cubit.chooseLanguage('de');
expect(cubit.state.sidebarOpen, isFalse);
});
test('a file from before the sidebar reads it closed', () async {
file.writeAsStringSync('{"appearance": "light"}');
expect(
await FileLookStore(file).read(),
const Look(appearance: Appearance.light),
);
});
test("a damaged file or an unknown value reads as the device's", () async {
file.writeAsStringSync('{"appearance": "dark", ');
expect(await FileLookStore(file).read(), const Look());

View file

@ -28,7 +28,9 @@ LeanCode's lints.
- **Widgets** (`lib/src/widget/`): `showAdaptiveSheet` with `SheetScaffold`;
`TopGradient`, `ScreenTint`, `ScreenColor`, `TintFrame`, `FramedScreen`
and `TabVisibility`; `FlushNavigationBar`, and `NavigationFrame`, every
app's frame around its tabs (the rail with the app's icon, the bar flush
app's frame around its tabs (the rail with the app's icon, opening from
expanded windows on into a sidebar with labels and what the app lists
there (`sidebarOpen`, `onSidebarChanged`, `sidebarSections`), the bar flush
with the foot with a fade above it, its rounded top corners and the
wave a tap sends over it in the colour of the screen in view, the tab
chosen in that colour made legible (`readableTone`), without Material's

View file

@ -17,5 +17,7 @@
"copy": "Kopieren",
"paletteColorName": "{name, select, carrot{Karotte} cerise{Kirschrot} cobalt{Kobalt} copper{Kupfer} enzian{Enzian} fern{Farn} forest{Wald} ocean{Ozean} olive{Olive} pacific{Pazifik} pickle{Essiggurke} pine{Kiefer} pink{Rosa} plum{Pflaume} purple{Lila} reef{Riff} sahara{Sahara} slateblue{Schieferblau} soil{Erde} strawberry{Erdbeere} other{{name}}}",
"colorPickerLabel": "Farbe: {color}",
"explain": "Was ist das?"
"explain": "Was ist das?",
"openSidebar": "Seitenleiste öffnen",
"closeSidebar": "Seitenleiste schließen"
}

View file

@ -41,5 +41,13 @@
"explain": "What's this?",
"@explain": {
"description": "Tooltip of the question mark that opens a text explaining a section or setting (HelpButton)."
},
"openSidebar": "Open sidebar",
"@openSidebar": {
"description": "Tooltip of the toggle at the foot of the rail opening it into a sidebar with labels."
},
"closeSidebar": "Close sidebar",
"@closeSidebar": {
"description": "Tooltip of the toggle at the foot of the sidebar closing it into the rail."
}
}

View file

@ -210,6 +210,18 @@ abstract class HalkyonUiLocalizations {
/// In en, this message translates to:
/// **'What\'s this?'**
String get explain;
/// Tooltip of the toggle at the foot of the rail opening it into a sidebar with labels.
///
/// In en, this message translates to:
/// **'Open sidebar'**
String get openSidebar;
/// Tooltip of the toggle at the foot of the sidebar closing it into the rail.
///
/// In en, this message translates to:
/// **'Close sidebar'**
String get closeSidebar;
}
class _HalkyonUiLocalizationsDelegate

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