Below medium windows the frame's bar is Material 3 Expressive's again, flush with the window's foot (decided 7 Nov 2026 by the developer): the theme's NavigationBar across the whole width, 64 dp in its own colour, running on beneath the system's bar as Material's does. Right above it a fade 32 dp high, from the background of the screen in view (the ScreenTint's, the theme's surface before a screen claims one) made see-through to that background, so what scrolls on beneath the bar fades out rather than being cut off; taps pass through it. The rail of wider windows stays as it was. halkyon_ui's FloatingNavigationBar becomes FlushNavigationBar, its side margin, lift and rounded card gone. Its coverOf is now the whole room content leaves at its end, the system's bar included (the body's bottom padding: bar, fade and system's bar inside the frame, the system's bar alone outside it), and floating buttons rise by buttonLiftOf, the same without the system's bar, which a Scaffold keeps them clear of itself. Lists, Todo's hour grid and the Plan's sheet keep using coverOf; the Profile's list no longer adds the system's bar a second time. Whats-New: The navigation bar sits at the bottom edge again, with a soft fade above it. Whats-New-De: Die Navigationsleiste sitzt wieder am unteren Rand, mit einem sanften Übergang darüber. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
421 lines
14 KiB
Dart
421 lines
14 KiB
Dart
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 flush bar below with a fade above it, 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 stands at the foot', (
|
||
tester,
|
||
) async {
|
||
await show(tester, width: 400, chosen: []);
|
||
|
||
expect(find.byType(FlushNavigationBar), findsOneWidget);
|
||
expect(find.byType(NavigationRail), findsNothing);
|
||
expect(find.text('The tab'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('below medium windows, the bar is flush with the foot: as wide '
|
||
"as the window, 64 high above the system's bar, in the theme's colour", (
|
||
tester,
|
||
) async {
|
||
tester.view.padding = const FakeViewPadding(bottom: 24);
|
||
tester.view.viewPadding = const FakeViewPadding(bottom: 24);
|
||
await show(tester, width: 400, chosen: []);
|
||
|
||
final bar = find.byType(NavigationBar);
|
||
expect(
|
||
tester.getRect(bar),
|
||
const Rect.fromLTRB(0, 800 - 64 - 24, 400, 800),
|
||
);
|
||
expect(tester.widget<NavigationBar>(bar).backgroundColor, isNull);
|
||
expect(tester.widget<NavigationBar>(bar).elevation, isNull);
|
||
});
|
||
|
||
testWidgets('right above the bar, a fade into the background of the screen '
|
||
'in view, which taps pass through to the screen', (tester) async {
|
||
tester.view.padding = const FakeViewPadding(bottom: 24);
|
||
tester.view.viewPadding = const FakeViewPadding(bottom: 24);
|
||
tester.view.physicalSize = const Size(400, 800);
|
||
tester.view.devicePixelRatio = 1;
|
||
addTearDown(tester.view.reset);
|
||
var tapped = 0;
|
||
const background = Color(0xFFFFE4C4);
|
||
await tester.pumpWidget(
|
||
testApp(
|
||
NavigationFrame(
|
||
appName: 'Halkyon Test',
|
||
icon: icon,
|
||
selectedIndex: 0,
|
||
onSelected: (_) {},
|
||
destinations: const [
|
||
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
|
||
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
|
||
],
|
||
child: TopGradient(
|
||
background: background,
|
||
child: GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: () => tapped++,
|
||
child: const SizedBox.expand(),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
final fade = find.byKey(FlushNavigationBar.fadeKey);
|
||
final bar = tester.getRect(find.byType(NavigationBar));
|
||
expect(
|
||
tester.getRect(fade),
|
||
Rect.fromLTRB(0, bar.top - FlushNavigationBar.fadeHeight, 400, bar.top),
|
||
);
|
||
final gradient =
|
||
(tester.widget<DecoratedBox>(fade).decoration as BoxDecoration)
|
||
.gradient!
|
||
as LinearGradient;
|
||
expect(gradient.colors, [background.withValues(alpha: 0), background]);
|
||
|
||
await tester.tapAt(tester.getCenter(fade));
|
||
expect(tapped, 1);
|
||
});
|
||
|
||
testWidgets('before a screen claims its background, the fade ends in the '
|
||
"theme's surface, light and dark", (tester) async {
|
||
tester.view.physicalSize = const Size(400, 800);
|
||
tester.view.devicePixelRatio = 1;
|
||
addTearDown(tester.view.reset);
|
||
for (final brightness in Brightness.values) {
|
||
await tester.pumpWidget(
|
||
testApp(
|
||
brightness: brightness,
|
||
NavigationFrame(
|
||
appName: 'Halkyon Test',
|
||
icon: icon,
|
||
selectedIndex: 0,
|
||
onSelected: (_) {},
|
||
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')),
|
||
),
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
final fade = find.byKey(FlushNavigationBar.fadeKey);
|
||
final surface = Theme.of(tester.element(fade)).colorScheme.surface;
|
||
final gradient =
|
||
(tester.widget<DecoratedBox>(fade).decoration as BoxDecoration)
|
||
.gradient!
|
||
as LinearGradient;
|
||
expect(gradient.colors.last, surface, reason: '$brightness');
|
||
}
|
||
});
|
||
|
||
testWidgets("a tab's content leaves room for the bar, its fade and the "
|
||
"system's bar; its floating button rises above the fade, the Scaffold "
|
||
"keeping it clear of the system's bar itself", (tester) async {
|
||
tester.view.padding = const FakeViewPadding(bottom: 24);
|
||
tester.view.viewPadding = const FakeViewPadding(bottom: 24);
|
||
tester.view.physicalSize = const Size(400, 800);
|
||
tester.view.devicePixelRatio = 1;
|
||
addTearDown(tester.view.reset);
|
||
late double cover;
|
||
await tester.pumpWidget(
|
||
testApp(
|
||
NavigationFrame(
|
||
appName: 'Halkyon Test',
|
||
icon: icon,
|
||
selectedIndex: 0,
|
||
onSelected: (_) {},
|
||
destinations: const [
|
||
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
|
||
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
|
||
],
|
||
child: Builder(
|
||
builder: (context) => Scaffold(
|
||
body: Builder(
|
||
builder: (context) {
|
||
cover = FlushNavigationBar.coverOf(context);
|
||
return const SizedBox.expand(key: Key('tab'));
|
||
},
|
||
),
|
||
floatingActionButton: Padding(
|
||
padding: EdgeInsets.only(
|
||
bottom: FlushNavigationBar.buttonLiftOf(context),
|
||
),
|
||
child: FloatingActionButton(
|
||
onPressed: () {},
|
||
child: const Icon(Icons.add),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(cover, FlushNavigationBar.fadeHeight + 64 + 24);
|
||
expect(
|
||
tester.getBottomLeft(find.byKey(const Key('tab'))).dy,
|
||
800,
|
||
reason: 'the content runs on beneath the fade and the bar',
|
||
);
|
||
expect(
|
||
tester.getRect(find.byType(FloatingActionButton)).bottom,
|
||
tester.getRect(find.byKey(FlushNavigationBar.fadeKey)).top - 16,
|
||
);
|
||
});
|
||
|
||
testWidgets("outside the frame, content leaves room for the system's bar "
|
||
'alone', (tester) async {
|
||
tester.view.padding = const FakeViewPadding(bottom: 24);
|
||
tester.view.viewPadding = const FakeViewPadding(bottom: 24);
|
||
tester.view.physicalSize = const Size(400, 800);
|
||
tester.view.devicePixelRatio = 1;
|
||
addTearDown(tester.view.reset);
|
||
late double cover;
|
||
late double lift;
|
||
await tester.pumpWidget(
|
||
testApp(
|
||
Scaffold(
|
||
body: Builder(
|
||
builder: (context) {
|
||
cover = FlushNavigationBar.coverOf(context);
|
||
lift = FlushNavigationBar.buttonLiftOf(context);
|
||
return const SizedBox.expand();
|
||
},
|
||
),
|
||
),
|
||
),
|
||
);
|
||
|
||
expect(cover, 24);
|
||
expect(lift, 0);
|
||
});
|
||
|
||
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(FlushNavigationBar), 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);
|
||
tester.view.viewPadding = 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(FlushNavigationBar), findsNothing);
|
||
expect(tester.getBottomLeft(find.byType(Scaffold).last).dy, 800 - 24);
|
||
});
|
||
|
||
testWidgets('a status stands above the bar below medium windows, and at '
|
||
"the foot of the screen's panel from there on", (tester) async {
|
||
for (final width in const [400.0, 800.0]) {
|
||
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: 0,
|
||
onSelected: (_) {},
|
||
status: const Text('Recording'),
|
||
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')),
|
||
),
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
final status = tester.getBottomLeft(find.text('Recording')).dy;
|
||
final tab = tester.getTopLeft(find.text('The tab')).dy;
|
||
expect(status, greaterThan(tab), reason: '$width');
|
||
if (width < 600) {
|
||
expect(
|
||
status,
|
||
lessThan(tester.getTopLeft(find.byType(NavigationBar)).dy),
|
||
);
|
||
}
|
||
}
|
||
});
|
||
|
||
testWidgets("the keyboard leaves the frame's tab its size: the tab's own "
|
||
'screen decides whether it makes way, as one with a field floating '
|
||
"over it doesn't", (tester) async {
|
||
tester.view.physicalSize = const Size(400, 800);
|
||
tester.view.devicePixelRatio = 1;
|
||
addTearDown(tester.view.reset);
|
||
Widget frame({required bool screenResizes}) => testApp(
|
||
NavigationFrame(
|
||
appName: 'Halkyon Test',
|
||
icon: icon,
|
||
selectedIndex: 0,
|
||
onSelected: (_) {},
|
||
destinations: const [
|
||
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
|
||
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
|
||
],
|
||
child: Scaffold(
|
||
resizeToAvoidBottomInset: screenResizes,
|
||
body: const SizedBox.expand(key: Key('tab')),
|
||
),
|
||
),
|
||
);
|
||
await tester.pumpWidget(frame(screenResizes: false));
|
||
final before = tester.getSize(find.byKey(const Key('tab')));
|
||
|
||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||
addTearDown(tester.view.resetViewInsets);
|
||
await tester.pump();
|
||
expect(tester.getSize(find.byKey(const Key('tab'))), before);
|
||
|
||
await tester.pumpWidget(frame(screenResizes: true));
|
||
expect(
|
||
tester.getSize(find.byKey(const Key('tab'))).height,
|
||
lessThan(before.height),
|
||
reason: 'a screen that resizes still makes way',
|
||
);
|
||
});
|
||
}
|