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:
@@ -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', (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user