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 show( WidgetTester tester, { required double width, required List 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, accent: const Color(0xFFEC3E7C), 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> ticks( WidgetTester tester, Future Function() act, ) async { final haptics = []; 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), ); final colour = find.descendant( of: find.byKey(FlushNavigationBar.barKey), matching: find.byType(ColoredBox), ); expect( tester.widget(colour.first).color, Theme.of(tester.element(bar)).colorScheme.surfaceContainer, ); }); 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, accent: const Color(0xFFEC3E7C), 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(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, accent: const Color(0xFFEC3E7C), 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(fade).decoration as BoxDecoration) .gradient! as LinearGradient; expect(gradient.colors.last, surface, reason: '$brightness'); } }); /// A tab's screen with a floating button, which paints the fade above /// the bar itself ([FrameFade]); its content's room in [cover]. Widget fadingScreen({ void Function(double cover)? cover, Key? key, bool resizes = true, }) => Builder( builder: (context) => Scaffold( key: key, extendBody: true, resizeToAvoidBottomInset: resizes, floatingActionButtonLocation: FrameFade.buttonLocation(context), body: Builder( builder: (context) { cover?.call(FlushNavigationBar.coverOf(context)); return ListView( key: const Key('tab'), children: [ for (var i = 0; i < 30; i++) SizedBox(height: 60, child: Text('Row $i')), ], ); }, ), floatingActionButton: FloatingActionButton( onPressed: () {}, child: const Icon(Icons.add), ), bottomNavigationBar: const FrameFade(), ), ); /// The frame on a phone 400 × 800 with a system bar 24 high, around /// [child]. Future showPhone(WidgetTester tester, Widget child, {Widget? status}) { 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); return tester.pumpWidget( testApp( NavigationFrame( appName: 'Halkyon Test', icon: icon, accent: const Color(0xFFEC3E7C), selectedIndex: 0, onSelected: (_) {}, status: status, destinations: const [ (label: 'Home', icon: Icon(Icons.home), selectedIcon: null), (label: 'Maps', icon: Icon(Icons.map), selectedIcon: null), ], child: child, ), ), ); } /// The fades painting: the frame's, or the screens'. Finder fades() => find.byKey(FlushNavigationBar.fadeKey); /// Whether the fade painting is the screen's own, beneath its button. bool screenFades(WidgetTester tester) => find .descendant(of: find.byType(FrameFade), matching: fades()) .evaluate() .isNotEmpty; testWidgets("a tab's content leaves room for the bar, its fade and the " "system's bar, running on beneath them; its floating button stands 16 " 'above the bar, over the fade, which the screen paints beneath it (the ' "developer's review, 9 Nov 2026)", (tester) async { late double cover; await showPhone(tester, fadingScreen(cover: (room) => cover = room)); 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', ); final bar = tester.getRect(find.byKey(FlushNavigationBar.barKey)); expect( tester.getRect(find.byType(FloatingActionButton)).bottom, bar.top - 16, ); expect(fades(), findsOneWidget, reason: 'one fade, not two'); expect(screenFades(tester), isTrue); expect(tester.getRect(fades()).bottom, bar.top); expect(tester.getRect(fades()).height, FlushNavigationBar.fadeHeight); }); testWidgets('the keyboard leaves the button where it stands on a screen ' "that doesn't make way for it (Todo's)", (tester) async { await showPhone(tester, fadingScreen(resizes: false)); await tester.pumpAndSettle(); final before = tester.getRect(find.byType(FloatingActionButton)); tester.view.viewInsets = const FakeViewPadding(bottom: 300); addTearDown(tester.view.resetViewInsets); await tester.pumpAndSettle(); expect(tester.getRect(find.byType(FloatingActionButton)), before); }); testWidgets('the button stays where it stands, behind the keyboard, on a ' 'screen that makes way for it (a search in the Library)', (tester) async { await showPhone(tester, fadingScreen()); await tester.pumpAndSettle(); final before = tester.getRect(find.byType(FloatingActionButton)); tester.view.viewInsets = const FakeViewPadding(bottom: 300); addTearDown(tester.view.resetViewInsets); await tester.pumpAndSettle(); expect(tester.getRect(find.byType(FloatingActionButton)), before); }); testWidgets("a screen without its own fade keeps the frame's", ( tester, ) async { await showPhone(tester, const Scaffold(body: Text('The tab'))); await tester.pumpAndSettle(); expect(fades(), findsOneWidget); expect(screenFades(tester), isFalse); }); testWidgets('a menu or a dialog over the screen leaves its fade the ' "screen's, beneath its button", (tester) async { await showPhone( tester, Builder( builder: (context) => Scaffold( extendBody: true, body: Center( child: TextButton( onPressed: () => showDialog( context: context, builder: (context) => const AlertDialog(content: Text('Hi')), ), child: const Text('Ask'), ), ), floatingActionButton: FloatingActionButton( onPressed: () {}, child: const Icon(Icons.add), ), floatingActionButtonLocation: FrameFade.buttonLocation(context), bottomNavigationBar: const FrameFade(), ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('Ask')); await tester.pumpAndSettle(); expect(find.text('Hi'), findsOneWidget); expect(fades(), findsOneWidget); expect(screenFades(tester), isTrue); }); testWidgets('a page over the screen in its tab without a fade of its own ' "has the frame's, and back the screen's returns", (tester) async { final tab = GlobalKey(); await showPhone( tester, Navigator( key: tab, onGenerateRoute: (_) => MaterialPageRoute(builder: (context) => fadingScreen()), ), ); await tester.pumpAndSettle(); expect(screenFades(tester), isTrue); tab.currentState!.push( MaterialPageRoute( builder: (context) => const Scaffold(body: Text('Inside')), ), ); await tester.pumpAndSettle(); expect(find.text('Inside'), findsOneWidget); expect(fades(), findsOneWidget); expect(screenFades(tester), isFalse); tab.currentState!.pop(); await tester.pumpAndSettle(); expect(fades(), findsOneWidget); expect(screenFades(tester), isTrue); }); testWidgets('with a status above the bar, the fade and the button stand ' 'above it', (tester) async { await showPhone( tester, fadingScreen(), status: const SizedBox(height: 40, child: Text('Recording')), ); await tester.pumpAndSettle(); final status = tester.getRect(find.text('Recording')); expect(tester.getRect(fades()).bottom, lessThanOrEqualTo(status.top)); expect( tester.getRect(find.byType(FloatingActionButton)).bottom, lessThanOrEqualTo(tester.getRect(fades()).bottom - 16), ); }); testWidgets('from medium windows on, a screen with its own fade paints ' 'none, as there is no bar', (tester) async { tester.view.physicalSize = const Size(1000, 800); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await tester.pumpWidget( testApp( NavigationFrame( appName: 'Halkyon Test', icon: icon, accent: const Color(0xFFEC3E7C), selectedIndex: 0, onSelected: (_) {}, destinations: const [ (label: 'Home', icon: Icon(Icons.home), selectedIcon: null), (label: 'Maps', icon: Icon(Icons.map), selectedIcon: null), ], child: fadingScreen(), ), ), ); await tester.pumpAndSettle(); expect(fades(), findsNothing); }); testWidgets("a screen's snack bar stands above its floating button, so " 'the button never takes its Undo (found 9 Nov 2026)', (tester) async { tester.view.physicalSize = const Size(400, 800); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); var undone = 0; var added = 0; await tester.pumpWidget( testApp( NavigationFrame( appName: 'Halkyon Test', icon: icon, accent: const Color(0xFFEC3E7C), 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: Center( child: TextButton( onPressed: () => showUndoSnackBar( context, message: 'Task completed', onUndo: () => undone++, ), child: const Text('Complete'), ), ), extendBody: true, floatingActionButtonLocation: FrameFade.buttonLocation(context), floatingActionButton: FloatingActionButton( onPressed: () => added++, child: const Icon(Icons.add), ), bottomNavigationBar: const FrameFade(), ), ), ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('Complete')); await tester.pumpAndSettle(); expect( tester.getRect(find.text('Undo')).bottom, lessThanOrEqualTo( tester.getRect(find.byType(FloatingActionButton)).top, ), ); await tester.tap(find.text('Undo')); expect((undone, added), (1, 0)); }); testWidgets("a screen's snack bar shown on a phone doesn't stay once the " 'window grows past it', (tester) async { final screens = GlobalKey(); tester.view.physicalSize = const Size(400, 800); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await tester.pumpWidget( testApp( NavigationFrame( appName: 'Halkyon Test', icon: icon, accent: const Color(0xFFEC3E7C), selectedIndex: 0, onSelected: (_) {}, destinations: const [ (label: 'Home', icon: Icon(Icons.home), selectedIcon: null), (label: 'Maps', icon: Icon(Icons.map), selectedIcon: null), ], // The screens keep their state as the frame's layout changes, as an // app's router keeps its navigator by its key. child: KeyedSubtree( key: screens, child: Builder( builder: (context) => Scaffold( body: Center( child: TextButton( onPressed: () => ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Archived')), ), child: const Text('Archive'), ), ), ), ), ), ), ), ); await tester.tap(find.text('Archive')); await tester.pumpAndSettle(); expect(find.text('Archived'), findsOneWidget); tester.view.physicalSize = const Size(1200, 800); await tester.pumpAndSettle(); await tester.pump(const Duration(seconds: 5)); await tester.pumpAndSettle(); expect(find.text('Archived'), findsNothing); }); 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; await tester.pumpWidget( testApp( Scaffold( body: Builder( builder: (context) { cover = FlushNavigationBar.coverOf(context); return const SizedBox.expand(); }, ), ), ), ); expect(cover, 24); }); testWidgets("from medium windows on, a rail with the app's icon on top, " 'which leads to the first tab', (tester) async { final chosen = []; 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 = []; 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 = []; 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, accent: const Color(0xFFEC3E7C), 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, accent: const Color(0xFFEC3E7C), 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, accent: const Color(0xFFEC3E7C), 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', ); }); }