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
7 changed files with 156 additions and 563 deletions
|
|
@ -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