feat(ui): add a sidebar, floating dock, snap sheets and drag to go back

Changelog: Sidebar, floating dock and drag to go back
This commit is contained in:
chen08209
2026-09-25 03:57:04 +08:00
parent ee6a49713a
commit 841d6035d5
78 changed files with 13296 additions and 1515 deletions
+71 -14
View File
@@ -1,15 +1,18 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/theme.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/manager/status_manager.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/views/disclaimer.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/glyph_finders.dart';
import '../helpers/test_app.dart';
Future<ProviderContainer> _pumpHost(
@@ -166,6 +169,42 @@ void main() {
expect(await result, 'picked');
});
testWidgets('a dialog stacked on a dialog brings its own scrim', (
tester,
) async {
await _pumpHost(tester);
final result = dialogs.showCommonDialog<void>(
child: Builder(
builder: (context) => TextButton(
onPressed: () {
dialogs.showCommonDialog<void>(
context: context,
child: const Text('inner'),
);
},
child: const Text('open inner'),
),
),
);
await tester.pumpAndSettle();
final outer = tester.element(find.text('open inner'));
final scrim = outer.colorScheme.scrim.withValues(alpha: 0.32);
expect(ModalRoute.of(outer)!.barrierColor, scrim);
await tester.tap(find.text('open inner'));
await tester.pumpAndSettle();
final inner = tester.element(find.text('inner'));
expect(ModalRoute.of(inner)!.barrierColor, scrim);
expect(find.byType(BackdropFilter), findsNothing);
Navigator.of(inner).pop();
await tester.pumpAndSettle();
Navigator.of(outer).pop();
await tester.pumpAndSettle();
await result;
});
testWidgets('showAllUpdatingMessagesDialog lists every message', (
tester,
) async {
@@ -185,7 +224,7 @@ void main() {
final messageText = tester.widget<Text>(find.text(longMessage));
expect(messageText.maxLines, 2);
expect(messageText.overflow, TextOverflow.ellipsis);
expect(find.byIcon(Icons.error_outline), findsNothing);
expect(find.byGlyph(AppGlyphs.error), findsNothing);
expect(
find.ancestor(
of: find.text(longMessage),
@@ -199,22 +238,40 @@ void main() {
expect(await result, isTrue);
});
testWidgets('showDisclaimer maps agree and exit to a boolean', (
testWidgets(
'requestDisclaimerConsent maps agree, exit, and back to a boolean',
(tester) async {
await _pumpHost(tester);
final agreed = requestDisclaimerConsent();
await tester.pumpAndSettle();
expect(find.text('Data collection and privacy'), findsOneWidget);
await tester.tap(find.text('Agree'));
await tester.pumpAndSettle();
expect(await agreed, isTrue);
final declined = requestDisclaimerConsent();
await tester.pumpAndSettle();
await tester.tap(find.text('Exit'));
await tester.pumpAndSettle();
expect(await declined, isFalse);
final dismissed = requestDisclaimerConsent();
await tester.pumpAndSettle();
rootNavigatorKey.currentState!.pop();
await tester.pumpAndSettle();
expect(await dismissed, isFalse);
},
);
testWidgets('DisclaimerView without consent shows no agree or exit', (
tester,
) async {
await _pumpHost(tester);
await _pumpHost(tester, homeBuilder: (_) => const DisclaimerView());
final agreed = dialogs.showDisclaimer();
await tester.pumpAndSettle();
await tester.tap(find.text('Agree'));
await tester.pumpAndSettle();
expect(await agreed, isTrue);
final declined = dialogs.showDisclaimer();
await tester.pumpAndSettle();
await tester.tap(find.text('Exit'));
await tester.pumpAndSettle();
expect(await declined, isFalse);
expect(find.text('Disclaimer'), findsWidgets);
expect(find.text('Agree'), findsNothing);
expect(find.text('Exit'), findsNothing);
});
testWidgets('showNotifier delivers text through the StatusManager host', (
+38 -8
View File
@@ -66,33 +66,44 @@ void main() {
});
});
group('getOverwriteLabel', () {
group('uniqueLabelFor', () {
String unique(String name, Set<String> taken) =>
uniqueLabelFor(name, fallback: 'Fallback', taken: taken.contains);
test('keeps a free label', () {
expect(unique('foo', {}), 'foo');
});
test('appends (1) to label without number', () {
expect(getOverwriteLabel('foo'), 'foo(1)');
expect(unique('foo', {'foo'}), 'foo(1)');
});
test('increments existing number', () {
expect(getOverwriteLabel('foo(1)'), 'foo(2)');
expect(unique('foo(1)', {'foo(1)'}), 'foo(2)');
});
test('increments higher numbers', () {
expect(getOverwriteLabel('foo(9)'), 'foo(10)');
expect(unique('foo(9)', {'foo(9)'}), 'foo(10)');
});
test('increments a two-digit suffix into three digits', () {
expect(getOverwriteLabel('foo(99)'), 'foo(100)');
expect(unique('foo(99)', {'foo(99)'}), 'foo(100)');
});
test('increments an existing three-digit suffix', () {
expect(getOverwriteLabel('foo(100)'), 'foo(101)');
expect(unique('foo(100)', {'foo(100)'}), 'foo(101)');
});
test('increments a bare parenthesized number', () {
expect(getOverwriteLabel('(1)'), '(2)');
expect(unique('(1)', {'(1)'}), '(2)');
});
test('increments a single-character label', () {
expect(getOverwriteLabel('a(1)'), 'a(2)');
expect(unique('a(1)', {'a(1)'}), 'a(2)');
});
test('steps past numbers already taken', () {
expect(unique('foo', {'foo', 'foo(1)', 'foo(2)'}), 'foo(3)');
});
});
@@ -138,6 +149,25 @@ void main() {
});
});
group('canExpandSidebar', () {
test('expands only in the desktop view mode', () {
expect(canExpandSidebar(getViewMode(maxLaptopWidth + 1)), isTrue);
expect(canExpandSidebar(getViewMode(maxLaptopWidth.toDouble())), isFalse);
expect(canExpandSidebar(ViewMode.mobile), isFalse);
});
});
group('windowControlsOverSidebar', () {
test('only macOS 11 and later draw the traffic lights over it', () {
expect(
windowControlsOverSidebar(isMacOS: true, version: 15),
macOSTrafficLightsArea,
);
expect(windowControlsOverSidebar(isMacOS: true, version: 10), Size.zero);
expect(windowControlsOverSidebar(isMacOS: false, version: 11), Size.zero);
});
});
group('getProxiesColumns', () {
test('minimum 2 columns', () {
expect(getProxiesColumns(100, ProxiesLayout.standard), 2);
+115
View File
@@ -78,6 +78,121 @@ void main() {
});
});
group('drag back', () {
Future<void> openPage(
WidgetTester tester,
double width,
Widget page,
) async {
setViewWidth(width);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
onPressed: () => BaseNavigator.push(context, page),
child: const Text('open'),
),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
}
const page = Scaffold(body: Center(child: Text('pushed page')));
for (final width in [400.0, 1400.0]) {
testWidgets('pops after a long right drag at width $width', (
tester,
) async {
await openPage(tester, width, page);
await tester.timedDrag(
find.text('pushed page'),
Offset(tester.getSize(find.byType(Scaffold).last).width * 0.7, 0),
const Duration(seconds: 1),
);
await tester.pumpAndSettle();
expect(find.text('pushed page'), findsNothing);
expect(find.text('open'), findsOneWidget);
});
testWidgets('stays after a short slow right drag at width $width', (
tester,
) async {
await openPage(tester, width, page);
await tester.timedDrag(
find.text('pushed page'),
const Offset(80, 0),
const Duration(seconds: 1),
);
await tester.pumpAndSettle();
expect(find.text('pushed page'), findsOneWidget);
expect(tester.getTopLeft(find.byType(Scaffold).last), Offset.zero);
});
}
testWidgets('pops on a quick right fling', (tester) async {
await openPage(tester, 400, page);
await tester.fling(find.text('pushed page'), const Offset(120, 0), 1500);
await tester.pumpAndSettle();
expect(find.text('pushed page'), findsNothing);
});
testWidgets('is disabled when the page vetoes popping', (tester) async {
await openPage(tester, 400, const PopScope(canPop: false, child: page));
await tester.timedDrag(
find.text('pushed page'),
const Offset(300, 0),
const Duration(seconds: 1),
);
await tester.pumpAndSettle();
expect(find.text('pushed page'), findsOneWidget);
});
testWidgets('yields to a horizontal scrollable inside the page', (
tester,
) async {
await openPage(
tester,
400,
Scaffold(
body: ListView(
scrollDirection: Axis.horizontal,
children: [
for (var i = 0; i < 20; i++)
SizedBox(width: 200, child: Text('item $i')),
],
),
),
);
final list = find.byType(Scrollable).last;
tester.state<ScrollableState>(list).position.jumpTo(600);
await tester.pump();
await tester.timedDrag(
list,
const Offset(300, 0),
const Duration(seconds: 1),
);
await tester.pumpAndSettle();
expect(find.byType(ListView), findsOneWidget);
});
});
group('route configuration', () {
test('desktop route exposes a transparent barrier and keeps state', () {
final route = CommonDesktopRoute<void>(builder: (_) => const SizedBox());
+85 -36
View File
@@ -23,28 +23,26 @@ Future<StatusManagerState> _pumpStatusManager(
return tester.state<StatusManagerState>(find.byType(StatusManager));
}
double _revealOf(WidgetTester tester, String text) {
return tester
.widget<SizeTransition>(
find
.ancestor(
of: find.text(text),
matching: find.byType(SizeTransition),
)
.first,
Finder _rowOf(String text) {
return find
.ancestor(
of: find.text(text),
matching: find.byWidgetPredicate(
(widget) => widget is Align && widget.heightFactor != null,
),
)
.sizeFactor
.value;
.first;
}
double _revealOf(WidgetTester tester, String text) {
return tester.widget<Align>(_rowOf(text)).heightFactor!;
}
double _opacityOf(WidgetTester tester, String text) {
final transition = find
.ancestor(of: find.text(text), matching: find.byType(SizeTransition))
.first;
return tester
.widget<FadeTransition>(
find
.descendant(of: transition, matching: find.byType(FadeTransition))
.descendant(of: _rowOf(text), matching: find.byType(FadeTransition))
.first,
)
.opacity
@@ -52,15 +50,21 @@ double _opacityOf(WidgetTester tester, String text) {
}
Offset _offsetOf(WidgetTester tester, String text) {
final size = find
.ancestor(of: find.text(text), matching: find.byType(SizeTransition))
.first;
return tester
.widget<SlideTransition>(
find.ancestor(of: size, matching: find.byType(SlideTransition)).first,
final translation = tester
.widget<Transform>(
find
.descendant(of: _rowOf(text), matching: find.byType(Transform))
.first,
)
.position
.value;
.transform
.getTranslation();
return Offset(translation.x, translation.y);
}
Finder _cardOf(String text) {
return find
.ancestor(of: find.text(text), matching: find.byType(Material))
.first;
}
void main() {
@@ -167,17 +171,16 @@ void main() {
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a new message slides in from the trailing edge', (tester) async {
testWidgets('a new message drops in from above', (tester) async {
final state = await _pumpStatusManager(tester);
state.message('animated');
await tester.pump();
expect(_offsetOf(tester, 'animated').dx, greaterThan(0));
expect(_offsetOf(tester, 'animated').dy, 0);
expect(_offsetOf(tester, 'animated').dy, lessThan(0));
expect(_offsetOf(tester, 'animated').dx, 0);
await tester.pump(const Duration(milliseconds: 60));
final mid = _offsetOf(tester, 'animated').dx;
expect(mid, greaterThan(0));
expect(_offsetOf(tester, 'animated').dy, lessThan(0));
await tester.pump(const Duration(milliseconds: 400));
expect(_offsetOf(tester, 'animated'), Offset.zero);
@@ -356,7 +359,7 @@ void main() {
state.message('swipe me');
await tester.pumpAndSettle();
await tester.drag(find.byType(Dismissible), const Offset(600, 0));
await tester.drag(_cardOf('swipe me'), const Offset(600, 0));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
await tester.pump(const Duration(milliseconds: 220));
@@ -373,7 +376,7 @@ void main() {
await tester.pumpAndSettle();
final gesture = await tester.startGesture(
tester.getCenter(find.byType(Dismissible)),
tester.getCenter(_cardOf('held while the finger stays down on the card')),
);
for (var step = 0; step < 4; step++) {
await gesture.moveBy(const Offset(10, 0));
@@ -410,10 +413,7 @@ void main() {
state.message('the newer message that would expire first');
await tester.pumpAndSettle();
final held = find.ancestor(
of: find.text('the older message sitting underneath the held one'),
matching: find.byType(Dismissible),
);
final held = _cardOf('the older message sitting underneath the held one');
final gesture = await tester.startGesture(tester.getCenter(held));
for (var step = 0; step < 4; step++) {
await gesture.moveBy(const Offset(10, 0));
@@ -450,7 +450,7 @@ void main() {
await tester.pumpAndSettle();
final gesture = await tester.startGesture(
tester.getCenter(find.byType(Dismissible)),
tester.getCenter(_cardOf('the message being held down by a finger')),
);
for (var step = 0; step < 4; step++) {
await gesture.moveBy(const Offset(10, 0));
@@ -469,13 +469,62 @@ void main() {
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a swiped message leaves in the direction of the swipe', (
tester,
) async {
for (final direction in [-1.0, 1.0]) {
final state = await _pumpStatusManager(tester);
state.message('swipe me');
await tester.pumpAndSettle();
final restingLeft = tester.getTopLeft(find.text('swipe me')).dx;
await tester.drag(_cardOf('swipe me'), Offset(300 * direction, 0));
await tester.pump();
final releasedAt = tester.getTopLeft(find.text('swipe me')).dx;
expect((releasedAt - restingLeft).sign, direction);
while (find.text('swipe me').evaluate().isNotEmpty) {
final left = tester.getTopLeft(find.text('swipe me')).dx;
expect((left - releasedAt) * direction, greaterThanOrEqualTo(0));
await tester.pump(const Duration(milliseconds: 40));
}
await tester.pumpWidget(const SizedBox.shrink());
}
});
testWidgets('a short drag springs back and keeps the message', (
tester,
) async {
final state = await _pumpStatusManager(tester);
state.message('swipe me');
await tester.pumpAndSettle();
final restingLeft = tester.getTopLeft(find.text('swipe me')).dx;
final gesture = await tester.startGesture(
tester.getCenter(_cardOf('swipe me')),
);
for (var step = 0; step < 4; step++) {
await gesture.moveBy(const Offset(-10, 0));
await tester.pump(const Duration(milliseconds: 50));
}
expect(tester.getTopLeft(find.text('swipe me')).dx, lessThan(restingLeft));
await tester.pump(const Duration(milliseconds: 500));
await gesture.up();
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('swipe me')).dx, restingLeft);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('swipe dismiss removes the message', (tester) async {
final state = await _pumpStatusManager(tester);
state.message('swipe me');
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
await tester.drag(find.byType(Dismissible), const Offset(600, 0));
await tester.drag(_cardOf('swipe me'), const Offset(600, 0));
await tester.pumpAndSettle();
expect(find.text('swipe me'), findsNothing);
+19 -1
View File
@@ -104,7 +104,7 @@ void main() {
expect(restored.autoRun, false);
expect(restored.openLogs, false);
expect(restored.closeConnections, true);
expect(restored.isAnimateToPage, true);
expect(restored.tabAnimation, TabAnimation.slide);
expect(restored.autoCheckUpdate, true);
expect(restored.sidebarExpanded, true);
expect(restored.minimizeOnExit, true);
@@ -178,6 +178,24 @@ void main() {
);
});
test('a legacy isAnimateToPage sets the tab animation', () {
expect(
AppSettingProps.fromJson({'isAnimateToPage': false}).tabAnimation,
TabAnimation.fade,
);
expect(
AppSettingProps.fromJson({'isAnimateToPage': true}).tabAnimation,
TabAnimation.slide,
);
expect(
AppSettingProps.fromJson({
'isAnimateToPage': false,
'tabAnimation': 'slide',
}).tabAnimation,
TabAnimation.slide,
);
});
test('a legacy showLabel sets whether the sidebar is expanded', () {
expect(
AppSettingProps.fromJson({'showLabel': false}).sidebarExpanded,
+484 -109
View File
@@ -1,5 +1,6 @@
import 'package:fl_clash/common/app_ports.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/manager/app_manager.dart';
import 'package:fl_clash/manager/theme_manager.dart';
@@ -8,7 +9,7 @@ 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/views/application_setting.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';
@@ -17,8 +18,11 @@ 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;
@@ -39,12 +43,12 @@ void main() {
NavigationItemsState(
value: [
NavigationItem(
icon: const Icon(Icons.space_dashboard),
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) => const SizedBox.shrink(),
),
NavigationItem(
icon: const Icon(Icons.construction),
glyph: AppGlyphs.tools,
label: PageLabel.tools,
builder: (_) => const SizedBox.shrink(),
),
@@ -66,16 +70,16 @@ void main() {
expect(globalState.navigatorKey.currentContext, isNotNull);
expect(container.read(viewSizeProvider), const Size(1200, 800));
expect(find.byType(NavigationBar), findsNothing);
expect(find.byType(FloatingNavigationBar), findsNothing);
await tester.pump();
expect(find.byType(NavigationRail), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
expect(find.byType(NavigationSidebar), findsOneWidget);
expect(find.byType(FloatingNavigationBar), findsNothing);
await tester.pump(const Duration(milliseconds: 150));
expect(find.byType(NavigationBar), findsNothing);
expect(find.byType(FloatingNavigationBar), findsNothing);
expect(tester.takeException(), isNull);
});
@@ -93,14 +97,14 @@ void main() {
NavigationItemsState(
value: [
NavigationItem(
icon: const Icon(Icons.space_dashboard),
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) => const _StatefulContent(
key: GlobalObjectKey(PageLabel.dashboard),
),
),
NavigationItem(
icon: const Icon(Icons.construction),
glyph: AppGlyphs.tools,
label: PageLabel.tools,
builder: (_) => const SizedBox.shrink(),
),
@@ -130,18 +134,32 @@ void main() {
final sidebarContainer = tester.widget<Container>(
sidebarBackground.first,
);
final colorScheme = Theme.of(
tester.element(find.byType(AppSidebarContainer)),
).colorScheme;
expect(sidebarContainer.color, colorScheme.surfaceContainer);
final sidebarEdge = tester.widget<Material>(
find
.descendant(
of: find.byType(AppSidebarContainer),
matching: find.byWidgetPredicate(
(widget) =>
widget is Material && widget.shape is BorderDirectional,
),
)
.first,
);
expect(
sidebarContainer.color,
Theme.of(
tester.element(find.byType(AppSidebarContainer)),
).colorScheme.surfaceContainer,
(sidebarEdge.shape! as BorderDirectional).end.color,
colorScheme.outlineVariant,
);
await tester.tap(find.text('count: 0'));
await tester.pump();
expect(find.text('count: 1'), findsOneWidget);
expect(find.byType(NavigationRail), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
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);
@@ -151,38 +169,107 @@ void main() {
}
expect(find.text('count: 1'), findsOneWidget);
expect(find.byType(NavigationRail), findsOneWidget);
expect(find.byType(NavigationBar), 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(NavigationRail),
matching: find.byIcon(Icons.construction),
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(NavigationRail), findsNothing);
expect(find.byType(NavigationBar), findsOneWidget);
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(NavigationRail), findsOneWidget);
expect(find.byType(NavigationBar), findsOneWidget);
expect(find.byType(NavigationSidebar), findsOneWidget);
expect(find.byType(FloatingNavigationBar), findsOneWidget);
await tester.pump(const Duration(milliseconds: 301));
expect(find.byType(NavigationRail), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
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 {
@@ -197,14 +284,14 @@ void main() {
NavigationItemsState(
value: [
NavigationItem(
icon: const Icon(Icons.space_dashboard),
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) => const ToolsView(
key: GlobalObjectKey(PageLabel.dashboard),
),
),
NavigationItem(
icon: const Icon(Icons.construction),
glyph: AppGlyphs.tools,
label: PageLabel.tools,
builder: (_) => const SizedBox.shrink(),
),
@@ -248,12 +335,12 @@ void main() {
NavigationItemsState(
value: [
NavigationItem(
icon: const Icon(Icons.space_dashboard),
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) => const SizedBox.shrink(),
),
NavigationItem(
icon: const Icon(Icons.article),
glyph: AppGlyphs.proxies,
label: PageLabel.logs,
modes: const [
NavigationItemMode.desktop,
@@ -262,7 +349,7 @@ void main() {
builder: (_) => const SizedBox.shrink(),
),
NavigationItem(
icon: const Icon(Icons.link),
glyph: AppGlyphs.connections,
label: PageLabel.connections,
modes: const [
NavigationItemMode.desktop,
@@ -271,7 +358,7 @@ void main() {
builder: (_) => const SizedBox.shrink(),
),
NavigationItem(
icon: const Icon(Icons.construction),
glyph: AppGlyphs.tools,
label: PageLabel.tools,
builder: (_) =>
const ToolsView(key: GlobalObjectKey(PageLabel.tools)),
@@ -294,7 +381,7 @@ void main() {
);
await tester.pump();
expect(find.byType(ToolsView), findsOneWidget);
expect(find.byType(NavigationBar), findsOneWidget);
expect(find.byType(FloatingNavigationBar), findsOneWidget);
for (var width = 520.0; width <= 1200; width += 20) {
tester.view.physicalSize = Size(width, 800);
@@ -305,7 +392,7 @@ void main() {
await tester.pump(const Duration(milliseconds: 301));
expect(tester.takeException(), isNull);
expect(find.byType(ToolsView), findsOneWidget);
expect(find.byType(NavigationRail), findsOneWidget);
expect(find.byType(NavigationSidebar), findsOneWidget);
expect(container.read(currentPageLabelProvider), PageLabel.tools);
for (var width = 1180.0; width >= 500; width -= 20) {
@@ -386,27 +473,32 @@ void main() {
);
await tester.pump();
final applicationItem = find.text('Application');
final generalItem = find.text('General');
await tester.scrollUntilVisible(
applicationItem,
generalItem,
500,
scrollable: find.byType(Scrollable).first,
);
await tester.tap(applicationItem);
await tester.tap(generalItem);
await tester.pumpAndSettle();
expect(find.byType(ApplicationSettingView), findsOneWidget);
expect(find.byType(GeneralView), findsOneWidget);
final logItem = find.text('Logcat');
await tester.scrollUntilVisible(
logItem,
500,
scrollable: find.byType(Scrollable).first,
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(ApplicationSettingView), findsOneWidget);
expect(find.byType(GeneralView), findsOneWidget);
expect(tester.takeException(), isNull);
},
);
@@ -425,7 +517,7 @@ void main() {
NavigationItemsState(
value: [
NavigationItem(
icon: const Icon(Icons.space_dashboard),
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) => Align(
alignment: Alignment.topLeft,
@@ -436,7 +528,7 @@ void main() {
),
),
NavigationItem(
icon: const Icon(Icons.article),
glyph: AppGlyphs.proxies,
label: PageLabel.proxies,
builder: (_) => Align(
alignment: Alignment.topLeft,
@@ -462,62 +554,59 @@ void main() {
),
);
await tester.pump();
expect(find.byType(NavigationRail), findsOneWidget);
expect(find.byType(NavigationSidebar), findsOneWidget);
bool focusInRail() {
bool focusInSidebar() {
final context = FocusManager.instance.primaryFocus?.context;
return context?.findAncestorWidgetOfExactType<NavigationRail>() != null;
return context?.findAncestorWidgetOfExactType<NavigationSidebar>() !=
null;
}
IconData? focusedRailIcon() {
Glyph? focusedSidebarIcon() {
final focusNode = FocusManager.instance.primaryFocus;
if (!focusInRail() || focusNode == null) {
if (!focusInSidebar() || focusNode == null) {
return null;
}
return [Icons.space_dashboard, Icons.article].reduce((closest, icon) {
final closestDistance =
(tester.getCenter(find.byIcon(closest)).dy -
focusNode.rect.center.dy)
.abs();
final distance =
(tester.getCenter(find.byIcon(icon)).dy -
focusNode.rect.center.dy)
.abs();
return distance < closestDistance ? icon : closest;
});
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 && !focusInRail(); i++) {
for (var i = 0; i < 30 && focusedSidebarIcon() == null; i++) {
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
}
expect(focusInRail(), isTrue);
expect(focusedRailIcon(), Icons.space_dashboard);
expect(focusedSidebarIcon(), AppGlyphs.dashboard);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(focusedRailIcon(), Icons.article);
expect(focusedSidebarIcon(), AppGlyphs.proxies);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(container.read(currentPageLabelProvider), PageLabel.proxies);
final rail = tester.widget<NavigationRail>(find.byType(NavigationRail));
expect(rail.selectedIndex, 1);
expect(focusedRailIcon(), Icons.article);
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(focusedRailIcon(), Icons.space_dashboard);
expect(focusedSidebarIcon(), AppGlyphs.dashboard);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(container.read(currentPageLabelProvider), PageLabel.dashboard);
expect(focusedRailIcon(), Icons.space_dashboard);
expect(focusedSidebarIcon(), AppGlyphs.dashboard);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(focusedRailIcon(), Icons.article);
expect(focusedSidebarIcon(), AppGlyphs.proxies);
expect(container.read(currentPageLabelProvider), PageLabel.dashboard);
},
);
@@ -538,10 +627,15 @@ void main() {
Positioned(
left: 200,
bottom: 0,
child: IconButton(
key: const ValueKey('content-action'),
onPressed: () {},
icon: const Icon(Icons.more_horiz),
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),
),
),
),
),
],
@@ -554,22 +648,22 @@ void main() {
NavigationItemsState(
value: [
NavigationItem(
icon: const Icon(Icons.space_dashboard),
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) => page('dashboard'),
),
NavigationItem(
icon: const Icon(Icons.folder),
glyph: AppGlyphs.profiles,
label: PageLabel.profiles,
builder: (_) => page('profiles'),
),
NavigationItem(
icon: const Icon(Icons.construction),
glyph: AppGlyphs.tools,
label: PageLabel.tools,
builder: (_) => page('tools'),
),
NavigationItem(
icon: const Icon(Icons.article),
glyph: AppGlyphs.proxies,
label: PageLabel.logs,
builder: (_) => page('logs'),
),
@@ -589,12 +683,13 @@ void main() {
),
);
await tester.pump();
expect(find.byType(NavigationBar), findsOneWidget);
expect(find.byType(FloatingNavigationBar), findsOneWidget);
NavigationBar navBar() =>
tester.widget<NavigationBar>(find.byType(NavigationBar));
FloatingNavigationBar navBar() => tester.widget<FloatingNavigationBar>(
find.byType(FloatingNavigationBar),
);
await tester.tap(find.byIcon(Icons.construction));
await tester.tap(_glyph(AppGlyphs.tools));
await tester.pumpAndSettle();
expect(container.read(currentPageLabelProvider), PageLabel.tools);
expect(navBar().selectedIndex, 2);
@@ -602,7 +697,8 @@ void main() {
bool focusInNav() {
final context = FocusManager.instance.primaryFocus?.context;
return context?.findAncestorWidgetOfExactType<NavigationBar>() != null;
return context?.findAncestorWidgetOfExactType<FloatingNavigationBar>() !=
null;
}
for (var i = 0; i < 20 && !focusInNav(); i++) {
@@ -619,11 +715,11 @@ void main() {
expect(navBar().selectedIndex, 1);
expect(find.text('page:profiles'), findsOneWidget);
await tester.tap(find.byIcon(Icons.construction));
await tester.tap(_glyph(AppGlyphs.tools));
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.byIcon(Icons.article));
await tester.tap(_glyph(AppGlyphs.proxies));
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.byIcon(Icons.folder));
await tester.tap(_glyph(AppGlyphs.profiles));
await tester.pumpAndSettle();
expect(container.read(currentPageLabelProvider), PageLabel.profiles);
expect(navBar().selectedIndex, 1);
@@ -642,6 +738,205 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('a non-floating bottom bar leaves pages undocked', (
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 docked:${DockedPageScope.of(context)} '
'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 docked:false 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 docked:true'), findsOneWidget);
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 {
@@ -657,7 +952,7 @@ void main() {
NavigationItemsState(
value: [
NavigationItem(
icon: const Icon(Icons.space_dashboard),
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) => CommonScaffold(
title: 'Search page',
@@ -670,7 +965,7 @@ void main() {
),
),
NavigationItem(
icon: const Icon(Icons.construction),
glyph: AppGlyphs.tools,
label: PageLabel.tools,
builder: (_) => const SizedBox.shrink(),
),
@@ -691,21 +986,82 @@ void main() {
);
await tester.pump();
await tester.tap(find.byIcon(Icons.search));
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(find.byIcon(Icons.construction));
await tester.tap(_glyph(AppGlyphs.tools));
await tester.pumpAndSettle();
expect(query, isEmpty);
await tester.tap(find.byIcon(Icons.space_dashboard));
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 {
@@ -721,7 +1077,7 @@ void main() {
NavigationItemsState(
value: [
NavigationItem(
icon: const Icon(Icons.space_dashboard),
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) => _NestedSearchLauncher(
onSearch: (value) {
@@ -730,7 +1086,7 @@ void main() {
),
),
NavigationItem(
icon: const Icon(Icons.construction),
glyph: AppGlyphs.tools,
label: PageLabel.tools,
builder: (_) => const SizedBox.shrink(),
),
@@ -753,17 +1109,14 @@ void main() {
await tester.tap(find.text('Open nested search'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.search));
await tester.tap(find.byGlyph(AppGlyphs.search));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'needle');
expect(query, 'needle');
final navigationRail = find.byType(NavigationRail);
final navigationRail = find.byType(NavigationSidebar);
await tester.tap(
find.descendant(
of: navigationRail,
matching: find.byIcon(Icons.construction),
),
find.descendant(of: navigationRail, matching: _glyph(AppGlyphs.tools)),
);
await tester.pumpAndSettle();
expect(query, isEmpty);
@@ -771,7 +1124,7 @@ void main() {
await tester.tap(
find.descendant(
of: navigationRail,
matching: find.byIcon(Icons.space_dashboard),
matching: _glyph(AppGlyphs.dashboard),
),
);
await tester.pumpAndSettle();
@@ -807,12 +1160,12 @@ void main() {
NavigationItemsState(
value: [
NavigationItem(
icon: const Icon(Icons.space_dashboard),
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) => pageContent('dashboard'),
),
NavigationItem(
icon: const Icon(Icons.folder),
glyph: AppGlyphs.profiles,
label: PageLabel.profiles,
builder: (_) => pageContent('profiles'),
),
@@ -832,27 +1185,28 @@ void main() {
),
);
await tester.pump();
expect(find.byType(NavigationRail), findsOneWidget);
expect(find.byType(NavigationSidebar), findsOneWidget);
Finder railIcon(IconData icon) => find.descendant(
of: find.byType(NavigationRail),
matching: find.byIcon(icon),
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(Icons.folder));
await tester.tap(railIcon(AppGlyphs.profiles));
await tester.pumpAndSettle();
expect(container.read(currentPageLabelProvider), PageLabel.profiles);
await tester.tap(railIcon(Icons.space_dashboard));
await tester.tap(railIcon(AppGlyphs.dashboard));
await tester.pumpAndSettle();
expect(container.read(currentPageLabelProvider), PageLabel.dashboard);
bool focusInRail() {
bool focusInSidebar() {
final context = FocusManager.instance.primaryFocus?.context;
return context?.findAncestorWidgetOfExactType<NavigationRail>() != null;
return context?.findAncestorWidgetOfExactType<NavigationSidebar>() !=
null;
}
for (var i = 0; i < 40 && !focusInRail(); i++) {
for (var i = 0; i < 40 && !focusInSidebar(); i++) {
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
expect(
@@ -861,7 +1215,7 @@ void main() {
reason: 'tab $i flipped the page',
);
}
expect(focusInRail(), isTrue);
expect(focusInSidebar(), isTrue);
expect(
find.text('page:profiles').hitTestable(),
findsNothing,
@@ -951,3 +1305,24 @@ class _HomeTestProfiles extends Profiles {
@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}');
}
@@ -302,6 +302,40 @@ void main() {
expect(exitingPosition.dy, lessThan(600));
});
testWidgets('settled content paints past its bounds', (tester) async {
Widget buildApp(bool visible) {
return MaterialApp(
home: Scaffold(
body: Column(
children: [
const Expanded(child: SizedBox()),
AnimatedVisibility.bottomNavigation(
visible: visible,
child: const SizedBox(width: 180, height: 80),
),
],
),
),
);
}
Clip clip() => tester
.widget<ClipRect>(
find.descendant(
of: find.byType(AnimatedVisibility),
matching: find.byType(ClipRect),
),
)
.clipBehavior;
await tester.pumpWidget(buildApp(true));
expect(clip(), Clip.none);
await tester.pumpWidget(buildApp(false));
await tester.pump(const Duration(milliseconds: 100));
expect(clip(), Clip.hardEdge);
});
testWidgets('horizontal transition clips without narrowing its child', (
tester,
) async {
+328
View File
@@ -0,0 +1,328 @@
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import '../helpers/glyph_finders.dart';
import '../helpers/test_app.dart';
void _noop() {}
const _mobile = Size(400, 900);
const _desktop = Size(1200, 900);
final _views = {'a mobile view': _mobile, 'a desktop view': _desktop};
Finder _container(Finder button) =>
find.descendant(of: button, matching: find.byType(Material)).first;
Iterable<Rect> _clipsAbove(RenderObject box) sync* {
var child = box;
for (var parent = box.parent; parent != null; parent = parent.parent) {
final clip = parent.describeApproximatePaintClip(child);
if (clip != null) {
yield MatrixUtils.transformRect(parent.getTransformTo(null), clip);
}
child = parent;
}
}
Future<Rect> _pumpBar(
WidgetTester tester, {
required Widget child,
required Size view,
required TargetPlatform platform,
}) async {
tester.view.physicalSize = view;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
TestApp(
overrides: [viewSizeProvider.overrideWithBuild((_, _) => view)],
homeBuilder: (page) => Navigator(
onGenerateInitialRoutes: (_, _) => [
MaterialPageRoute<void>(builder: (_) => const SizedBox()),
MaterialPageRoute<void>(builder: (_) => page),
],
),
child: Theme(
data: ThemeData(platform: platform),
child: child,
),
),
);
await tester.pumpAndSettle();
return tester.getRect(find.byType(NavigationToolbar).first);
}
void main() {
for (final view in _views.entries) {
final inset = appBarActionInset(view.value == _mobile);
for (final platform in [TargetPlatform.android, TargetPlatform.macOS]) {
testWidgets(
'an icon action mirrors the leading button in ${view.key} on $platform',
(tester) async {
final bar = await _pumpBar(
tester,
view: view.value,
platform: platform,
child: const CommonScaffold(
title: 'Title',
actions: [
IconButton(onPressed: _noop, icon: GlyphIcon(AppGlyphs.sync)),
],
body: SizedBox(),
),
);
final leading = tester.getRect(_container(find.byType(BackButton)));
final action = tester.getRect(
_container(find.widgetWithGlyph(IconButton, AppGlyphs.sync)),
);
expect(leading.left, inset);
expect(bar.right - action.right, inset);
},
);
testWidgets(
'a sheet page ends where its back button starts in ${view.key} '
'on $platform',
(tester) async {
final bar = await _pumpBar(
tester,
view: view.value,
platform: platform,
child: const SheetProvider(
type: SheetType.page,
child: CommonScaffold(
title: 'Providers',
actions: [
AppBarActionButton(
data: IconButtonData(
glyph: AppGlyphs.sync,
onPressed: _noop,
),
),
],
body: SizedBox(),
),
),
);
final leading = tester.getRect(_container(find.byType(BackButton)));
final action = tester.getRect(
_container(find.widgetWithGlyph(IconButton, AppGlyphs.sync)),
);
expect(leading.left, inset);
expect(bar.right - action.right, inset);
},
);
}
testWidgets(
'a text action is a pill as tall as the leading button in ${view.key}',
(tester) async {
final bar = await _pumpBar(
tester,
view: view.value,
platform: TargetPlatform.android,
child: const CommonScaffold(
title: 'Title',
actions: [FilledButton.tonal(onPressed: _noop, child: Text('Add'))],
body: SizedBox(),
),
);
final leading = tester.getRect(_container(find.byType(BackButton)));
final action = tester.getRect(find.byType(FilledButton));
expect(leading.width, leading.height);
expect(action.height, leading.height);
expect(action.width, greaterThan(action.height));
expect(bar.right - action.right, inset);
},
);
}
testWidgets('two bar buttons share one pressable group', (tester) async {
final bar = await _pumpBar(
tester,
view: _desktop,
platform: TargetPlatform.android,
child: const CommonScaffold(
title: 'Title',
iconActions: [
IconButtonData(glyph: AppGlyphs.sync, onPressed: _noop),
IconButtonData(glyph: AppGlyphs.sort, onPressed: _noop),
],
body: SizedBox(),
),
);
final group = find.byType(TonalButtonGroup);
final leading = tester.getRect(_container(find.byType(BackButton)));
expect(group, findsOneWidget);
expect(
find.descendant(of: group, matching: find.byType(IconButton)),
findsNWidgets(2),
);
expect(
find.descendant(of: group, matching: find.byType(ElasticPress)),
findsOneWidget,
);
expect(tester.getSize(group).height, leading.height);
expect(bar.right - tester.getRect(group).right, appBarActionInset(false));
});
testWidgets('raw action widgets press on their own, one gap apart', (
tester,
) async {
await _pumpBar(
tester,
view: _desktop,
platform: TargetPlatform.android,
child: const CommonScaffold(
title: 'Title',
actions: [
IconButton(onPressed: _noop, icon: GlyphIcon(AppGlyphs.sync)),
IconButton(onPressed: _noop, icon: GlyphIcon(AppGlyphs.sort)),
],
body: SizedBox(),
),
);
final first = find.widgetWithGlyph(IconButton, AppGlyphs.sync);
final second = find.widgetWithGlyph(IconButton, AppGlyphs.sort);
expect(find.byType(TonalButtonGroup), findsNothing);
for (final button in [first, second, find.byType(BackButton)]) {
expect(
find.ancestor(of: button, matching: find.byType(ElasticPress)),
findsOneWidget,
);
}
expect(
tester.getRect(second).left - tester.getRect(first).right,
AppBarActionEdge.container.gap,
);
});
for (final type in [SheetType.page, SheetType.bottomSheet]) {
testWidgets('a press swells past the bar of a ${type.name} unclipped', (
tester,
) async {
tester.view.padding = const FakeViewPadding(top: 24);
addTearDown(tester.view.resetPadding);
await _pumpBar(
tester,
view: _mobile,
platform: TargetPlatform.android,
child: SheetProvider(
type: type,
child: const CommonScaffold(
title: 'Title',
actions: [
IconButton(onPressed: _noop, icon: GlyphIcon(AppGlyphs.sync)),
],
body: SizedBox(width: double.infinity, height: 200),
),
),
);
final presses = find.byType(ElasticPress).evaluate();
expect(presses, isNotEmpty);
for (final press in presses) {
final box = press.renderObject! as RenderBox;
final rest = box.localToGlobal(Offset.zero) & box.size;
// A press swells an eighth of the button and pulls 7/32 further.
final swell = rest.inflate(rest.shortestSide * 11 / 32);
for (final clip in _clipsAbove(box)) {
expect(clip.expandToInclude(swell), clip);
}
}
});
}
for (final (name, view, type, compact) in [
('a phone page', _mobile, null, true),
('a desktop page', _desktop, null, false),
('a desktop side sheet', _desktop, SheetType.sideSheet, true),
('a phone bottom sheet', _mobile, SheetType.bottomSheet, true),
]) {
testWidgets('$name keeps to its width class', (tester) async {
const scaffold = CommonScaffold(
title: 'Title',
actions: [
IconButton(onPressed: _noop, icon: GlyphIcon(AppGlyphs.sync)),
],
body: SizedBox(width: double.infinity, height: 200),
);
final bar = await _pumpBar(
tester,
view: view,
platform: TargetPlatform.android,
child: type == null
? scaffold
: SheetProvider(type: type, child: scaffold),
);
final title = tester.getRect(
find.descendant(of: find.byType(AppBar), matching: find.text('Title')),
);
final action = tester.getRect(
_container(find.widgetWithGlyph(IconButton, AppGlyphs.sync)),
);
if (type == SheetType.bottomSheet) {
expect(title.center.dx, moreOrLessEquals(bar.center.dx));
} else {
expect(title.center.dx, lessThan(bar.center.dx));
}
expect(bar.right - action.right, appBarActionInset(compact));
});
}
testWidgets('searching keeps the bar buttons where they stood', (
tester,
) async {
await _pumpBar(
tester,
view: _mobile,
platform: TargetPlatform.android,
child: CommonScaffold(
title: 'Title',
searchState: AppBarSearchState(onSearch: (_) {}),
body: const SizedBox(),
),
);
final leading = find.descendant(
of: find.byType(AppBar),
matching: find.byType(IconButton),
);
Color? glyphColor(Finder button) => IconTheme.of(
tester.element(
find
.descendant(
of: button,
matching: find.byWidgetPredicate(
(widget) => widget is Icon || widget is GlyphIcon,
),
)
.first,
),
).color;
final back = tester.getRect(_container(leading.first));
final backColor = glyphColor(leading.first);
final search = find.widgetWithGlyph(IconButton, AppGlyphs.search);
final trailing = tester.getRect(_container(search));
await tester.tap(search);
await tester.pumpAndSettle();
final clear = find.widgetWithGlyph(IconButton, AppGlyphs.close);
expect(tester.getRect(_container(leading.first)), back);
expect(glyphColor(leading.first), backColor);
expect(tester.getRect(_container(clear)).right, trailing.right);
});
}
+4 -32
View File
@@ -1,4 +1,3 @@
import 'package:fl_clash/widgets/float_layout.dart';
import 'package:fl_clash/widgets/inherited.dart';
import 'package:fl_clash/widgets/scaffold.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -36,6 +35,7 @@ void main() {
),
);
return TestApp(
wrapInProviderScope: true,
includeNavigatorKey: false,
child: inset == null
? scaffold
@@ -60,6 +60,7 @@ void main() {
) async {
Widget buildWith({double inset = 0, bool withFab = false}) {
return TestApp(
wrapInProviderScope: true,
includeNavigatorKey: false,
child: BottomInsetScope(
inset: inset,
@@ -101,6 +102,7 @@ void main() {
) async {
Widget buildWith({double inset = 0, bool withFab = false}) {
return TestApp(
wrapInProviderScope: true,
includeNavigatorKey: false,
child: BottomInsetScope(
inset: inset,
@@ -132,6 +134,7 @@ void main() {
testWidgets('does not reserve FAB space on TV', (tester) async {
await tester.pumpWidget(
TestApp(
wrapInProviderScope: true,
includeNavigatorKey: false,
child: BottomInsetScope(
inset: navigationInset,
@@ -151,35 +154,4 @@ void main() {
expect(contentInset, navigationInset);
expect(tester.takeException(), isNull);
});
testWidgets('float layout stacks its own inset on the inherited one', (
tester,
) async {
await tester.pumpWidget(
TestApp(
includeNavigatorKey: false,
child: BottomInsetScope(
inset: navigationInset,
child: Scaffold(
body: FloatLayout(
isTV: false,
floatingWidget: FloatWrapper(
child: FloatingActionButton(
onPressed: () {},
child: const Icon(Icons.save),
),
),
child: contentProbe(),
),
),
),
),
);
expect(contentInset, navigationInset + fabInset);
final stackBottom = tester.getRect(find.byType(FloatLayout)).bottom;
final fabBottom = tester.getRect(find.byType(FloatWrapper)).bottom;
expect(stackBottom - fabBottom, navigationInset);
expect(tester.takeException(), isNull);
});
}
+261
View File
@@ -0,0 +1,261 @@
import 'package:fl_clash/common/navigator.dart';
import 'package:fl_clash/widgets/inherited.dart';
import 'package:fl_clash/widgets/open_container.dart';
import 'package:fl_clash/widgets/paged_sheet.dart';
import 'package:fl_clash/widgets/sheet.dart';
import 'package:fl_clash/widgets/side_sheet.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
Future<void> _dragRight(WidgetTester tester, Finder finder, double dx) {
return tester.timedDrag(finder, Offset(dx, 0), const Duration(seconds: 1));
}
Future<void> _pumpOpener(
WidgetTester tester,
void Function(BuildContext context) open,
) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () => open(context),
child: const Text('open'),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
}
void main() {
group('controls that take a horizontal drag', () {
var sliderValue = 0.5;
Widget page() {
return Scaffold(
body: Column(
children: [
const SizedBox(height: 200, child: Center(child: Text('blank'))),
SizedBox(
height: 80,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
for (var i = 0; i < 20; i++)
SizedBox(width: 120, child: Text('chip $i')),
],
),
),
StatefulBuilder(
builder: (context, setState) => Slider(
value: sliderValue,
onChanged: (value) => setState(() => sliderValue = value),
),
),
],
),
);
}
Future<void> openPage(WidgetTester tester) {
sliderValue = 0.5;
return _pumpOpener(
tester,
(context) => Navigator.of(
context,
).push(CommonRoute<void>(builder: (_) => page())),
);
}
testWidgets('absorb the drag, even at the scroll start', (tester) async {
await openPage(tester);
await _dragRight(tester, find.text('chip 0'), 500);
await tester.pumpAndSettle();
expect(find.text('blank'), findsOneWidget);
await _dragRight(tester, find.byType(Slider), 300);
await tester.pumpAndSettle();
expect(find.text('blank'), findsOneWidget);
expect(sliderValue, greaterThan(0.5));
});
testWidgets('leave the blank area free to drag back', (tester) async {
await openPage(tester);
await _dragRight(tester, find.text('blank'), 600);
await tester.pumpAndSettle();
expect(find.text('blank'), findsNothing);
expect(find.text('open'), findsOneWidget);
});
});
testWidgets(
'a slow mouse drag across a text field selects instead of dragging back',
(tester) async {
final controller = TextEditingController(text: 'select these words');
addTearDown(controller.dispose);
await _pumpOpener(
tester,
(context) => Navigator.of(context).push(
CommonDesktopRoute<void>(
builder: (_) => Scaffold(
body: Column(
children: [
const SizedBox(
height: 200,
child: Center(child: Text('blank')),
),
SizedBox(
width: 300,
child: TextField(controller: controller),
),
],
),
),
),
),
);
final gesture = await tester.startGesture(
tester.getCenter(find.byType(EditableText)),
kind: PointerDeviceKind.mouse,
);
for (var i = 0; i < 40; i++) {
await gesture.moveBy(const Offset(1.5, 0));
await tester.pump();
}
await gesture.up();
await tester.pumpAndSettle();
expect(controller.selection.isCollapsed, isFalse);
await tester.dragFrom(
tester.getCenter(find.text('blank')),
const Offset(600, 0),
kind: PointerDeviceKind.mouse,
);
await tester.pumpAndSettle();
expect(find.text('blank'), findsNothing);
expect(find.text('open'), findsOneWidget);
},
variant: TargetPlatformVariant.desktop(),
);
testWidgets('an open container drags back to its closed tile', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(
width: 160,
height: 80,
child: OpenContainer<void>(
closedBuilder: (_, open) =>
TextButton(onPressed: open, child: const Text('tile')),
openBuilder: (_, _) =>
const Scaffold(body: Center(child: Text('opened'))),
),
),
),
),
),
);
await tester.tap(find.text('tile'));
await tester.pumpAndSettle();
await _dragRight(tester, find.text('opened'), 600);
await tester.pumpAndSettle();
expect(find.text('opened'), findsNothing);
expect(find.text('tile'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.tap(find.text('tile'));
await tester.pumpAndSettle();
await _dragRight(tester, find.text('opened'), 600);
for (var i = 0; i < 10; i++) {
await tester.pump(const Duration(milliseconds: 30));
expect(find.text('tile'), findsOneWidget);
}
await tester.pumpAndSettle();
expect(find.text('opened'), findsNothing);
});
testWidgets('a modal side sheet drags off to close', (tester) async {
await _pumpOpener(
tester,
(context) => showModalSideSheet<void>(
context: context,
constraints: const BoxConstraints(maxWidth: 300),
builder: (_) =>
const SizedBox.expand(child: Center(child: Text('side'))),
),
);
await _dragRight(tester, find.text('side'), 60);
await tester.pumpAndSettle();
expect(find.text('side'), findsOneWidget);
await _dragRight(tester, find.text('side'), 220);
await tester.pumpAndSettle();
expect(find.text('side'), findsNothing);
});
testWidgets('a paged sheet drags back one page and keeps its first', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: SheetProvider(
type: SheetType.bottomSheet,
child: Align(
alignment: Alignment.bottomCenter,
child: PagedSheet(
child: Navigator(
onGenerateInitialRoutes: (_, _) => [
PagedSheetRoute<void>(
builder: (context) => SizedBox(
height: 300,
child: TextButton(
onPressed: () => Navigator.of(context).push(
PagedSheetRoute<void>(
builder: (_) => const SizedBox(
height: 400,
child: Center(child: Text('second')),
),
),
),
child: const Text('first'),
),
),
),
],
),
),
),
),
),
);
await tester.tap(find.text('first'));
await tester.pumpAndSettle();
await _dragRight(tester, find.text('second'), 600);
await tester.pumpAndSettle();
expect(find.text('second'), findsNothing);
expect(find.text('first'), findsOneWidget);
await _dragRight(tester, find.text('first'), 600);
await tester.pumpAndSettle();
expect(find.text('first'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
+58 -8
View File
@@ -12,6 +12,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
class _FakePathProvider extends PathProviderPlatform {
final String root;
@@ -35,6 +36,21 @@ class _NoopSetupAction extends SetupAction {
void autoApplyProfile() {}
}
class _RenameConflictProfilesAction extends ProfilesAction {
final put = <Profile>[];
@override
Future<({List<Profile> renameIn, List<Profile> conflicts})> providerRename(
Profile previous,
Profile next,
) async =>
(renameIn: const <Profile>[], conflicts: [Profile.normal(label: 'Work')]);
@override
void putProfile(Profile profile, {Iterable<int> renameIn = const []}) =>
put.add(profile);
}
Profile _urlProfile() =>
Profile.normal(label: 'test', url: 'https://example.com/sub');
@@ -43,6 +59,7 @@ Profile _urlProfile() =>
Future<FocusNode> pumpEditProfile(
WidgetTester tester, {
Profile? profile,
List<Override> overrides = const [],
}) async {
tester.view.physicalSize = const Size(900, 800);
tester.view.devicePixelRatio = 1;
@@ -50,7 +67,10 @@ Future<FocusNode> pumpEditProfile(
addTearDown(tester.view.resetDevicePixelRatio);
final container = ProviderContainer(
overrides: [setupActionProvider.overrideWith(() => _NoopSetupAction())],
overrides: [
setupActionProvider.overrideWith(() => _NoopSetupAction()),
...overrides,
],
);
addTearDown(container.dispose);
// Unmount the widget tree before the container is disposed so State.dispose
@@ -60,6 +80,7 @@ Future<FocusNode> pumpEditProfile(
await tester.pumpWidget(const SizedBox());
});
globalState.container = container;
container.read(viewSizeProvider.notifier).value = const Size(900, 800);
final outsideFocus = FocusNode();
addTearDown(outsideFocus.dispose);
@@ -68,6 +89,7 @@ Future<FocusNode> pumpEditProfile(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
navigatorKey: globalState.navigatorKey,
localizationsDelegates: const [
AppLocalizations.delegate,
...GlobalMaterialLocalizations.delegates,
@@ -113,9 +135,9 @@ Future<FocusNode> pumpEditProfile(
return outsideFocus;
}
bool _isFabFocused() {
bool _isInAppBar() {
final context = FocusManager.instance.primaryFocus?.context;
return context?.findAncestorWidgetOfExactType<FloatingActionButton>() != null;
return context?.findAncestorWidgetOfExactType<AppBar>() != null;
}
bool _isTextFieldFocused() {
@@ -137,16 +159,44 @@ void main() {
} catch (_) {}
});
testWidgets('tabbing into the edit page starts with the form', (
testWidgets('tabbing into the edit page reaches the form past its bar', (
tester,
) async {
final outsideFocus = await pumpEditProfile(tester);
expect(FocusManager.instance.primaryFocus, outsideFocus);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
for (var i = 0; i < 4 && !_isTextFieldFocused(); i++) {
if (i > 0) {
expect(_isInAppBar(), isTrue);
}
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
}
expect(_isTextFieldFocused(), isTrue);
expect(_isFabFocused(), isFalse);
expect(find.byType(FloatingActionButton), findsNothing);
expect(find.byTooltip('Save'), findsOneWidget);
});
testWidgets('a name a user subscription already has is not saved', (
tester,
) async {
final action = _RenameConflictProfilesAction();
await pumpEditProfile(
tester,
overrides: [profilesActionProvider.overrideWith(() => action)],
);
await tester.enterText(
find.widgetWithText(TextFormField, currentAppLocalizations.name),
'Taken',
);
await tester.tap(find.byTooltip(currentAppLocalizations.save));
await tester.pumpAndSettle();
expect(
find.textContaining('subscriptions of Work already have Taken'),
findsOneWidget,
);
expect(action.put, isEmpty);
});
}
+133 -1
View File
@@ -1,3 +1,6 @@
import 'dart:async';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter/services.dart';
@@ -17,7 +20,7 @@ Widget _buildPage({bool wrapNavigator = false}) {
),
floatingActionButton: CommonFloatingActionButton(
onPressed: () {},
icon: const Icon(Icons.add),
icon: const GlyphIcon(AppGlyphs.add),
label: 'add',
),
);
@@ -139,6 +142,100 @@ void main() {
expect(_focusedItemKey(), 'item1');
});
testWidgets('down stays on the last control when nothing lies below', (
tester,
) async {
final outsideFocus = await _pumpWithOutsideFocus(tester, _buildPage());
for (var i = 0; i < 4; i++) {
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
}
expect(_isFabFocused(), isTrue);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(_isFabFocused(), isTrue);
expect(FocusManager.instance.primaryFocus, isNot(outsideFocus));
});
testWidgets('left picks the sidebar item beside the focused row', (
tester,
) async {
final sidebarFocus = List.generate(3, (_) => FocusNode());
for (final node in sidebarFocus) {
addTearDown(node.dispose);
}
await tester.pumpWidget(
MaterialApp(
home: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
children: [
for (final node in sidebarFocus)
Focus(
focusNode: node,
child: const SizedBox(width: 80, height: 48),
),
],
),
Expanded(child: _buildPage(wrapNavigator: true)),
],
),
),
);
await tester.pump();
sidebarFocus.first.requestFocus();
await tester.pump();
for (var i = 0; i < 5; i++) {
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
}
expect(_focusedItemKey(), 'item2');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.pump();
expect(FocusManager.instance.primaryFocus, sidebarFocus[2]);
});
testWidgets('down from the last control reaches a bar below the page', (
tester,
) async {
final barFocus = FocusNode();
addTearDown(barFocus.dispose);
await tester.pumpWidget(
MaterialApp(
home: Column(
children: [
Expanded(child: _buildPage()),
Focus(
focusNode: barFocus,
child: const SizedBox(height: 80, width: double.infinity),
),
],
),
),
);
await tester.pump();
barFocus.requestFocus();
await tester.pump();
for (var i = 0; i < 4; i++) {
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
}
expect(_isFabFocused(), isTrue);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(FocusManager.instance.primaryFocus, barFocus);
});
testWidgets('left leaves page content for an adjacent sidebar', (
tester,
) async {
@@ -170,4 +267,39 @@ void main() {
expect(FocusManager.instance.primaryFocus, sidebarFocus);
});
testWidgets('left stays in a pushed page instead of the page it covers', (
tester,
) async {
final coveredFocus = FocusNode();
addTearDown(coveredFocus.dispose);
await tester.pumpWidget(
MaterialApp(
home: Row(
children: [
Focus(
focusNode: coveredFocus,
child: const SizedBox(width: 80, height: 600),
),
const Expanded(child: SizedBox()),
],
),
),
);
unawaited(
tester
.state<NavigatorState>(find.byType(Navigator))
.push(MaterialPageRoute<void>(builder: (_) => _buildPage())),
);
await tester.pumpAndSettle();
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
expect(_focusedItemKey(), 'item0');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.pump();
expect(_focusedItemKey(), 'item0');
});
}
+251
View File
@@ -0,0 +1,251 @@
import 'dart:async';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/paged_sheet.dart';
import 'package:fl_clash/widgets/scaffold.dart';
import 'package:fl_clash/widgets/sheet.dart';
import 'package:fl_clash/widgets/sheet_header.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/test_app.dart';
const _view = Size(400, 900);
const _rowHeight = 48.0;
Widget _list(int rows) {
return CommonScaffold(
title: 'title',
body: ListView(
shrinkWrap: true,
children: [
for (var index = 0; index < rows; index++)
ListTile(
title: Text('row $index'),
minTileHeight: _rowHeight,
onTap: () {},
),
],
),
);
}
void main() {
Future<Future<Object?>> openSheet(
WidgetTester tester,
WidgetBuilder builder, {
SheetProps props = const SheetProps(),
}) async {
tester.view.physicalSize = _view;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
late Future<Object?> result;
await tester.pumpWidget(
TestApp(
overrides: [viewSizeProvider.overrideWithBuild((_, _) => _view)],
child: Builder(
builder: (context) => TextButton(
onPressed: () {
result = showSheet<Object?>(
context: context,
props: props,
builder: builder,
);
},
child: const Text('open'),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
return result;
}
double sheetTop(WidgetTester tester) =>
tester.getTopLeft(find.byType(SheetDragHandle)).dy;
Finder content() => find.byType(Scrollable).last;
double contentOffset(WidgetTester tester) =>
tester.state<ScrollableState>(content()).position.pixels;
bool isOpen(WidgetTester tester) => tester.any(find.text('title'));
testWidgets('a short sheet rests at its content height and closes when '
'its content is dragged down', (tester) async {
final result = await openSheet(tester, (_) => _list(3));
var closed = false;
unawaited(result.then((_) => closed = true));
final top = sheetTop(tester);
expect(top, greaterThan(_view.height / 2));
expect(tester.getBottomLeft(find.text('row 2')).dy, lessThan(_view.height));
await tester.drag(find.text('row 1'), const Offset(0, 40));
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(top, 1));
expect(isOpen(tester), isTrue);
await tester.drag(find.text('row 1'), Offset(0, _view.height - top));
await tester.pumpAndSettle();
expect(closed, isTrue);
expect(isOpen(tester), isFalse);
});
testWidgets('a long sheet stops at the short cap, scrolls its content '
'first and closes from the top', (tester) async {
await openSheet(tester, (_) => _list(60));
final top = sheetTop(tester);
expect(top, closeTo(_view.height * 7 / 16, 60));
await tester.drag(content(), const Offset(0, -200));
await tester.pumpAndSettle();
expect(contentOffset(tester), greaterThan(0));
expect(sheetTop(tester), closeTo(top, 1));
await tester.drag(content(), const Offset(0, 200));
await tester.pumpAndSettle();
expect(contentOffset(tester), 0);
expect(isOpen(tester), isTrue);
await tester.fling(content(), const Offset(0, 300), 2000);
await tester.pumpAndSettle();
expect(isOpen(tester), isFalse);
});
testWidgets('a sheet that controls its scrolling may grow past the cap', (
tester,
) async {
await openSheet(
tester,
(_) => _list(60),
props: const SheetProps(isScrollControlled: true),
);
expect(sheetTop(tester), lessThan(_view.height * 7 / 16 - 60));
});
testWidgets('a tap outside closes the sheet', (tester) async {
await openSheet(tester, (_) => _list(3));
await tester.tapAt(const Offset(200, 40));
await tester.pumpAndSettle();
expect(isOpen(tester), isFalse);
});
testWidgets('a nested sheet decides what a drag away and a tap outside do', (
tester,
) async {
final dismissals = <bool>[];
await openSheet(
tester,
(_) =>
NestedPagedSheet(builder: (_) => _list(3), onDismiss: dismissals.add),
props: nestedPagedSheetProps,
);
final top = sheetTop(tester);
await tester.fling(find.text('row 1'), const Offset(0, 300), 2000);
await tester.pumpAndSettle();
await tester.tapAt(const Offset(200, 40));
await tester.pumpAndSettle();
expect(dismissals, [false, false]);
expect(isOpen(tester), isTrue);
expect(sheetTop(tester), closeTo(top, 1));
});
for (final closes in [true, false]) {
testWidgets('a nested sheet that ${closes ? 'closes' : 'stays'} after a '
'drag away springs back while it decides', (tester) async {
final decision = Completer<void>();
await openSheet(
tester,
(_) => NestedPagedSheet(
builder: (_) => _list(3),
onDismiss: (_) async {
await decision.future;
if (closes) {
globalState.navigatorKey.currentState!.pop();
}
},
),
props: nestedPagedSheetProps,
);
final top = sheetTop(tester);
await tester.fling(find.text('row 1'), const Offset(0, 300), 2000);
await tester.pump();
expect(sheetTop(tester), greaterThan(top));
await tester.pump(const Duration(seconds: 1));
await tester.pump(const Duration(seconds: 1));
expect(isOpen(tester), isTrue);
expect(sheetTop(tester), closeTo(top, 1));
decision.complete();
var lowest = top;
for (var frame = 0; frame < 30 && isOpen(tester); frame++) {
await tester.pump(const Duration(milliseconds: 16));
if (!isOpen(tester)) {
break;
}
final current = sheetTop(tester);
expect(current, greaterThanOrEqualTo(lowest - 1));
lowest = current;
}
await tester.pumpAndSettle();
expect(isOpen(tester), !closes);
if (!closes) {
expect(sheetTop(tester), closeTo(top, 1));
}
});
}
testWidgets(
'pages kept alive in a nested sheet scroll on their own controllers and '
'still hand a drag down to the sheet',
(tester) async {
final dismissals = <bool>[];
Widget page(String name, {VoidCallback? onNext}) => CommonScaffold(
title: 'title',
body: ListView(
children: [
if (onNext != null)
TextButton(onPressed: onNext, child: const Text('next')),
for (var index = 0; index < 60; index++)
SizedBox(height: _rowHeight, child: Text('$name $index')),
],
),
);
await openSheet(
tester,
(_) => NestedPagedSheet(
builder: (context) => page(
'a',
onNext: () => Navigator.of(
context,
).push(PagedSheetRoute(builder: (_) => page('b'))),
),
onDismiss: dismissals.add,
),
props: nestedPagedSheetProps,
);
await tester.tap(find.text('next'));
await tester.pumpAndSettle();
await tester.drag(find.text('b 5'), const Offset(0, -200));
await tester.pump(const Duration(seconds: 3));
await tester.pumpAndSettle();
await tester.drag(find.text('b 8'), const Offset(0, 200));
await tester.pumpAndSettle();
await tester.fling(find.text('b 3'), const Offset(0, 300), 2000);
await tester.pumpAndSettle();
expect(dismissals, [true]);
},
variant: TargetPlatformVariant.only(TargetPlatform.macOS),
);
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:fl_clash/widgets/scroll.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('the hint follows the thumb of a list in reading order', (
tester,
) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FloatingScrollbar(
controller: controller,
hintBuilder: (fraction) => fraction.toStringAsFixed(1),
child: ListView.builder(
controller: controller,
itemCount: 200,
itemBuilder: (_, index) =>
SizedBox(height: 40, child: Text('$index')),
),
),
),
),
);
final gesture = await tester.startGesture(
tester.getCenter(find.byType(Scrollable)),
);
for (var i = 0; i < 25; i++) {
await gesture.moveBy(const Offset(0, -2000));
await tester.pump();
}
final listRect = tester.getRect(find.byType(Scrollable));
final hint = find.byKey(const ValueKey('scrollbarHintPill'));
expect(hint, findsOneWidget);
expect(tester.getCenter(hint).dy, greaterThan(listRect.center.dy));
await gesture.up();
await tester.pumpAndSettle(const Duration(seconds: 1));
});
}
+102
View File
@@ -0,0 +1,102 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/views/theme.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import '../helpers/glyph_finders.dart';
import '../helpers/test_app.dart';
void _noop() {}
const _width = 520.0;
List<Widget> _actions() => const [
CommonMinIconButtonTheme(
child: IconButton.filledTonal(
onPressed: _noop,
icon: GlyphIcon(AppGlyphs.delete, fill: 1),
),
),
CommonMinFilledButtonTheme(
child: FilledButton(onPressed: _noop, child: Text('Select all')),
),
];
Future<Rect> _pumpHeader(WidgetTester tester, Widget header) async {
tester.view.physicalSize = const Size(_width, 200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
TestApp(
child: Builder(
builder: (context) {
globalState.measure = Measure.of(context, 1);
return Material(child: header);
},
),
),
);
await tester.pumpAndSettle();
return tester.getRect(find.byType(FilledButton));
}
void main() {
testWidgets('both headers end their actions at the same inset', (
tester,
) async {
final list = await _pumpHeader(
tester,
ListHeader(title: 'Exclude SSIDs', actions: _actions()),
);
final info = await _pumpHeader(
tester,
InfoHeader(
info: const Info(label: 'Added rules'),
actions: _actions(),
),
);
expect(_width - list.right, _width - info.right);
});
testWidgets('a card header ends where a plain one does', (tester) async {
final info = await _pumpHeader(
tester,
InfoHeader(
info: const Info(label: 'Theme color'),
actions: _actions(),
),
);
final card = await _pumpHeader(
tester,
ItemCard(
info: const Info(label: 'Theme color'),
actions: _actions(),
space: 8,
child: const SizedBox(),
),
);
expect(_width - card.right, _width - info.right);
});
testWidgets('a header spaces its actions like an app bar', (tester) async {
await _pumpHeader(
tester,
InfoHeader(
info: const Info(label: 'Added rules'),
actions: _actions(),
),
);
final icon = tester.getRect(
find.widgetWithGlyph(IconButton, AppGlyphs.delete),
);
final pill = tester.getRect(find.byType(FilledButton));
expect(pill.left - icon.right, appBarActionSpace);
});
}
+132
View File
@@ -0,0 +1,132 @@
import 'dart:async';
import 'package:fl_clash/widgets/inherited.dart';
import 'package:fl_clash/widgets/keyboard_inset_hold.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
class _InsetProbe extends StatelessWidget {
const _InsetProbe(this.builds);
final List<double> builds;
@override
Widget build(BuildContext context) {
builds.add(MediaQuery.viewInsetsOf(context).bottom);
return const Material(
child: Align(
alignment: Alignment.topCenter,
child: SizedBox(width: 200, child: TextField()),
),
);
}
}
void main() {
late List<double> builds;
setUp(() => builds = []);
void useKeyboardView(WidgetTester tester) {
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetViewInsets);
}
double shownInset(WidgetTester tester) {
return MediaQuery.viewInsetsOf(
tester.element(find.byType(_InsetProbe)),
).bottom;
}
Future<void> setKeyboard(WidgetTester tester, double height) async {
tester.view.viewInsets = FakeViewPadding(bottom: height);
await tester.pump();
}
Future<void> openDialog(WidgetTester tester) async {
useKeyboardView(tester);
await tester.pumpWidget(
MaterialApp(home: KeyboardInsetHold(child: _InsetProbe(builds))),
);
unawaited(
showDialog<void>(
context: tester.element(find.byType(_InsetProbe)),
builder: (_) => const Center(child: Text('dialog')),
),
);
await tester.pumpAndSettle();
}
testWidgets('a page under a dialog sits out the keyboard it raises', (
tester,
) async {
await openDialog(tester);
builds.clear();
for (final height in [100.0, 200.0, 300.0]) {
await setKeyboard(tester, height);
}
expect(builds, isEmpty);
expect(shownInset(tester), 0);
});
testWidgets('an uncovered page waits for the keyboard to come down to it', (
tester,
) async {
await openDialog(tester);
await setKeyboard(tester, 300);
Navigator.of(tester.element(find.text('dialog'))).pop();
await tester.pump();
expect(shownInset(tester), 0);
await setKeyboard(tester, 150);
expect(shownInset(tester), 0);
await setKeyboard(tester, 0);
await setKeyboard(tester, 300);
expect(shownInset(tester), 300);
});
testWidgets('an uncovered page takes the keyboard once focus is inside', (
tester,
) async {
await openDialog(tester);
await setKeyboard(tester, 300);
Navigator.of(tester.element(find.text('dialog'))).pop();
await tester.pumpAndSettle();
expect(shownInset(tester), 0);
await tester.tap(find.byType(TextField));
await tester.pump();
expect(shownInset(tester), 300);
});
testWidgets('an inactive tab sits out the keyboard until it comes down', (
tester,
) async {
Widget tab({required bool isActive}) {
return MaterialApp(
home: PageActivityScope(
isActive: isActive,
child: KeyboardInsetHold(child: _InsetProbe(builds)),
),
);
}
useKeyboardView(tester);
await tester.pumpWidget(tab(isActive: false));
await setKeyboard(tester, 300);
expect(shownInset(tester), 0);
await tester.pumpWidget(tab(isActive: true));
expect(shownInset(tester), 0);
await setKeyboard(tester, 0);
await setKeyboard(tester, 300);
expect(shownInset(tester), 300);
});
}
+646
View File
@@ -0,0 +1,646 @@
import 'dart:typed_data';
import 'package:fl_clash/common/app_ports.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/icons/icons.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/views/dashboard/widgets/start_button.dart';
import 'package:fl_clash/views/navigation.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../helpers/test_app.dart';
const _mobileSize = Size(400, 800);
final _lens = find.descendant(
of: find.byType(FloatingNavigationBar),
matching: find.byType(PositionedDirectional),
);
const _addLabel = 'Add';
IconButtonData _addAction({bool isLoading = false}) {
return IconButtonData(
glyph: AppGlyphs.add,
onPressed: () {},
tooltip: _addLabel,
isLoading: isLoading,
);
}
Widget _page(String title, {IconButtonData? primaryAction}) {
return CommonScaffold(
title: title,
primaryAction: primaryAction,
body: Text('page:$title'),
);
}
Future<ProviderContainer> _pumpHome(
WidgetTester tester, {
List<Override> overrides = const [],
bool settle = true,
}) async {
tester.view.physicalSize = _mobileSize;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWithValue([
const Profile(id: 1, autoUpdateDuration: Duration.zero),
]),
suspendProvider.overrideWithValue(false),
...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', primaryAction: _addAction()),
),
NavigationItem(
glyph: AppGlyphs.tools,
label: PageLabel.tools,
builder: (_) => _page('tools'),
),
],
),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container.read(viewSizeProvider.notifier).value = _mobileSize;
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(includeNavigatorKey: false, child: HomePage()),
),
);
if (settle) {
await tester.pumpAndSettle();
} else {
await tester.pump(const Duration(seconds: 1));
}
return container;
}
// A press only paints, so its swell shows in the paint transform.
Rect _painted(WidgetTester tester, RenderObject box, Rect resting) {
Matrix4? transform;
expect(
box,
paints..everything((method, arguments) {
if (method == #transform) {
transform ??= Matrix4.fromFloat64List(arguments.single as Float64List);
}
return true;
}),
);
final local = Offset.zero & resting.size;
return (transform == null
? local
: MatrixUtils.transformRect(transform!, local))
.shift(resting.topLeft);
}
void main() {
setUp(() {
navigationPort = navigation;
addTearDown(() => navigationPort = null);
});
testWidgets('the start button holds the end of the dock on the dashboard', (
tester,
) async {
final container = await _pumpHome(tester);
final bar = tester.getRect(find.byType(FloatingNavigationBar));
final button = tester.getRect(find.byType(FloatingActionButton));
expect(find.byType(StartButton), findsOneWidget);
expect(button.width, button.height);
expect(button.height, bar.height);
expect(button.center.dy, bar.center.dy);
expect(button.left - bar.right, 8);
expect((bar.left + button.right) / 2, _mobileSize.width / 2);
await tester.drag(
find.byType(FloatingNavigationBar),
Offset(bar.width * 0.7, 0),
warnIfMissed: false,
);
await tester.pumpAndSettle();
expect(container.read(currentPageLabelProvider), PageLabel.tools);
expect(find.byType(StartButton), findsNothing);
final alone = tester.getRect(find.byType(FloatingNavigationBar));
expect(alone.center.dx, _mobileSize.width / 2);
expect(alone.width, lessThan(_mobileSize.width - 40));
expect(tester.takeException(), isNull);
});
testWidgets('a tap that just misses the bar still reaches it', (
tester,
) async {
final container = await _pumpHome(tester);
final bar = tester.getRect(find.byType(FloatingNavigationBar));
final extent = (bar.width - 8) / 3;
final tools = bar.left + 4 + extent * 2.5;
await tester.tapAt(Offset(tools, bar.bottom + 12));
await tester.pumpAndSettle();
expect(container.read(currentPageLabelProvider), PageLabel.dashboard);
await tester.tapAt(Offset(tools, bar.bottom + 6));
await tester.pumpAndSettle();
expect(container.read(currentPageLabelProvider), PageLabel.tools);
final alone = tester.getRect(find.byType(FloatingNavigationBar));
await tester.tapAt(Offset(alone.left - 6, alone.top - 6));
await tester.pumpAndSettle();
expect(container.read(currentPageLabelProvider), PageLabel.dashboard);
});
testWidgets('the lens keeps up with a finger that never pauses', (
tester,
) async {
await _pumpHome(tester);
final bar = tester.getRect(find.byType(FloatingNavigationBar));
final extent = (bar.width - 8) / 3;
var finger = Offset(bar.left + 4 + extent / 2, bar.center.dy);
final gesture = await tester.startGesture(finger);
await tester.pump(const Duration(milliseconds: 16));
for (var i = 0; i < 30; i++) {
finger += Offset(extent * 2 / 30, 0);
await gesture.moveTo(finger);
await tester.pump(const Duration(milliseconds: 16));
}
expect(tester.getCenter(_lens).dx, closeTo(finger.dx, extent * 0.3));
await gesture.up();
await tester.pumpAndSettle();
});
testWidgets('a flick carries the lens on to the next item', (tester) async {
final container = await _pumpHome(tester);
final bar = tester.getRect(find.byType(FloatingNavigationBar));
final extent = (bar.width - 8) / 3;
final start = Offset(bar.left + 4 + extent / 2, bar.center.dy);
Future<void> drag(Duration duration) async {
await tester.timedDragFrom(start, Offset(extent * 0.35, 0), duration);
await tester.pumpAndSettle();
}
await drag(const Duration(seconds: 1));
expect(container.read(currentPageLabelProvider), PageLabel.dashboard);
await drag(const Duration(milliseconds: 50));
expect(container.read(currentPageLabelProvider), PageLabel.profiles);
});
testWidgets('a press lifts the lens past the bar until it is let go', (
tester,
) async {
await _pumpHome(tester);
final bar = tester.getRect(find.byType(FloatingNavigationBar));
final resting = tester.getRect(_lens);
expect(resting.height, bar.height - 8);
final gesture = await tester.startGesture(resting.center);
await tester.pumpAndSettle();
final lifted = tester.getRect(_lens);
expect(lifted.top, lessThan(bar.top));
expect(lifted.bottom, greaterThan(bar.bottom));
await gesture.up();
await tester.pumpAndSettle();
expect(tester.getRect(_lens), resting);
});
testWidgets(
'the docked start button swells and follows a drag like the lens',
(tester) async {
await _pumpHome(tester);
final press = find.ancestor(
of: find.byType(FloatingActionButton),
matching: find.byType(ElasticPress),
);
final resting = tester.getRect(press);
Rect painted() => _painted(
tester,
tester.renderObject<RenderProxyBox>(press).child!,
resting,
);
final gesture = await tester.startGesture(resting.center);
await tester.pumpAndSettle();
final pressed = painted();
expect(tester.getRect(press), resting);
expect(pressed.center, resting.center);
expect(pressed.width, greaterThan(resting.width + 8));
expect(
pressed.width / pressed.height,
moreOrLessEquals(resting.width / resting.height),
);
await gesture.moveBy(const Offset(-60, 0));
await tester.pumpAndSettle();
final pulled = painted();
expect(pulled.center.dx, lessThan(resting.center.dx - 4));
expect(pulled.width / pulled.height, greaterThan(1));
await gesture.up();
await tester.pumpAndSettle();
expect(painted(), resting);
},
);
testWidgets('the bar swells under a pointer and stretches past its end', (
tester,
) async {
await _pumpHome(tester);
final bar = find.byType(FloatingNavigationBar);
final resting = tester.getRect(bar);
Rect painted() => _painted(tester, tester.renderObject(bar), resting);
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(mouse.removePointer);
await mouse.addPointer(location: Offset.zero);
await mouse.moveTo(resting.center);
await tester.pumpAndSettle();
final hovered = painted();
expect(hovered.center, resting.center);
expect(hovered.width, greaterThan(resting.width));
await mouse.down(resting.center);
await tester.pumpAndSettle();
final pressed = painted();
expect(tester.getRect(bar), resting);
expect(pressed.center, resting.center);
expect(pressed.width, greaterThan(hovered.width));
expect(pressed.height, greaterThan(resting.height));
await mouse.moveTo(resting.centerRight + const Offset(80, 0));
await tester.pumpAndSettle();
final pulled = painted();
expect(pulled.center.dx, greaterThan(resting.center.dx + 4));
expect(pulled.width, greaterThan(pressed.width));
await mouse.up();
await mouse.moveTo(Offset.zero);
await tester.pumpAndSettle();
expect(painted(), resting);
});
testWidgets(
'a hovering pointer highlights and draws only the item under it',
(tester) async {
await _pumpHome(
tester,
overrides: [
currentPageLabelProvider.overrideWithBuild((_, _) => PageLabel.tools),
],
);
final bar = find.byType(FloatingNavigationBar);
final tools = find
.descendant(of: bar, matching: find.byType(GlyphIcon))
.last;
final resting = tester.getRect(tools);
// The lens is drawn first, so the highlight is the last positioned child.
final positioned = find.descendant(
of: bar,
matching: find.byType(Positioned),
);
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(mouse.removePointer);
await mouse.addPointer(location: Offset.zero);
await mouse.moveTo(resting.center + const Offset(30, 0));
await tester.pumpAndSettle();
final hovered = tester.getRect(tools);
expect(hovered.width, moreOrLessEquals(resting.width));
expect(positioned, findsNWidgets(2));
final highlight =
tester.getCenter(positioned.last).dx - resting.center.dx;
final drawn = hovered.center.dx - resting.center.dx;
expect(drawn, greaterThan(0.5));
expect(drawn, moreOrLessEquals(highlight / 2, epsilon: 0.5));
expect(highlight, lessThan(30));
expect(
tester.getCenter(positioned.first).dx,
moreOrLessEquals(tester.getCenter(positioned.last).dx),
);
await mouse.moveTo(resting.center - const Offset(80, 0));
for (var i = 0; i < 30; i++) {
await tester.pump(const Duration(milliseconds: 16));
expect(
tester.getRect(tools).center.dx,
inInclusiveRange(resting.center.dx - 0.5, hovered.center.dx),
);
}
await tester.pumpAndSettle();
expect(tester.getCenter(tools).dx, moreOrLessEquals(resting.center.dx));
await mouse.moveTo(resting.center);
await tester.pumpAndSettle();
await mouse.down(resting.center);
await tester.pumpAndSettle();
expect(positioned, findsOneWidget);
await mouse.up();
await tester.pumpAndSettle();
expect(positioned, findsNWidgets(2));
await mouse.moveTo(Offset.zero);
await tester.pumpAndSettle();
expect(tester.getCenter(tools).dx, moreOrLessEquals(resting.center.dx));
expect(positioned, findsOneWidget);
},
);
testWidgets('a tap leaves no highlight behind', (tester) async {
await _pumpHome(tester);
final bar = find.byType(FloatingNavigationBar);
final tools = find
.descendant(of: bar, matching: find.byType(GlyphIcon))
.last;
final positioned = find.descendant(
of: bar,
matching: find.byType(Positioned),
);
final finger = await tester.createGesture();
addTearDown(finger.removePointer);
await finger.addPointer(location: Offset.zero);
await finger.moveTo(tester.getCenter(tools));
await tester.pumpAndSettle();
expect(positioned, findsOneWidget);
await finger.down(tester.getCenter(tools));
await finger.up();
await tester.pumpAndSettle();
expect(positioned, findsOneWidget);
});
testWidgets('a phone page shows its primary action in its bar', (
tester,
) async {
final container = await _pumpHome(tester);
container
.read(currentPageLabelProvider.notifier)
.toPage(PageLabel.profiles);
await tester.pumpAndSettle();
final action = find.byWidgetPredicate(
(widget) =>
widget is AppBarActionButton && widget.data.tooltip == _addLabel,
);
expect(action, findsOneWidget);
expect(
find.ancestor(of: action, matching: find.byType(AppBar)),
findsOneWidget,
);
expect(
find.descendant(
of: find.byType(CommonScaffold),
matching: find.byType(FloatingActionButton),
),
findsNothing,
);
expect(tester.takeException(), isNull);
});
testWidgets(
'the docked start button stays an icon-only circle while it runs',
(tester) async {
await _pumpHome(
tester,
settle: false,
overrides: [
runTimeProvider.overrideWithBuild(
(_, _) => const Duration(
hours: 2,
minutes: 13,
seconds: 8,
).inMilliseconds,
),
],
);
final bar = tester.getRect(find.byType(FloatingNavigationBar));
final button = tester.getRect(find.byType(FloatingActionButton));
expect(button.width, button.height);
expect(button.height, bar.height);
expect(find.text('02:13:08'), findsNothing);
expect(find.byType(RunTimeText), findsNothing);
expect(tester.takeException(), isNull);
},
);
testWidgets('a page off the dock shows its primary action as a FAB', (
tester,
) async {
await tester.pumpWidget(
TestApp(
wrapInProviderScope: true,
child: _page('page', primaryAction: _addAction()),
),
);
final fab = find.byType(FloatingActionButton);
expect(fab, findsOneWidget);
expect(find.text(_addLabel), findsOneWidget);
expect(find.byType(AppBarActionButton), findsNothing);
await tester.pumpWidget(
TestApp(
wrapInProviderScope: true,
child: _page('page', primaryAction: _addAction(isLoading: true)),
),
);
await tester.pumpAndSettle();
expect(
tester
.widget<AnimatedScale>(
find.ancestor(of: fab, matching: find.byType(AnimatedScale)),
)
.scale,
0,
);
expect(tester.takeException(), isNull);
});
testWidgets('four destinations keep to their width and five fill the row', (
tester,
) async {
const size = Size(560, 800);
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
Future<Rect> pumpBar(int count) async {
await tester.pumpWidget(
TestApp(
child: Scaffold(
body: Align(
alignment: Alignment.bottomCenter,
child: NavigationDock(
destinations: [
for (var index = 0; index < count; index++)
NavigationDockDestination(
glyph: AppGlyphs.tools,
label: '$index',
),
],
selectedIndex: 0,
onSelected: (_) {},
),
),
),
),
);
return tester.getRect(find.byType(FloatingNavigationBar));
}
final four = await pumpBar(4);
expect(four.center.dx, size.width / 2);
expect(four.width, lessThan(size.width - 42));
final five = await pumpBar(5);
expect(five.left, 21);
expect(five.right, size.width - 21);
});
testWidgets('a long label shrinks to fit, then ellipsizes behind a tooltip', (
tester,
) async {
tester.view.physicalSize = const Size(360, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
Future<List<Text>> pumpLabels(String label) async {
await tester.pumpWidget(
TestApp(
child: Scaffold(
body: Align(
alignment: Alignment.bottomCenter,
child: NavigationDock(
destinations: [
for (final text in ['Home', 'Apps', 'Logs', label])
NavigationDockDestination(
glyph: AppGlyphs.tools,
label: text,
),
],
selectedIndex: 0,
onSelected: (_) {},
),
),
),
),
);
return tester
.widgetList<Text>(
find.descendant(
of: find.byType(FloatingNavigationBar),
matching: find.byType(Text),
),
)
.toList();
}
final tooltips = find.descendant(
of: find.byType(FloatingNavigationBar),
matching: find.byType(Tooltip),
);
var labels = await pumpLabels('Profile');
final shrunk = labels.first.style!.fontSize!;
expect(shrunk, lessThan(10));
expect(shrunk, greaterThan(9));
expect(labels.map((text) => text.style!.fontSize).toSet(), {shrunk});
expect(tooltips, findsNothing);
labels = await pumpLabels('Configuration');
expect(labels.first.style!.fontSize, closeTo(9, 0.001));
expect(tester.widget<Tooltip>(tooltips).message, 'Configuration');
});
testWidgets(
'an elastic button leaves a press to the swell and keeps the icon theme',
(tester) async {
await tester.pumpWidget(
TestApp(
child: Scaffold(
body: IconTheme.merge(
data: const IconThemeData(fill: 1),
child: Row(
children: [
ElasticButton(
child: IconButton.filled(
tooltip: _addLabel,
onPressed: () {},
icon: const GlyphIcon(AppGlyphs.add),
),
),
ElasticButton(
child: FilledButton(
onPressed: () {},
child: const Text(_addLabel),
),
),
ElasticButton(
child: FloatingActionButton(
heroTag: null,
onPressed: () {},
child: const GlyphIcon(AppGlyphs.copy),
),
),
],
),
),
),
),
);
InkWell inkOf(Type button) => tester.widget<InkWell>(
find.descendant(
of: find.byType(button),
matching: find.byType(InkWell),
),
);
for (final button in [IconButton, FilledButton]) {
final ink = inkOf(button);
expect(ink.splashFactory, NoSplash.splashFactory);
expect(
ink.overlayColor!.resolve({WidgetState.pressed}),
Colors.transparent,
);
}
final fabInk = find.descendant(
of: find.byType(FloatingActionButton),
matching: find.byType(InkWell),
);
final fabTheme = Theme.of(tester.element(fabInk));
expect(fabTheme.splashFactory, NoSplash.splashFactory);
expect(fabTheme.highlightColor, Colors.transparent);
expect(
IconTheme.of(tester.element(find.byType(GlyphIcon).first)).fill,
1,
);
},
);
}
+80
View File
@@ -57,6 +57,86 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('morphs the closed shape and color into the open page', (
tester,
) async {
const closedShape = RoundedSuperellipseBorder(
borderRadius: BorderRadius.all(Radius.circular(24)),
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(
width: 160,
height: 80,
child: OpenContainer<void>(
closedShape: closedShape,
closedColor: Colors.red,
openColor: Colors.blue,
curve: Curves.linear,
transitionDuration: const Duration(milliseconds: 300),
closedBuilder: (_, open) {
return GestureDetector(
onTap: open,
child: const Text('Card'),
);
},
openBuilder: (_, close) {
return GestureDetector(
onTap: () => close(),
child: const Text('Page'),
);
},
),
),
),
),
),
);
Material container() {
return tester.widget<Material>(
find
.ancestor(of: find.text('Page'), matching: find.byType(Material))
.first,
);
}
double corner(ShapeBorder? shape) {
final border = shape! as RoundedSuperellipseBorder;
return border.borderRadius.resolve(TextDirection.ltr).topLeft.x;
}
final closedMaterial = tester.widget<Material>(
find
.ancestor(of: find.text('Card'), matching: find.byType(Material))
.first,
);
expect(closedMaterial.shape, closedShape);
await tester.tap(find.text('Card'));
await tester.pump();
expect(corner(container().shape), 24);
expect(container().color, Colors.red);
await tester.pump(const Duration(milliseconds: 150));
expect(corner(container().shape), closeTo(12, 0.01));
await tester.pumpAndSettle();
expect(container().shape, isNull);
await tester.tap(find.text('Page'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 150));
expect(corner(container().shape), closeTo(12, 0.01));
await tester.pumpAndSettle();
expect(find.text('Page'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets(
'fadeThrough opens by callback and supports interrupted reverse',
(tester) async {
+439 -28
View File
@@ -1,12 +1,47 @@
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/widgets/popup.dart';
import 'package:flutter/rendering.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/glyph_finders.dart';
import '../helpers/test_app.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final surfaces = find.byWidgetPredicate(
(widget) => widget is Material && widget.type == MaterialType.card,
);
double shadowOf(WidgetTester tester, Finder surface) {
final box = tester.widget<DecoratedBox>(
find
.ancestor(
of: surface,
matching: find.byWidgetPredicate(
(widget) =>
widget is DecoratedBox &&
widget.decoration is ShapeDecoration &&
(widget.decoration as ShapeDecoration).shadows != null,
),
)
.first,
);
final shadows = (box.decoration as ShapeDecoration).shadows!;
return shadows.fold(0.0, (sum, shadow) => sum + shadow.color.a);
}
Rect shrinkingContent(WidgetTester tester) {
final content = tester.layers.whereType<ImageFilterLayer>().last;
final transform = content.firstChild! as TransformLayer;
final popup = tester.getRect(find.byKey(const Key('popup')));
return MatrixUtils.transformRect(
transform.transform!,
Offset.zero & popup.size,
).shift(popup.topLeft);
}
Future<PopupOpen> pumpBox(WidgetTester tester) async {
late PopupOpen open;
await tester.pumpWidget(
@@ -125,6 +160,262 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('a point placement opens the popup from that point', (
tester,
) async {
late BuildContext context;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (value) {
context = value;
return const SizedBox.expand();
},
),
),
);
Navigator.of(context).push(
CommonPopupRoute<void>(
barrierLabel: 'Dismiss',
placement: PopupPlacement.belowPoint,
anchorOf: () => const Offset(100, 120) & Size.zero,
builder: (_) =>
const SizedBox(width: 80, height: 80, key: Key('popup')),
),
);
await tester.pumpAndSettle();
expect(
tester.getTopLeft(find.byKey(const Key('popup'))),
const Offset(100, 120),
);
});
for (final (name, point, avoid, top) in [
('flips above a point too close to the bottom', 560.0, null, 480.0),
(
'sits above the rect it avoids',
250.0,
const Rect.fromLTRB(0, 200, 800, 300),
112.0,
),
(
'drops below the rect it avoids when above is full',
250.0,
const Rect.fromLTRB(0, 40, 800, 300),
308.0,
),
]) {
testWidgets('a point placement $name', (tester) async {
late BuildContext context;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (value) {
context = value;
return const SizedBox.expand();
},
),
),
);
Navigator.of(context).push(
CommonPopupRoute<void>(
barrierLabel: 'Dismiss',
placement: PopupPlacement.belowPoint,
anchorOf: () => Offset(100, point) & Size.zero,
avoid: avoid,
builder: (_) =>
const SizedBox(width: 80, height: 80, key: Key('popup')),
),
);
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.byKey(const Key('popup'))).dy, top);
});
}
testWidgets(
'a point placement flips above a point the keyboard would cover',
(tester) async {
tester.view.viewInsets = const FakeViewPadding(bottom: 900);
addTearDown(tester.view.resetViewInsets);
late BuildContext context;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (value) {
context = value;
return const SizedBox.expand();
},
),
),
);
Navigator.of(context).push(
CommonPopupRoute<void>(
barrierLabel: 'Dismiss',
placement: PopupPlacement.belowPoint,
anchorOf: () => const Offset(100, 250) & Size.zero,
builder: (_) =>
const SizedBox(width: 80, height: 80, key: Key('popup')),
),
);
await tester.pumpAndSettle();
expect(tester.getBottomLeft(find.byKey(const Key('popup'))).dy, 250);
},
);
testWidgets('a non-modal popup lets touches and focus stay on the page', (
tester,
) async {
var taps = 0;
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
late BuildContext context;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (value) {
context = value;
return Focus(
focusNode: focusNode,
autofocus: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => taps++,
child: const SizedBox.expand(),
),
);
},
),
),
);
await tester.pump();
expect(focusNode.hasFocus, isTrue);
Navigator.of(context).push(
CommonPopupRoute<void>(
barrierLabel: 'Dismiss',
placement: PopupPlacement.belowPoint,
modal: false,
anchorOf: () => const Offset(100, 120) & Size.zero,
builder: (_) =>
const SizedBox(width: 80, height: 80, key: Key('popup')),
),
);
await tester.pumpAndSettle();
expect(focusNode.hasFocus, isTrue);
await tester.tapAt(const Offset(10, 10));
await tester.pumpAndSettle();
expect(taps, 1);
expect(find.byKey(const Key('popup')), findsNothing);
expect(focusNode.hasFocus, isTrue);
});
testWidgets('only a checked item carries a check', (tester) async {
final open = await pumpMenu(tester, [
const CommonPopupMenuItem(label: 'on', checked: true),
const CommonPopupMenuItem(label: 'off', checked: false),
const CommonPopupMenuItem(label: 'plain'),
]);
open();
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.ancestor(of: find.text('on'), matching: find.byType(Row)),
matching: find.byGlyph(AppGlyphs.check),
),
findsOneWidget,
);
expect(find.byGlyph(AppGlyphs.check), findsOneWidget);
});
testWidgets('the popup grows out of its anchor and closes back into it', (
tester,
) async {
final open = await pumpBox(tester);
final anchor = tester.getRect(find.byType(CommonPopupBox));
double sourceOpacity() => tester
.widget<Opacity>(
find.descendant(
of: find.byType(CommonPopupBox),
matching: find.byType(Opacity),
),
)
.opacity;
Rect? platter() {
final clips = tester.layers.whereType<ClipPathLayer>();
return clips.isEmpty ? null : clips.last.clipPath!.getBounds();
}
open();
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
final opening = platter()!;
final popup = tester.getRect(find.byKey(const Key('popup')));
expect(opening.width, lessThan(popup.width));
expect(opening.height, lessThan(popup.height));
expect(opening.overlaps(anchor), isTrue);
await tester.pumpAndSettle();
expect(platter(), isNull);
expect(sourceOpacity(), 0);
Navigator.of(tester.element(find.byKey(const Key('popup')))).pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 60));
final closing = shrinkingContent(tester);
expect(closing.width, lessThan(popup.width));
expect(closing.overlaps(anchor), isTrue);
await tester.pumpAndSettle();
expect(find.byKey(const Key('popup')), findsNothing);
expect(sourceOpacity(), 1);
});
testWidgets('the platter flies to the popup center on an arc', (
tester,
) async {
final open = await pumpBox(tester);
final anchor = tester.getRect(find.byType(CommonPopupBox)).center;
Offset platter() => tester.layers
.whereType<ClipPathLayer>()
.last
.clipPath!
.getBounds()
.center;
open();
await tester.pump();
await tester.pump(const Duration(milliseconds: 60));
final opening = platter();
final popup = tester.getRect(find.byKey(const Key('popup'))).center;
final across = (opening.dx - anchor.dx) / (popup.dx - anchor.dx);
final down = (opening.dy - anchor.dy) / (popup.dy - anchor.dy);
expect(across, greaterThan(0));
expect(down, greaterThan(across + 0.1));
await tester.pumpAndSettle();
Navigator.of(tester.element(find.byKey(const Key('popup')))).pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
final closing = shrinkingContent(tester).center;
final back = (popup.dx - closing.dx) / (popup.dx - anchor.dx);
final up = (popup.dy - closing.dy) / (popup.dy - anchor.dy);
expect(back, greaterThan(up + 0.1));
await tester.pumpAndSettle();
});
testWidgets('dismissing the barrier closes the popup', (tester) async {
final open = await pumpBox(tester);
open();
@@ -146,16 +437,39 @@ void main() {
open();
await tester.pumpAndSettle();
expect(find.byIcon(Icons.chevron_right), findsOneWidget);
expect(find.byGlyph(AppGlyphs.chevronForward), findsOneWidget);
await tester.tap(find.text('parent'));
await tester.pumpAndSettle();
expect(find.text('child'), findsOneWidget);
expect(find.byType(Card), findsNWidgets(2));
expect(surfaces, findsNWidgets(2));
expect(find.text('parent'), findsNWidgets(2));
});
testWidgets('a sub menu opened mid-flight is not held in the platter', (
tester,
) async {
final open = await pumpMenu(tester, [
const CommonPopupMenuItem(
label: 'parent',
subItems: [CommonPopupMenuItem(label: 'child')],
),
]);
bool inPlatter() => tester.layers.whereType<ImageFilterLayer>().isNotEmpty;
open();
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(inPlatter(), isTrue);
await tester.tap(find.text('parent'), warnIfMissed: false);
await tester.pump();
expect(inPlatter(), isFalse);
await tester.pumpAndSettle();
});
testWidgets('the sub menu header lands exactly on the opening item', (
tester,
) async {
@@ -182,7 +496,7 @@ void main() {
final open = await pumpMenu(tester, [CommonPopupMenuItem(label: label)]);
open();
await tester.pumpAndSettle();
final width = tester.getSize(find.byType(Card)).width;
final width = tester.getSize(surfaces).width;
await tester.tapAt(const Offset(10, 10));
await tester.pumpAndSettle();
return width;
@@ -213,7 +527,18 @@ void main() {
await tester.pumpAndSettle();
expect(find.byType(Divider), findsOneWidget);
expect(tester.getSize(find.byType(Divider)).height, greaterThan(0));
final header = find.ancestor(
of: find.text('parent').last,
matching: find.byType(InkWell),
);
final divider = find.byType(Divider);
expect(tester.getTopLeft(divider).dy, tester.getBottomLeft(header).dy);
expect(tester.getSize(divider).height, 17);
expect(
tester.getTopLeft(find.text('child')).dy -
tester.getBottomLeft(divider).dy,
greaterThanOrEqualTo(12),
);
});
testWidgets('the header folds the sub menu back into its item', (
@@ -325,7 +650,7 @@ void main() {
final open = await pumpMenu(tester, [
CommonPopupMenuItem(label: 'plain', onPressed: () {}),
CommonPopupMenuItem(
icon: Icons.delete_outlined,
glyph: AppGlyphs.delete,
label: 'delete',
danger: true,
onPressed: () {},
@@ -343,7 +668,7 @@ void main() {
colorScheme.error,
);
expect(
tester.widget<Icon>(find.byIcon(Icons.delete_outlined)).color,
tester.widget<GlyphIcon>(find.byGlyph(AppGlyphs.delete)).color,
colorScheme.error,
);
expect(
@@ -393,9 +718,9 @@ void main() {
await tester.tap(find.text('parent'));
await tester.pumpAndSettle();
expect(find.byType(Card), findsNWidgets(2));
final parentWidth = tester.getSize(find.byType(Card).first).width;
final childWidth = tester.getSize(find.byType(Card).last).width;
expect(surfaces, findsNWidgets(2));
final parentWidth = tester.getSize(surfaces.first).width;
final childWidth = tester.getSize(surfaces.last).width;
expect(childWidth, greaterThanOrEqualTo(parentWidth));
});
@@ -411,14 +736,11 @@ void main() {
await tester.tap(find.text('parent'));
await tester.pumpAndSettle();
final elevations = tester
.widgetList<Card>(find.byType(Card))
.map((card) => card.elevation)
.toList();
expect(elevations, hasLength(2));
expect(elevations[0], isNotNull);
expect(elevations[1], isNotNull);
expect(elevations[0]!, lessThan(elevations[1]!));
expect(surfaces, findsNWidgets(2));
expect(
shadowOf(tester, surfaces.first),
lessThan(shadowOf(tester, surfaces.last)),
);
});
testWidgets('a sub menu settles wider than the parent it opened from', (
@@ -432,13 +754,13 @@ void main() {
]);
open();
await tester.pumpAndSettle();
final parentWidth = tester.getSize(find.byType(Card)).width;
final parentWidth = tester.getSize(surfaces).width;
await tester.tap(find.text('parent'));
await tester.pumpAndSettle();
expect(
tester.getSize(find.byType(Card).last).width,
tester.getSize(surfaces.last).width,
closeTo(parentWidth * 1.12, 0.01),
);
});
@@ -454,14 +776,14 @@ void main() {
]);
open();
await tester.pumpAndSettle();
final parentWidth = tester.getSize(find.byType(Card)).width;
final parentWidth = tester.getSize(surfaces).width;
await tester.tap(find.text('parent'));
await tester.pump();
final active = find.byType(Card).last;
final active = surfaces.last;
expect(tester.getSize(active).width, parentWidth);
expect(tester.widget<Card>(active).elevation, 0);
expect(shadowOf(tester, active), 0);
await tester.pump(const Duration(milliseconds: 40));
final midWidth = tester.getSize(active).width;
@@ -486,13 +808,13 @@ void main() {
]);
open();
await tester.pumpAndSettle();
final parentWidth = tester.getSize(find.byType(Card)).width;
final parentWidth = tester.getSize(surfaces).width;
await tester.tap(find.text('parent'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 130));
await tester.pump(const Duration(milliseconds: 150));
final active = find.byType(Card).last;
final active = surfaces.last;
final midSize = tester.getSize(active);
expect(midSize.width, greaterThanOrEqualTo(parentWidth * 1.12));
@@ -520,14 +842,14 @@ void main() {
await tester.tap(find.text('parent'));
await tester.pump();
final foldedSize = tester.getSize(find.byType(Card).last);
final foldedSize = tester.getSize(surfaces.last);
await tester.pumpAndSettle();
await tester.tap(find.text('parent').last);
Size? lastSize;
var frames = 0;
while (find.byType(Card).evaluate().length > 1) {
lastSize = tester.getSize(find.byType(Card).last);
while (surfaces.evaluate().length > 1) {
lastSize = tester.getSize(surfaces.last);
await tester.pump(const Duration(milliseconds: 16));
frames++;
expect(frames, lessThan(60));
@@ -537,4 +859,93 @@ void main() {
expect(lastSize.height, closeTo(foldedSize.height, 1));
await tester.pumpAndSettle();
});
testWidgets('every level draws its shadow on the frame it is asked for', (
tester,
) async {
final open = await pumpMenu(tester, [
const CommonPopupMenuItem(
label: 'parent',
subItems: [CommonPopupMenuItem(label: 'child')],
),
]);
open();
await tester.pumpAndSettle();
await tester.tap(find.text('parent'));
await tester.pumpAndSettle();
final durations = tester
.widgetList<Material>(surfaces)
.map((surface) => surface.animationDuration)
.toSet();
expect(durations, {Duration.zero});
});
testWidgets('the parent hands its shadow over without a step', (
tester,
) async {
final open = await pumpMenu(tester, [
const CommonPopupMenuItem(
label: 'parent',
subItems: [CommonPopupMenuItem(label: 'child')],
),
]);
open();
await tester.pumpAndSettle();
final restingShadow = shadowOf(tester, surfaces);
await tester.tap(find.text('parent'));
await tester.pump();
expect(shadowOf(tester, surfaces.first), restingShadow);
expect(shadowOf(tester, surfaces.last), 0);
await tester.pumpAndSettle();
expect(shadowOf(tester, surfaces.first), lessThan(restingShadow));
expect(shadowOf(tester, surfaces.last), restingShadow);
await tester.tap(find.text('parent').last);
await tester.pumpAndSettle();
expect(shadowOf(tester, surfaces), restingShadow);
});
testWidgets('a parent keeps its scroll offset while a sub menu is open', (
tester,
) async {
final open = await pumpMenu(tester, [
for (var index = 0; index < 4; index++)
CommonPopupMenuItem(label: 'item $index'),
const CommonPopupMenuItem(
label: 'parent',
subItems: [CommonPopupMenuItem(label: 'child')],
),
for (var index = 4; index < 30; index++)
CommonPopupMenuItem(label: 'item $index'),
]);
open();
await tester.pumpAndSettle();
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
scrollable.position.jumpTo(100);
await tester.pumpAndSettle();
await tester.tap(find.text('parent'));
await tester.pumpAndSettle();
expect(find.text('child'), findsOneWidget);
final parentScrollable = tester.state<ScrollableState>(
find.byType(Scrollable).first,
);
expect(identical(parentScrollable, scrollable), isTrue);
expect(parentScrollable.position.pixels, 100);
await tester.tap(find.text('parent').last);
await tester.pumpAndSettle();
expect(
identical(
tester.state<ScrollableState>(find.byType(Scrollable)),
scrollable,
),
isTrue,
);
expect(scrollable.position.pixels, 100);
});
}
+205
View File
@@ -0,0 +1,205 @@
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
Widget _buildApp({required bool enabled, required Widget home}) {
return MaterialApp(
builder: (context, child) {
return RemoteFocusAdapter(enabled: enabled, child: child!);
},
home: home,
);
}
Widget _buildForm() {
return Scaffold(
body: Column(
children: [
const TextField(key: ValueKey('name')),
const TextField(key: ValueKey('url'), minLines: 1, maxLines: 5),
Row(
children: [
TextButton(
key: const ValueKey('cancel'),
onPressed: () {},
child: const Text('cancel'),
),
TextButton(
key: const ValueKey('submit'),
onPressed: () {},
child: const Text('submit'),
),
],
),
],
),
);
}
bool _hasFocus(WidgetTester tester, Key key) {
final editable = find.descendant(
of: find.byKey(key),
matching: find.byType(EditableText),
);
if (editable.evaluate().isNotEmpty) {
return tester
.state<EditableTextState>(editable)
.widget
.focusNode
.hasPrimaryFocus;
}
final label = find.descendant(
of: find.byKey(key),
matching: find.byType(Text),
);
return Focus.of(tester.element(label.first)).hasPrimaryFocus;
}
bool _onActionRow(WidgetTester tester) {
return _hasFocus(tester, const ValueKey('cancel')) ||
_hasFocus(tester, const ValueKey('submit'));
}
TextEditingController _controllerOf(WidgetTester tester, Key key) {
return tester
.state<EditableTextState>(
find.descendant(
of: find.byKey(key),
matching: find.byType(EditableText),
),
)
.widget
.controller;
}
void main() {
testWidgets('a pushed dialog focuses its first control', (tester) async {
await tester.pumpWidget(
_buildApp(
enabled: true,
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
key: const ValueKey('open'),
onPressed: () {
showDialog<void>(
context: context,
builder: (_) => AlertDialog(
title: const Text('title'),
actions: [
TextButton(
key: const ValueKey('ok'),
onPressed: () {},
child: const Text('ok'),
),
],
),
);
},
child: const Text('open'),
),
),
),
),
);
await tester.tap(find.byKey(const ValueKey('open')));
await tester.pumpAndSettle();
expect(_hasFocus(tester, const ValueKey('ok')), isTrue);
});
testWidgets('a pushed dialog keeps focus on its scope when disabled', (
tester,
) async {
await tester.pumpWidget(
_buildApp(
enabled: false,
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
key: const ValueKey('open'),
onPressed: () {
showDialog<void>(
context: context,
builder: (_) => AlertDialog(
actions: [
TextButton(
key: const ValueKey('ok'),
onPressed: () {},
child: const Text('ok'),
),
],
),
);
},
child: const Text('open'),
),
),
),
),
);
await tester.tap(find.byKey(const ValueKey('open')));
await tester.pumpAndSettle();
expect(_hasFocus(tester, const ValueKey('ok')), isFalse);
expect(FocusManager.instance.primaryFocus, isA<FocusScopeNode>());
});
testWidgets('down leaves an empty field for the control below', (
tester,
) async {
await tester.pumpWidget(_buildApp(enabled: true, home: _buildForm()));
await tester.tap(find.byKey(const ValueKey('url')));
await tester.pump();
expect(_hasFocus(tester, const ValueKey('url')), isTrue);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(_onActionRow(tester), isTrue);
});
testWidgets('up leaves the first line for the field above', (tester) async {
await tester.pumpWidget(_buildApp(enabled: true, home: _buildForm()));
await tester.enterText(find.byKey(const ValueKey('url')), 'https://a.b');
await tester.pump();
expect(_hasFocus(tester, const ValueKey('url')), isTrue);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
expect(_hasFocus(tester, const ValueKey('name')), isTrue);
});
testWidgets('left inside the text moves the caret, at the start it leaves', (
tester,
) async {
await tester.pumpWidget(_buildApp(enabled: true, home: _buildForm()));
await tester.enterText(find.byKey(const ValueKey('url')), 'ab');
await tester.pump();
final controller = _controllerOf(tester, const ValueKey('url'));
controller.selection = const TextSelection.collapsed(offset: 1);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.pump();
expect(_hasFocus(tester, const ValueKey('url')), isTrue);
expect(controller.selection.baseOffset, 0);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(_onActionRow(tester), isTrue);
});
testWidgets('arrows stay in the field when disabled', (tester) async {
await tester.pumpWidget(_buildApp(enabled: false, home: _buildForm()));
await tester.tap(find.byKey(const ValueKey('url')));
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(_hasFocus(tester, const ValueKey('url')), isTrue);
});
}
+103
View File
@@ -0,0 +1,103 @@
import 'package:fl_clash/widgets/route_motion_hold.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
class _Feed extends StatefulWidget {
const _Feed({required this.source});
final ValueNotifier<int> source;
@override
State<_Feed> createState() => _FeedState();
}
class _FeedState extends State<_Feed> with RouteMotionHoldMixin<_Feed> {
late int _shown = widget.source.value;
@override
void initState() {
super.initState();
widget.source.addListener(_handleSource);
}
@override
void dispose() {
widget.source.removeListener(_handleSource);
super.dispose();
}
void _handleSource() {
updateWhenRouteSettled(() => setState(() => _shown = widget.source.value));
}
@override
Widget build(BuildContext context) {
return Scaffold(body: Text('shown $_shown'));
}
}
void main() {
late ValueNotifier<int> source;
late GlobalKey<NavigatorState> navigator;
setUp(() {
source = ValueNotifier(0);
navigator = GlobalKey<NavigatorState>();
});
tearDown(() => source.dispose());
Future<void> pumpFeed(WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
navigatorKey: navigator,
home: _Feed(source: source),
),
);
}
testWidgets('applies updates at once while the route is still', (
tester,
) async {
await pumpFeed(tester);
source.value = 1;
await tester.pump();
expect(find.text('shown 1'), findsOneWidget);
});
testWidgets('holds updates while covered and replays the latest', (
tester,
) async {
await pumpFeed(tester);
navigator.currentState!.push(
MaterialPageRoute<void>(builder: (_) => const Scaffold()),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
source.value = 1;
source.value = 2;
await tester.pump();
expect(find.text('shown 0', skipOffstage: false), findsOneWidget);
await tester.pumpAndSettle();
expect(find.text('shown 2', skipOffstage: false), findsOneWidget);
});
testWidgets('holds updates through a user gesture', (tester) async {
await pumpFeed(tester);
navigator.currentState!.didStartUserGesture();
await tester.pump();
source.value = 3;
await tester.pump();
expect(find.text('shown 0'), findsOneWidget);
navigator.currentState!.didStopUserGesture();
await tester.pump();
expect(find.text('shown 3'), findsOneWidget);
});
}
+226
View File
@@ -0,0 +1,226 @@
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/glyph_finders.dart';
import '../helpers/test_app.dart';
Future<List<String>> _pumpBar(
WidgetTester tester, {
bool hasSearch = false,
String? primary,
bool primaryLoading = false,
bool foldPrimary = false,
List<String> icons = const [],
List<String> menuItems = const [],
List<String> selection = const [],
}) async {
final taps = <String>[];
IconButtonData action(String label, {bool isLoading = false}) {
return IconButtonData(
glyph: AppGlyphs.add,
onPressed: () => taps.add(label),
tooltip: label,
isLoading: isLoading,
);
}
await tester.pumpWidget(
TestApp(
wrapInProviderScope: true,
child: DockedPageScope(
docked: true,
child: CommonScaffold(
title: 'Page',
searchState: hasSearch ? AppBarSearchState(onSearch: (_) {}) : null,
primaryAction: primary == null
? null
: action(primary, isLoading: primaryLoading),
iconActions: [for (final label in icons) action(label)],
foldPrimaryAction: foldPrimary,
selectionActions: [for (final label in selection) action(label)],
menuItems: [
for (final label in menuItems)
CommonPopupMenuItem(
label: label,
onPressed: () => taps.add(label),
),
],
body: const SizedBox.expand(),
),
),
),
);
return taps;
}
Finder _inBar(Finder finder) =>
find.descendant(of: find.byType(AppBar), matching: finder);
List<String> _barButtons(WidgetTester tester) => [
for (final button in tester.widgetList<IconButton>(
_inBar(find.byType(IconButton)),
))
button.tooltip ?? '',
];
Future<void> _openMenu(WidgetTester tester) async {
await tester.tap(_inBar(find.byGlyph(AppGlyphs.more)));
await tester.pumpAndSettle();
}
List<String> _menuLabels(WidgetTester tester, List<String> labels) {
final shown = [
for (final label in labels)
if (find.text(label).evaluate().isNotEmpty) label,
];
return shown..sort(
(a, b) => tester
.getTopLeft(find.text(a))
.dy
.compareTo(tester.getTopLeft(find.text(b)).dy),
);
}
Finder _inGroup(String tooltip) => find.ancestor(
of: find.byTooltip(tooltip),
matching: find.byType(TonalButtonGroup),
);
void main() {
testWidgets('a bar with three buttons groups two beside the third', (
tester,
) async {
await _pumpBar(tester, primary: 'Add', icons: ['Sync', 'Sort']);
expect(_barButtons(tester), ['Add', 'Sync', 'Sort']);
expect(_inBar(find.byGlyph(AppGlyphs.more)), findsNothing);
expect(_inGroup('Add'), findsOneWidget);
expect(_inGroup('Sync'), findsOneWidget);
expect(_inGroup('Sort'), findsNothing);
await _pumpBar(tester, hasSearch: true, menuItems: ['Settings']);
expect(_barButtons(tester), ['Search', 'More']);
});
testWidgets('the menu stands apart from the grouped buttons', (tester) async {
await _pumpBar(
tester,
hasSearch: true,
primary: 'Delay test',
menuItems: ['Settings'],
);
expect(_barButtons(tester), ['Search', 'Delay test', 'More']);
expect(_inGroup('Search'), findsOneWidget);
expect(_inGroup('Delay test'), findsOneWidget);
expect(_inGroup('More'), findsNothing);
});
testWidgets('past three a bar without search keeps two actions', (
tester,
) async {
final taps = await _pumpBar(
tester,
primary: 'Add',
icons: ['Sync', 'Sort', 'Filter'],
);
expect(_barButtons(tester), ['Add', 'Sync', 'More']);
await _openMenu(tester);
expect(_menuLabels(tester, ['Add', 'Sync', 'Sort', 'Filter']), [
'Sort',
'Filter',
]);
await tester.tap(find.text('Filter'));
await tester.pumpAndSettle();
expect(taps, ['Filter']);
});
testWidgets('past three a searchable bar keeps its primary action', (
tester,
) async {
await _pumpBar(
tester,
hasSearch: true,
primary: 'Delay test',
icons: ['Locate'],
menuItems: ['Settings'],
);
expect(_barButtons(tester), ['Search', 'Delay test', 'More']);
await _openMenu(tester);
expect(_menuLabels(tester, ['Delay test', 'Locate', 'Settings']), [
'Locate',
'Settings',
]);
});
testWidgets('a bar that folds its primary action keeps icons in sight', (
tester,
) async {
final taps = await _pumpBar(
tester,
primary: 'Add',
primaryLoading: true,
foldPrimary: true,
icons: ['Update', 'Sort', 'Filter'],
);
expect(_barButtons(tester), ['Update', 'Sort', 'More']);
await _openMenu(tester);
expect(_menuLabels(tester, ['Add', 'Update', 'Sort', 'Filter']), [
'Add',
'Filter',
]);
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
expect(taps, isEmpty, reason: 'a running action folds in disabled');
});
testWidgets('a selection keeps its actions grouped beside the search', (
tester,
) async {
final taps = await _pumpBar(
tester,
hasSearch: true,
selection: ['Delete', 'Select all'],
);
expect(_barButtons(tester), ['Search', 'Delete', 'Select all']);
expect(_inBar(find.byGlyph(AppGlyphs.more)), findsNothing);
expect(
find.ancestor(
of: find.byTooltip('Search'),
matching: find.byType(TonalButtonGroup),
),
findsNothing,
);
expect(
find.ancestor(
of: find.byTooltip('Delete'),
matching: find.byType(TonalButtonGroup),
),
findsOneWidget,
);
await tester.tap(_inBar(find.byTooltip('Search')));
await tester.pumpAndSettle();
expect(
_barButtons(tester),
containsAllInOrder(['Clear search', 'Delete', 'Select all']),
);
expect(_inBar(find.byGlyph(AppGlyphs.more)), findsNothing);
await tester.tap(find.byTooltip('Select all'));
expect(taps, ['Select all']);
});
}
+12 -12
View File
@@ -1,6 +1,7 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/theme.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/views/dashboard/widget_registry.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
@@ -14,6 +15,8 @@ import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/glyph_finders.dart';
void main() {
testWidgets('back layers are consumed from inner to outer', (tester) async {
var innerActive = true;
@@ -200,7 +203,7 @@ void main() {
),
);
await tester.tap(find.byIcon(Icons.search));
await tester.tap(find.byGlyph(AppGlyphs.search));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
@@ -237,7 +240,7 @@ void main() {
),
);
await tester.tap(find.byIcon(Icons.search));
await tester.tap(find.byGlyph(AppGlyphs.search));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
@@ -304,9 +307,7 @@ void main() {
);
await tester.pump(const Duration(milliseconds: 301));
await tester.tap(find.byIcon(Icons.more_vert));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.search));
await tester.tap(find.byGlyph(AppGlyphs.search));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
@@ -357,7 +358,7 @@ void main() {
const DashboardState(
dashboardWidgets: [
DashboardWidget.networkSpeed,
DashboardWidget.outboundModeV2,
DashboardWidget.outboundMode,
],
),
),
@@ -384,23 +385,22 @@ void main() {
await tester.pump(const Duration(milliseconds: 500));
final deleteButton = find.ancestor(
of: find.byIcon(Icons.close).first,
of: find.byGlyph(AppGlyphs.close).first,
matching: find.byType(IconButton),
);
tester.widget<IconButton>(deleteButton).onPressed!();
await tester.pump();
await tester.pump(const Duration(milliseconds: 301));
await tester.pump();
expect(
tester.state<SuperGridState>(find.byType(SuperGrid)).snapshotChildren,
[DashboardWidget.outboundModeV2.widget],
);
expect(tester.state<SuperGridState>(find.byType(SuperGrid)).items, [
DashboardWidget.outboundMode.widget,
]);
await tester.binding.handlePopRoute();
await tester.pump(const Duration(milliseconds: 500));
expect(find.byKey(const ValueKey('edit-icon')), findsOneWidget);
expect(container.read(appSettingProvider).dashboardWidgets, [
DashboardWidget.outboundModeV2,
DashboardWidget.outboundMode,
]);
});
}
+141
View File
@@ -0,0 +1,141 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/glyph_finders.dart';
import '../helpers/test_app.dart';
Future<void> _sendFind(WidgetTester tester) async {
final modifier = controlSingleActivator(LogicalKeyboardKey.keyF).meta
? LogicalKeyboardKey.metaLeft
: LogicalKeyboardKey.controlLeft;
await tester.sendKeyDownEvent(modifier);
await tester.sendKeyEvent(LogicalKeyboardKey.keyF);
await tester.sendKeyUpEvent(modifier);
await tester.pumpAndSettle();
}
Widget _page({
ValueChanged<String>? onSearch,
List<Widget> actions = const [],
List<IconButtonData> searchActions = const [],
}) {
return CommonScaffold(
title: 'Page',
searchState: onSearch == null
? null
: AppBarSearchState(onSearch: onSearch),
actions: actions,
searchActions: searchActions,
body: const SizedBox.expand(),
);
}
void main() {
testWidgets('the find shortcut opens search and refocuses the field', (
tester,
) async {
await tester.pumpWidget(
TestApp(wrapInProviderScope: true, child: _page(onSearch: (_) {})),
);
await _sendFind(tester);
expect(find.byType(TextField), findsOneWidget);
await tester.enterText(find.byType(TextField), 'needle');
FocusManager.instance.primaryFocus?.unfocus();
await tester.pump();
await _sendFind(tester);
final field = tester.widget<TextField>(find.byType(TextField));
expect(field.focusNode!.hasFocus, isTrue);
expect(
field.controller!.selection,
const TextSelection(baseOffset: 0, extentOffset: 6),
);
});
testWidgets('the find shortcut leaves a page without search alone', (
tester,
) async {
await tester.pumpWidget(TestApp(wrapInProviderScope: true, child: _page()));
await _sendFind(tester);
expect(find.byType(TextField), findsNothing);
});
testWidgets('the find shortcut skips a page under another route', (
tester,
) async {
await tester.pumpWidget(
TestApp(wrapInProviderScope: true, child: _page(onSearch: (_) {})),
);
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
navigator.push(
MaterialPageRoute<void>(
builder: (_) => const Scaffold(body: Text('covering route')),
),
);
await tester.pumpAndSettle();
await _sendFind(tester);
expect(find.byType(TextField, skipOffstage: false), findsNothing);
navigator.pop();
await tester.pumpAndSettle();
await _sendFind(tester);
expect(find.byType(TextField), findsOneWidget);
});
testWidgets('the find shortcut skips an inactive page', (tester) async {
final searches = <String>[];
await tester.pumpWidget(
TestApp(
wrapInProviderScope: true,
child: PageActivityScope(
isActive: false,
child: _page(onSearch: searches.add),
),
),
);
await _sendFind(tester);
expect(searches, isEmpty);
expect(find.byType(TextField), findsNothing);
});
testWidgets('search actions stay beside the field while actions hide', (
tester,
) async {
await tester.pumpWidget(
TestApp(
wrapInProviderScope: true,
child: _page(
onSearch: (_) {},
actions: const [Text('page action')],
searchActions: [
IconButtonData(
glyph: AppGlyphs.add,
onPressed: () {},
tooltip: 'search action',
),
],
),
),
);
expect(find.text('page action'), findsOneWidget);
expect(find.byTooltip('search action'), findsNothing);
await tester.tap(find.byGlyph(AppGlyphs.search));
await tester.pumpAndSettle();
expect(find.text('page action'), findsNothing);
expect(find.byTooltip('search action'), findsOneWidget);
});
}
+81
View File
@@ -6,12 +6,14 @@ class _Host extends StatefulWidget {
final List<int> data;
final bool enable;
final VoidCallback onCancelToEnd;
final VoidCallback onResumeToEnd;
final ScrollController controller;
const _Host({
required this.data,
required this.enable,
required this.onCancelToEnd,
required this.onResumeToEnd,
required this.controller,
});
@@ -29,6 +31,7 @@ class _HostState extends State<_Host> {
dataSource: widget.data,
enable: widget.enable,
onCancelToEnd: widget.onCancelToEnd,
onResumeToEnd: widget.onResumeToEnd,
child: ListView.builder(
controller: widget.controller,
itemCount: widget.data.length,
@@ -44,10 +47,12 @@ class _HostState extends State<_Host> {
void main() {
late ScrollController controller;
late int cancelCount;
late int resumeCount;
setUp(() {
controller = ScrollController();
cancelCount = 0;
resumeCount = 0;
});
tearDown(() {
@@ -65,6 +70,7 @@ void main() {
enable: enable,
controller: controller,
onCancelToEnd: () => cancelCount++,
onResumeToEnd: () => resumeCount++,
),
);
}
@@ -107,6 +113,81 @@ void main() {
expect(cancelCount, 1);
});
testWidgets('resumes when the user scrolls back to the end', (tester) async {
await pump(tester, data: List.generate(20, (i) => i));
await pump(tester, data: List.generate(21, (i) => i));
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, 200));
await tester.pumpAndSettle();
expect(cancelCount, 1);
await pump(tester, data: List.generate(21, (i) => i), enable: false);
await tester.drag(find.byType(ListView), const Offset(0, -100));
await tester.pumpAndSettle();
expect(resumeCount, 0);
await tester.drag(find.byType(ListView), const Offset(0, -400));
await tester.pumpAndSettle();
expect(controller.offset, controller.position.maxScrollExtent);
expect(resumeCount, 1);
});
// The viewport is 600 tall and a row is 100, so six rows fill a screen.
Future<void> pumpAtEnd(WidgetTester tester) async {
await pump(tester, data: List.generate(20, (i) => i));
await pump(tester, data: List.generate(21, (i) => i));
await tester.pumpAndSettle();
expect(controller.offset, controller.position.maxScrollExtent);
}
testWidgets('a batch shorter than a screen animates to the end', (
tester,
) async {
await pumpAtEnd(tester);
await pump(tester, data: List.generate(25, (i) => i));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(controller.offset, lessThan(controller.position.maxScrollExtent));
await tester.pumpAndSettle();
expect(controller.offset, controller.position.maxScrollExtent);
});
testWidgets('a batch past a screenful jumps instead, with nothing left to '
'carry through an animation', (tester) async {
await pumpAtEnd(tester);
await pump(tester, data: List.generate(41, (i) => i));
await tester.pump();
await tester.pump();
expect(controller.offset, controller.position.maxScrollExtent);
});
testWidgets('a viewport that shrinks keeps a following list at the end', (
tester,
) async {
tester.view.physicalSize = const Size(800, 600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await pumpAtEnd(tester);
tester.view.physicalSize = const Size(800, 400);
await tester.pumpAndSettle();
expect(controller.offset, controller.position.maxScrollExtent);
await pump(tester, data: List.generate(21, (i) => i), enable: false);
tester.view.physicalSize = const Size(800, 300);
await tester.pumpAndSettle();
expect(controller.offset, lessThan(controller.position.maxScrollExtent));
});
testWidgets('re-enabling scrolls back to the end', (tester) async {
final data = List.generate(20, (i) => i);
await pump(tester, data: data, enable: false);
+171 -21
View File
@@ -1,21 +1,35 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/widgets/inherited.dart';
import 'package:fl_clash/widgets/scaffold.dart';
import 'package:fl_clash/widgets/scroll.dart';
import 'package:fl_clash/widgets/sheet.dart';
import 'package:fl_clash/widgets/sheet_header.dart';
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/test_app.dart';
Widget _list() {
Widget _list({ScrollController? controller}) {
return ListView.builder(
controller: controller,
itemCount: 100,
itemBuilder: (_, index) => SizedBox(height: 40, child: Text('$index')),
);
}
Widget _sheet(Widget body) {
return TestApp(
wrapInProviderScope: true,
child: SheetProvider(
type: SheetType.bottomSheet,
child: CommonScaffold(title: 'title', body: body),
),
);
}
final _desktop = TargetPlatformVariant.only(TargetPlatform.macOS);
final _everyPlatform = TargetPlatformVariant(const {
const _everyPlatform = TargetPlatformVariant({
TargetPlatform.macOS,
TargetPlatform.android,
});
@@ -57,21 +71,10 @@ void main() {
);
testWidgets(
'a transparent tool bar keeps the scroll bar below the header',
'a bottom sheet keeps the scroll bar below its floating header',
variant: _everyPlatform,
(tester) async {
await tester.pumpWidget(
TestApp(
child: SheetProvider(
type: SheetType.bottomSheet,
child: AdaptiveSheetScaffold(
title: 'title',
sheetTransparentToolBar: true,
body: _list(),
),
),
),
);
await tester.pumpWidget(_sheet(_list()));
await tester.pumpAndSettle();
final scrollBar = tester.widget<CommonScrollBar>(
@@ -82,23 +85,170 @@ void main() {
);
testWidgets(
'an opaque tool bar shows the scroll bar without an inset',
'a floating scroll bar is the only bar its list gets',
variant: _everyPlatform,
(tester) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(
TestApp(
child: SheetProvider(
type: SheetType.bottomSheet,
child: AdaptiveSheetScaffold(title: 'title', body: _list()),
_sheet(
FloatingScrollbar(
controller: controller,
hintBuilder: (_) => 'hint',
child: _list(controller: controller),
),
),
);
await tester.pumpAndSettle();
expect(find.byType(CommonScrollBar), findsOneWidget);
final scrollBar = tester.widget<CommonScrollBar>(
find.byType(CommonScrollBar),
);
expect(scrollBar.padding, EdgeInsets.zero);
expect(scrollBar.padding, const EdgeInsets.only(top: sheetAppBarHeight));
},
);
testWidgets('a sheet header lets what scrolls under it read through', (
tester,
) async {
await tester.pumpWidget(_sheet(_list()));
await tester.pumpAndSettle();
final cover = tester.widget<DecoratedBox>(
find
.descendant(
of: find.ancestor(
of: find.byType(SheetToolBar),
matching: find.byType(FloatingHeader),
),
matching: find.byType(DecoratedBox),
)
.first,
);
final gradient = (cover.decoration as BoxDecoration).gradient;
expect(gradient, isA<LinearGradient>());
expect(gradient!.colors.map((color) => color.a), everyElement(lessThan(1)));
});
for (final type in [null, SheetType.page, SheetType.sideSheet]) {
testWidgets(
'${type?.name ?? 'a page'} floats its bar over the list under it',
variant: _everyPlatform,
(tester) async {
final scaffold = CommonScaffold(title: 'title', body: _list());
await tester.pumpWidget(
TestApp(
wrapInProviderScope: true,
child: type == null
? scaffold
: SheetProvider(type: type, child: scaffold),
),
);
await tester.pumpAndSettle();
final appBar = tester.widget<AppBar>(find.byType(AppBar));
expect(appBar.forceMaterialTransparency, isTrue);
expect(
find.ancestor(
of: find.byType(AppBar),
matching: find.byType(FloatingHeader),
),
findsOneWidget,
);
final barBottom = tester.getRect(find.byType(AppBar)).bottom;
expect(tester.getRect(find.text('0')).top, barBottom);
await tester.drag(find.byType(Scrollable), const Offset(0, -100));
await tester.pumpAndSettle();
expect(tester.getRect(find.text('2')).top, lessThan(barBottom));
},
);
}
testWidgets('a page with a title widget floats its bar as a titled one', (
tester,
) async {
await tester.pumpWidget(
TestApp(
wrapInProviderScope: true,
child: CommonScaffold(titleWidget: const TextField(), body: _list()),
),
);
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byType(AppBar),
matching: find.byType(TextField),
),
findsOneWidget,
);
expect(
find.ancestor(
of: find.byType(AppBar),
matching: find.byType(FloatingHeader),
),
findsOneWidget,
);
final barBottom = tester.getRect(find.byType(AppBar)).bottom;
expect(tester.getRect(find.text('0')).top, barBottom);
await tester.drag(find.text('0'), const Offset(0, -100));
await tester.pumpAndSettle();
expect(tester.getRect(find.text('2')).top, lessThan(barBottom));
});
testWidgets(
'keyboard focus moving up clears the floating bar',
variant: _everyPlatform,
(tester) async {
FocusManager.instance.highlightStrategy =
FocusHighlightStrategy.alwaysTraditional;
addTearDown(
() => FocusManager.instance.highlightStrategy =
FocusHighlightStrategy.automatic,
);
await tester.pumpWidget(
TestApp(
wrapInProviderScope: true,
child: CommonScaffold(
title: 'title',
body: ListView.builder(
itemCount: 100,
itemBuilder: (_, index) =>
Focus(child: SizedBox(height: 40, child: Text('$index'))),
),
),
),
);
await tester.pumpAndSettle();
await tester.drag(find.byType(Scrollable), const Offset(0, -410));
await tester.pumpAndSettle();
final barBottom = tester.getRect(find.byType(AppBar)).bottom;
var below = 0;
while (find.text('$below').evaluate().isEmpty ||
tester.getRect(find.text('$below')).top < barBottom) {
below++;
}
final above = find.text('${below - 1}');
expect(tester.getRect(above).top, lessThan(barBottom));
Focus.of(tester.element(find.text('$below'))).requestFocus();
await tester.pumpAndSettle();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pumpAndSettle();
expect(
FocusManager.instance.primaryFocus,
Focus.of(tester.element(above)),
);
expect(tester.getRect(above).top, moreOrLessEquals(barBottom));
},
);
}
+79
View File
@@ -0,0 +1,79 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/test_app.dart';
Future<void> _pumpStackedSheets(WidgetTester tester, Size size) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final container = ProviderContainer(
overrides: [viewSizeProvider.overrideWithBuild((_, _) => size)],
);
addTearDown(container.dispose);
globalState.container = container;
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(
child: Scaffold(
body: Builder(
builder: (context) => FilledButton(
onPressed: () {
showSheet<void>(
context: context,
builder: (_) => Builder(
builder: (context) => FilledButton(
onPressed: () {
showSheet<void>(
context: context,
builder: (_) => const Text('inner'),
);
},
child: const Text('open inner'),
),
),
);
},
child: const Text('open outer'),
),
),
),
),
),
);
}
Color? _barrierColorAround(WidgetTester tester, String text) {
return ModalRoute.of(tester.element(find.text(text)))!.barrierColor;
}
void main() {
for (final (kind, size) in const [
('side sheet', Size(1000, 800)),
('bottom sheet', Size(400, 800)),
]) {
testWidgets('a $kind stacked on a sheet brings its own scrim', (
tester,
) async {
await _pumpStackedSheets(tester, size);
await tester.tap(find.text('open outer'));
await tester.pumpAndSettle();
final scheme = tester.element(find.text('open inner')).colorScheme;
final scrim = scheme.scrim.withValues(alpha: 0.32);
expect(_barrierColorAround(tester, 'open inner'), scrim);
await tester.tap(find.text('open inner'));
await tester.pumpAndSettle();
expect(_barrierColorAround(tester, 'inner'), scrim);
expect(find.byType(BackdropFilter), findsNothing);
});
}
}
+100
View File
@@ -0,0 +1,100 @@
import 'dart:async';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/widgets/sheet.dart';
import 'package:fl_clash/widgets/sheet_navigator.dart';
import 'package:fl_clash/widgets/side_sheet.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
void main() {
final hostKey = GlobalKey<NavigatorState>();
late BuildContext pageContext;
Widget pages(Widget child) => SheetPagesNavigator(
onGenerateInitialRoutes: (_, _) => [
MaterialPageRoute<void>(builder: (_) => child),
],
);
Future<void> pumpNested(WidgetTester tester, {bool isMobileView = true}) {
return tester.pumpWidget(
ProviderScope(
overrides: [isMobileViewProvider.overrideWithValue(isMobileView)],
child: MaterialApp(
home: Navigator(
key: hostKey,
onGenerateInitialRoutes: (_, _) => [
MaterialPageRoute<void>(
builder: (_) => pages(
pages(
Builder(
builder: (context) {
pageContext = context;
return const SizedBox.expand();
},
),
),
),
),
],
),
),
),
);
}
testWidgets('skips every sheet navigator up to the one hosting them', (
tester,
) async {
await pumpNested(tester);
expect(Navigator.of(pageContext).widget, isA<SheetPagesNavigator>());
expect(sheetNavigatorOf(pageContext), hostKey.currentState);
});
testWidgets('a side sheet opened from a sheet page goes over the sheet', (
tester,
) async {
await pumpNested(tester);
unawaited(
showModalSideSheet<void>(
context: pageContext,
builder: (_) => const Text('side'),
),
);
await tester.pumpAndSettle();
expect(
Navigator.of(tester.element(find.text('side'))),
hostKey.currentState,
);
});
for (final isMobileView in [true, false]) {
testWidgets(
'a sheet opened from a sheet page closes itself, not the page, through '
'the context its builder gets${isMobileView ? '' : ' on desktop'}',
(tester) async {
await pumpNested(tester, isMobileView: isMobileView);
String? picked;
unawaited(
showSheet<String>(
context: pageContext,
builder: (context) => TextButton(
onPressed: () => Navigator.of(context).pop('picked'),
child: const Text('pick'),
),
).then((value) => picked = value),
);
await tester.pumpAndSettle();
await tester.tap(find.text('pick'));
await tester.pumpAndSettle();
expect(picked, 'picked');
expect(find.text('pick'), findsNothing);
expect(pageContext.mounted, isTrue);
},
);
}
}
+236
View File
@@ -0,0 +1,236 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/widgets/inherited.dart';
import 'package:fl_clash/widgets/scaffold.dart';
import 'package:fl_clash/widgets/search_field.dart';
import 'package:fl_clash/widgets/sheet.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import '../helpers/glyph_finders.dart';
import '../helpers/test_app.dart';
void main() {
late List<String> queries;
setUp(() => queries = []);
Widget scaffold({required SheetType? type, Widget? body}) {
final scaffold = CommonScaffold(
title: 'title',
searchState: AppBarSearchState(onSearch: queries.add),
body: body ?? const SizedBox(),
);
return TestApp(
wrapInProviderScope: true,
child: type == null
? scaffold
: SheetProvider(type: type, child: scaffold),
);
}
testWidgets('a bottom sheet searches from a docked field, not a bar button', (
tester,
) async {
await tester.pumpWidget(scaffold(type: SheetType.bottomSheet));
await tester.pumpAndSettle();
expect(find.byType(SearchField), findsOneWidget);
expect(
find.descendant(
of: find.byType(AppBar),
matching: find.byGlyph(AppGlyphs.search),
),
findsNothing,
);
expect(find.text('title'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'wiki');
await tester.pumpAndSettle();
expect(queries, ['wiki']);
// The title survives searching, because nothing took the bar over.
expect(find.text('title'), findsOneWidget);
});
testWidgets('a page and a side sheet keep the bar button', (tester) async {
for (final type in [null, SheetType.page, SheetType.sideSheet]) {
await tester.pumpWidget(scaffold(type: type));
await tester.pumpAndSettle();
expect(find.byType(SearchField), findsNothing, reason: '$type');
expect(
find.descendant(
of: find.byType(AppBar),
matching: find.byGlyph(AppGlyphs.search),
),
findsOneWidget,
reason: '$type',
);
}
});
Future<void> openModalSheet(WidgetTester tester, {Widget? body}) async {
await tester.pumpWidget(
TestApp(
wrapInProviderScope: true,
child: Builder(
builder: (context) => TextButton(
onPressed: () => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => SheetProvider(
type: SheetType.bottomSheet,
child: CommonScaffold(
title: 'title',
searchState: AppBarSearchState(onSearch: queries.add),
body: body ?? const SizedBox(height: 200),
),
),
),
child: const Text('open'),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
}
testWidgets('a docked field leaves the list room to scroll clear of it', (
tester,
) async {
late double inset;
await openModalSheet(
tester,
body: Builder(
builder: (context) {
inset = BottomInsetScope.of(context);
return const SizedBox(height: 200);
},
),
);
expect(inset, BottomInsetScope.dockedSearchInset);
expect(
tester.getSize(find.byType(SearchField)).height,
BottomInsetScope.dockedSearchHeight,
);
});
testWidgets('a docked field carries its own opaque surface', (tester) async {
await openModalSheet(tester);
final decoration = tester
.widget<TextField>(find.byType(TextField))
.decoration!;
final theme = Theme.of(tester.element(find.byType(TextField)));
final fill = decoration.fillColor ?? theme.inputDecorationTheme.fillColor;
expect(decoration.filled ?? theme.inputDecorationTheme.filled, isTrue);
expect(fill, theme.colorScheme.surfaceContainerHigh);
expect(fill!.a, 1);
// The dock itself paints nothing, so no band spans the sheet behind it.
final dock = tester.widget<Material>(
find
.ancestor(
of: find.byType(SearchField),
matching: find.byType(Material),
)
.first,
);
expect(dock.type, MaterialType.transparency);
});
testWidgets('a docked field is a pill', (tester) async {
await openModalSheet(tester);
final theme = Theme.of(tester.element(find.byType(TextField)));
final border = theme.inputDecorationTheme.border;
expect(border, isA<AppInputBorder>());
expect((border! as AppInputBorder).borderRadius, AppRadius.full);
});
testWidgets('a docked field clears the bottom safe area once', (
tester,
) async {
tester.view.physicalSize = const Size(400, 900);
tester.view.devicePixelRatio = 1;
tester.view.padding = const FakeViewPadding(bottom: 34);
tester.view.viewPadding = const FakeViewPadding(bottom: 34);
addTearDown(tester.view.reset);
await openModalSheet(tester);
expect(
tester.getBottomLeft(find.byType(SearchField)).dy,
900 - 34 - BottomInsetScope.dockedSearchMargin,
);
});
testWidgets('a docked field stays above the keyboard', (tester) async {
tester.view.physicalSize = const Size(400, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await openModalSheet(tester);
expect(tester.getBottomLeft(find.byType(SearchField)).dy, lessThan(900));
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
addTearDown(tester.view.resetViewInsets);
await tester.pumpAndSettle();
expect(tester.getBottomLeft(find.byType(SearchField)).dy, lessThan(600));
});
testWidgets('a snap sheet keeps its docked field above the keyboard', (
tester,
) async {
tester.view.physicalSize = const Size(400, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
TestApp(
wrapInProviderScope: true,
child: Builder(
builder: (context) => TextButton(
onPressed: () => showSnapSheet(
context,
builder: (_, controller) => CommonScaffold(
title: 'title',
searchState: AppBarSearchState(onSearch: queries.add),
body: ListView.builder(
controller: controller,
itemCount: 60,
itemBuilder: (_, index) =>
SizedBox(height: 40, child: Text('$index')),
),
),
),
child: const Text('open'),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.byType(SearchField), findsOneWidget);
expect(tester.getBottomLeft(find.byType(SearchField)).dy, lessThan(900));
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
addTearDown(tester.view.resetViewInsets);
await tester.pumpAndSettle();
expect(tester.getBottomLeft(find.byType(SearchField)).dy, lessThan(600));
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(seconds: 2));
});
}
+393
View File
@@ -0,0 +1,393 @@
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/widgets/sidebar.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/test_app.dart';
const _destinations = [
SidebarDestination(glyph: AppGlyphs.dashboard, label: 'Dashboard'),
SidebarDestination(glyph: AppGlyphs.proxies, label: 'Proxies'),
SidebarDestination(glyph: AppGlyphs.profiles, label: 'Profiles'),
];
Finder _glyph(Glyph glyph) => find.byWidgetPredicate(
(widget) => widget is AnimatedGlyph && widget.glyph == glyph,
);
double _fillOf(WidgetTester tester, Glyph glyph) {
final paint = tester.widget<CustomPaint>(
find.descendant(of: _glyph(glyph), matching: find.byType(CustomPaint)),
);
return (paint.painter! as GlyphPainter).fill;
}
Widget _sidebar({
List<SidebarDestination> destinations = _destinations,
int selectedIndex = 0,
bool expanded = true,
ValueChanged<int>? onSelected,
VoidCallback? onToggle,
Size windowControls = Size.zero,
EdgeInsets safePadding = EdgeInsets.zero,
}) {
return TestApp(
child: Scaffold(
body: Row(
children: [
SizedBox(
height: 400,
child: Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(context).copyWith(padding: safePadding),
child: NavigationSidebar(
destinations: destinations,
selectedIndex: selectedIndex,
expanded: expanded,
onSelected: onSelected ?? (_) {},
onToggle: onToggle,
windowControls: windowControls,
),
),
),
),
const Expanded(child: SizedBox.expand(key: ValueKey('content'))),
],
),
),
);
}
Finder get _sidebarFinder => find.byType(NavigationSidebar);
Finder _indicator(WidgetTester tester) {
final primary = Theme.of(tester.element(_sidebarFinder)).colorScheme.primary;
return find.byWidgetPredicate(
(widget) =>
widget is DecoratedBox &&
widget.decoration is ShapeDecoration &&
(widget.decoration as ShapeDecoration).color == primary,
);
}
Finder _labels(String label) => find.ancestor(
of: find.text(label),
matching: find.byWidgetPredicate(
(widget) => widget is Opacity && widget.alwaysIncludeSemantics,
),
);
Iterable<double> _labelOpacities(WidgetTester tester, String label) {
return tester.widgetList<Opacity>(_labels(label)).map((o) => o.opacity);
}
Finder _shown(String label) => find.descendant(
of: find.byWidgetPredicate(
(widget) =>
widget is Opacity &&
widget.alwaysIncludeSemantics &&
widget.opacity == 1,
),
matching: find.text(label),
);
Finder _rowTooltip(String message) => find.byWidgetPredicate(
(widget) => widget is Tooltip && widget.message == message,
);
void main() {
testWidgets('the selected row carries a centered indicator', (tester) async {
await tester.pumpWidget(_sidebar(selectedIndex: 1));
final indicator = tester.getRect(_indicator(tester));
expect(indicator.width, 3);
expect(indicator.height, 16);
expect(
indicator.center.dy,
moreOrLessEquals(tester.getCenter(_glyph(AppGlyphs.proxies)).dy),
);
expect(tester.getSize(_sidebarFinder).width, 220);
expect(_labelOpacities(tester, 'Proxies'), [1]);
expect(
tester.getSemantics(find.text('Proxies')),
isSemantics(isSelected: true, isButton: true),
);
});
testWidgets('the indicator stretches toward the new row and settles on it', (
tester,
) async {
await tester.pumpWidget(_sidebar());
final start = tester.getRect(_indicator(tester));
await tester.pumpWidget(_sidebar(selectedIndex: 2));
await tester.pump(const Duration(milliseconds: 160));
final middle = tester.getRect(_indicator(tester));
expect(middle.top, greaterThanOrEqualTo(start.top));
expect(middle.height, greaterThan(16));
await tester.pumpAndSettle();
final end = tester.getRect(_indicator(tester));
expect(end.height, 16);
expect(
end.center.dy,
moreOrLessEquals(tester.getCenter(_glyph(AppGlyphs.profiles)).dy),
);
});
testWidgets('only the selected row fills its icon', (tester) async {
await tester.pumpWidget(_sidebar());
expect(_fillOf(tester, AppGlyphs.dashboard), 1);
expect(_fillOf(tester, AppGlyphs.profiles), 0);
await tester.pumpWidget(_sidebar(selectedIndex: 2));
await tester.pumpAndSettle();
expect(_fillOf(tester, AppGlyphs.dashboard), 0);
expect(_fillOf(tester, AppGlyphs.profiles), 1);
});
testWidgets('the toggle widens the sidebar in place when it can', (
tester,
) async {
var toggled = 0;
int? selected;
await tester.pumpWidget(
_sidebar(
expanded: false,
onToggle: () => toggled++,
onSelected: (index) => selected = index,
),
);
expect(tester.getSize(_sidebarFinder).width, 48);
expect(_labelOpacities(tester, 'Proxies'), [0]);
expect(_rowTooltip('Proxies'), findsOneWidget);
await tester.tap(_glyph(AppGlyphs.profiles));
expect(selected, 2);
await tester.tap(find.byTooltip('Expand'));
await tester.pump();
expect(toggled, 1);
expect(find.text('Proxies'), findsOneWidget);
await tester.pumpWidget(_sidebar(onToggle: () {}));
await tester.pump(const Duration(milliseconds: 120));
final width = tester.getSize(_sidebarFinder).width;
expect(width, allOf(greaterThan(48), lessThan(220)));
expect(tester.takeException(), isNull);
await tester.pumpAndSettle();
expect(tester.getSize(_sidebarFinder).width, 220);
expect(_rowTooltip('Proxies'), findsNothing);
expect(find.byTooltip('Collapse'), findsOneWidget);
});
testWidgets('an expanded row whose label is cut off keeps its tooltip', (
tester,
) async {
const long = 'Подключения и активные соединения';
await tester.pumpWidget(
_sidebar(
destinations: const [
..._destinations,
SidebarDestination(glyph: AppGlyphs.connections, label: long),
],
onToggle: () {},
),
);
expect(_rowTooltip(long), findsOneWidget);
expect(_rowTooltip('Proxies'), findsNothing);
});
testWidgets('the toggle glyph follows the pane as it widens', (tester) async {
List<Rect> glyph(String tooltip) => [
for (final shape
in tester
.widget<GlyphIcon>(
find.descendant(
of: find.byTooltip(tooltip),
matching: find.byType(GlyphIcon),
),
)
.glyph
.shapes)
shape.path.getBounds(),
];
await tester.pumpWidget(_sidebar(expanded: false, onToggle: () {}));
final collapsed = glyph('Expand');
await tester.pumpWidget(_sidebar(onToggle: () {}));
await tester.pump(const Duration(milliseconds: 120));
final midway = glyph('Collapse');
expect(midway, isNot(collapsed));
await tester.pumpAndSettle();
expect(glyph('Collapse'), isNot(midway));
});
testWidgets('window controls keep their corner clear', (tester) async {
await tester.pumpWidget(
_sidebar(
expanded: false,
onToggle: () {},
windowControls: const Size(78, 32),
),
);
final sidebar = tester.getRect(_sidebarFinder);
expect(sidebar.width, 78);
final toggle = find.byTooltip('Expand');
expect(
tester.getTopLeft(toggle).dy,
greaterThanOrEqualTo(sidebar.top + 32),
);
expect(tester.getCenter(toggle).dx, sidebar.center.dx);
expect(tester.getCenter(_glyph(AppGlyphs.profiles)).dx, sidebar.center.dx);
final selectedItem = tester.getRect(
find
.ancestor(
of: _glyph(AppGlyphs.dashboard),
matching: find.byType(Material),
)
.first,
);
expect(selectedItem.size, const Size(54, 40));
expect(selectedItem.center.dx, sidebar.center.dx);
final folder = tester.getCenter(_glyph(AppGlyphs.profiles));
await tester.pumpWidget(
_sidebar(onToggle: () {}, windowControls: const Size(78, 32)),
);
await tester.pumpAndSettle();
expect(tester.getSize(_sidebarFinder).width, 220);
expect(tester.getCenter(_glyph(AppGlyphs.profiles)), folder);
});
group('without room to widen', () {
Future<void> openOverlay(WidgetTester tester) async {
await tester.tap(find.byTooltip('Expand'));
await tester.pumpAndSettle();
expect(find.text('Proxies'), findsNWidgets(2));
}
testWidgets('the toggle opens the pane over the content', (tester) async {
await tester.pumpWidget(_sidebar(expanded: false));
final contentWidth = tester
.getSize(find.byKey(const ValueKey('content')))
.width;
await openOverlay(tester);
expect(tester.getSize(_sidebarFinder).width, 48);
expect(
tester.getSize(find.byKey(const ValueKey('content'))).width,
contentWidth,
);
expect(_labelOpacities(tester, 'Proxies'), unorderedEquals([0, 1]));
expect(
tester.getTopLeft(find.byTooltip('Collapse')),
tester.getTopLeft(find.byTooltip('Expand')),
);
final paneEdge = tester.widget<Material>(
find
.byWidgetPredicate(
(widget) =>
widget is Material && widget.shape is BorderDirectional,
)
.first,
);
expect(
(paneEdge.shape! as BorderDirectional).end.color,
Theme.of(tester.element(_sidebarFinder)).colorScheme.outlineVariant,
);
await tester.tap(find.byTooltip('Collapse'));
await tester.pumpAndSettle();
expect(find.text('Proxies'), findsOneWidget);
});
testWidgets('the pane covers the safe area around the sidebar', (
tester,
) async {
const safePadding = EdgeInsets.only(left: 40, top: 24, bottom: 16);
await tester.pumpWidget(
_sidebar(expanded: false, safePadding: safePadding),
);
final sidebar = tester.getRect(_sidebarFinder);
expect(sidebar.width, 48 + safePadding.left);
final toggle = tester.getTopLeft(find.byTooltip('Expand'));
expect(toggle.dx, greaterThanOrEqualTo(sidebar.left + safePadding.left));
expect(toggle.dy, greaterThanOrEqualTo(sidebar.top + safePadding.top));
await openOverlay(tester);
final pane = tester.getRect(
find
.byWidgetPredicate(
(widget) =>
widget is Material && widget.shape is BorderDirectional,
)
.first,
);
expect(pane.topLeft, sidebar.topLeft);
expect(pane.height, sidebar.height);
expect(pane.width, 220 + safePadding.left);
expect(tester.getTopLeft(find.byTooltip('Collapse')), toggle);
});
testWidgets('choosing a row selects it and closes the pane', (
tester,
) async {
int? selected;
await tester.pumpWidget(
_sidebar(expanded: false, onSelected: (index) => selected = index),
);
await openOverlay(tester);
await tester.tap(_shown('Profiles'));
await tester.pumpAndSettle();
expect(selected, 2);
expect(find.text('Profiles'), findsOneWidget);
});
testWidgets('a tap outside or escape dismisses the pane', (tester) async {
await tester.pumpWidget(_sidebar(expanded: false));
await openOverlay(tester);
await tester.tapAt(const Offset(600, 300));
await tester.pumpAndSettle();
expect(find.text('Proxies'), findsOneWidget);
await openOverlay(tester);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(find.text('Proxies'), findsOneWidget);
});
testWidgets('a tooltip inside the pane lays out over it', (tester) async {
await tester.pumpWidget(_sidebar(expanded: false));
await openOverlay(tester);
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.addPointer(
location: tester.getCenter(find.byTooltip('Collapse')),
);
await tester.pump(const Duration(seconds: 1));
expect(find.text('Collapse'), findsOneWidget);
});
testWidgets('widening the window closes the pane', (tester) async {
await tester.pumpWidget(_sidebar(expanded: false));
await openOverlay(tester);
await tester.pumpWidget(_sidebar(expanded: false, onToggle: () {}));
await tester.pump();
expect(find.text('Proxies'), findsOneWidget);
});
});
}
+521
View File
@@ -0,0 +1,521 @@
import 'dart:async';
import 'dart:math';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/widgets/scaffold.dart';
import 'package:fl_clash/widgets/sheet.dart';
import 'package:fl_clash/widgets/sheet_header.dart';
import 'package:fl_clash/widgets/side_sheet.dart';
import 'package:fl_clash/widgets/snap_sheet.dart';
import 'package:flutter/gestures.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/test_app.dart';
const _mobile = Size(400, 900);
const _desktop = Size(1200, 900);
Widget _sheet(BuildContext context, ScrollController? controller) {
return CommonScaffold(
title: 'title',
body: ListView.builder(
controller: controller,
itemCount: 60,
// Past the floating header, so it can be tapped.
itemBuilder: (context, index) => index == 3
? TextButton(
onPressed: () => Navigator.of(context).pop('done'),
child: const Text('done'),
)
: SizedBox(height: 40, child: Text('$index')),
),
);
}
void main() {
Future<void> setView(WidgetTester tester, Size size) async {
tester.view.physicalSize = size;
ProviderScope.containerOf(
tester.element(find.text('open')),
).read(viewSizeProvider.notifier).value = size;
await tester.pumpAndSettle();
}
Future<Future<Object?>> openSheet(
WidgetTester tester, {
SnapSheetBuilder builder = _sheet,
}) async {
tester.view.physicalSize = _mobile;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
late Future<Object?> result;
await tester.pumpWidget(
TestApp(
wrapInProviderScope: true,
child: Builder(
builder: (context) => TextButton(
onPressed: () {
result = showSnapSheet<Object?>(context, builder: builder);
},
child: const Text('open'),
),
),
),
);
await setView(tester, _mobile);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
return result;
}
double sheetTop(WidgetTester tester) =>
tester.getTopLeft(find.byType(SheetDragHandle)).dy;
double contentOffset(WidgetTester tester) => tester
.state<ScrollableState>(find.byType(Scrollable).last)
.position
.pixels;
Finder content() => find.byType(Scrollable).last;
Future<void> close(WidgetTester tester) async {
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(seconds: 2));
}
testWidgets('a drag on a collapsed sheet expands it and leaves the content '
'where it was', (tester) async {
await openSheet(tester);
final collapsed = _mobile.height * (1 - snapSheetDetents.first);
final expanded = _mobile.height * (1 - snapSheetDetents.last);
expect(sheetTop(tester), closeTo(collapsed, 1));
await tester.drag(content(), const Offset(0, -300));
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(expanded, 1));
expect(contentOffset(tester), 0);
await tester.drag(content(), const Offset(0, -100));
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(expanded, 1));
expect(contentOffset(tester), greaterThan(0));
await close(tester);
});
testWidgets('the keyboard raises the sheet without rebuilding its content', (
tester,
) async {
var builds = 0;
await openSheet(
tester,
builder: (context, controller) {
builds++;
return _sheet(context, controller);
},
);
addTearDown(tester.view.resetViewInsets);
final shown = builds;
for (final height in [100.0, 200.0, 300.0]) {
tester.view.viewInsets = FakeViewPadding(bottom: height);
await tester.pump();
}
expect(builds, shown);
await tester.pumpAndSettle();
expect(
sheetTop(tester),
closeTo(_mobile.height * (1 - snapSheetDetents.last), 1),
);
await close(tester);
});
testWidgets('a drag down past half of the collapsed sheet closes it', (
tester,
) async {
final result = await openSheet(tester);
var closed = false;
unawaited(result.then((_) => closed = true));
await tester.drag(content(), const Offset(0, 300));
await tester.pumpAndSettle();
expect(closed, isTrue);
expect(find.text('title'), findsNothing);
await close(tester);
});
testWidgets('a short drag down springs the collapsed sheet back and a fling '
'down closes it', (tester) async {
final result = await openSheet(tester);
var closed = false;
unawaited(result.then((_) => closed = true));
final collapsed = _mobile.height * (1 - snapSheetDetents.first);
await tester.drag(content(), const Offset(0, 80));
await tester.pumpAndSettle();
expect(closed, isFalse);
expect(sheetTop(tester), closeTo(collapsed, 1));
await tester.fling(content(), const Offset(0, 80), 1500);
await tester.pumpAndSettle();
expect(closed, isTrue);
await close(tester);
});
testWidgets('a tap while the sheet settles stays off its content', (
tester,
) async {
final result = await openSheet(tester);
var closed = false;
unawaited(result.then((_) => closed = true));
await tester.fling(content(), const Offset(0, -100), 1000);
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.text('done'), warnIfMissed: false);
await tester.pumpAndSettle();
expect(closed, isFalse);
await tester.tap(find.text('done'));
await tester.pumpAndSettle();
expect(closed, isTrue);
await close(tester);
});
testWidgets('the content takes taps again once the sheet looks still', (
tester,
) async {
final result = await openSheet(tester);
var closed = false;
unawaited(result.then((_) => closed = true));
await tester.fling(content(), const Offset(0, -100), 1000);
for (var i = 0; i < 42; i++) {
await tester.pump(const Duration(milliseconds: 16));
}
await tester.tap(find.text('done'));
await tester.pump();
expect(closed, isTrue);
await close(tester);
});
testWidgets('a short flick up lands on the detent its velocity projects to', (
tester,
) async {
await openSheet(tester);
final collapsed = _mobile.height * (1 - snapSheetDetents.first);
final expanded = _mobile.height * (1 - snapSheetDetents.last);
await tester.drag(content(), const Offset(0, -60));
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(collapsed, 1));
await tester.fling(content(), const Offset(0, -60), 800);
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(expanded, 1));
await close(tester);
});
testWidgets('a hard fling up settles without overshooting the detent', (
tester,
) async {
await openSheet(tester);
final expanded = _mobile.height * (1 - snapSheetDetents.last);
await tester.fling(content(), const Offset(0, -100), 8000);
var highest = sheetTop(tester);
for (var i = 0; i < 60; i++) {
await tester.pump(const Duration(milliseconds: 16));
highest = min(highest, sheetTop(tester));
}
expect(highest, greaterThan(expanded - 1));
await close(tester);
});
testWidgets('a sideways wheel tick leaves a settling sheet on its way', (
tester,
) async {
await openSheet(tester);
final expanded = _mobile.height * (1 - snapSheetDetents.last);
final pointer = TestPointer(1, PointerDeviceKind.mouse);
await tester.sendEventToBinding(pointer.hover(tester.getCenter(content())));
await tester.sendEventToBinding(pointer.scroll(const Offset(0, 50)));
await tester.pump(const Duration(milliseconds: 30));
await tester.sendEventToBinding(pointer.scroll(const Offset(50, 0)));
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(expanded, 1));
await close(tester);
});
testWidgets('a collapsed sheet fits its content on screen', (tester) async {
await openSheet(tester);
expect(
tester.getBottomLeft(content()).dy,
lessThanOrEqualTo(_mobile.height),
);
await close(tester);
});
testWidgets('a second drag up while the sheet expands scrolls the content '
'at once and the sheet still opens', (tester) async {
await openSheet(tester);
final expanded = _mobile.height * (1 - snapSheetDetents.last);
await tester.fling(content(), const Offset(0, -100), 1000);
await tester.pump(const Duration(milliseconds: 100));
expect(sheetTop(tester), greaterThan(expanded + 1));
expect(contentOffset(tester), 0);
await tester.drag(content(), const Offset(0, -300));
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(expanded, 1));
expect(contentOffset(tester), closeTo(300 - kDragSlopDefault, 1));
await close(tester);
});
testWidgets('a drag down while the sheet expands takes it back', (
tester,
) async {
await openSheet(tester);
final collapsed = _mobile.height * (1 - snapSheetDetents.first);
await tester.fling(content(), const Offset(0, -100), 1000);
await tester.pump(const Duration(milliseconds: 100));
await tester.drag(content(), const Offset(0, 150));
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(collapsed, 1));
expect(contentOffset(tester), 0);
await close(tester);
});
testWidgets('a wheel tick on a collapsed sheet expands it and leaves the '
'content where it was', (tester) async {
await openSheet(tester);
final expanded = _mobile.height * (1 - snapSheetDetents.last);
final pointer = TestPointer(1, PointerDeviceKind.mouse);
final location = tester.getCenter(content());
await tester.sendEventToBinding(pointer.hover(location));
await tester.sendEventToBinding(pointer.scroll(const Offset(0, 50)));
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(expanded, 1));
expect(contentOffset(tester), 0);
await tester.sendEventToBinding(pointer.scroll(const Offset(0, 50)));
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(expanded, 1));
expect(contentOffset(tester), 50);
await close(tester);
});
testWidgets('a wheel tick on a sheet too short to scroll expands it', (
tester,
) async {
await openSheet(
tester,
builder: (_, controller) => CommonScaffold(
title: 'title',
body: ListView(
controller: controller,
children: const [SizedBox(height: 40, child: Text('0'))],
),
),
);
final expanded = _mobile.height * (1 - snapSheetDetents.last);
final pointer = TestPointer(1, PointerDeviceKind.mouse);
await tester.sendEventToBinding(pointer.hover(tester.getCenter(content())));
await tester.sendEventToBinding(pointer.scroll(const Offset(0, 50)));
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(expanded, 1));
await close(tester);
});
testWidgets('a wheel turn that scrolls the content to its top leaves the '
'sheet where it is, and the next turn takes it down', (tester) async {
await openSheet(tester);
final collapsed = _mobile.height * (1 - snapSheetDetents.first);
final expanded = _mobile.height * (1 - snapSheetDetents.last);
final pointer = TestPointer(1, PointerDeviceKind.mouse);
await tester.sendEventToBinding(pointer.hover(tester.getCenter(content())));
await tester.sendEventToBinding(pointer.scroll(const Offset(0, 50)));
await tester.pumpAndSettle();
await tester.sendEventToBinding(pointer.scroll(const Offset(0, 50)));
await tester.pumpAndSettle();
expect(contentOffset(tester), 50);
for (var tick = 0; tick < 4; tick++) {
await tester.sendEventToBinding(
pointer.scroll(
const Offset(0, -50),
timeStamp: Duration(seconds: 1, milliseconds: 16 * tick),
),
);
await tester.pump(const Duration(milliseconds: 16));
}
await tester.pumpAndSettle();
expect(contentOffset(tester), 0);
expect(sheetTop(tester), closeTo(expanded, 1));
await tester.sendEventToBinding(
pointer.scroll(
const Offset(0, -50),
timeStamp: const Duration(seconds: 2),
),
);
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(collapsed, 1));
await close(tester);
});
testWidgets('a settle cut short by a resize lands on the detent of the new '
'size', (tester) async {
await openSheet(tester);
await tester.drag(content(), const Offset(0, -300));
await tester.pumpAndSettle();
await tester.fling(content(), const Offset(0, 120), 900);
await tester.pump(const Duration(milliseconds: 30));
const shorter = Size(400, 500);
await setView(tester, shorter);
expect(
sheetTop(tester),
closeTo(shorter.height * (1 - snapSheetDetents.first), 1),
);
await close(tester);
});
testWidgets('the sheet follows the view across the mobile breakpoint and '
'reports the result of the form it closes in', (tester) async {
final result = await openSheet(tester);
Object? closedWith;
var closed = false;
unawaited(
result.then((value) {
closedWith = value;
closed = true;
}),
);
expect(find.byType(SheetDragHandle), findsOneWidget);
await setView(tester, _desktop);
expect(find.byType(SideSheet), findsOneWidget);
expect(find.byType(SheetDragHandle), findsNothing);
expect(find.text('title'), findsOneWidget);
await setView(tester, _mobile);
expect(find.byType(SideSheet), findsNothing);
expect(find.byType(SheetDragHandle), findsOneWidget);
expect(closed, isFalse);
await tester.tap(find.text('done'));
await tester.pumpAndSettle();
expect(closed, isTrue);
expect(closedWith, 'done');
expect(find.text('title'), findsNothing);
await close(tester);
});
testWidgets('a side sheet on a page navigator the mobile layout drops comes '
'back as a bottom sheet', (tester) async {
tester.view.physicalSize = _desktop;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
late Future<Object?> result;
final page = Builder(
key: GlobalKey(),
builder: (context) => TextButton(
onPressed: () {
result = showSnapSheet<Object?>(context, builder: _sheet);
},
child: const Text('open'),
),
);
await tester.pumpWidget(
TestApp(
wrapInProviderScope: true,
child: Consumer(
builder: (context, ref, _) => ref.watch(isMobileViewProvider)
? page
: Navigator(
pages: [MaterialPage<void>(child: page)],
onDidRemovePage: (_) {},
),
),
),
);
await setView(tester, _desktop);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.byType(SideSheet), findsOneWidget);
Object? closedWith;
var closed = false;
unawaited(
result.then((value) {
closedWith = value;
closed = true;
}),
);
await setView(tester, _mobile);
expect(find.byType(SideSheet), findsNothing);
expect(find.byType(SheetDragHandle), findsOneWidget);
expect(closed, isFalse);
await tester.tap(find.text('done'));
await tester.pumpAndSettle();
expect(closed, isTrue);
expect(closedWith, 'done');
await close(tester);
});
}
+137
View File
@@ -0,0 +1,137 @@
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
const _labels = <int, String>{0: 'One', 1: 'Two', 2: 'Three'};
Future<void> _pumpTabBar(
WidgetTester tester, {
required ValueChanged<int?> onValueChanged,
Set<int> disabledChildren = const {},
}) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusTraversalGroup(
policy: PageTraversalPolicy(),
child: PageFocusScope(
child: Center(
child: CommonTabBar<int>(
groupValue: 0,
disabledChildren: disabledChildren,
thumbColor: Colors.blue,
backgroundColor: Colors.grey,
children: {
for (final entry in _labels.entries)
entry.key: Text(entry.value),
},
onValueChanged: onValueChanged,
),
),
),
),
),
),
);
await tester.pumpAndSettle();
}
String? _focusedLabel() {
final context = FocusManager.instance.primaryFocus?.context;
if (context == null) {
return null;
}
final labels = find
.descendant(
of: find.byWidget(context.widget),
matching: find.byType(Text),
)
.evaluate()
.map((element) => (element.widget as Text).data)
.whereType<String>()
.toSet();
return labels.isEmpty ? null : labels.first;
}
bool _hasFocusRing(WidgetTester tester, String label) {
return tester
.widgetList<DecoratedBox>(
find.ancestor(
of: find.text(label).first,
matching: find.byType(DecoratedBox),
),
)
.any((box) {
final decoration = box.decoration;
return decoration is ShapeDecoration &&
decoration.shape is RoundedSuperellipseBorder &&
(decoration.shape as RoundedSuperellipseBorder).side.style ==
BorderStyle.solid;
});
}
Future<void> _focusFirstSegment(WidgetTester tester) async {
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
expect(_focusedLabel(), 'One', reason: 'tab should seed focus on a segment');
}
void main() {
testWidgets('arrow right walks from one segment to the next', (tester) async {
await _pumpTabBar(tester, onValueChanged: (_) {});
await _focusFirstSegment(tester);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(_focusedLabel(), 'Two');
});
testWidgets('the focused segment shows a focus ring', (tester) async {
await _pumpTabBar(tester, onValueChanged: (_) {});
expect(_hasFocusRing(tester, 'One'), isFalse);
await _focusFirstSegment(tester);
expect(_hasFocusRing(tester, 'One'), isTrue);
expect(_hasFocusRing(tester, 'Two'), isFalse);
});
testWidgets('activating a focused segment reports its value', (tester) async {
final values = <int?>[];
await _pumpTabBar(tester, onValueChanged: values.add);
await _focusFirstSegment(tester);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(_focusedLabel(), 'Two');
await tester.sendKeyEvent(LogicalKeyboardKey.select);
await tester.pumpAndSettle();
expect(values, <int>[1]);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(values, <int>[1, 1]);
});
testWidgets('traversal skips a disabled segment', (tester) async {
final values = <int?>[];
await _pumpTabBar(
tester,
onValueChanged: values.add,
disabledChildren: const {1},
);
await _focusFirstSegment(tester);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(_focusedLabel(), 'Three');
await tester.sendKeyEvent(LogicalKeyboardKey.select);
await tester.pumpAndSettle();
expect(values, <int>[2]);
});
}
+16 -37
View File
@@ -1,13 +1,16 @@
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/test_app.dart';
Widget _action() {
return CommonFloatingActionButton(
key: const ValueKey('action'),
onPressed: () {},
icon: const Icon(Icons.add),
icon: const GlyphIcon(AppGlyphs.add),
label: 'action',
);
}
@@ -36,9 +39,10 @@ bool _isActionFocused() {
void main() {
testWidgets('non-TV CommonScaffold keeps the Scaffold FAB', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: CommonScaffold(
appBar: AppBar(title: const Text('page')),
TestApp(
wrapInProviderScope: true,
child: CommonScaffold(
title: 'page',
isTV: false,
floatingActionButton: _action(),
body: _content(),
@@ -55,9 +59,10 @@ void main() {
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: CommonScaffold(
appBar: AppBar(title: const Text('page')),
TestApp(
wrapInProviderScope: true,
child: CommonScaffold(
title: 'page',
isTV: true,
floatingActionButton: _action(),
body: _content(),
@@ -85,13 +90,14 @@ void main() {
final outsideFocus = FocusNode();
addTearDown(outsideFocus.dispose);
await tester.pumpWidget(
MaterialApp(
home: Column(
TestApp(
wrapInProviderScope: true,
child: Column(
children: [
Focus(focusNode: outsideFocus, child: const SizedBox()),
Expanded(
child: CommonScaffold(
appBar: AppBar(title: const Text('page')),
title: 'page',
isTV: true,
floatingActionButton: _action(),
body: _content(),
@@ -109,31 +115,4 @@ void main() {
expect(_isActionFocused(), isTrue);
});
testWidgets('FloatLayout uses the same fixed top action layout on TV', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FloatLayout(
isTV: true,
floatingWidget: _action(),
child: _content(),
),
),
),
);
final action = find.byKey(const ValueKey('action'));
final firstItem = find.byKey(const ValueKey('item0'));
final initialActionTop = tester.getTopLeft(action).dy;
expect(initialActionTop, lessThan(tester.getTopLeft(firstItem).dy));
await tester.drag(firstItem, const Offset(0, -300));
await tester.pumpAndSettle();
expect(tester.getTopLeft(action).dy, initialActionTop);
});
}
+8 -5
View File
@@ -1,4 +1,5 @@
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/manager/hotkey_manager.dart';
import 'package:fl_clash/models/models.dart';
@@ -12,6 +13,8 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/glyph_finders.dart';
void main() {
testWidgets('desktop escape runs the back flow and exits search', (
tester,
@@ -47,7 +50,7 @@ void main() {
),
);
await tester.tap(find.byIcon(Icons.search));
await tester.tap(find.byGlyph(AppGlyphs.search));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
@@ -89,7 +92,7 @@ void main() {
NavigationItemsState(
value: [
NavigationItem(
icon: const Icon(Icons.space_dashboard),
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) => CommonScaffold(
key: const GlobalObjectKey(PageLabel.dashboard),
@@ -103,7 +106,7 @@ void main() {
),
),
NavigationItem(
icon: const Icon(Icons.construction),
glyph: AppGlyphs.tools,
label: PageLabel.tools,
builder: (_) => const SizedBox.shrink(),
),
@@ -133,9 +136,9 @@ void main() {
),
);
await tester.pump();
expect(find.byType(NavigationRail), findsOneWidget);
expect(find.byType(NavigationSidebar), findsOneWidget);
await tester.tap(find.byIcon(Icons.search));
await tester.tap(find.byGlyph(AppGlyphs.search));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
await tester.enterText(find.byType(TextField), 'needle');