Files
FlClash/test/pages/home_test.dart
T
chen08209 7c9627fffb feat(ui): add Liquid Glass and slide-to-pick popup menus
Changelog: Optional two-tier Liquid Glass and slide-to-pick menus
2026-10-17 17:21:43 +08:00

1497 lines
49 KiB
Dart

import 'package:fl_clash/common/app_localizations.dart';
import 'package:fl_clash/common/app_ports.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/manager/app_manager.dart';
import 'package:fl_clash/manager/theme_manager.dart';
import 'package:fl_clash/manager/window_manager.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/pages/home.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/views/config/general.dart';
import 'package:fl_clash/views/tools.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:fl_clash/views/navigation.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/glyph_finders.dart';
import '../helpers/test_app.dart';
Finder _glyph(Glyph glyph) => find.byGlyph(glyph);
void main() {
setUp(() {
navigationPort = navigation;
addTearDown(() => navigationPort = null);
});
testWidgets('initial desktop layout does not animate mobile navigation out', (
tester,
) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final container = ProviderContainer(
overrides: [
navigationItemsStateProvider.overrideWithValue(
NavigationItemsState(
value: [
NavigationItem(
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) => const SizedBox.shrink(),
),
NavigationItem(
glyph: AppGlyphs.tools,
label: PageLabel.tools,
builder: (_) => const SizedBox.shrink(),
),
],
),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
expect(container.read(viewSizeProvider), Size.zero);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const _ThemeManagedTestApp(),
),
);
expect(globalState.navigatorKey.currentContext, isNotNull);
expect(container.read(viewSizeProvider), const Size(1200, 800));
expect(find.byType(FloatingNavigationBar), findsNothing);
await tester.pump();
expect(find.byType(NavigationSidebar), findsOneWidget);
expect(find.byType(FloatingNavigationBar), findsNothing);
await tester.pump(const Duration(milliseconds: 150));
expect(find.byType(FloatingNavigationBar), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets(
'screen-size transition preserves current content and animates navigation',
(tester) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final container = ProviderContainer(
overrides: [
navigationItemsStateProvider.overrideWithValue(
NavigationItemsState(
value: [
NavigationItem(
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) => const _StatefulContent(
key: GlobalObjectKey(PageLabel.dashboard),
),
),
NavigationItem(
glyph: AppGlyphs.tools,
label: PageLabel.tools,
builder: (_) => const SizedBox.shrink(),
),
],
),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container.read(viewSizeProvider.notifier).value = const Size(1200, 800);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(includeNavigatorKey: false, child: HomePage()),
),
);
await tester.pump();
final sidebarBackground = find.descendant(
of: find.byType(AppSidebarContainer),
matching: find.byWidgetPredicate(
(widget) => widget is Container && widget.child is Row,
),
);
final sidebarContainer = tester.widget<Container>(
sidebarBackground.first,
);
final colorScheme = Theme.of(
tester.element(find.byType(AppSidebarContainer)),
).colorScheme;
expect(sidebarContainer.color, SurfaceLevel.page.colorOf(colorScheme));
final sidebarSurface = tester.widget<Material>(
find
.ancestor(
of: find.byType(NavigationSidebar),
matching: find.byType(Material),
)
.first,
);
expect(sidebarSurface.color, colorScheme.chromeSurface);
expect(sidebarSurface.color, isNot(sidebarContainer.color));
expect(
(sidebarSurface.shape! as BorderDirectional).end,
Divider.createBorderSide(
tester.element(find.byType(NavigationSidebar)),
),
);
await tester.tap(find.text('count: 0'));
await tester.pump();
expect(find.text('count: 1'), findsOneWidget);
expect(find.byType(NavigationSidebar), findsOneWidget);
expect(find.byType(FloatingNavigationBar), findsNothing);
for (var width = 1180.0; width >= 500; width -= 20) {
tester.view.physicalSize = Size(width, 800);
container.read(viewSizeProvider.notifier).value = Size(width, 800);
await tester.pump(const Duration(milliseconds: 16));
expect(tester.takeException(), isNull, reason: 'width: $width');
}
expect(find.text('count: 1'), findsOneWidget);
expect(find.byType(NavigationSidebar), findsOneWidget);
expect(find.byType(FloatingNavigationBar), findsOneWidget);
await tester.pump(const Duration(milliseconds: 150));
expect(tester.takeException(), isNull);
final outgoingTools = find.descendant(
of: find.byType(NavigationSidebar),
matching: _glyph(AppGlyphs.tools),
);
await tester.tap(outgoingTools, warnIfMissed: false);
await tester.pump();
expect(container.read(currentPageLabelProvider), PageLabel.dashboard);
await tester.pump(const Duration(milliseconds: 301));
expect(find.byType(NavigationSidebar), findsNothing);
expect(find.byType(FloatingNavigationBar), findsOneWidget);
tester.view.physicalSize = const Size(1200, 800);
container.read(viewSizeProvider.notifier).value = const Size(1200, 800);
await tester.pump();
expect(find.text('count: 1'), findsOneWidget);
expect(find.byType(NavigationSidebar), findsOneWidget);
expect(find.byType(FloatingNavigationBar), findsOneWidget);
await tester.pump(const Duration(milliseconds: 301));
expect(find.byType(NavigationSidebar), findsOneWidget);
expect(find.byType(FloatingNavigationBar), findsNothing);
expect(tester.takeException(), isNull);
},
);
testWidgets('the sidebar widens in place only at desktop width', (
tester,
) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final container = ProviderContainer(
overrides: [
navigationItemsStateProvider.overrideWithValue(
NavigationItemsState(
value: [
NavigationItem(
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) => const SizedBox.shrink(),
),
NavigationItem(
glyph: AppGlyphs.tools,
label: PageLabel.tools,
builder: (_) => const SizedBox.shrink(),
),
],
),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container.read(viewSizeProvider.notifier).value = const Size(1200, 800);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(includeNavigatorKey: false, child: HomePage()),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
final sidebar = find.byType(NavigationSidebar);
expect(tester.getSize(sidebar).width, 220);
await tester.tap(find.byTooltip('Collapse'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(container.read(appSettingProvider).sidebarExpanded, isFalse);
expect(tester.getSize(sidebar).width, 48);
await tester.tap(find.byTooltip('Expand'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(tester.getSize(sidebar).width, 220);
tester.view.physicalSize = const Size(800, 800);
container.read(viewSizeProvider.notifier).value = const Size(800, 800);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(tester.getSize(sidebar).width, 48);
await tester.tap(find.byTooltip('Expand'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(tester.getSize(sidebar).width, 48);
expect(find.text('Tools'), findsNWidgets(2));
expect(container.read(appSettingProvider).sidebarExpanded, isTrue);
expect(tester.takeException(), isNull);
});
testWidgets(
'list content stays valid while resizing through the breakpoint',
(tester) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final container = ProviderContainer(
overrides: [
navigationItemsStateProvider.overrideWithValue(
NavigationItemsState(
value: [
NavigationItem(
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) => const ToolsView(
key: GlobalObjectKey(PageLabel.dashboard),
),
),
NavigationItem(
glyph: AppGlyphs.tools,
label: PageLabel.tools,
builder: (_) => const SizedBox.shrink(),
),
],
),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container.read(viewSizeProvider.notifier).value = const Size(1200, 800);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(includeNavigatorKey: false, child: HomePage()),
),
);
await tester.pump();
for (var width = 1180.0; width >= 380; width -= 20) {
tester.view.physicalSize = Size(width, 800);
container.read(viewSizeProvider.notifier).value = Size(width, 800);
await tester.pump(const Duration(milliseconds: 16));
expect(tester.takeException(), isNull, reason: 'width: $width');
}
},
);
testWidgets(
'tools page survives widening past the breakpoint with more items',
(tester) async {
tester.view.physicalSize = const Size(500, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final container = ProviderContainer(
overrides: [
navigationItemsStateProvider.overrideWithValue(
NavigationItemsState(
value: [
NavigationItem(
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) => const SizedBox.shrink(),
),
NavigationItem(
glyph: AppGlyphs.proxies,
label: PageLabel.logs,
modes: const [
NavigationItemMode.desktop,
NavigationItemMode.more,
],
builder: (_) => const SizedBox.shrink(),
),
NavigationItem(
glyph: AppGlyphs.connections,
label: PageLabel.connections,
modes: const [
NavigationItemMode.desktop,
NavigationItemMode.more,
],
builder: (_) => const SizedBox.shrink(),
),
NavigationItem(
glyph: AppGlyphs.tools,
label: PageLabel.tools,
builder: (_) =>
const ToolsView(key: GlobalObjectKey(PageLabel.tools)),
),
],
),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container.read(viewSizeProvider.notifier).value = const Size(500, 800);
container.read(currentPageLabelProvider.notifier).toPage(PageLabel.tools);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(includeNavigatorKey: false, child: HomePage()),
),
);
await tester.pump();
expect(find.byType(ToolsView), findsOneWidget);
expect(find.byType(FloatingNavigationBar), findsOneWidget);
for (var width = 520.0; width <= 1200; width += 20) {
tester.view.physicalSize = Size(width, 800);
container.read(viewSizeProvider.notifier).value = Size(width, 800);
await tester.pump(const Duration(milliseconds: 16));
expect(tester.takeException(), isNull, reason: 'width: $width');
}
await tester.pump(const Duration(milliseconds: 301));
expect(tester.takeException(), isNull);
expect(find.byType(ToolsView), findsOneWidget);
expect(find.byType(NavigationSidebar), findsOneWidget);
expect(container.read(currentPageLabelProvider), PageLabel.tools);
for (var width = 1180.0; width >= 500; width -= 20) {
tester.view.physicalSize = Size(width, 800);
container.read(viewSizeProvider.notifier).value = Size(width, 800);
await tester.pump(const Duration(milliseconds: 16));
expect(tester.takeException(), isNull, reason: 'width: $width');
}
await tester.pump(const Duration(milliseconds: 301));
expect(tester.takeException(), isNull);
expect(find.byType(ToolsView), findsOneWidget);
expect(container.read(currentPageLabelProvider), PageLabel.tools);
},
);
testWidgets(
'profile trailing controls stay valid while a maximized window restores',
(tester) async {
tester.view.physicalSize = const Size(1440, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final profile = Profile.normal();
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => _HomeTestProfiles([profile])),
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
versionProvider.overrideWithBuild((_, _) => 15),
],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(currentPageLabelProvider.notifier)
.toPage(PageLabel.profiles);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(
includeNavigatorKey: false,
child: ThemeManager(
child: WindowHeaderContainer(child: HomePage()),
),
),
),
);
await tester.pump();
await tester.pump();
expect(tester.takeException(), isNull);
tester.view.physicalSize = const Size(380, 900);
await tester.pump(const Duration(milliseconds: 16));
expect(tester.takeException(), isNull);
},
);
testWidgets(
'desktop navigation keeps the tools route when logs are enabled',
(tester) async {
tester.view.physicalSize = const Size(1400, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final container = ProviderContainer();
addTearDown(container.dispose);
globalState.container = container;
container.read(viewSizeProvider.notifier).value = const Size(1400, 1000);
container.read(currentPageLabelProvider.notifier).toPage(PageLabel.tools);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(includeNavigatorKey: false, child: HomePage()),
),
);
await tester.pump();
final generalItem = find.text('General');
await tester.scrollUntilVisible(
generalItem,
500,
scrollable: find.byType(Scrollable).first,
);
await tester.tap(generalItem);
await tester.pumpAndSettle();
expect(find.byType(GeneralView), findsOneWidget);
final logItem = find.text('Logcat');
await tester.scrollUntilVisible(
logItem,
500,
scrollable: find
.descendant(
of: find.byType(GeneralView),
matching: find.byType(Scrollable),
)
.first,
);
await tester.tap(logItem);
await tester.pumpAndSettle();
expect(container.read(appSettingProvider).openLogs, isTrue);
expect(find.byType(GeneralView), findsOneWidget);
expect(tester.takeException(), isNull);
},
);
testWidgets(
'desktop navigation keeps arrow traversal after keyboard page changes',
(tester) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final container = ProviderContainer(
overrides: [
navigationItemsStateProvider.overrideWithValue(
NavigationItemsState(
value: [
NavigationItem(
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) => Align(
alignment: Alignment.topLeft,
child: IconButton(
onPressed: () {},
icon: const Icon(Icons.more_horiz),
),
),
),
NavigationItem(
glyph: AppGlyphs.proxies,
label: PageLabel.proxies,
builder: (_) => Align(
alignment: Alignment.topLeft,
child: IconButton(
onPressed: () {},
icon: const Icon(Icons.more_vert),
),
),
),
],
),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container.read(viewSizeProvider.notifier).value = const Size(1200, 800);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(includeNavigatorKey: false, child: HomePage()),
),
);
await tester.pump();
expect(find.byType(NavigationSidebar), findsOneWidget);
bool focusInSidebar() {
final context = FocusManager.instance.primaryFocus?.context;
return context?.findAncestorWidgetOfExactType<NavigationSidebar>() !=
null;
}
Glyph? focusedSidebarIcon() {
final focusNode = FocusManager.instance.primaryFocus;
if (!focusInSidebar() || focusNode == null) {
return null;
}
for (final glyph in [AppGlyphs.dashboard, AppGlyphs.proxies]) {
if (focusNode.rect.contains(tester.getCenter(_glyph(glyph)))) {
return glyph;
}
}
return null;
}
for (var i = 0; i < 30 && focusedSidebarIcon() == null; i++) {
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
}
expect(focusedSidebarIcon(), AppGlyphs.dashboard);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(focusedSidebarIcon(), AppGlyphs.proxies);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(container.read(currentPageLabelProvider), PageLabel.proxies);
final sidebar = tester.widget<NavigationSidebar>(
find.byType(NavigationSidebar),
);
expect(sidebar.selectedIndex, 1);
expect(focusedSidebarIcon(), AppGlyphs.proxies);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
expect(focusedSidebarIcon(), AppGlyphs.dashboard);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(container.read(currentPageLabelProvider), PageLabel.dashboard);
expect(focusedSidebarIcon(), AppGlyphs.dashboard);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(focusedSidebarIcon(), AppGlyphs.proxies);
expect(container.read(currentPageLabelProvider), PageLabel.dashboard);
},
);
testWidgets('mobile bottom navigation keeps page and highlight consistent', (
tester,
) async {
tester.view.physicalSize = const Size(500, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
Widget page(String label) {
return Stack(
clipBehavior: Clip.none,
children: [
Text('page:$label'),
Positioned(
left: 200,
bottom: 0,
child: Builder(
builder: (context) => Padding(
padding: EdgeInsets.only(bottom: BottomInsetScope.of(context)),
child: IconButton(
key: const ValueKey('content-action'),
onPressed: () {},
icon: const Icon(Icons.more_horiz),
),
),
),
),
],
);
}
final container = ProviderContainer(
overrides: [
navigationItemsStateProvider.overrideWithValue(
NavigationItemsState(
value: [
NavigationItem(
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) => page('dashboard'),
),
NavigationItem(
glyph: AppGlyphs.profiles,
label: PageLabel.profiles,
builder: (_) => page('profiles'),
),
NavigationItem(
glyph: AppGlyphs.tools,
label: PageLabel.tools,
builder: (_) => page('tools'),
),
NavigationItem(
glyph: AppGlyphs.proxies,
label: PageLabel.logs,
builder: (_) => page('logs'),
),
],
),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container.read(viewSizeProvider.notifier).value = const Size(500, 800);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(includeNavigatorKey: false, child: HomePage()),
),
);
await tester.pump();
expect(find.byType(FloatingNavigationBar), findsOneWidget);
FloatingNavigationBar navBar() => tester.widget<FloatingNavigationBar>(
find.byType(FloatingNavigationBar),
);
await tester.tap(_glyph(AppGlyphs.tools));
await tester.pumpAndSettle();
expect(container.read(currentPageLabelProvider), PageLabel.tools);
expect(navBar().selectedIndex, 2);
expect(find.text('page:tools'), findsOneWidget);
bool focusInNav() {
final context = FocusManager.instance.primaryFocus?.context;
return context?.findAncestorWidgetOfExactType<FloatingNavigationBar>() !=
null;
}
for (var i = 0; i < 20 && !focusInNav(); i++) {
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
}
expect(focusInNav(), isTrue);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(container.read(currentPageLabelProvider), PageLabel.profiles);
expect(navBar().selectedIndex, 1);
expect(find.text('page:profiles'), findsOneWidget);
await tester.tap(_glyph(AppGlyphs.tools));
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(_glyph(AppGlyphs.proxies));
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(_glyph(AppGlyphs.profiles));
await tester.pumpAndSettle();
expect(container.read(currentPageLabelProvider), PageLabel.profiles);
expect(navBar().selectedIndex, 1);
expect(find.text('page:profiles'), findsOneWidget);
expect(focusInNav(), isTrue);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
final focusedIconButton = FocusManager.instance.primaryFocus?.context
?.findAncestorWidgetOfExactType<IconButton>();
expect(
focusedIconButton?.key,
const ValueKey('content-action'),
reason: 'up from the bottom bar must enter the current page',
);
expect(tester.takeException(), isNull);
});
testWidgets('a page that leaves the navigation hands the screen to the '
'first one until it returns', (tester) async {
tester.view.physicalSize = const Size(500, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
NavigationItem item(Glyph glyph, PageLabel label) => NavigationItem(
glyph: glyph,
label: label,
builder: (_) => Builder(
builder: (context) => Text(
'page:${label.name} active:${PageActivityScope.isActiveOf(context)}',
),
),
);
final dashboard = item(AppGlyphs.dashboard, PageLabel.dashboard);
final proxies = item(AppGlyphs.proxies, PageLabel.proxies);
final profiles = item(AppGlyphs.profiles, PageLabel.profiles);
final container = ProviderContainer(
overrides: [
navigationItemsStateProvider.overrideWith(
(ref) =>
NavigationItemsState(value: ref.watch(_navigationItemsProvider)),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container.read(viewSizeProvider.notifier).value = const Size(500, 800);
final items = container.read(_navigationItemsProvider.notifier)
..set([dashboard, proxies, profiles]);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(includeNavigatorKey: false, child: HomePage()),
),
);
await tester.pump();
await tester.tap(_glyph(AppGlyphs.proxies));
await tester.pumpAndSettle();
int selectedIndex() => tester
.widget<FloatingNavigationBar>(find.byType(FloatingNavigationBar))
.selectedIndex;
expect(selectedIndex(), 1);
expect(find.text('page:proxies active:true'), findsOneWidget);
items.set([dashboard, profiles]);
await tester.pumpAndSettle();
expect(selectedIndex(), 0);
expect(find.text('page:dashboard active:true'), findsOneWidget);
expect(find.textContaining('page:profiles'), findsNothing);
expect(container.read(currentPageLabelProvider), PageLabel.proxies);
items.set([dashboard, proxies, profiles]);
await tester.pumpAndSettle();
expect(selectedIndex(), 1);
expect(find.text('page:proxies active:true'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('a non-floating bottom bar leaves the page no bottom inset', (
tester,
) async {
tester.view.physicalSize = const Size(500, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
Widget page(String label) {
return Builder(
builder: (context) =>
Text('page:$label inset:${BottomInsetScope.of(context)}'),
);
}
final container = ProviderContainer(
overrides: [
navigationItemsStateProvider.overrideWithValue(
NavigationItemsState(
value: [
NavigationItem(
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) => page('dashboard'),
),
NavigationItem(
glyph: AppGlyphs.tools,
label: PageLabel.tools,
builder: (_) => page('tools'),
),
],
),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container.read(viewSizeProvider.notifier).value = const Size(500, 800);
container
.read(appSettingProvider.notifier)
.update((state) => state.copyWith(floatingNavigationBar: false));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(includeNavigatorKey: false, child: HomePage()),
),
);
await tester.pump();
expect(find.byType(FloatingNavigationBar), findsNothing);
expect(find.byType(NavigationBar), findsOneWidget);
expect(find.text('page:dashboard inset:0.0'), findsOneWidget);
await tester.tap(_glyph(AppGlyphs.tools));
await tester.pumpAndSettle();
expect(container.read(currentPageLabelProvider), PageLabel.tools);
expect(
tester.widget<NavigationBar>(find.byType(NavigationBar)).selectedIndex,
1,
);
container
.read(appSettingProvider.notifier)
.update((state) => state.copyWith(floatingNavigationBar: true));
await tester.pumpAndSettle();
expect(find.byType(NavigationBar), findsNothing);
expect(find.byType(FloatingNavigationBar), findsOneWidget);
expect(find.textContaining('page:tools inset:'), findsOneWidget);
expect(find.text('page:tools inset:0.0'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('the fade tab switch cross-fades the pages in place', (
tester,
) async {
tester.view.physicalSize = const Size(500, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final container = ProviderContainer(
overrides: [
navigationItemsStateProvider.overrideWithValue(
NavigationItemsState(
value: [
NavigationItem(
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) => const Text('page:dashboard'),
),
NavigationItem(
glyph: AppGlyphs.tools,
label: PageLabel.tools,
builder: (_) => const Text('page:tools'),
),
],
),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container.read(viewSizeProvider.notifier).value = const Size(500, 800);
container
.read(appSettingProvider.notifier)
.update((state) => state.copyWith(tabAnimation: TabAnimation.fade));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(includeNavigatorKey: false, child: HomePage()),
),
);
await tester.pump();
final start = tester.getTopLeft(find.text('page:dashboard')).dx;
container.read(currentPageLabelProvider.notifier).toPage(PageLabel.tools);
await tester.pump();
await tester.pump(const Duration(milliseconds: 60));
double opacityOf(String text) => tester
.widget<Opacity>(
find.ancestor(of: find.text(text), matching: find.byType(Opacity)),
)
.opacity;
expect(tester.getTopLeft(find.text('page:dashboard')).dx, start);
expect(tester.getTopLeft(find.text('page:tools')).dx, start);
expect(opacityOf('page:dashboard'), inExclusiveRange(0, 1));
expect(
opacityOf('page:dashboard') + opacityOf('page:tools'),
moreOrLessEquals(1),
);
await tester.pumpAndSettle();
expect(container.read(currentPageLabelProvider), PageLabel.tools);
expect(tester.getTopLeft(find.text('page:tools')).dx, start);
expect(tester.takeException(), isNull);
});
testWidgets('a far mobile page switch builds no page in between', (
tester,
) async {
tester.view.physicalSize = const Size(500, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final inits = <PageLabel>[];
final container = ProviderContainer(
overrides: [
navigationItemsStateProvider.overrideWithValue(
NavigationItemsState(
value: [
for (final (glyph, label) in [
(AppGlyphs.dashboard, PageLabel.dashboard),
(AppGlyphs.proxies, PageLabel.proxies),
(AppGlyphs.profiles, PageLabel.profiles),
(AppGlyphs.tools, PageLabel.tools),
])
NavigationItem(
glyph: glyph,
label: label,
builder: (_) => _InitRecorder(label: label, inits: inits),
),
],
),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container.read(viewSizeProvider.notifier).value = const Size(500, 800);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(includeNavigatorKey: false, child: HomePage()),
),
);
await tester.pumpAndSettle();
await tester.tap(_glyph(AppGlyphs.tools));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('page:dashboard'), findsOneWidget);
expect(find.text('page:tools'), findsOneWidget);
await tester.pumpAndSettle();
expect(find.text('page:dashboard'), findsNothing);
expect(find.text('page:tools'), findsOneWidget);
await tester.tap(_glyph(AppGlyphs.dashboard));
await tester.pumpAndSettle();
expect(find.text('page:dashboard'), findsOneWidget);
expect(inits.toSet(), {PageLabel.dashboard, PageLabel.tools});
expect(tester.takeException(), isNull);
});
testWidgets('switching home pages exits a generic search layer', (
tester,
) async {
tester.view.physicalSize = const Size(500, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
var query = '';
final container = ProviderContainer(
overrides: [
navigationItemsStateProvider.overrideWithValue(
NavigationItemsState(
value: [
NavigationItem(
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) => CommonScaffold(
title: 'Search page',
searchState: AppBarSearchState(
onSearch: (value) {
query = value;
},
),
body: const SizedBox(),
),
),
NavigationItem(
glyph: AppGlyphs.tools,
label: PageLabel.tools,
builder: (_) => const SizedBox.shrink(),
),
],
),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container.read(viewSizeProvider.notifier).value = const Size(500, 800);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(includeNavigatorKey: false, child: HomePage()),
),
);
await tester.pump();
await tester.tap(find.byGlyph(AppGlyphs.search));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
await tester.enterText(find.byType(TextField), 'needle');
expect(query, 'needle');
await tester.tap(_glyph(AppGlyphs.tools));
await tester.pumpAndSettle();
expect(query, isEmpty);
await tester.tap(_glyph(AppGlyphs.dashboard));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsNothing);
});
testWidgets('a page kept alive off screen stops asking for frames', (
tester,
) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final container = ProviderContainer(
overrides: [
navigationItemsStateProvider.overrideWithValue(
NavigationItemsState(
value: [
NavigationItem(
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) => const SizedBox.shrink(),
),
NavigationItem(
glyph: AppGlyphs.profiles,
label: PageLabel.profiles,
builder: (_) =>
const Center(child: CircularProgressIndicator()),
),
],
),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container.read(viewSizeProvider.notifier).value = const Size(1200, 800);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(includeNavigatorKey: false, child: HomePage()),
),
);
await tester.pump();
Finder railIcon(Glyph glyph) => find.descendant(
of: find.byType(NavigationSidebar),
matching: _glyph(glyph),
);
await tester.tap(railIcon(AppGlyphs.profiles));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(tester.binding.hasScheduledFrame, isTrue);
await tester.tap(railIcon(AppGlyphs.dashboard));
await tester.pumpAndSettle();
expect(
find.byType(CircularProgressIndicator, skipOffstage: false),
findsOneWidget,
);
expect(tester.binding.hasScheduledFrame, isFalse);
});
testWidgets('desktop nested route inherits home page activity', (
tester,
) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
var query = '';
final container = ProviderContainer(
overrides: [
navigationItemsStateProvider.overrideWithValue(
NavigationItemsState(
value: [
NavigationItem(
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) => _NestedSearchLauncher(
onSearch: (value) {
query = value;
},
),
),
NavigationItem(
glyph: AppGlyphs.tools,
label: PageLabel.tools,
builder: (_) => const SizedBox.shrink(),
),
],
),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container.read(viewSizeProvider.notifier).value = const Size(1200, 800);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(includeNavigatorKey: false, child: HomePage()),
),
);
await tester.pump();
await tester.tap(find.text('Open nested search'));
await tester.pumpAndSettle();
await tester.tap(find.byGlyph(AppGlyphs.search));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'needle');
expect(query, 'needle');
final navigationRail = find.byType(NavigationSidebar);
await tester.tap(
find.descendant(of: navigationRail, matching: _glyph(AppGlyphs.tools)),
);
await tester.pumpAndSettle();
expect(query, isEmpty);
await tester.tap(
find.descendant(
of: navigationRail,
matching: _glyph(AppGlyphs.dashboard),
),
);
await tester.pumpAndSettle();
expect(find.text('Nested search'), findsOneWidget);
expect(find.byType(TextField), findsNothing);
});
testWidgets('a desktop page left open reopens in the mobile layout', (
tester,
) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final results = <String?>[];
final container = ProviderContainer(
overrides: [
navigationItemsStateProvider.overrideWithValue(
NavigationItemsState(
value: [
NavigationItem(
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) => _PageOpener(
key: const GlobalObjectKey(PageLabel.dashboard),
onClosed: results.add,
),
),
NavigationItem(
glyph: AppGlyphs.tools,
label: PageLabel.tools,
builder: (_) => const SizedBox.shrink(),
),
],
),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container.read(viewSizeProvider.notifier).value = const Size(1200, 800);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(includeNavigatorKey: false, child: HomePage()),
),
);
await tester.pump();
await tester.tap(find.text('Open page'));
await tester.pumpAndSettle();
expect(
ModalRoute.of(tester.element(find.text('Sub page'))),
isA<CommonDesktopRoute<String>>(),
);
tester.view.physicalSize = const Size(500, 800);
container.read(viewSizeProvider.notifier).value = const Size(500, 800);
await tester.pumpAndSettle();
expect(find.text('Sub page'), findsOneWidget);
final reopened = tester.element(find.text('Sub page'));
expect(ModalRoute.of(reopened), isA<CommonRoute<String>>());
expect(results, isEmpty);
Navigator.of(reopened).pop('done');
await tester.pumpAndSettle();
expect(find.text('Sub page'), findsNothing);
expect(results, ['done']);
expect(tester.takeException(), isNull);
});
testWidgets(
'desktop tabbing past page content does not scroll the PageView',
(tester) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
Widget pageContent(String label) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('page:$label'),
const SizedBox(height: 16),
TextButton(onPressed: () {}, child: const Text('button')),
],
),
);
}
final container = ProviderContainer(
overrides: [
navigationItemsStateProvider.overrideWithValue(
NavigationItemsState(
value: [
NavigationItem(
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) => pageContent('dashboard'),
),
NavigationItem(
glyph: AppGlyphs.profiles,
label: PageLabel.profiles,
builder: (_) => pageContent('profiles'),
),
],
),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container.read(viewSizeProvider.notifier).value = const Size(1200, 800);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(includeNavigatorKey: false, child: HomePage()),
),
);
await tester.pump();
expect(find.byType(NavigationSidebar), findsOneWidget);
Finder railIcon(Glyph glyph) => find.descendant(
of: find.byType(NavigationSidebar),
matching: _glyph(glyph),
);
// Visit another page so its content stays alive in the PageView cache.
await tester.tap(railIcon(AppGlyphs.profiles));
await tester.pumpAndSettle();
expect(container.read(currentPageLabelProvider), PageLabel.profiles);
await tester.tap(railIcon(AppGlyphs.dashboard));
await tester.pumpAndSettle();
expect(container.read(currentPageLabelProvider), PageLabel.dashboard);
bool focusInSidebar() {
final context = FocusManager.instance.primaryFocus?.context;
return context?.findAncestorWidgetOfExactType<NavigationSidebar>() !=
null;
}
for (var i = 0; i < 40 && !focusInSidebar(); i++) {
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
expect(
container.read(currentPageLabelProvider),
PageLabel.dashboard,
reason: 'tab $i flipped the page',
);
}
expect(focusInSidebar(), isTrue);
expect(
find.text('page:profiles').hitTestable(),
findsNothing,
reason: 'focus traversal scrolled the PageView to another page',
);
expect(tester.takeException(), isNull);
},
);
}
class _ThemeManagedTestApp extends StatelessWidget {
const _ThemeManagedTestApp();
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorKey: globalState.navigatorKey,
localizationsDelegates: const [
AppLocalizations.delegate,
AppUiLocalizations.delegate,
...GlobalMaterialLocalizations.delegates,
],
supportedLocales: AppLocalizations.delegate.supportedLocales,
builder: (_, child) => ThemeManager(child: child!),
home: const HomePage(),
);
}
}
class _StatefulContent extends StatefulWidget {
const _StatefulContent({super.key});
@override
State<_StatefulContent> createState() => _StatefulContentState();
}
class _StatefulContentState extends State<_StatefulContent> {
var _count = 0;
@override
Widget build(BuildContext context) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
leading: const SizedBox(width: 80),
title: TextButton(
onPressed: () {
setState(() {
_count++;
});
},
child: Text('count: $_count'),
),
);
}
}
class _NestedSearchLauncher extends StatelessWidget {
final ValueChanged<String> onSearch;
const _NestedSearchLauncher({required this.onSearch});
@override
Widget build(BuildContext context) {
return Center(
child: FilledButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => CommonScaffold(
title: 'Nested search',
searchState: AppBarSearchState(onSearch: onSearch),
body: const SizedBox(),
),
),
);
},
child: const Text('Open nested search'),
),
);
}
}
class _PageOpener extends StatelessWidget {
final ValueChanged<String?> onClosed;
const _PageOpener({super.key, required this.onClosed});
@override
Widget build(BuildContext context) {
return Center(
child: FilledButton(
onPressed: () async {
onClosed(
await BaseNavigator.push<String>(
context,
const Scaffold(body: Center(child: Text('Sub page'))),
),
);
},
child: const Text('Open page'),
),
);
}
}
final _navigationItemsProvider =
NotifierProvider<_NavigationItems, List<NavigationItem>>(
_NavigationItems.new,
);
class _NavigationItems extends Notifier<List<NavigationItem>> {
@override
List<NavigationItem> build() => const [];
void set(List<NavigationItem> items) => state = items;
}
class _HomeTestProfiles extends Profiles {
final List<Profile> initial;
_HomeTestProfiles(this.initial);
@override
List<Profile> build() => initial;
}
class _InitRecorder extends StatefulWidget {
const _InitRecorder({required this.label, required this.inits});
final PageLabel label;
final List<PageLabel> inits;
@override
State<_InitRecorder> createState() => _InitRecorderState();
}
class _InitRecorderState extends State<_InitRecorder> {
@override
void initState() {
super.initState();
widget.inits.add(widget.label);
}
@override
Widget build(BuildContext context) => Text('page:${widget.label.name}');
}