The developer's request of 10 Nov 2026, decided in ADR 0042: on mobile data every app holds its sync back, rows and files alike, and asks. halkyon_app becomes an Android plugin, its first native code, registered in every app by Flutter: HalkyonAppPlugin.java says which network the device is on and whether it is metered as Android has it (no NET_CAPABILITY_NOT_METERED: cellular, a phone's hotspot, Wi-Fi marked metered). Java, so it applies no Kotlin Gradle Plugin. MobileDataGate, one per device, holds the setting (Ask first, Sync without files, Sync everything; kept in <app support>/mobile_data.json), the network, and the person's answer, which holds until the device moves to another network or off a metered one. SyncService asks it before each sync: held back, nothing goes, the status counts what waits and asks; an answer or Wi-Fi syncs at once, and syncing now while held back is Sync now. Each app's frame shows the banner above every tab, in halkyon_ui's new NavigationFrame.banner slot (On mobile data · 3 changes waiting, with Not now, Without files and Sync now); the Sync card in Settings chooses the setting on a phone and says when files wait for Wi-Fi. Learn's files neither go up nor come down while held back. Whats-New: On mobile data the app no longer syncs by itself: a banner asks first, and Settings › Sync chooses whether to ask, sync without files, or sync everything. Whats-New-De: Mit mobilen Daten synchronisiert die App nicht mehr von selbst: Ein Hinweis fragt zuerst, und unter Einstellungen › Synchronisierung wählst du, ob sie fragt, ohne Dateien oder alles synchronisiert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
776 lines
26 KiB
Dart
776 lines
26 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,
|
||
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<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),
|
||
);
|
||
final colour = find.descendant(
|
||
of: find.byKey(FlushNavigationBar.barKey),
|
||
matching: find.byType(ColoredBox),
|
||
);
|
||
expect(
|
||
tester.widget<ColoredBox>(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<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,
|
||
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<DecoratedBox>(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<void> 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<void>(
|
||
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<NavigatorState>();
|
||
await showPhone(
|
||
tester,
|
||
Navigator(
|
||
key: tab,
|
||
onGenerateRoute: (_) =>
|
||
MaterialPageRoute<void>(builder: (context) => fadingScreen()),
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
expect(screenFades(tester), isTrue);
|
||
|
||
tab.currentState!.push(
|
||
MaterialPageRoute<void>(
|
||
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 = <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,
|
||
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('a banner stands above every tab, its screen below it '
|
||
"without the system's top inset, which the banner keeps clear of "
|
||
'itself', (tester) async {
|
||
for (final width in const [400.0, 800.0]) {
|
||
tester.view.physicalSize = Size(width, 800);
|
||
tester.view.devicePixelRatio = 1;
|
||
tester.view.padding = const FakeViewPadding(top: 24);
|
||
addTearDown(tester.view.reset);
|
||
Widget frame({Widget? banner}) => testApp(
|
||
NavigationFrame(
|
||
appName: 'Halkyon Test',
|
||
icon: icon,
|
||
accent: const Color(0xFFEC3E7C),
|
||
selectedIndex: 0,
|
||
onSelected: (_) {},
|
||
banner: banner,
|
||
destinations: const [
|
||
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
|
||
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
|
||
],
|
||
child: Scaffold(
|
||
appBar: AppBar(title: const Text('The tab')),
|
||
body: const SizedBox.expand(),
|
||
),
|
||
),
|
||
);
|
||
await tester.pumpWidget(frame());
|
||
await tester.pumpAndSettle();
|
||
final appBarAlone = tester.getTopLeft(find.byType(AppBar)).dy;
|
||
|
||
await tester.pumpWidget(
|
||
frame(
|
||
banner: const SafeArea(
|
||
bottom: false,
|
||
child: SizedBox(height: 48, child: Text('On mobile data')),
|
||
),
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
final banner = tester.getRect(find.text('On mobile data'));
|
||
// On a phone right below the status bar; wider, at the top of the
|
||
// screen's panel, inset in the frame.
|
||
expect(
|
||
banner.top,
|
||
width < 600 ? 24 : greaterThanOrEqualTo(24),
|
||
reason: '$width: below the status bar',
|
||
);
|
||
final appBar = tester.getRect(find.byType(AppBar));
|
||
expect(appBar.top, banner.bottom, reason: '$width');
|
||
expect(appBar.top, greaterThan(appBarAlone), reason: '$width');
|
||
expect(
|
||
tester.getTopLeft(find.text('The tab')).dy - appBar.top,
|
||
lessThan(24),
|
||
reason:
|
||
"$width: the app bar doesn't keep clear of the status bar again",
|
||
);
|
||
}
|
||
});
|
||
|
||
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',
|
||
);
|
||
});
|
||
}
|