refactor(ui,learn,todo,train,meditate): the navigation bar's one look, the debug section gone
The developer decided on 8 Nov 2026, after trying the variants on the phone: the wave in the colour of the screen in view, without Material's ripple, the indicator kept and there at once, a 1.2 s wave, top corners of 20 dp, the bar's own neutral surface. Learn's bar stays cobalt, the colour its tabs wash in, rather than its icon's purple. So the bar has one look: FlushNavigationBar.waveDuration and cornerRadius are constants, and the debug section goes from the four Settings screens with what it switched (NavigationBarDebugSection, debugNavigationBarStyle, effectiveNavigationBarStyle, the style record and the variants A, C and F, the speeds, the corners and the tinted surface). Their tests go too; those stating the look kept stay. Learn's sign-out helper no longer has to scroll past the section. ADR 0030's amendment of D60, the halkyon_ui README and Learn's AGENTS.md record the decision and the alternatives set aside. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
60d53ed262
commit
56f40d4d65
14 changed files with 156 additions and 563 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.
|
||||
- **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, decided 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: `shippingNavigationBarStyle`, which a debug build's Settings switches live in its section Navigation bar (debug), halkyon_ui's `NavigationBarDebugSection`, which also tints the bar's surface with the screen's colour, neutral by default), 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 floating buttons lifted by `FlushNavigationBar.buttonLiftOf` (the same without the system's bar, which a Scaffold keeps its button clear of itself); 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), 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 floating buttons lifted by `FlushNavigationBar.buttonLiftOf` (the same without the system's bar, which a Scaffold keeps its button clear of itself); 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`).
|
||||
|
|
|
|||
|
|
@ -103,9 +103,6 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
// The navigation bar's looks to try on a phone, in debug
|
||||
// builds alone (halkyon_ui's).
|
||||
const NavigationBarDebugSection(),
|
||||
const SizedBox(height: 12),
|
||||
SectionCard(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
|
|
|
|||
|
|
@ -47,9 +47,6 @@ void main() {
|
|||
/// Signs out from Settings, which asks how to start.
|
||||
Future<void> signOut(WidgetTester tester, {bool passwords = true}) async {
|
||||
await openSettings(tester, passwords: passwords);
|
||||
// Below the debug builds' section of the navigation bar's looks.
|
||||
await tester.ensureVisible(find.text('Sign out'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Sign out'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,9 +110,6 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
// The navigation bar's looks to try on a phone, in debug
|
||||
// builds alone (halkyon_ui's).
|
||||
const NavigationBarDebugSection(),
|
||||
const SizedBox(height: 12),
|
||||
SectionCard(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
|
|
|
|||
|
|
@ -105,9 +105,6 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
// The navigation bar's looks to try on a phone, in debug
|
||||
// builds alone (halkyon_ui's).
|
||||
const NavigationBarDebugSection(),
|
||||
const SizedBox(height: 12),
|
||||
SectionCard(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
|
|
|
|||
|
|
@ -162,9 +162,6 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
// The navigation bar's looks to try on a phone, in debug
|
||||
// builds alone (halkyon_ui's).
|
||||
const NavigationBarDebugSection(),
|
||||
const SizedBox(height: 12),
|
||||
SectionCard(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
|
|
|
|||
|
|
@ -1494,9 +1494,16 @@ Strava. What was found, 27 Oct:
|
|||
20 dp, a tap on another destination sending a wave over the bar from it, 1.2 s, in the
|
||||
colour of the screen in view (a project's own while it shows, since 8 Nov 2026; the
|
||||
app's before a screen claims one), the destination chosen in that colour, darkened or
|
||||
lightened until it reads at 4.5:1, without Material's ripple or
|
||||
the indicator growing, the variant E of the developer's mockup, which a debug build's
|
||||
Settings switches live among the others). Driven by
|
||||
lightened until it reads at 4.5:1, without Material's ripple or the indicator growing,
|
||||
the indicator kept, on the bar's own neutral surface. The developer chose it on 8 Nov
|
||||
2026 after trying the variants live on the phone (the mockup's E): over Material's bar
|
||||
alone (A), which says nothing of where a tap landed; over the wave with Material's fill
|
||||
(C), whose ripple and growing indicator fight the wave; over the wave without the
|
||||
indicator (F), which leaves the tab chosen marked by its colour alone; and over a
|
||||
surface tinted with the screen's colour, which muddies the bar against the screen's own
|
||||
wash. Learn's tabs wash in cobalt, so its bar is cobalt there, not its icon's purple,
|
||||
by the developer's choice. The debug section that switched the variants went with the
|
||||
decision). Driven by
|
||||
go_router as Learn's (a `StatefulShellRoute`, each tab keeping its own stack): a
|
||||
workout's page opens in Home and a day's in Profile with the tabs beside them, the
|
||||
route and plan editors and the recording over them; over keeping `Navigator.push`
|
||||
|
|
|
|||
|
|
@ -31,9 +31,9 @@ LeanCode's lints.
|
|||
app's frame around its tabs (the rail with the app's icon, 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`),
|
||||
`shippingNavigationBarStyle`,
|
||||
switched live in a debug build's `NavigationBarDebugSection`, a tick on every choice, a status such as a recording under way above
|
||||
chosen in that colour made legible (`readableTone`), without Material's
|
||||
ripple or the indicator growing, on the bar's neutral surface (decided
|
||||
8 Nov 2026, ADR 0030's amendment of D60), a tick on every choice, a status such as a recording under way above
|
||||
the bar), whichever router drives it;
|
||||
`AppLoadingIndicator`, `ButtonLoadingIndicator`, `AppRefreshIndicator`;
|
||||
`SectionCard`, `SliverCentered`, `EmptyListMessage`, `CountBadge`,
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ export 'src/widget/keyboard_back_on_resume.dart';
|
|||
export 'src/widget/launch_screen.dart';
|
||||
export 'src/widget/lift_while_dragged.dart';
|
||||
export 'src/widget/more_info_sheet.dart';
|
||||
export 'src/widget/navigation_bar_style.dart';
|
||||
export 'src/widget/navigation_frame.dart';
|
||||
export 'src/widget/palette_color_picker.dart';
|
||||
export 'src/widget/reorder_grip.dart';
|
||||
|
|
|
|||
|
|
@ -18,8 +18,10 @@ typedef NavigationWave = ({int index, int serial});
|
|||
/// scrolls on beneath the bar fades out rather than being cut off; taps
|
||||
/// pass through it to the screen.
|
||||
///
|
||||
/// A [wave] in [accent] spreads over the bar from the destination tapped
|
||||
/// and fades, over [waveDuration], behind the destinations.
|
||||
/// A [wave] in [accent], the colour of the screen in view, spreads over
|
||||
/// the bar from the destination tapped and fades, over [waveDuration],
|
||||
/// behind the destinations (decided by the developer 8 Nov 2026, after
|
||||
/// trying the variants on a phone).
|
||||
class const FlushNavigationBar({
|
||||
/// The bar: a [NavigationBar] without a background or elevation of its
|
||||
/// own, which this paints beneath the wave.
|
||||
|
|
@ -31,22 +33,12 @@ class const FlushNavigationBar({
|
|||
/// The colour of the screen in view, the wave's.
|
||||
required final Color accent,
|
||||
|
||||
/// The bar's own colour, beneath the wave: the theme's, or a little of
|
||||
/// the screen's colour mixed in.
|
||||
required final Color surface,
|
||||
|
||||
/// How many destinations the bar has, for where the wave starts.
|
||||
required final int destinationCount,
|
||||
|
||||
/// The latest wave; none while null.
|
||||
final NavigationWave? wave,
|
||||
|
||||
/// How long a wave takes.
|
||||
final Duration waveDuration = const Duration(milliseconds: 1200),
|
||||
|
||||
/// How round the bar's top corners are.
|
||||
final double cornerRadius = 0,
|
||||
|
||||
/// What stands above the bar while it matters (Train's recording under
|
||||
/// way), on [background], below the fade; none while null.
|
||||
final Widget? status,
|
||||
|
|
@ -55,6 +47,18 @@ class const FlushNavigationBar({
|
|||
/// How high the fade above the bar is.
|
||||
static const fadeHeight = 32.0;
|
||||
|
||||
/// How long a wave takes.
|
||||
static const waveDuration = Duration(milliseconds: 1200);
|
||||
|
||||
/// How round the bar's top corners are.
|
||||
static const cornerRadius = 20.0;
|
||||
|
||||
/// The bar's own colour beneath the wave: Material 3's, as the bar would
|
||||
/// paint itself, whatever the screen's colour.
|
||||
static Color surfaceOf(BuildContext context) =>
|
||||
NavigationBarTheme.of(context).backgroundColor ??
|
||||
Theme.of(context).colorScheme.surfaceContainer;
|
||||
|
||||
/// The fade above the bar, for tests to find.
|
||||
static const fadeKey = Key('flush-navigation-bar-fade');
|
||||
|
||||
|
|
@ -113,19 +117,18 @@ class const FlushNavigationBar({
|
|||
),
|
||||
ClipRRect(
|
||||
key: barKey,
|
||||
borderRadius: cornerRadius == 0
|
||||
? BorderRadius.zero
|
||||
: BorderRadius.vertical(top: Radius.circular(cornerRadius)),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(cornerRadius),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: ColoredBox(color: surface),
|
||||
child: ColoredBox(color: surfaceOf(context)),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: _WaveLayer(
|
||||
wave: wave,
|
||||
color: accent,
|
||||
duration: waveDuration,
|
||||
count: destinationCount,
|
||||
),
|
||||
),
|
||||
|
|
@ -143,7 +146,6 @@ class const FlushNavigationBar({
|
|||
class const _WaveLayer({
|
||||
required final NavigationWave? wave,
|
||||
required final Color color,
|
||||
required final Duration duration,
|
||||
required final int count,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
|
|
@ -155,13 +157,12 @@ class _WaveLayerState()
|
|||
with SingleTickerProviderStateMixin {
|
||||
late final _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.duration,
|
||||
duration: FlushNavigationBar.waveDuration,
|
||||
)..addStatusListener((_) => setState(() {}));
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_WaveLayer oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_controller.duration = widget.duration;
|
||||
if (widget.wave != null && widget.wave != oldWidget.wave) {
|
||||
_controller.forward(from: 0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,182 +0,0 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:halkyon_ui/src/widget/section_card.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// How the navigation bar below medium windows answers a tap (the variants
|
||||
/// the developer tried on the phone, 7 Nov 2026, by their letters).
|
||||
enum NavigationBarVariant(
|
||||
/// Its letter in the mockup and the debug section.
|
||||
final String letter,
|
||||
) {
|
||||
/// A: Material's bar as it is, its ripple and its indicator growing.
|
||||
material('A'),
|
||||
|
||||
/// C: a wave in the app's colour from the destination tapped, the
|
||||
/// destination chosen in that colour, Material's ripple and growing
|
||||
/// indicator kept.
|
||||
wave('C'),
|
||||
|
||||
/// E: the wave and the colour without the fill: no ripple, the indicator
|
||||
/// there at once.
|
||||
waveWithoutFill('E'),
|
||||
|
||||
/// F: as E, without the indicator.
|
||||
waveWithoutIndicator('F');
|
||||
|
||||
/// Whether a tap sends a wave and the destination chosen takes the app's
|
||||
/// colour.
|
||||
bool get waves => this != material;
|
||||
}
|
||||
|
||||
/// The navigation bar's look: its variant, how long the wave takes, how
|
||||
/// round the bar's top corners are, and whether the bar's surface takes a
|
||||
/// little of the screen's colour.
|
||||
typedef NavigationBarStyle = ({
|
||||
NavigationBarVariant variant,
|
||||
Duration waveDuration,
|
||||
double cornerRadius,
|
||||
bool tintedSurface,
|
||||
});
|
||||
|
||||
/// The look every release build has: the wave without the fill, 1.2 s,
|
||||
/// corners of 20 dp (the developer's choice, 7 Nov 2026), on the bar's own
|
||||
/// neutral surface.
|
||||
const NavigationBarStyle shippingNavigationBarStyle = (
|
||||
variant: NavigationBarVariant.waveWithoutFill,
|
||||
waveDuration: Duration(milliseconds: 1200),
|
||||
cornerRadius: 20,
|
||||
tintedSurface: false,
|
||||
);
|
||||
|
||||
/// The look a debug build shows, switched live in Settings' debug section
|
||||
/// (`NavigationBarDebugSection`); kept in memory alone, the shipping look at
|
||||
/// every start. Release builds never read it.
|
||||
final debugNavigationBarStyle = ValueNotifier<NavigationBarStyle>(
|
||||
shippingNavigationBarStyle,
|
||||
);
|
||||
|
||||
/// The look the frame shows: in a [debug] build the debug section's, else
|
||||
/// the shipping one.
|
||||
NavigationBarStyle effectiveNavigationBarStyle({bool debug = kDebugMode}) =>
|
||||
debug ? debugNavigationBarStyle.value : shippingNavigationBarStyle;
|
||||
|
||||
/// Settings' section switching the navigation bar's look live while testing
|
||||
/// on a phone: variant, the wave's time, the corners, the bar's surface
|
||||
/// neutral or tinted with the screen's colour. In debug builds
|
||||
/// alone, and in English alone, as no release ever shows it. Every app puts
|
||||
/// it before About; to remove it, delete the section from the apps' Settings
|
||||
/// and [debugNavigationBarStyle], and let the frame take
|
||||
/// [shippingNavigationBarStyle].
|
||||
class const NavigationBarDebugSection({
|
||||
/// Whether this is a debug build; tests pass false for a release's.
|
||||
@visibleForTesting final bool debug = kDebugMode,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
/// The wave's times on offer.
|
||||
static const durations = [700, 900, 1200, 1600, 2000];
|
||||
|
||||
/// The corners on offer.
|
||||
static const corners = [0.0, 12.0, 20.0, 28.0];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!debug) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: debugNavigationBarStyle,
|
||||
builder: (context, style, _) {
|
||||
void set({
|
||||
NavigationBarVariant? variant,
|
||||
Duration? waveDuration,
|
||||
double? cornerRadius,
|
||||
bool? tintedSurface,
|
||||
}) => debugNavigationBarStyle.value = (
|
||||
variant: variant ?? style.variant,
|
||||
waveDuration: waveDuration ?? style.waveDuration,
|
||||
cornerRadius: cornerRadius ?? style.cornerRadius,
|
||||
tintedSurface: tintedSurface ?? style.tintedSurface,
|
||||
);
|
||||
String seconds(int ms) =>
|
||||
'${(ms / 1000).toString().replaceAll(RegExp(r'\.0$'), '')} s';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: SectionCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Navigation bar (debug)',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_Choice(
|
||||
label: 'Variant',
|
||||
values: NavigationBarVariant.values,
|
||||
selected: style.variant,
|
||||
name: (variant) => variant.letter,
|
||||
onSelected: (variant) => set(variant: variant),
|
||||
),
|
||||
_Choice(
|
||||
label: 'Wave',
|
||||
values: durations,
|
||||
selected: style.waveDuration.inMilliseconds,
|
||||
name: seconds,
|
||||
onSelected: (ms) =>
|
||||
set(waveDuration: Duration(milliseconds: ms)),
|
||||
),
|
||||
_Choice(
|
||||
label: 'Corners',
|
||||
values: corners,
|
||||
selected: style.cornerRadius,
|
||||
name: (radius) => radius.toStringAsFixed(0),
|
||||
onSelected: (radius) => set(cornerRadius: radius),
|
||||
),
|
||||
_Choice(
|
||||
label: 'Bar surface',
|
||||
values: const [false, true],
|
||||
selected: style.tintedSurface,
|
||||
name: (tinted) => tinted ? 'Tinted' : 'Neutral',
|
||||
onSelected: (tinted) => set(tintedSurface: tinted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A row of chips choosing one of [values].
|
||||
class const _Choice<T>({
|
||||
required final String label,
|
||||
required final List<T> values,
|
||||
required final T selected,
|
||||
required final String Function(T value) name,
|
||||
required final ValueChanged<T> onSelected,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 72, child: Text(label)),
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final value in values)
|
||||
ChoiceChip(
|
||||
label: Text(name(value)),
|
||||
selected: value == selected,
|
||||
onSelected: (_) => onSelected(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ import 'package:flutter/services.dart';
|
|||
import 'package:halkyon_ui/src/layout/window_size.dart';
|
||||
import 'package:halkyon_ui/src/theme/readable_tone.dart';
|
||||
import 'package:halkyon_ui/src/widget/flush_navigation_bar.dart';
|
||||
import 'package:halkyon_ui/src/widget/navigation_bar_style.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';
|
||||
|
|
@ -34,13 +33,13 @@ typedef FrameDestination = ({
|
|||
/// passed on too, for the app to go back to its first screen, as Material
|
||||
/// prescribes.
|
||||
///
|
||||
/// The bar's look is [shippingNavigationBarStyle] (the developer's choice,
|
||||
/// 7 Nov 2026), in debug builds the one Settings' debug section switches
|
||||
/// to: a tap on another destination sends a wave over the bar from it,
|
||||
/// unless animations are off, and the destination chosen takes the colour
|
||||
/// of the screen in view ([ScreenTint.color], [accent] until a screen
|
||||
/// claims one), legible on the bar; a tab changed otherwise (a link, back)
|
||||
/// sends none, as the wave answers the finger.
|
||||
/// On the bar (the developer's choice, 8 Nov 2026, after trying the
|
||||
/// variants on a phone), a tap on another destination sends a wave over it
|
||||
/// from that destination, unless animations are off, and the destination
|
||||
/// chosen takes the colour of the screen in view ([ScreenTint.color],
|
||||
/// [accent] until a screen claims one), legible on the bar, without
|
||||
/// Material's ripple and with its indicator there at once; a tab changed
|
||||
/// otherwise (a link, back) sends no wave, as the wave answers the finger.
|
||||
class const NavigationFrame({
|
||||
/// The tabs, in order.
|
||||
required final List<FrameDestination> destinations,
|
||||
|
|
@ -89,25 +88,15 @@ class _NavigationFrameState() extends State<NavigationFrame> {
|
|||
/// The latest wave sent by a tap; none before one.
|
||||
NavigationWave? _wave;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
debugNavigationBarStyle.addListener(_restyle);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
debugNavigationBarStyle.removeListener(_restyle);
|
||||
_tint.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _restyle() => setState(() {});
|
||||
|
||||
void _select(int index) {
|
||||
HapticFeedback.selectionClick();
|
||||
if (index != widget.selectedIndex &&
|
||||
effectiveNavigationBarStyle().variant.waves &&
|
||||
!MediaQuery.disableAnimationsOf(context)) {
|
||||
setState(
|
||||
() => _wave = (index: index, serial: (_wave?.serial ?? 0) + 1),
|
||||
|
|
@ -156,19 +145,8 @@ class _NavigationFrameState() extends State<NavigationFrame> {
|
|||
),
|
||||
);
|
||||
}
|
||||
final style = effectiveNavigationBarStyle();
|
||||
final variant = style.variant;
|
||||
final withoutFill =
|
||||
variant == NavigationBarVariant.waveWithoutFill ||
|
||||
variant == NavigationBarVariant.waveWithoutIndicator;
|
||||
final theme = Theme.of(context);
|
||||
final surface = theme.colorScheme.surface;
|
||||
final neutral =
|
||||
NavigationBarTheme.of(context).backgroundColor ??
|
||||
theme.colorScheme.surfaceContainer;
|
||||
final dark = theme.brightness == Brightness.dark;
|
||||
final surface = Theme.of(context).colorScheme.surface;
|
||||
final still = MediaQuery.disableAnimationsOf(context);
|
||||
const colourChange = Duration(milliseconds: 300);
|
||||
return ScreenTintScope(
|
||||
notifier: _tint,
|
||||
// Behind the screens and in the fade above the bar, the background of
|
||||
|
|
@ -184,28 +162,15 @@ class _NavigationFrameState() extends State<NavigationFrame> {
|
|||
tween: ColorTween(end: _tint.background ?? surface),
|
||||
duration: const Duration(milliseconds: 250),
|
||||
builder: (context, background, _) => TweenAnimationBuilder<Color?>(
|
||||
tween: ColorTween(
|
||||
end: style.tintedSurface
|
||||
? Color.alphaBlend(
|
||||
target.withValues(alpha: dark ? 0.08 : 0.06),
|
||||
neutral,
|
||||
)
|
||||
: neutral,
|
||||
tween: ColorTween(end: target),
|
||||
duration: still
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 300),
|
||||
builder: (context, colour, _) => _phone(
|
||||
context,
|
||||
background: background ?? surface,
|
||||
colour: colour ?? target,
|
||||
),
|
||||
duration: still ? Duration.zero : colourChange,
|
||||
builder: (context, barSurface, _) =>
|
||||
TweenAnimationBuilder<Color?>(
|
||||
tween: ColorTween(end: target),
|
||||
duration: still ? Duration.zero : colourChange,
|
||||
builder: (context, colour, _) => _phone(
|
||||
context,
|
||||
style: style,
|
||||
withoutFill: withoutFill,
|
||||
background: background ?? surface,
|
||||
barSurface: barSurface ?? neutral,
|
||||
colour: colour ?? target,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -213,35 +178,26 @@ class _NavigationFrameState() extends State<NavigationFrame> {
|
|||
);
|
||||
}
|
||||
|
||||
/// The frame below medium windows, its bar in [colour] on [barSurface],
|
||||
/// the screens on [background].
|
||||
/// The frame below medium windows, its bar in [colour], the screens on
|
||||
/// [background].
|
||||
Widget _phone(
|
||||
BuildContext context, {
|
||||
required NavigationBarStyle style,
|
||||
required bool withoutFill,
|
||||
required Color background,
|
||||
required Color barSurface,
|
||||
required Color colour,
|
||||
}) {
|
||||
final variant = style.variant;
|
||||
final destinations = widget.destinations;
|
||||
final indicator = colour.withValues(alpha: 0.22);
|
||||
final barSurface = FlushNavigationBar.surfaceOf(context);
|
||||
final Widget bar = NavigationBar(
|
||||
// The flush bar paints the bar's colour beneath the wave.
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
selectedIndex: widget.selectedIndex,
|
||||
onDestinationSelected: _select,
|
||||
indicatorColor: switch (variant) {
|
||||
NavigationBarVariant.material => null,
|
||||
NavigationBarVariant.waveWithoutIndicator => Colors.transparent,
|
||||
_ => indicator,
|
||||
},
|
||||
// Without the fill: no ripple, the indicator there at once.
|
||||
overlayColor: withoutFill
|
||||
? const WidgetStatePropertyAll(Colors.transparent)
|
||||
: null,
|
||||
animationDuration: withoutFill ? Duration.zero : null,
|
||||
indicatorColor: indicator,
|
||||
// Without Material's fill: no ripple, the indicator there at once.
|
||||
overlayColor: const WidgetStatePropertyAll(Colors.transparent),
|
||||
animationDuration: Duration.zero,
|
||||
destinations: [
|
||||
for (final destination in destinations)
|
||||
NavigationDestination(
|
||||
|
|
@ -279,29 +235,21 @@ class _NavigationFrameState() extends State<NavigationFrame> {
|
|||
)
|
||||
: FlushNavigationBar(
|
||||
background: background,
|
||||
surface: barSurface,
|
||||
accent: colour,
|
||||
destinationCount: destinations.length,
|
||||
wave: _wave,
|
||||
waveDuration: style.waveDuration,
|
||||
cornerRadius: style.cornerRadius,
|
||||
status: widget.status,
|
||||
bar: variant.waves
|
||||
? _Chosen(
|
||||
// The colour as text on the bar and on its indicator
|
||||
// (WCAG AA), a person's light colours darkened in
|
||||
// light mode, dark ones lightened in dark.
|
||||
accent: readableTone(
|
||||
colour,
|
||||
brightness: Theme.of(context).brightness,
|
||||
on: [
|
||||
barSurface,
|
||||
Color.alphaBlend(indicator, barSurface),
|
||||
],
|
||||
),
|
||||
child: bar,
|
||||
)
|
||||
: bar,
|
||||
bar: _Chosen(
|
||||
// The colour as text on the bar and on its indicator (WCAG
|
||||
// AA), a person's light colours darkened in light mode, dark
|
||||
// ones lightened in dark.
|
||||
accent: readableTone(
|
||||
colour,
|
||||
brightness: Theme.of(context).brightness,
|
||||
on: [barSurface, Color.alphaBlend(indicator, barSurface)],
|
||||
),
|
||||
child: bar,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,8 +24,6 @@ void main() {
|
|||
|
||||
final accent = PaletteColor.strawberry.background;
|
||||
|
||||
tearDown(() => debugNavigationBarStyle.value = shippingNavigationBarStyle);
|
||||
|
||||
/// The frame around [child] on a phone, in [brightness].
|
||||
Future<void> show(
|
||||
WidgetTester tester,
|
||||
|
|
@ -150,50 +148,38 @@ void main() {
|
|||
final darkest = byLuminance.first;
|
||||
|
||||
for (final brightness in Brightness.values) {
|
||||
for (final tinted in [false, true]) {
|
||||
testWidgets('in the $brightness theme, the bar '
|
||||
'${tinted ? 'tinted' : 'neutral'}: the lightest '
|
||||
'(${lightest.name}), the darkest (${darkest.name}) and every '
|
||||
'other', (tester) async {
|
||||
debugNavigationBarStyle.value = (
|
||||
variant: NavigationBarVariant.waveWithoutFill,
|
||||
waveDuration: const Duration(milliseconds: 1200),
|
||||
cornerRadius: 20.0,
|
||||
tintedSurface: tinted,
|
||||
testWidgets('in the $brightness theme: the lightest '
|
||||
'(${lightest.name}), the darkest (${darkest.name}) and every other', (
|
||||
tester,
|
||||
) async {
|
||||
for (final color in [lightest, darkest, ...PaletteColor.values]) {
|
||||
await show(tester, screen(color.background), brightness: brightness);
|
||||
await tester.pumpAndSettle();
|
||||
final tone = chosenIcon(tester)!;
|
||||
final surface = barSurface(tester);
|
||||
final onIndicator = Color.alphaBlend(
|
||||
color.background.withValues(alpha: 0.22),
|
||||
surface,
|
||||
);
|
||||
for (final color in [lightest, darkest, ...PaletteColor.values]) {
|
||||
await show(
|
||||
tester,
|
||||
screen(color.background),
|
||||
brightness: brightness,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final tone = chosenIcon(tester)!;
|
||||
final surface = barSurface(tester);
|
||||
final onIndicator = Color.alphaBlend(
|
||||
color.background.withValues(alpha: 0.22),
|
||||
surface,
|
||||
);
|
||||
expect(
|
||||
TopGradient.contrast(tone, surface),
|
||||
greaterThanOrEqualTo(4.5),
|
||||
reason: '${color.name} on the bar',
|
||||
);
|
||||
expect(
|
||||
TopGradient.contrast(tone, onIndicator),
|
||||
greaterThanOrEqualTo(4.5),
|
||||
reason: '${color.name} on its indicator',
|
||||
);
|
||||
final label = find.text('Home');
|
||||
expect(
|
||||
tester.widget<Text>(label).style?.color ??
|
||||
DefaultTextStyle.of(tester.element(label)).style.color,
|
||||
tone,
|
||||
reason: '${color.name}: the label as the icon',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
expect(
|
||||
TopGradient.contrast(tone, surface),
|
||||
greaterThanOrEqualTo(4.5),
|
||||
reason: '${color.name} on the bar',
|
||||
);
|
||||
expect(
|
||||
TopGradient.contrast(tone, onIndicator),
|
||||
greaterThanOrEqualTo(4.5),
|
||||
reason: '${color.name} on its indicator',
|
||||
);
|
||||
final label = find.text('Home');
|
||||
expect(
|
||||
tester.widget<Text>(label).style?.color ??
|
||||
DefaultTextStyle.of(tester.element(label)).style.color,
|
||||
tone,
|
||||
reason: '${color.name}: the label as the icon',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -219,29 +205,17 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
testWidgets("the bar's surface stays neutral unless tinted, which mixes in "
|
||||
'6 % of the colour in light mode and 8 % in dark', (tester) async {
|
||||
testWidgets("the bar's surface is the theme's own, whatever the screen's "
|
||||
'colour, light and dark', (tester) async {
|
||||
final project = PaletteColor.ocean.background;
|
||||
for (final brightness in Brightness.values) {
|
||||
debugNavigationBarStyle.value = shippingNavigationBarStyle;
|
||||
await show(tester, screen(project), brightness: brightness);
|
||||
await tester.pumpAndSettle();
|
||||
final neutral = Theme.of(
|
||||
tester.element(find.byType(NavigationBar)),
|
||||
).colorScheme.surfaceContainer;
|
||||
expect(barSurface(tester), neutral, reason: '$brightness');
|
||||
|
||||
debugNavigationBarStyle.value = (
|
||||
variant: NavigationBarVariant.waveWithoutFill,
|
||||
waveDuration: const Duration(milliseconds: 1200),
|
||||
cornerRadius: 20.0,
|
||||
tintedSurface: true,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final share = brightness == Brightness.dark ? 0.08 : 0.06;
|
||||
expect(
|
||||
barSurface(tester),
|
||||
Color.alphaBlend(project.withValues(alpha: share), neutral),
|
||||
Theme.of(
|
||||
tester.element(find.byType(NavigationBar)),
|
||||
).colorScheme.surfaceContainer,
|
||||
reason: '$brightness',
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ import 'package:material_ui/material_ui.dart';
|
|||
|
||||
import '../support/test_app.dart';
|
||||
|
||||
/// The bar's look below medium windows (the developer's choice of 7 Nov
|
||||
/// 2026): a wave in the app's colour spreading from the destination tapped,
|
||||
/// the destination chosen in that colour, no fill, rounded top corners; and
|
||||
/// the section switching them while testing, in debug builds alone.
|
||||
/// The bar's look below medium windows (the developer's choice of 8 Nov
|
||||
/// 2026, after trying the variants on a phone): a wave in the screen's
|
||||
/// colour spreading from the destination tapped, the destination chosen in
|
||||
/// that colour, without Material's fill, rounded top corners.
|
||||
void main() {
|
||||
/// A 1 × 1 PNG, the app's icon in these tests.
|
||||
final icon = MemoryImage(
|
||||
|
|
@ -24,8 +24,6 @@ void main() {
|
|||
|
||||
const accent = Color(0xFFEC3E7C);
|
||||
|
||||
tearDown(() => debugNavigationBarStyle.value = shippingNavigationBarStyle);
|
||||
|
||||
/// The frame on tab [selected], its choices passed to [onSelected];
|
||||
/// animations off where [disableAnimations].
|
||||
Widget frame({
|
||||
|
|
@ -75,20 +73,9 @@ void main() {
|
|||
|
||||
final wave = find.byKey(FlushNavigationBar.waveKey);
|
||||
|
||||
testWidgets('the shipping look is the wave without the fill, 1.2 s, with '
|
||||
'corners of 20 on a neutral surface, which the debug section starts '
|
||||
'from', (tester) async {
|
||||
expect(shippingNavigationBarStyle, (
|
||||
variant: NavigationBarVariant.waveWithoutFill,
|
||||
waveDuration: const Duration(milliseconds: 1200),
|
||||
cornerRadius: 20.0,
|
||||
tintedSurface: false,
|
||||
));
|
||||
expect(debugNavigationBarStyle.value, shippingNavigationBarStyle);
|
||||
});
|
||||
|
||||
testWidgets('a tap on another destination sends a wave over the bar, gone '
|
||||
'once its time is up', (tester) async {
|
||||
'after its 1.2 s', (tester) async {
|
||||
expect(FlushNavigationBar.waveDuration, const Duration(milliseconds: 1200));
|
||||
await phone(tester);
|
||||
await tester.pumpWidget(following());
|
||||
expect(wave, findsNothing);
|
||||
|
|
@ -104,24 +91,9 @@ void main() {
|
|||
reason: 'it covers the bar, clipped to it',
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 1000));
|
||||
expect(wave, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the wave takes as long as the style says', (tester) async {
|
||||
await phone(tester);
|
||||
debugNavigationBarStyle.value = (
|
||||
variant: NavigationBarVariant.waveWithoutFill,
|
||||
waveDuration: const Duration(seconds: 2),
|
||||
cornerRadius: 20.0,
|
||||
tintedSurface: false,
|
||||
);
|
||||
await tester.pumpWidget(following());
|
||||
await tester.tap(find.text('Maps'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 1500));
|
||||
expect(wave, findsOneWidget);
|
||||
await tester.pump(const Duration(milliseconds: 600));
|
||||
await tester.pump(const Duration(milliseconds: 800));
|
||||
expect(wave, findsOneWidget, reason: 'not yet 1.2 s');
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
expect(wave, findsNothing);
|
||||
});
|
||||
|
||||
|
|
@ -150,168 +122,57 @@ void main() {
|
|||
expect(wave, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Material alone sends no wave', (tester) async {
|
||||
testWidgets('the destination chosen is in the app colour, legible, its '
|
||||
"indicator tinted with it and there at once, without Material's "
|
||||
'ripple', (tester) async {
|
||||
await phone(tester);
|
||||
debugNavigationBarStyle.value = (
|
||||
variant: NavigationBarVariant.material,
|
||||
waveDuration: const Duration(milliseconds: 1200),
|
||||
cornerRadius: 20.0,
|
||||
tintedSurface: false,
|
||||
NavigationBar bar() => tester.widget(find.byType(NavigationBar));
|
||||
Color? iconColor(IconData data) => IconTheme.of(
|
||||
tester.element(find.byIcon(data)),
|
||||
).color;
|
||||
// The app colour as legible on the bar and on its indicator.
|
||||
Color tone() {
|
||||
final surface = Theme.of(
|
||||
tester.element(find.byType(NavigationBar)),
|
||||
).colorScheme.surfaceContainer;
|
||||
return readableTone(
|
||||
accent,
|
||||
brightness: Brightness.light,
|
||||
on: [
|
||||
surface,
|
||||
Color.alphaBlend(accent.withValues(alpha: 0.22), surface),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
await tester.pumpWidget(frame(selected: 1));
|
||||
await tester.pumpAndSettle();
|
||||
expect(iconColor(Icons.map), tone());
|
||||
expect(iconColor(Icons.home), isNot(tone()));
|
||||
expect(
|
||||
tester.widget<Text>(find.text('Maps')).style?.color ??
|
||||
DefaultTextStyle.of(tester.element(find.text('Maps'))).style.color,
|
||||
tone(),
|
||||
);
|
||||
await tester.pumpWidget(following());
|
||||
await tester.tap(find.text('Maps'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(wave, findsNothing);
|
||||
expect(bar().indicatorColor, accent.withValues(alpha: 0.22));
|
||||
expect(
|
||||
bar().overlayColor?.resolve({WidgetState.pressed}),
|
||||
Colors.transparent,
|
||||
);
|
||||
expect(bar().animationDuration, Duration.zero);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'the destination chosen is in the app colour, legible, its indicator '
|
||||
'tinted with it; without the fill no ripple and no growing indicator; '
|
||||
'the last variant without the indicator',
|
||||
(tester) async {
|
||||
await phone(tester);
|
||||
NavigationBar bar() => tester.widget(find.byType(NavigationBar));
|
||||
Color? iconColor(IconData data) => IconTheme.of(
|
||||
tester.element(find.byIcon(data)),
|
||||
).color;
|
||||
// The app colour as legible on the bar and on its indicator.
|
||||
Color tone() {
|
||||
final surface = Theme.of(
|
||||
tester.element(find.byType(NavigationBar)),
|
||||
).colorScheme.surfaceContainer;
|
||||
return readableTone(
|
||||
accent,
|
||||
brightness: Brightness.light,
|
||||
on: [
|
||||
surface,
|
||||
Color.alphaBlend(accent.withValues(alpha: 0.22), surface),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
debugNavigationBarStyle.value = (
|
||||
variant: NavigationBarVariant.wave,
|
||||
waveDuration: const Duration(milliseconds: 1200),
|
||||
cornerRadius: 20.0,
|
||||
tintedSurface: false,
|
||||
);
|
||||
await tester.pumpWidget(frame(selected: 1));
|
||||
await tester.pumpAndSettle();
|
||||
expect(iconColor(Icons.map), tone());
|
||||
expect(iconColor(Icons.home), isNot(tone()));
|
||||
expect(
|
||||
tester.widget<Text>(find.text('Maps')).style?.color ??
|
||||
DefaultTextStyle.of(tester.element(find.text('Maps'))).style.color,
|
||||
tone(),
|
||||
);
|
||||
expect(bar().indicatorColor, accent.withValues(alpha: 0.22));
|
||||
expect(bar().overlayColor, isNull, reason: 'with the fill');
|
||||
|
||||
debugNavigationBarStyle.value = shippingNavigationBarStyle;
|
||||
await tester.pumpAndSettle();
|
||||
expect(iconColor(Icons.map), tone());
|
||||
expect(bar().indicatorColor, accent.withValues(alpha: 0.22));
|
||||
expect(
|
||||
bar().overlayColor?.resolve({WidgetState.pressed}),
|
||||
Colors.transparent,
|
||||
);
|
||||
expect(bar().animationDuration, Duration.zero);
|
||||
|
||||
debugNavigationBarStyle.value = (
|
||||
variant: NavigationBarVariant.waveWithoutIndicator,
|
||||
waveDuration: const Duration(milliseconds: 1200),
|
||||
cornerRadius: 20.0,
|
||||
tintedSurface: false,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(iconColor(Icons.map), tone());
|
||||
expect(bar().indicatorColor, Colors.transparent);
|
||||
|
||||
debugNavigationBarStyle.value = (
|
||||
variant: NavigationBarVariant.material,
|
||||
waveDuration: const Duration(milliseconds: 1200),
|
||||
cornerRadius: 20.0,
|
||||
tintedSurface: false,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(iconColor(Icons.map), isNot(tone()));
|
||||
expect(bar().indicatorColor, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets("the bar's top corners are rounded as the style says, the "
|
||||
'screen showing through them', (tester) async {
|
||||
testWidgets("the bar's top corners are rounded, 20 dp, the screen showing "
|
||||
'through them', (tester) async {
|
||||
await phone(tester);
|
||||
BorderRadiusGeometry corners() => tester
|
||||
.widget<ClipRRect>(find.byKey(FlushNavigationBar.barKey))
|
||||
.borderRadius;
|
||||
|
||||
await tester.pumpWidget(frame(selected: 0));
|
||||
expect(corners(), const BorderRadius.vertical(top: Radius.circular(20)));
|
||||
|
||||
debugNavigationBarStyle.value = (
|
||||
variant: NavigationBarVariant.waveWithoutFill,
|
||||
waveDuration: const Duration(milliseconds: 1200),
|
||||
cornerRadius: 0.0,
|
||||
tintedSurface: false,
|
||||
);
|
||||
await tester.pump();
|
||||
expect(corners(), BorderRadius.zero);
|
||||
});
|
||||
|
||||
testWidgets('the debug section switches the look live, and shows nothing '
|
||||
'outside debug builds', (tester) async {
|
||||
await phone(tester);
|
||||
await tester.pumpWidget(
|
||||
testApp(
|
||||
const Scaffold(
|
||||
body: SingleChildScrollView(child: NavigationBarDebugSection()),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('Navigation bar (debug)'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('F'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('2 s'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('28'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Tinted'));
|
||||
await tester.pump();
|
||||
expect(debugNavigationBarStyle.value, (
|
||||
variant: NavigationBarVariant.waveWithoutIndicator,
|
||||
waveDuration: const Duration(seconds: 2),
|
||||
cornerRadius: 28.0,
|
||||
tintedSurface: true,
|
||||
));
|
||||
|
||||
await tester.pumpWidget(
|
||||
testApp(
|
||||
const Scaffold(body: NavigationBarDebugSection(debug: false)),
|
||||
),
|
||||
);
|
||||
expect(find.text('Navigation bar (debug)'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('outside debug builds the frame keeps the shipping look '
|
||||
'whatever the debug section holds', (tester) async {
|
||||
expect(FlushNavigationBar.cornerRadius, 20);
|
||||
expect(
|
||||
effectiveNavigationBarStyle(debug: false),
|
||||
shippingNavigationBarStyle,
|
||||
tester
|
||||
.widget<ClipRRect>(find.byKey(FlushNavigationBar.barKey))
|
||||
.borderRadius,
|
||||
const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
);
|
||||
debugNavigationBarStyle.value = (
|
||||
variant: NavigationBarVariant.material,
|
||||
waveDuration: const Duration(seconds: 2),
|
||||
cornerRadius: 0.0,
|
||||
tintedSurface: false,
|
||||
);
|
||||
expect(
|
||||
effectiveNavigationBarStyle(debug: false),
|
||||
shippingNavigationBarStyle,
|
||||
);
|
||||
// Tests run as a debug build.
|
||||
expect(effectiveNavigationBarStyle(), debugNavigationBarStyle.value);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue