feat(shell): Material 3 Expressive's shorter navigation bar on phones
The navigation bar below medium windows becomes Material 3 Expressive's flexible one, which Flutter's Material library doesn't have yet: 64 dp high instead of 80, the label of the tab in view in the secondary colour (the tokens of Compose's ShortNavigationBar). Its pill stays Flutter's, 64 dp wide rather than 56, as Flutter fixes it. A trial for review, on its own branch, beside the floating one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
95a26eb803
commit
f4c502f128
3 changed files with 52 additions and 4 deletions
|
|
@ -95,7 +95,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`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `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), and a studyset's topic opens on top of it with `context.push` (the same route, keyed by id, so several can stack); the breadcrumb's steps go up with `context.go`, so back then leads further up. 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`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them 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), 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 a studyset's topic opens on top of it with `context.push` (the same route, keyed by id, so several can stack); the breadcrumb's steps go up with `context.go`, so back then leads further up. 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` (`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, a starred star); `PaletteColor` for the 20 colours a person can give content; `TopGradient` for a colour washing down from the top of every tab's first screen, Settings and Connect Claude (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. `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. **Fonts are bundled, never fetched** (`core/theme/app_fonts.dart`, `assets/fonts/ATTRIBUTION.md`): Noto Sans 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`; 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`, `core/layout/`), 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 are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `RequiredFieldLabel` and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `liftWhileDragged`, `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), and the sheets below).
|
||||
- **Lists that can grow long are built as they scroll into view**: a studyset's tabs are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a `ReorderGrip` in place of each row's options; positions from `core/sync/fractional_position.dart`), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand. **The card browser** (`feature/card/widget/card_browser_screen.dart`, decided 2026-10-09: the older design, gaps filled) opens from Browse beside the flashcards' tab, over the tabs (`/studysets/:id/flashcards`, `?subtree=1` with everything inside): grid or detail (one card beside the list; on a phone above a strip), as last left per studyset on the device (`DeviceSettings.detailedBrowsers`); search over front, back, reading, meanings, hints, explanation and tag names (katakana as hiragana, romaji finding kana; `card_browser.dart`); sort (own order, newest, oldest, A–Z by reading, due first); a filter sheet (kind, one tag, state: new, learning, due, buried, reported); and choosing cards (a toggle; shift-click or long-press for a range) to move (`showCardMoveSheet`), tag, untag, start afresh or delete with Undo. It reads every card of the studysets, as lists do (schedules from the review repository's cache).
|
||||
|
|
|
|||
|
|
@ -4,9 +4,37 @@ 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, the app's own fonts ([appFontFamily]), [ThemeExtras], and the
|
||||
/// current style of progress indicators.
|
||||
ThemeData appTheme(Brightness brightness) => ThemeData(
|
||||
/// scheme, the app's own fonts ([appFontFamily]), [ThemeExtras], the
|
||||
/// current style of progress indicators, and Material 3 Expressive's
|
||||
/// shorter navigation bar.
|
||||
ThemeData appTheme(Brightness brightness) {
|
||||
final theme = _baseTheme(brightness);
|
||||
return theme.copyWith(navigationBarTheme: _flexibleNavigationBar(theme));
|
||||
}
|
||||
|
||||
/// Material 3 Expressive's flexible navigation bar, which Flutter's Material
|
||||
/// library doesn't have yet (checked with v1.6.0): 64 dp high instead of 80,
|
||||
/// the label of the tab in view in the secondary colour (the tokens of
|
||||
/// Compose's `ShortNavigationBar`). Its pill stays Flutter's, 64 dp wide
|
||||
/// rather than the 56 dp the tokens give, as Flutter fixes it.
|
||||
NavigationBarThemeData _flexibleNavigationBar(ThemeData theme) {
|
||||
final scheme = theme.colorScheme;
|
||||
final label = theme.textTheme.labelMedium;
|
||||
return NavigationBarThemeData(
|
||||
height: 64,
|
||||
labelTextStyle: WidgetStateProperty.resolveWith(
|
||||
(states) => label?.copyWith(
|
||||
color: states.contains(WidgetState.disabled)
|
||||
? scheme.onSurfaceVariant.withValues(alpha: 0.38)
|
||||
: states.contains(WidgetState.selected)
|
||||
? scheme.secondary
|
||||
: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ThemeData _baseTheme(Brightness brightness) => ThemeData(
|
||||
useMaterial3: true,
|
||||
fontFamily: appFontFamily,
|
||||
fontFamilyFallback: appFontFallback,
|
||||
|
|
|
|||
|
|
@ -118,6 +118,26 @@ void main() {
|
|||
expect(find.byType(NavigationRail), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets("the navigation bar is Material 3 Expressive's shorter one, "
|
||||
'the label of the tab in view in the secondary colour', (tester) async {
|
||||
await showApp(tester, width: 400);
|
||||
|
||||
expect(tester.getSize(find.byType(NavigationBar)).height, 64);
|
||||
final scheme = Theme.of(tester.element(find.byType(NavigationBar)))
|
||||
.colorScheme;
|
||||
Color? colorOf(String label) {
|
||||
final text = find.descendant(
|
||||
of: find.byType(NavigationBar),
|
||||
matching: find.text(label),
|
||||
);
|
||||
return tester.widget<Text>(text).style?.color ??
|
||||
DefaultTextStyle.of(tester.element(text)).style.color;
|
||||
}
|
||||
|
||||
expect(colorOf('Home'), scheme.secondary);
|
||||
expect(colorOf('Library'), scheme.onSurfaceVariant);
|
||||
});
|
||||
|
||||
testWidgets("a studyset's note keeps the tabs beside it", (tester) async {
|
||||
tester.view.physicalSize = const Size(1200, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
|
|
|
|||
Loading…
Reference in a new issue