feat(ui): every app's frame around its tabs, Learn's moved into halkyon_ui
NavigationFrame is Learn's AppShell without its router and its tabs: a navigation rail from medium windows on with the app's icon on top, leading to the first tab, a bar floating above the foot below, the background of the screen in view around it, and a tick on every choice. TabsStack is Learn's container of the tabs' navigators, every tab kept alive and told whether it shows. Learn's shell is built on both, its tests unchanged; Train's is next. The rail's icon is drawn from 120 px at most in memory, as Learn's was. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
57262ebf73
commit
b45db6ea7c
9 changed files with 444 additions and 171 deletions
|
|
@ -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.
|
- **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`, 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), floating above the bottom edge (`FloatingNavigationBar`): the tab screens run on beneath it (the shell's `extendBody`), every list ending with room for it (the body's bottom padding) and floating buttons lifted by `FloatingNavigationBar.coverOf`, with the background of the screen in view around it in between (`ScreenTint`); 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 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), floating above the bottom edge (`FloatingNavigationBar`): the tab screens run on beneath it (the shell's `extendBody`), every list ending with room for it (the body's bottom padding) and floating buttons lifted by `FloatingNavigationBar.coverOf`, with the background of the screen in view around it in between (`ScreenTint`); 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.
|
- **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), lined up with the floating bar, 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), 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).
|
- **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), lined up with the floating bar, 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), 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`).
|
- **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`).
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,17 @@
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
/// The frame around the four tabs: a navigation rail at the side from medium
|
/// The frame around the four tabs, every Halkyon app's ([NavigationFrame]):
|
||||||
/// windows on, a navigation bar floating above the bottom below that
|
/// a navigation rail at the side from medium windows on, the kingfisher atop
|
||||||
/// ([FloatingNavigationBar]), the background of the screen in view around
|
/// it, and a navigation bar floating above the bottom below that. Every tab
|
||||||
/// it ([ScreenTint]). Every tab keeps its
|
/// keeps its own stack of screens, and the navigation stays visible on all
|
||||||
/// own stack of screens, and the navigation stays visible on all of them;
|
/// of them; screens that need the whole window are routes outside the shell.
|
||||||
/// screens that need the whole window are routes outside the shell.
|
|
||||||
///
|
|
||||||
/// From medium windows on, the window takes the colour of the screen in view
|
|
||||||
/// ([ScreenTint], decided 2026-10-10, as Vaia does): the rail sits on it,
|
|
||||||
/// and the screen is a rounded panel inset on it, with its own wash at the
|
|
||||||
/// top. Below that, each screen washes its top itself.
|
|
||||||
///
|
///
|
||||||
/// Choosing the tab that is already selected goes back to its first screen,
|
/// Choosing the tab that is already selected goes back to its first screen,
|
||||||
/// as Material prescribes. Every choice ticks: the lightest haptic the
|
/// as Material prescribes.
|
||||||
/// platform has (a clock tick on Android), where the device has a motor and
|
|
||||||
/// the system's touch feedback is on.
|
|
||||||
class const AppShell({
|
class const AppShell({
|
||||||
/// The tabs' navigators, from `StatefulShellRoute`.
|
/// The tabs' navigators, from `StatefulShellRoute`.
|
||||||
required final StatefulNavigationShell navigationShell,
|
required final StatefulNavigationShell navigationShell,
|
||||||
|
|
@ -30,32 +21,17 @@ class const AppShell({
|
||||||
final int Function(BuildContext context)? profileBadge,
|
final int Function(BuildContext context)? profileBadge,
|
||||||
|
|
||||||
/// Whether the navigation bar below medium windows steps aside, for a
|
/// Whether the navigation bar below medium windows steps aside, for a
|
||||||
/// screen with a bar of its own at the bottom: Material 3 puts the
|
/// screen with a bar of its own at the bottom (decided 2026-10-11,
|
||||||
/// navigation bar on the top-level screens and the actions of those below
|
/// [NavigationFrame.hidesNavigationBar]).
|
||||||
/// in a toolbar, never both at once (decided 2026-10-11, as Vaia does). The
|
|
||||||
/// rail of wider windows stays.
|
|
||||||
final bool hidesNavigationBar = false,
|
final bool hidesNavigationBar = false,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends StatefulWidget {
|
}) extends StatelessWidget {
|
||||||
@override
|
|
||||||
State<AppShell> createState() => _AppShellState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AppShellState() extends State<AppShell> {
|
|
||||||
final _tint = ScreenTint();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_tint.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final navigationShell = widget.navigationShell;
|
final l10n = AppLocalizations.of(context);
|
||||||
final destinations = _destinations(AppLocalizations.of(context));
|
final destinations = _destinations(l10n);
|
||||||
// The Profile tab says how many things are new behind it.
|
// The Profile tab says how many things are new behind it.
|
||||||
final unseen = widget.profileBadge?.call(context) ?? 0;
|
final unseen = profileBadge?.call(context) ?? 0;
|
||||||
Widget icon(_Destination destination, IconData data) =>
|
Widget icon(_Destination destination, IconData data) =>
|
||||||
identical(destination, destinations.last)
|
identical(destination, destinations.last)
|
||||||
? Badge.count(
|
? Badge.count(
|
||||||
|
|
@ -64,116 +40,26 @@ class _AppShellState() extends State<AppShell> {
|
||||||
child: Icon(data),
|
child: Icon(data),
|
||||||
)
|
)
|
||||||
: Icon(data);
|
: Icon(data);
|
||||||
if (context.windowSize.isAtLeastMedium) {
|
return NavigationFrame(
|
||||||
return ScreenTintScope(
|
appName: l10n.appTitle,
|
||||||
notifier: _tint,
|
// The kingfisher has a white background, which the frame's rounded
|
||||||
child: TintFrame(
|
// tile keeps reading on light and dark rails alike.
|
||||||
tint: _tint,
|
icon: const AssetImage('assets/icon/halkyon_icon.png'),
|
||||||
rail: NavigationRail(
|
selectedIndex: navigationShell.currentIndex,
|
||||||
backgroundColor: Colors.transparent,
|
onSelected: (index) => navigationShell.goBranch(
|
||||||
selectedIndex: navigationShell.currentIndex,
|
index,
|
||||||
labelType: NavigationRailLabelType.all,
|
initialLocation: index == navigationShell.currentIndex,
|
||||||
onDestinationSelected: _select,
|
|
||||||
leading: _RailLogo(onTap: () => _select(0)),
|
|
||||||
destinations: [
|
|
||||||
for (final destination in destinations)
|
|
||||||
NavigationRailDestination(
|
|
||||||
icon: icon(destination, destination.icon),
|
|
||||||
selectedIcon: icon(destination, destination.selectedIcon),
|
|
||||||
label: Text(destination.label),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: navigationShell,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final bar = FloatingNavigationBar(
|
|
||||||
bar: NavigationBar(
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
elevation: 0,
|
|
||||||
selectedIndex: navigationShell.currentIndex,
|
|
||||||
onDestinationSelected: _select,
|
|
||||||
destinations: [
|
|
||||||
for (final destination in destinations)
|
|
||||||
NavigationDestination(
|
|
||||||
icon: icon(destination, destination.icon),
|
|
||||||
selectedIcon: icon(destination, destination.selectedIcon),
|
|
||||||
label: destination.label,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
hidesNavigationBar: hidesNavigationBar,
|
||||||
final surface = Theme.of(context).colorScheme.surface;
|
destinations: [
|
||||||
return ScreenTintScope(
|
for (final destination in destinations)
|
||||||
notifier: _tint,
|
(
|
||||||
// Around the bar, the background of the screen in view, changing as
|
label: destination.label,
|
||||||
// the frame's colour does.
|
icon: icon(destination, destination.icon),
|
||||||
child: ListenableBuilder(
|
selectedIcon: icon(destination, destination.selectedIcon),
|
||||||
listenable: _tint,
|
|
||||||
builder: (context, _) => TweenAnimationBuilder<Color?>(
|
|
||||||
tween: ColorTween(end: _tint.background ?? surface),
|
|
||||||
duration: const Duration(milliseconds: 250),
|
|
||||||
builder: (context, background, _) => Scaffold(
|
|
||||||
backgroundColor: background,
|
|
||||||
// The screens run on beneath the floating bar, showing around
|
|
||||||
// it, as iOS 26's tab bar floats over content; their lists end
|
|
||||||
// with room for it (the body's bottom padding says how much).
|
|
||||||
extendBody: !widget.hidesNavigationBar,
|
|
||||||
body: navigationShell,
|
|
||||||
// Without the bar, a strip as high as the system's own bar, in
|
|
||||||
// the screen's background, keeps every screen inside clear of
|
|
||||||
// it.
|
|
||||||
bottomNavigationBar: widget.hidesNavigationBar
|
|
||||||
? SizedBox(height: MediaQuery.paddingOf(context).bottom)
|
|
||||||
: bar,
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
child: navigationShell,
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _select(int index) {
|
|
||||||
HapticFeedback.selectionClick();
|
|
||||||
widget.navigationShell.goBranch(
|
|
||||||
index,
|
|
||||||
initialLocation: index == widget.navigationShell.currentIndex,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The kingfisher atop the rail, leading Home ([onTap]) as a website's
|
|
||||||
/// logo does. The icon has a white background, so it sits in a rounded tile
|
|
||||||
/// that reads on light and dark rails alike.
|
|
||||||
class const _RailLogo({required final VoidCallback onTap})
|
|
||||||
extends StatelessWidget {
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final l10n = AppLocalizations.of(context);
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
child: Tooltip(
|
|
||||||
message: l10n.shellTabHome,
|
|
||||||
child: Material(
|
|
||||||
type: MaterialType.transparency,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: BorderRadius.circular(9),
|
|
||||||
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: l10n.appTitle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,27 +2,10 @@ import 'package:go_router/go_router.dart';
|
||||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
/// The container of the tabs' navigators for a `StatefulShellRoute`, as
|
/// The container of the tabs' navigators for a `StatefulShellRoute`:
|
||||||
/// go_router's indexed stack builds it: every tab stays alive, the hidden
|
/// halkyon_ui's [TabsStack], every tab alive and learning whether it shows.
|
||||||
/// ones offstage with their animations paused. Each also learns from
|
|
||||||
/// [TabVisibility] whether it shows.
|
|
||||||
Widget tabsContainer(
|
Widget tabsContainer(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
StatefulNavigationShell shell,
|
StatefulNavigationShell shell,
|
||||||
List<Widget> tabs,
|
List<Widget> tabs,
|
||||||
) => IndexedStack(
|
) => TabsStack(index: shell.currentIndex, children: tabs);
|
||||||
index: shell.currentIndex,
|
|
||||||
children: [
|
|
||||||
for (final (index, tab) in tabs.indexed)
|
|
||||||
Offstage(
|
|
||||||
offstage: index != shell.currentIndex,
|
|
||||||
child: TickerMode(
|
|
||||||
enabled: index == shell.currentIndex,
|
|
||||||
child: TabVisibility(
|
|
||||||
visible: index == shell.currentIndex,
|
|
||||||
child: tab,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,14 @@ LeanCode's lints.
|
||||||
`readableContentMaxWidth`.
|
`readableContentMaxWidth`.
|
||||||
- **Widgets** (`lib/src/widget/`): `showAdaptiveSheet` with `SheetScaffold`;
|
- **Widgets** (`lib/src/widget/`): `showAdaptiveSheet` with `SheetScaffold`;
|
||||||
`TopGradient`, `ScreenTint`, `ScreenColor`, `TintFrame`, `FramedScreen`
|
`TopGradient`, `ScreenTint`, `ScreenColor`, `TintFrame`, `FramedScreen`
|
||||||
and `TabVisibility`; `FloatingNavigationBar`; `AppLoadingIndicator`,
|
and `TabVisibility`; `FloatingNavigationBar`, and `NavigationFrame`, every
|
||||||
`ButtonLoadingIndicator`, `AppRefreshIndicator`; `SectionCard`,
|
app's frame around its tabs (the rail with the app's icon, the floating
|
||||||
`SliverCentered`, `EmptyListMessage`, `CountBadge`, `TitleWithKicker`,
|
bar, a tick on every choice), whichever router drives it;
|
||||||
`CollapsingRow`, `ScrollHidingFab`, `liftWhileDragged`, `ReorderToggle`,
|
`AppLoadingIndicator`, `ButtonLoadingIndicator`, `AppRefreshIndicator`;
|
||||||
`ReorderGrip`; `ChevronField`, `requiredDecoration`, `FieldInfoButton`,
|
`SectionCard`, `SliverCentered`, `EmptyListMessage`, `CountBadge`,
|
||||||
`CountStepper`, `PaletteColorPicker`, `TextLimits` with
|
`TitleWithKicker`, `CollapsingRow`, `ScrollHidingFab`, `liftWhileDragged`,
|
||||||
|
`ReorderToggle`, `ReorderGrip`; `ChevronField`, `requiredDecoration`,
|
||||||
|
`FieldInfoButton`, `CountStepper`, `PaletteColorPicker`, `TextLimits` with
|
||||||
`nearLimitCounter`; `confirmDestructiveAction`, `showUndoSnackBar`,
|
`nearLimitCounter`; `confirmDestructiveAction`, `showUndoSnackBar`,
|
||||||
`showMoreInfoSheet`; `FoundById`; `KeyboardBackOnResume`; `BrandWordmark`
|
`showMoreInfoSheet`; `FoundById`; `KeyboardBackOnResume`; `BrandWordmark`
|
||||||
and `LaunchScreen`, which take the app's word and icon.
|
and `LaunchScreen`, which take the app's word and icon.
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export 'src/widget/keyboard_back_on_resume.dart';
|
||||||
export 'src/widget/launch_screen.dart';
|
export 'src/widget/launch_screen.dart';
|
||||||
export 'src/widget/lift_while_dragged.dart';
|
export 'src/widget/lift_while_dragged.dart';
|
||||||
export 'src/widget/more_info_sheet.dart';
|
export 'src/widget/more_info_sheet.dart';
|
||||||
|
export 'src/widget/navigation_frame.dart';
|
||||||
export 'src/widget/palette_color_picker.dart';
|
export 'src/widget/palette_color_picker.dart';
|
||||||
export 'src/widget/reorder_grip.dart';
|
export 'src/widget/reorder_grip.dart';
|
||||||
export 'src/widget/reorder_toggle.dart';
|
export 'src/widget/reorder_toggle.dart';
|
||||||
|
|
|
||||||
189
packages/halkyon_ui/lib/src/widget/navigation_frame.dart
Normal file
189
packages/halkyon_ui/lib/src/widget/navigation_frame.dart
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:halkyon_ui/src/layout/window_size.dart';
|
||||||
|
import 'package:halkyon_ui/src/widget/floating_navigation_bar.dart';
|
||||||
|
import 'package:halkyon_ui/src/widget/screen_tint.dart';
|
||||||
|
import 'package:halkyon_ui/src/widget/tinted_frame.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// A tab of a [NavigationFrame]: its label and icons, built by the app (a
|
||||||
|
/// badge around one); its selected icon its icon where null.
|
||||||
|
typedef FrameDestination = ({
|
||||||
|
String label,
|
||||||
|
Widget icon,
|
||||||
|
Widget? selectedIcon,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The frame around an app's tabs, every Halkyon app's (decided 2026-10-30,
|
||||||
|
/// from Halkyon Learn's): a navigation rail at the side from medium windows
|
||||||
|
/// on, the app's [icon] atop it leading to the first tab as a website's logo
|
||||||
|
/// does, and below that a bar floating above the bottom
|
||||||
|
/// ([FloatingNavigationBar]), the background of the screen in view around it
|
||||||
|
/// ([ScreenTint]).
|
||||||
|
///
|
||||||
|
/// From medium windows on, the window takes the colour of the screen in view
|
||||||
|
/// ([ScreenTint], as Vaia does): the rail sits on it, and the screen is a
|
||||||
|
/// rounded panel inset on it, with its own wash at the top. Below that, each
|
||||||
|
/// screen washes its top itself.
|
||||||
|
///
|
||||||
|
/// Every choice ticks before [onSelected]: the lightest haptic the platform
|
||||||
|
/// has (a clock tick on Android), where the device has a motor and the
|
||||||
|
/// system's touch feedback is on. Choosing the tab already selected is
|
||||||
|
/// passed on too, for the app to go back to its first screen, as Material
|
||||||
|
/// prescribes.
|
||||||
|
class const NavigationFrame({
|
||||||
|
/// The tabs, in order.
|
||||||
|
required final List<FrameDestination> destinations,
|
||||||
|
|
||||||
|
/// The tab showing.
|
||||||
|
required final int selectedIndex,
|
||||||
|
|
||||||
|
/// Called with the tab chosen, after the tick.
|
||||||
|
required final ValueChanged<int> onSelected,
|
||||||
|
|
||||||
|
/// The app's name, as its icon atop the rail is read out.
|
||||||
|
required final String appName,
|
||||||
|
|
||||||
|
/// The app's icon, square, atop the rail.
|
||||||
|
required final ImageProvider icon,
|
||||||
|
|
||||||
|
/// The tab's screens.
|
||||||
|
required final Widget child,
|
||||||
|
|
||||||
|
/// Whether the bar below medium windows steps aside, for a screen with a
|
||||||
|
/// bar of its own at the bottom: Material 3 puts the navigation bar on the
|
||||||
|
/// top-level screens and the actions of those below in a toolbar, never
|
||||||
|
/// both at once (as Vaia does). The rail of wider windows stays.
|
||||||
|
final bool hidesNavigationBar = false,
|
||||||
|
super.key,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<NavigationFrame> createState() => _NavigationFrameState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NavigationFrameState() extends State<NavigationFrame> {
|
||||||
|
final _tint = ScreenTint();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tint.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _select(int index) {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
widget.onSelected(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final destinations = widget.destinations;
|
||||||
|
if (context.windowSize.isAtLeastMedium) {
|
||||||
|
return ScreenTintScope(
|
||||||
|
notifier: _tint,
|
||||||
|
child: TintFrame(
|
||||||
|
tint: _tint,
|
||||||
|
rail: NavigationRail(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
selectedIndex: widget.selectedIndex,
|
||||||
|
labelType: NavigationRailLabelType.all,
|
||||||
|
onDestinationSelected: _select,
|
||||||
|
leading: _RailIcon(
|
||||||
|
icon: widget.icon,
|
||||||
|
appName: widget.appName,
|
||||||
|
first: destinations.first.label,
|
||||||
|
onTap: () => _select(0),
|
||||||
|
),
|
||||||
|
destinations: [
|
||||||
|
for (final destination in destinations)
|
||||||
|
NavigationRailDestination(
|
||||||
|
icon: destination.icon,
|
||||||
|
selectedIcon: destination.selectedIcon,
|
||||||
|
label: Text(destination.label),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: widget.child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final bar = FloatingNavigationBar(
|
||||||
|
bar: NavigationBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
selectedIndex: widget.selectedIndex,
|
||||||
|
onDestinationSelected: _select,
|
||||||
|
destinations: [
|
||||||
|
for (final destination in destinations)
|
||||||
|
NavigationDestination(
|
||||||
|
icon: destination.icon,
|
||||||
|
selectedIcon: destination.selectedIcon,
|
||||||
|
label: destination.label,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final surface = Theme.of(context).colorScheme.surface;
|
||||||
|
return ScreenTintScope(
|
||||||
|
notifier: _tint,
|
||||||
|
// Around the bar, the background of the screen in view, changing as
|
||||||
|
// the frame's colour does.
|
||||||
|
child: ListenableBuilder(
|
||||||
|
listenable: _tint,
|
||||||
|
builder: (context, _) => TweenAnimationBuilder<Color?>(
|
||||||
|
tween: ColorTween(end: _tint.background ?? surface),
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
builder: (context, background, _) => Scaffold(
|
||||||
|
backgroundColor: background,
|
||||||
|
// The screens run on beneath the floating bar, showing around
|
||||||
|
// it, as iOS 26's tab bar floats over content; their lists end
|
||||||
|
// with room for it (the body's bottom padding says how much).
|
||||||
|
extendBody: !widget.hidesNavigationBar,
|
||||||
|
body: widget.child,
|
||||||
|
// Without the bar, a strip as high as the system's own bar, in
|
||||||
|
// the screen's background, keeps every screen inside clear of
|
||||||
|
// it.
|
||||||
|
bottomNavigationBar: widget.hidesNavigationBar
|
||||||
|
? SizedBox(height: MediaQuery.paddingOf(context).bottom)
|
||||||
|
: bar,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The app's icon atop the rail, leading to the first tab ([onTap], named
|
||||||
|
/// by [first]) as a website's logo does. In a rounded tile, so that an icon
|
||||||
|
/// with a background of its own reads on light and dark rails alike.
|
||||||
|
class const _RailIcon({
|
||||||
|
required final ImageProvider icon,
|
||||||
|
required final String appName,
|
||||||
|
required final String first,
|
||||||
|
required final VoidCallback onTap,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Tooltip(
|
||||||
|
message: first,
|
||||||
|
child: Material(
|
||||||
|
type: MaterialType.transparency,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(9),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(9),
|
||||||
|
child: Image(
|
||||||
|
// The icons are 1024 px square; a 40 dp logo needs no more
|
||||||
|
// than this in memory, even at three times the density.
|
||||||
|
image: ResizeImage(icon, width: 120),
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
semanticLabel: appName,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'package:halkyon_ui/src/widget/navigation_frame.dart'
|
||||||
|
show NavigationFrame;
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
/// Whether the tab a screen is in is the one showing. An app whose tabs stay
|
/// Whether the tab a screen is in is the one showing. An app whose tabs stay
|
||||||
|
|
@ -21,3 +23,31 @@ class const TabVisibility({
|
||||||
bool updateShouldNotify(TabVisibility oldWidget) =>
|
bool updateShouldNotify(TabVisibility oldWidget) =>
|
||||||
visible != oldWidget.visible;
|
visible != oldWidget.visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The tabs of a [NavigationFrame]'s app, as go_router's indexed stack builds
|
||||||
|
/// them for a `StatefulShellRoute`: every tab stays alive, the hidden ones
|
||||||
|
/// offstage with their animations paused, and each learns from
|
||||||
|
/// [TabVisibility] whether it shows.
|
||||||
|
class const TabsStack({
|
||||||
|
/// The tab showing.
|
||||||
|
required final int index,
|
||||||
|
|
||||||
|
/// The tabs, in order.
|
||||||
|
required final List<Widget> children,
|
||||||
|
super.key,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => IndexedStack(
|
||||||
|
index: index,
|
||||||
|
children: [
|
||||||
|
for (final (i, tab) in children.indexed)
|
||||||
|
Offstage(
|
||||||
|
offstage: i != index,
|
||||||
|
child: TickerMode(
|
||||||
|
enabled: i == index,
|
||||||
|
child: TabVisibility(visible: i == index, child: tab),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
163
packages/halkyon_ui/test/widget/navigation_frame_test.dart
Normal file
163
packages/halkyon_ui/test/widget/navigation_frame_test.dart
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
import '../support/test_app.dart';
|
||||||
|
|
||||||
|
/// Every Halkyon app's frame around its tabs: a rail with the app's icon
|
||||||
|
/// from medium windows on, a floating bar below, a tick on every choice.
|
||||||
|
void main() {
|
||||||
|
/// A 1 × 1 PNG, the app's icon in these tests.
|
||||||
|
final icon = MemoryImage(
|
||||||
|
Uint8List.fromList(const [
|
||||||
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, //
|
||||||
|
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||||
|
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00,
|
||||||
|
0x0B, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x60, 0x00, 0x02, 0x00,
|
||||||
|
0x00, 0x05, 0x00, 0x01, 0x7A, 0x5E, 0xAB, 0x3F, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The frame in a window [width] wide, on tab [selected]; the tabs chosen
|
||||||
|
/// go into [chosen].
|
||||||
|
Future<void> show(
|
||||||
|
WidgetTester tester, {
|
||||||
|
required double width,
|
||||||
|
required List<int> chosen,
|
||||||
|
int selected = 1,
|
||||||
|
}) async {
|
||||||
|
tester.view.physicalSize = Size(width, 800);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
testApp(
|
||||||
|
NavigationFrame(
|
||||||
|
appName: 'Halkyon Test',
|
||||||
|
icon: icon,
|
||||||
|
selectedIndex: selected,
|
||||||
|
onSelected: chosen.add,
|
||||||
|
destinations: const [
|
||||||
|
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
|
||||||
|
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
|
||||||
|
(label: 'Profile', icon: Icon(Icons.person), selectedIcon: null),
|
||||||
|
],
|
||||||
|
child: const Scaffold(body: Text('The tab')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ticks the platform was asked for while [act] ran.
|
||||||
|
Future<List<Object?>> ticks(
|
||||||
|
WidgetTester tester,
|
||||||
|
Future<void> Function() act,
|
||||||
|
) async {
|
||||||
|
final haptics = <Object?>[];
|
||||||
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||||
|
SystemChannels.platform,
|
||||||
|
(call) async {
|
||||||
|
if (call.method == 'HapticFeedback.vibrate') {
|
||||||
|
haptics.add(call.arguments);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
addTearDown(
|
||||||
|
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||||
|
SystemChannels.platform,
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await act();
|
||||||
|
return haptics;
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('below medium windows, a bar floats above the foot', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await show(tester, width: 400, chosen: []);
|
||||||
|
|
||||||
|
expect(find.byType(FloatingNavigationBar), findsOneWidget);
|
||||||
|
expect(find.byType(NavigationRail), findsNothing);
|
||||||
|
expect(find.text('The tab'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets("from medium windows on, a rail with the app's icon on top, "
|
||||||
|
'which leads to the first tab', (tester) async {
|
||||||
|
final chosen = <int>[];
|
||||||
|
await show(tester, width: 800, chosen: chosen);
|
||||||
|
|
||||||
|
expect(find.byType(NavigationRail), findsOneWidget);
|
||||||
|
expect(find.byType(NavigationBar), findsNothing);
|
||||||
|
expect(find.bySemanticsLabel('Halkyon Test'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Home'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(chosen, [0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('every choice on the bar ticks, lightly', (tester) async {
|
||||||
|
final chosen = <int>[];
|
||||||
|
await show(tester, width: 400, chosen: chosen);
|
||||||
|
final haptics = await ticks(tester, () async {
|
||||||
|
await tester.tap(find.text('Profile'));
|
||||||
|
await tester.pump();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(haptics, ['HapticFeedbackType.selectionClick']);
|
||||||
|
expect(chosen, [2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('every choice on the rail ticks, lightly', (tester) async {
|
||||||
|
final chosen = <int>[];
|
||||||
|
await show(tester, width: 800, chosen: chosen);
|
||||||
|
final haptics = await ticks(tester, () async {
|
||||||
|
await tester.tap(find.text('Maps'));
|
||||||
|
await tester.pump();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(haptics, ['HapticFeedbackType.selectionClick']);
|
||||||
|
expect(chosen, [1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the window crossing from compact to medium and back swaps '
|
||||||
|
'the bar for the rail', (tester) async {
|
||||||
|
await show(tester, width: 400, chosen: []);
|
||||||
|
for (final width in const [800.0, 400.0]) {
|
||||||
|
tester.view.physicalSize = Size(width, 800);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(find.byType(FloatingNavigationBar), findsOneWidget);
|
||||||
|
expect(find.text('The tab'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the bar steps aside where a screen has a bar of its own, '
|
||||||
|
"leaving the system's bar its room", (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(400, 800);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
tester.view.padding = const FakeViewPadding(bottom: 24);
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
testApp(
|
||||||
|
NavigationFrame(
|
||||||
|
appName: 'Halkyon Test',
|
||||||
|
icon: icon,
|
||||||
|
selectedIndex: 0,
|
||||||
|
onSelected: (_) {},
|
||||||
|
hidesNavigationBar: true,
|
||||||
|
destinations: const [
|
||||||
|
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
|
||||||
|
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
|
||||||
|
],
|
||||||
|
child: const Scaffold(body: Text('The tab')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byType(FloatingNavigationBar), findsNothing);
|
||||||
|
expect(tester.getBottomLeft(find.byType(Scaffold).last).dy, 800 - 24);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -61,4 +61,23 @@ void main() {
|
||||||
|
|
||||||
expect(visible, isTrue);
|
expect(visible, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('the tabs stack shows the tab chosen, keeps the others alive, '
|
||||||
|
'and tells each whether it shows', (tester) async {
|
||||||
|
final visible = <String, bool>{};
|
||||||
|
Widget tab(String name) => Builder(
|
||||||
|
builder: (context) {
|
||||||
|
visible[name] = TabVisibility.of(context);
|
||||||
|
return Text(name);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
testApp(TabsStack(index: 1, children: [tab('Home'), tab('Maps')])),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Maps'), findsOneWidget);
|
||||||
|
expect(find.text('Home'), findsNothing, reason: 'offstage');
|
||||||
|
expect(find.text('Home', skipOffstage: false), findsOneWidget);
|
||||||
|
expect(visible, {'Home': false, 'Maps': true});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue