Optimize app layout
This commit is contained in:
@@ -0,0 +1,647 @@
|
||||
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/l10n/l10n.dart';
|
||||
import 'package:fl_clash/manager/app_manager.dart';
|
||||
import 'package:fl_clash/manager/theme_manager.dart';
|
||||
import 'package:fl_clash/manager/window_manager.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/pages/home.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/views/application_setting.dart';
|
||||
import 'package:fl_clash/views/tools.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'screen-size transition preserves current content and animates navigation',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(1200, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
navigationItemsStateProvider.overrideWithValue(
|
||||
NavigationItemsState(
|
||||
value: [
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.space_dashboard),
|
||||
label: PageLabel.dashboard,
|
||||
builder: (_) => const _StatefulContent(
|
||||
key: GlobalObjectKey(PageLabel.dashboard),
|
||||
),
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.construction),
|
||||
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 MaterialApp(home: HomePage()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final sidebarBackground = find.descendant(
|
||||
of: find.byType(AppSidebarContainer),
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) => widget is Container && widget.child is Row,
|
||||
),
|
||||
);
|
||||
final sidebarContainer = tester.widget<Container>(
|
||||
sidebarBackground.first,
|
||||
);
|
||||
expect(
|
||||
sidebarContainer.color,
|
||||
Theme.of(
|
||||
tester.element(find.byType(AppSidebarContainer)),
|
||||
).colorScheme.surfaceContainer,
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
for (var width = 1180.0; width >= 500; width -= 20) {
|
||||
tester.view.physicalSize = Size(width, 800);
|
||||
container.read(viewSizeProvider.notifier).value = Size(width, 800);
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(tester.takeException(), isNull, reason: 'width: $width');
|
||||
}
|
||||
|
||||
expect(find.text('count: 1'), findsOneWidget);
|
||||
expect(find.byType(NavigationRail), findsOneWidget);
|
||||
expect(find.byType(NavigationBar), 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),
|
||||
);
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 301));
|
||||
expect(find.byType(NavigationRail), findsOneWidget);
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'list content stays valid while resizing through the breakpoint',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(1200, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
navigationItemsStateProvider.overrideWithValue(
|
||||
NavigationItemsState(
|
||||
value: [
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.space_dashboard),
|
||||
label: PageLabel.dashboard,
|
||||
builder: (_) => const ToolsView(
|
||||
key: GlobalObjectKey(PageLabel.dashboard),
|
||||
),
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.construction),
|
||||
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(child: HomePage()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
for (var width = 1180.0; width >= 380; width -= 20) {
|
||||
tester.view.physicalSize = Size(width, 800);
|
||||
container.read(viewSizeProvider.notifier).value = Size(width, 800);
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(tester.takeException(), isNull, reason: 'width: $width');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'profile trailing controls stay valid while a maximized window restores',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(1440, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final profile = Profile.normal();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
profilesProvider.overrideWith(() => _HomeTestProfiles([profile])),
|
||||
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
|
||||
versionProvider.overrideWithBuild((_, _) => 15),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
globalState.container = container;
|
||||
container
|
||||
.read(currentPageLabelProvider.notifier)
|
||||
.toPage(PageLabel.profiles);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const _TestApp(
|
||||
child: ThemeManager(
|
||||
child: WindowHeaderContainer(child: HomePage()),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
tester.view.physicalSize = const Size(380, 900);
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'desktop navigation keeps the tools route when logs are enabled',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(1400, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
globalState.container = container;
|
||||
container.read(viewSizeProvider.notifier).value = const Size(1400, 1000);
|
||||
container.read(currentPageLabelProvider.notifier).toPage(PageLabel.tools);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const _TestApp(child: HomePage()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final applicationItem = find.text('Application');
|
||||
await tester.scrollUntilVisible(
|
||||
applicationItem,
|
||||
500,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await tester.tap(applicationItem);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(ApplicationSettingView), findsOneWidget);
|
||||
|
||||
final logItem = find.text('Logcat');
|
||||
await tester.scrollUntilVisible(
|
||||
logItem,
|
||||
500,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await tester.tap(logItem);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(container.read(appSettingProvider).openLogs, isTrue);
|
||||
expect(find.byType(ApplicationSettingView), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'desktop navigation keeps arrow traversal after keyboard page changes',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(1200, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
navigationItemsStateProvider.overrideWithValue(
|
||||
NavigationItemsState(
|
||||
value: [
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.space_dashboard),
|
||||
label: PageLabel.dashboard,
|
||||
builder: (_) => Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.more_horiz),
|
||||
),
|
||||
),
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.article),
|
||||
label: PageLabel.proxies,
|
||||
builder: (_) => Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.more_vert),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
globalState.container = container;
|
||||
container.read(viewSizeProvider.notifier).value = const Size(1200, 800);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const _TestApp(child: HomePage()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byType(NavigationRail), findsOneWidget);
|
||||
|
||||
bool focusInRail() {
|
||||
final context = FocusManager.instance.primaryFocus?.context;
|
||||
return context?.findAncestorWidgetOfExactType<NavigationRail>() != null;
|
||||
}
|
||||
|
||||
IconData? focusedRailIcon() {
|
||||
final focusNode = FocusManager.instance.primaryFocus;
|
||||
if (!focusInRail() || 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 (var i = 0; i < 30 && !focusInRail(); i++) {
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pump();
|
||||
}
|
||||
expect(focusInRail(), isTrue);
|
||||
expect(focusedRailIcon(), Icons.space_dashboard);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.pump();
|
||||
expect(focusedRailIcon(), Icons.article);
|
||||
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);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||
await tester.pump();
|
||||
expect(focusedRailIcon(), Icons.space_dashboard);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
|
||||
expect(container.read(currentPageLabelProvider), PageLabel.dashboard);
|
||||
expect(focusedRailIcon(), Icons.space_dashboard);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.pump();
|
||||
|
||||
expect(focusedRailIcon(), Icons.article);
|
||||
expect(container.read(currentPageLabelProvider), PageLabel.dashboard);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('mobile bottom navigation keeps page and highlight consistent', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(500, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
Widget page(String label) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Text('page:$label'),
|
||||
Positioned(
|
||||
left: 200,
|
||||
bottom: 0,
|
||||
child: IconButton(
|
||||
key: const ValueKey('content-action'),
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.more_horiz),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
navigationItemsStateProvider.overrideWithValue(
|
||||
NavigationItemsState(
|
||||
value: [
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.space_dashboard),
|
||||
label: PageLabel.dashboard,
|
||||
builder: (_) => page('dashboard'),
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.folder),
|
||||
label: PageLabel.profiles,
|
||||
builder: (_) => page('profiles'),
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.construction),
|
||||
label: PageLabel.tools,
|
||||
builder: (_) => page('tools'),
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.article),
|
||||
label: PageLabel.logs,
|
||||
builder: (_) => page('logs'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
globalState.container = container;
|
||||
container.read(viewSizeProvider.notifier).value = const Size(500, 800);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const _TestApp(child: HomePage()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
|
||||
NavigationBar navBar() =>
|
||||
tester.widget<NavigationBar>(find.byType(NavigationBar));
|
||||
|
||||
await tester.tap(find.byIcon(Icons.construction));
|
||||
await tester.pumpAndSettle();
|
||||
expect(container.read(currentPageLabelProvider), PageLabel.tools);
|
||||
expect(navBar().selectedIndex, 2);
|
||||
expect(find.text('page:tools'), findsOneWidget);
|
||||
|
||||
bool focusInNav() {
|
||||
final context = FocusManager.instance.primaryFocus?.context;
|
||||
return context?.findAncestorWidgetOfExactType<NavigationBar>() != null;
|
||||
}
|
||||
|
||||
for (var i = 0; i < 20 && !focusInNav(); i++) {
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pump();
|
||||
}
|
||||
expect(focusInNav(), isTrue);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pumpAndSettle();
|
||||
expect(container.read(currentPageLabelProvider), PageLabel.profiles);
|
||||
expect(navBar().selectedIndex, 1);
|
||||
expect(find.text('page:profiles'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.construction));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.tap(find.byIcon(Icons.article));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.tap(find.byIcon(Icons.folder));
|
||||
await tester.pumpAndSettle();
|
||||
expect(container.read(currentPageLabelProvider), PageLabel.profiles);
|
||||
expect(navBar().selectedIndex, 1);
|
||||
expect(find.text('page:profiles'), findsOneWidget);
|
||||
expect(focusInNav(), isTrue);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||
await tester.pump();
|
||||
final focusedIconButton = FocusManager.instance.primaryFocus?.context
|
||||
?.findAncestorWidgetOfExactType<IconButton>();
|
||||
expect(
|
||||
focusedIconButton?.key,
|
||||
const ValueKey('content-action'),
|
||||
reason: 'up from the bottom bar must enter the current page',
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'desktop tabbing past page content does not scroll the PageView',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(1200, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
Widget pageContent(String label) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('page:$label'),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(onPressed: () {}, child: const Text('button')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
navigationItemsStateProvider.overrideWithValue(
|
||||
NavigationItemsState(
|
||||
value: [
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.space_dashboard),
|
||||
label: PageLabel.dashboard,
|
||||
builder: (_) => pageContent('dashboard'),
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.folder),
|
||||
label: PageLabel.profiles,
|
||||
builder: (_) => pageContent('profiles'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
globalState.container = container;
|
||||
container.read(viewSizeProvider.notifier).value = const Size(1200, 800);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const _TestApp(child: HomePage()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byType(NavigationRail), findsOneWidget);
|
||||
|
||||
Finder railIcon(IconData icon) => find.descendant(
|
||||
of: find.byType(NavigationRail),
|
||||
matching: find.byIcon(icon),
|
||||
);
|
||||
|
||||
// Visit another page so its content stays alive in the PageView cache.
|
||||
await tester.tap(railIcon(Icons.folder));
|
||||
await tester.pumpAndSettle();
|
||||
expect(container.read(currentPageLabelProvider), PageLabel.profiles);
|
||||
await tester.tap(railIcon(Icons.space_dashboard));
|
||||
await tester.pumpAndSettle();
|
||||
expect(container.read(currentPageLabelProvider), PageLabel.dashboard);
|
||||
|
||||
bool focusInRail() {
|
||||
final context = FocusManager.instance.primaryFocus?.context;
|
||||
return context?.findAncestorWidgetOfExactType<NavigationRail>() != null;
|
||||
}
|
||||
|
||||
for (var i = 0; i < 40 && !focusInRail(); i++) {
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pump();
|
||||
expect(
|
||||
container.read(currentPageLabelProvider),
|
||||
PageLabel.dashboard,
|
||||
reason: 'tab $i flipped the page',
|
||||
);
|
||||
}
|
||||
expect(focusInRail(), isTrue);
|
||||
expect(
|
||||
find.text('page:profiles').hitTestable(),
|
||||
findsNothing,
|
||||
reason: 'focus traversal scrolled the PageView to another page',
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _TestApp extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const _TestApp({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: AppLocalizations.delegate.supportedLocales,
|
||||
builder: (context, child) {
|
||||
globalState.measure = Measure.of(context, 1);
|
||||
globalState.theme = CommonTheme.of(context, 1);
|
||||
return child!;
|
||||
},
|
||||
home: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatefulContent extends StatefulWidget {
|
||||
const _StatefulContent({super.key});
|
||||
|
||||
@override
|
||||
State<_StatefulContent> createState() => _StatefulContentState();
|
||||
}
|
||||
|
||||
class _StatefulContentState extends State<_StatefulContent> {
|
||||
var _count = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
leading: const SizedBox(width: 80),
|
||||
title: TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_count++;
|
||||
});
|
||||
},
|
||||
child: Text('count: $_count'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HomeTestProfiles extends Profiles {
|
||||
final List<Profile> initial;
|
||||
|
||||
_HomeTestProfiles(this.initial);
|
||||
|
||||
@override
|
||||
List<Profile> build() => initial;
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import 'package:fl_clash/common/constant.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/providers/app.dart';
|
||||
import 'package:fl_clash/providers/config.dart';
|
||||
import 'package:fl_clash/providers/database.dart';
|
||||
import 'package:fl_clash/providers/state.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:riverpod/riverpod.dart';
|
||||
|
||||
void main() {
|
||||
late ProviderContainer container;
|
||||
|
||||
setUp(() {
|
||||
container = ProviderContainer(
|
||||
overrides: [profilesProvider.overrideWith(_TestProfiles.new)],
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
container.dispose();
|
||||
});
|
||||
|
||||
test('group derivation sanitizes runtime state and respects clash mode', () {
|
||||
final groups = [
|
||||
const Group(
|
||||
name: 'Visible',
|
||||
type: GroupType.Selector,
|
||||
now: 'Selected',
|
||||
hidden: false,
|
||||
all: [Proxy(name: 'Selected', type: 'Direct', now: 'runtime')],
|
||||
),
|
||||
const Group(name: 'Hidden', type: GroupType.Selector, hidden: true),
|
||||
Group(name: GroupName.GLOBAL.name, type: GroupType.Selector),
|
||||
];
|
||||
container.read(groupsProvider.notifier).update((_) => groups);
|
||||
container
|
||||
.read(patchClashConfigProvider.notifier)
|
||||
.update((state) => state.copyWith(mode: Mode.rule));
|
||||
|
||||
final ruleGroups = container.read(currentGroupsStateProvider).value;
|
||||
expect(ruleGroups.map((group) => group.name), ['Visible']);
|
||||
expect(ruleGroups.single.now, isEmpty);
|
||||
expect(ruleGroups.single.all.single.now, isEmpty);
|
||||
|
||||
container
|
||||
.read(patchClashConfigProvider.notifier)
|
||||
.update((state) => state.copyWith(mode: Mode.global));
|
||||
expect(container.read(currentGroupsStateProvider).value, hasLength(3));
|
||||
|
||||
container
|
||||
.read(patchClashConfigProvider.notifier)
|
||||
.update((state) => state.copyWith(mode: Mode.direct));
|
||||
expect(container.read(currentGroupsStateProvider).value, isEmpty);
|
||||
});
|
||||
|
||||
test('navigation providers select items for width and current page', () {
|
||||
container
|
||||
.read(viewSizeProvider.notifier)
|
||||
.update((_) => Size(maxMobileWidth.toDouble(), 800));
|
||||
final mobile = container.read(currentNavigationItemsStateProvider).value;
|
||||
expect(
|
||||
mobile.map((item) => item.label),
|
||||
containsAll([PageLabel.dashboard, PageLabel.profiles, PageLabel.tools]),
|
||||
);
|
||||
expect(
|
||||
mobile.map((item) => item.label),
|
||||
isNot(contains(PageLabel.connections)),
|
||||
);
|
||||
|
||||
container
|
||||
.read(viewSizeProvider.notifier)
|
||||
.update((_) => const Size(1200, 800));
|
||||
container
|
||||
.read(currentPageLabelProvider.notifier)
|
||||
.toPage(PageLabel.connections);
|
||||
final desktop = container.read(navigationStateProvider);
|
||||
expect(desktop.viewMode, ViewMode.desktop);
|
||||
expect(desktop.currentIndex, greaterThan(0));
|
||||
expect(
|
||||
desktop.navigationItems[desktop.currentIndex].label,
|
||||
PageLabel.connections,
|
||||
);
|
||||
|
||||
container
|
||||
.read(currentPageLabelProvider.notifier)
|
||||
.toPage(PageLabel.resources);
|
||||
expect(container.read(navigationStateProvider).currentIndex, 0);
|
||||
});
|
||||
|
||||
test('layout and page state providers compose their dependencies', () {
|
||||
final profile = Profile.normal(label: 'Primary');
|
||||
_profiles(container).replace([profile]);
|
||||
container.read(currentProfileIdProvider.notifier).update((_) => profile.id);
|
||||
container
|
||||
.read(viewSizeProvider.notifier)
|
||||
.update((_) => const Size(1000, 800));
|
||||
container.read(sideWidthProvider.notifier).update((_) => 200);
|
||||
|
||||
final profiles = container.read(profilesStateProvider);
|
||||
expect(profiles.profiles.single.label, 'Primary');
|
||||
expect(profiles.currentProfileId, profile.id);
|
||||
|
||||
final dashboard = container.read(dashboardStateProvider);
|
||||
expect(dashboard.dashboardWidgets, isNotEmpty);
|
||||
|
||||
final actions = container.read(proxiesActionsStateProvider);
|
||||
expect(actions.pageLabel, PageLabel.dashboard);
|
||||
expect(actions.hasProviders, isFalse);
|
||||
expect(actions.type, ProxiesType.tab);
|
||||
});
|
||||
|
||||
test(
|
||||
'proxy list and tab providers filter groups and preserve selections',
|
||||
() {
|
||||
final profile = Profile.normal().copyWith(
|
||||
currentGroupName: 'Group B',
|
||||
unfoldSet: {'Group A'},
|
||||
);
|
||||
final groups = [
|
||||
const Group(
|
||||
name: 'Group A',
|
||||
type: GroupType.Selector,
|
||||
hidden: false,
|
||||
all: [
|
||||
Proxy(name: 'Alpha', type: 'Direct'),
|
||||
Proxy(name: 'Beta', type: 'Direct'),
|
||||
],
|
||||
),
|
||||
const Group(
|
||||
name: 'Group B',
|
||||
type: GroupType.URLTest,
|
||||
hidden: false,
|
||||
testUrl: 'https://group.test',
|
||||
all: [Proxy(name: 'Gamma', type: 'Direct')],
|
||||
),
|
||||
];
|
||||
_profiles(container).replace([profile]);
|
||||
container
|
||||
.read(currentProfileIdProvider.notifier)
|
||||
.update((_) => profile.id);
|
||||
container.read(groupsProvider.notifier).update((_) => groups);
|
||||
container
|
||||
.read(patchClashConfigProvider.notifier)
|
||||
.update((state) => state.copyWith(mode: Mode.rule));
|
||||
container
|
||||
.read(viewSizeProvider.notifier)
|
||||
.update((_) => const Size(900, 800));
|
||||
|
||||
expect(container.read(filterGroupsStateProvider('')).value, hasLength(2));
|
||||
final filtered = container.read(filterGroupsStateProvider('ALP')).value;
|
||||
expect(filtered, hasLength(1));
|
||||
expect(filtered.single.all.single.name, 'Alpha');
|
||||
|
||||
container
|
||||
.read(queryProvider(QueryTag.proxies).notifier)
|
||||
.update((_) => 'ga');
|
||||
final list = container.read(proxiesListStateProvider);
|
||||
expect(list.groups.single.name, 'Group B');
|
||||
expect(list.currentUnfoldSet, {'Group A'});
|
||||
|
||||
final tab = container.read(proxiesTabStateProvider);
|
||||
expect(tab.currentGroupName, 'Group B');
|
||||
expect(tab.groups.single.all.single.name, 'Gamma');
|
||||
final controller = container.read(proxiesTabControllerStateProvider);
|
||||
expect(controller.a, ['Group B']);
|
||||
expect(controller.b, 'Group B');
|
||||
|
||||
final selector = container.read(
|
||||
proxyGroupSelectorStateProvider('Group A', 'be'),
|
||||
);
|
||||
expect(selector.proxies.single.name, 'Beta');
|
||||
expect(selector.groupType, GroupType.Selector);
|
||||
|
||||
final missing = container.read(
|
||||
proxyGroupSelectorStateProvider('Missing', ''),
|
||||
);
|
||||
expect(missing.proxies, isEmpty);
|
||||
expect(missing.groupType, GroupType.Selector);
|
||||
},
|
||||
);
|
||||
|
||||
test('runtime, VPN, tray, and DNS states follow live state', () {
|
||||
container
|
||||
.read(runTimeProvider.notifier)
|
||||
.update((_) => DateTime(2026).millisecondsSinceEpoch);
|
||||
container
|
||||
.read(networkSettingProvider.notifier)
|
||||
.update(
|
||||
(state) => state.copyWith(
|
||||
systemProxy: false,
|
||||
bypassDomain: const ['localhost'],
|
||||
autoSetSystemDns: true,
|
||||
),
|
||||
);
|
||||
container
|
||||
.read(patchClashConfigProvider.notifier)
|
||||
.update(
|
||||
(state) => state.copyWith(
|
||||
mixedPort: 8899,
|
||||
mode: Mode.global,
|
||||
tun: state.tun.copyWith(enable: true),
|
||||
),
|
||||
);
|
||||
expect(container.read(autoSetSystemDnsStateProvider).a, isFalse);
|
||||
|
||||
container
|
||||
.read(authorizedTunEnableProvider.notifier)
|
||||
.update((_) => TunAuthorizationState.authorized);
|
||||
|
||||
final proxy = container.read(proxyStateProvider);
|
||||
expect(proxy.isStart, isTrue);
|
||||
expect(proxy.systemProxy, isFalse);
|
||||
expect(proxy.bassDomain, ['localhost']);
|
||||
expect(proxy.port, 8899);
|
||||
expect(container.read(isStartProvider), isTrue);
|
||||
|
||||
final tray = container.read(trayStateProvider);
|
||||
expect(tray.mode, Mode.global);
|
||||
expect(tray.port, 8899);
|
||||
expect(tray.tunEnable, isTrue);
|
||||
expect(tray.isStart, isTrue);
|
||||
|
||||
final vpn = container.read(vpnStateProvider);
|
||||
expect(vpn.stack, container.read(patchClashConfigProvider).tun.stack);
|
||||
expect(vpn.vpnProps, container.read(vpnSettingProvider));
|
||||
|
||||
final dns = container.read(autoSetSystemDnsStateProvider);
|
||||
expect(dns.a, isTrue);
|
||||
expect(dns.b, isTrue);
|
||||
|
||||
container
|
||||
.read(authorizedTunEnableProvider.notifier)
|
||||
.update((_) => TunAuthorizationState.unauthorized);
|
||||
expect(container.read(autoSetSystemDnsStateProvider).a, isFalse);
|
||||
|
||||
container.read(excludeSSIDsProvider.notifier).update((_) => ['Office']);
|
||||
container.read(currentSSIDProvider.notifier).update((_) => 'Office');
|
||||
expect(container.read(suspendProvider), isTrue);
|
||||
expect(container.read(proxyStateProvider).isStart, isFalse);
|
||||
});
|
||||
|
||||
test('selection and delay providers resolve groups and profile state', () {
|
||||
final profile = Profile.normal().copyWith(
|
||||
selectedMap: {'Selector': 'Leaf'},
|
||||
unfoldSet: {'Selector'},
|
||||
);
|
||||
const groups = [
|
||||
Group(
|
||||
name: 'Selector',
|
||||
type: GroupType.Selector,
|
||||
all: [Proxy(name: 'Leaf', type: 'Direct')],
|
||||
),
|
||||
];
|
||||
_profiles(container).replace([profile]);
|
||||
container.read(currentProfileIdProvider.notifier).update((_) => profile.id);
|
||||
container.read(groupsProvider.notifier).update((_) => groups);
|
||||
container
|
||||
.read(delayDataSourceProvider.notifier)
|
||||
.setDelay(
|
||||
const Delay(
|
||||
name: 'Leaf',
|
||||
url: 'https://www.gstatic.com/generate_204',
|
||||
value: 42,
|
||||
),
|
||||
);
|
||||
|
||||
expect(container.read(selectedMapProvider), {'Selector': 'Leaf'});
|
||||
expect(container.read(unfoldSetProvider), {'Selector'});
|
||||
expect(container.read(proxyNameProvider('Selector')), 'Leaf');
|
||||
expect(container.read(selectedProxyNameProvider('Selector')), 'Leaf');
|
||||
expect(
|
||||
container.read(realSelectedProxyStateProvider('Selector')).proxyName,
|
||||
'Leaf',
|
||||
);
|
||||
expect(container.read(delayProvider(proxyName: 'Selector')), 42);
|
||||
expect(
|
||||
container.read(
|
||||
proxyDescProvider(const Proxy(name: 'Selector', type: 'Selector')),
|
||||
),
|
||||
'Selector(Leaf)',
|
||||
);
|
||||
expect(
|
||||
container.read(
|
||||
proxyDescProvider(const Proxy(name: 'Leaf', type: 'Direct')),
|
||||
),
|
||||
'Direct',
|
||||
);
|
||||
});
|
||||
|
||||
test('theme and simple derived providers cover fallback branches', () {
|
||||
expect(container.read(currentBrightnessProvider), Brightness.dark);
|
||||
container
|
||||
.read(systemBrightnessProvider.notifier)
|
||||
.update((_) => Brightness.light);
|
||||
container
|
||||
.read(themeSettingProvider.notifier)
|
||||
.update((state) => state.copyWith(themeMode: ThemeMode.system));
|
||||
expect(container.read(currentBrightnessProvider), Brightness.light);
|
||||
|
||||
final fallback = container.read(
|
||||
genColorSchemeProvider(Brightness.light, ignoreConfig: true),
|
||||
);
|
||||
expect(fallback.brightness, Brightness.light);
|
||||
final explicit = container.read(
|
||||
genColorSchemeProvider(Brightness.dark, color: Colors.purple),
|
||||
);
|
||||
expect(explicit.brightness, Brightness.dark);
|
||||
|
||||
expect(
|
||||
container.read(realTestUrlProvider('https://custom.test')),
|
||||
'https://custom.test',
|
||||
);
|
||||
expect(container.read(isCurrentPageProvider(PageLabel.dashboard)), isTrue);
|
||||
expect(
|
||||
container.read(
|
||||
isCurrentPageProvider(
|
||||
PageLabel.logs,
|
||||
handler: (_, viewMode) => viewMode == ViewMode.mobile,
|
||||
),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('package, hotkey, profile, and overwrite providers expose defaults', () {
|
||||
const package = Package(
|
||||
packageName: 'app.example',
|
||||
label: 'Example',
|
||||
system: false,
|
||||
internet: true,
|
||||
lastUpdateTime: 1,
|
||||
);
|
||||
container.read(packagesProvider.notifier).update((_) => [package]);
|
||||
final packageList = container.read(packageListSelectorStateProvider);
|
||||
expect(packageList.packages, [package]);
|
||||
expect(
|
||||
packageList.accessControlProps,
|
||||
container.read(vpnSettingProvider).accessControlProps,
|
||||
);
|
||||
|
||||
const action = HotKeyAction(
|
||||
action: HotAction.start,
|
||||
key: 1,
|
||||
modifiers: {KeyboardModifier.control},
|
||||
);
|
||||
container.read(hotKeyActionsProvider.notifier).update((_) => [action]);
|
||||
expect(container.read(getHotKeyActionProvider(HotAction.start)), action);
|
||||
expect(
|
||||
container.read(getHotKeyActionProvider(HotAction.tun)).action,
|
||||
HotAction.tun,
|
||||
);
|
||||
|
||||
final profile = Profile.normal().copyWith(
|
||||
overwriteType: OverwriteType.custom,
|
||||
);
|
||||
_profiles(container).replace([profile]);
|
||||
expect(container.read(profileProvider(profile.id)), profile);
|
||||
expect(
|
||||
container.read(overwriteTypeProvider(profile.id)),
|
||||
OverwriteType.custom,
|
||||
);
|
||||
expect(container.read(overwriteTypeProvider(-1)), OverwriteType.standard);
|
||||
|
||||
expect(
|
||||
container.read(accessControlStateProvider),
|
||||
const AccessControlProps(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
_TestProfiles _profiles(ProviderContainer container) {
|
||||
return container.read(profilesProvider.notifier) as _TestProfiles;
|
||||
}
|
||||
|
||||
class _TestProfiles extends Profiles {
|
||||
@override
|
||||
List<Profile> build() => [];
|
||||
|
||||
void replace(List<Profile> profiles) {
|
||||
state = profiles;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import 'package:fl_clash/widgets/animated_visibility.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('releases horizontal layout space throughout the exit', (
|
||||
tester,
|
||||
) async {
|
||||
final visibilityKey = GlobalKey();
|
||||
final contentKey = GlobalKey();
|
||||
|
||||
Widget buildApp(bool visible) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
AnimatedVisibility.sidebar(
|
||||
key: visibilityKey,
|
||||
visible: visible,
|
||||
child: const SizedBox(width: 180, height: 80),
|
||||
),
|
||||
Expanded(child: SizedBox(key: contentKey)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await tester.pumpWidget(buildApp(true));
|
||||
expect(tester.getSize(find.byKey(contentKey)).width, 620);
|
||||
|
||||
await tester.pumpWidget(buildApp(false));
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
|
||||
final midTransitionWidth = tester.getSize(find.byKey(visibilityKey)).width;
|
||||
final midContentWidth = tester.getSize(find.byKey(contentKey)).width;
|
||||
expect(midTransitionWidth, allOf(greaterThan(0), lessThan(180)));
|
||||
expect(midContentWidth, allOf(greaterThan(620), lessThan(800)));
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 149));
|
||||
final nearEndContentWidth = tester.getSize(find.byKey(contentKey)).width;
|
||||
expect(tester.getSize(find.byKey(visibilityKey)).width, greaterThan(0));
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 2));
|
||||
|
||||
expect(tester.getSize(find.byKey(visibilityKey)).width, 0);
|
||||
expect(tester.getSize(find.byKey(contentKey)).width, 800);
|
||||
expect(800 - nearEndContentWidth, lessThan(5));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('releases vertical layout space throughout the exit', (
|
||||
tester,
|
||||
) async {
|
||||
final visibilityKey = GlobalKey();
|
||||
final contentKey = GlobalKey();
|
||||
|
||||
Widget buildApp(bool visible) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(child: SizedBox(key: contentKey)),
|
||||
AnimatedVisibility.bottomNavigation(
|
||||
key: visibilityKey,
|
||||
visible: visible,
|
||||
child: const SizedBox(width: 180, height: 80),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await tester.pumpWidget(buildApp(true));
|
||||
expect(tester.getSize(find.byKey(contentKey)).height, 520);
|
||||
|
||||
await tester.pumpWidget(buildApp(false));
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
|
||||
final midTransitionHeight = tester
|
||||
.getSize(find.byKey(visibilityKey))
|
||||
.height;
|
||||
final midContentHeight = tester.getSize(find.byKey(contentKey)).height;
|
||||
expect(midTransitionHeight, allOf(greaterThan(0), lessThan(80)));
|
||||
expect(midContentHeight, allOf(greaterThan(520), lessThan(600)));
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 149));
|
||||
final nearEndContentHeight = tester.getSize(find.byKey(contentKey)).height;
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 2));
|
||||
|
||||
expect(tester.getSize(find.byKey(visibilityKey)).height, 0);
|
||||
expect(tester.getSize(find.byKey(contentKey)).height, 600);
|
||||
expect(600 - nearEndContentHeight, lessThan(5));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('shows initially visible content without a transition', (
|
||||
tester,
|
||||
) async {
|
||||
final contentKey = GlobalKey();
|
||||
|
||||
Widget buildApp(bool visible) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
AnimatedVisibility.sidebar(
|
||||
visible: visible,
|
||||
child: SizedBox(key: contentKey, width: 180, height: 80),
|
||||
),
|
||||
const Expanded(child: SizedBox()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await tester.pumpWidget(buildApp(true));
|
||||
|
||||
expect(tester.getTopLeft(find.byKey(contentKey)), Offset.zero);
|
||||
});
|
||||
|
||||
testWidgets('fades and slides content while entering', (tester) async {
|
||||
final visibilityKey = GlobalKey();
|
||||
final contentKey = GlobalKey();
|
||||
|
||||
Widget buildApp(bool visible) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
AnimatedVisibility.sidebar(
|
||||
key: visibilityKey,
|
||||
visible: visible,
|
||||
child: SizedBox(key: contentKey, width: 180, height: 80),
|
||||
),
|
||||
const Expanded(child: SizedBox()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await tester.pumpWidget(buildApp(false));
|
||||
await tester.pumpWidget(buildApp(true));
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
|
||||
final fadeTransition = find.descendant(
|
||||
of: find.byKey(visibilityKey),
|
||||
matching: find.byType(FadeTransition),
|
||||
);
|
||||
expect(fadeTransition, findsOneWidget);
|
||||
expect(
|
||||
tester.widget<FadeTransition>(fadeTransition).opacity.value,
|
||||
allOf(greaterThan(0), lessThan(1)),
|
||||
);
|
||||
expect(tester.getTopLeft(find.byKey(contentKey)).dx, lessThan(0));
|
||||
});
|
||||
|
||||
testWidgets('fades content while exiting', (tester) async {
|
||||
final visibilityKey = GlobalKey();
|
||||
|
||||
Widget buildApp(bool visible) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
AnimatedVisibility.sidebar(
|
||||
key: visibilityKey,
|
||||
visible: visible,
|
||||
child: const SizedBox(width: 180, height: 80),
|
||||
),
|
||||
const Expanded(child: SizedBox()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await tester.pumpWidget(buildApp(true));
|
||||
await tester.pumpWidget(buildApp(false));
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
|
||||
final fadeTransition = find.descendant(
|
||||
of: find.byKey(visibilityKey),
|
||||
matching: find.byType(FadeTransition),
|
||||
);
|
||||
expect(fadeTransition, findsOneWidget);
|
||||
expect(
|
||||
tester.widget<FadeTransition>(fadeTransition).opacity.value,
|
||||
allOf(greaterThan(0), lessThan(1)),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('decelerates entry and accelerates exit', (tester) async {
|
||||
final visibilityKey = GlobalKey();
|
||||
|
||||
Widget buildApp(bool visible) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
AnimatedVisibility.sidebar(
|
||||
key: visibilityKey,
|
||||
visible: visible,
|
||||
child: const SizedBox(width: 180, height: 80),
|
||||
),
|
||||
const Expanded(child: SizedBox()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double opacity() {
|
||||
final fadeTransition = find.descendant(
|
||||
of: find.byKey(visibilityKey),
|
||||
matching: find.byType(FadeTransition),
|
||||
);
|
||||
return tester.widget<FadeTransition>(fadeTransition).opacity.value;
|
||||
}
|
||||
|
||||
await tester.pumpWidget(buildApp(false));
|
||||
await tester.pumpWidget(buildApp(true));
|
||||
await tester.pump(const Duration(milliseconds: 75));
|
||||
expect(opacity(), greaterThan(0.6));
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 225));
|
||||
await tester.pumpWidget(buildApp(false));
|
||||
await tester.pump(const Duration(milliseconds: 75));
|
||||
expect(opacity(), greaterThan(0.75));
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
expect(opacity(), greaterThan(0.5));
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 74));
|
||||
expect(opacity(), lessThan(0.1));
|
||||
});
|
||||
|
||||
testWidgets('horizontal content exits from right to left', (tester) async {
|
||||
final contentKey = GlobalKey();
|
||||
|
||||
Widget buildApp(bool visible) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
AnimatedVisibility.sidebar(
|
||||
visible: visible,
|
||||
child: SizedBox(key: contentKey, width: 180, height: 80),
|
||||
),
|
||||
const Expanded(child: SizedBox()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await tester.pumpWidget(buildApp(true));
|
||||
final visiblePosition = tester.getTopLeft(find.byKey(contentKey));
|
||||
|
||||
await tester.pumpWidget(buildApp(false));
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
|
||||
final exitingPosition = tester.getTopLeft(find.byKey(contentKey));
|
||||
expect(exitingPosition.dx, lessThan(visiblePosition.dx - 25));
|
||||
expect(exitingPosition.dy, closeTo(visiblePosition.dy, 0.01));
|
||||
});
|
||||
|
||||
testWidgets('bottom navigation remains visible while moving down', (
|
||||
tester,
|
||||
) async {
|
||||
final contentKey = GlobalKey();
|
||||
|
||||
Widget buildApp(bool visible) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
const Expanded(child: SizedBox()),
|
||||
AnimatedVisibility.bottomNavigation(
|
||||
visible: visible,
|
||||
child: SizedBox(key: contentKey, width: 180, height: 80),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await tester.pumpWidget(buildApp(true));
|
||||
final visiblePosition = tester.getTopLeft(find.byKey(contentKey));
|
||||
|
||||
await tester.pumpWidget(buildApp(false));
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
|
||||
final exitingPosition = tester.getTopLeft(find.byKey(contentKey));
|
||||
expect(exitingPosition.dx, closeTo(visiblePosition.dx, 0.01));
|
||||
expect(exitingPosition.dy, greaterThan(visiblePosition.dy + 20));
|
||||
expect(exitingPosition.dy, lessThan(600));
|
||||
});
|
||||
|
||||
testWidgets('horizontal transition clips without narrowing its child', (
|
||||
tester,
|
||||
) async {
|
||||
final visibilityKey = GlobalKey();
|
||||
final contentKey = GlobalKey();
|
||||
|
||||
Widget buildApp(bool visible) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
AnimatedVisibility.sidebar(
|
||||
key: visibilityKey,
|
||||
visible: visible,
|
||||
child: SizedBox(
|
||||
key: contentKey,
|
||||
width: 180,
|
||||
child: const ListTile(
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 16),
|
||||
leading: SizedBox(width: 80),
|
||||
title: Text('Title'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Expanded(child: SizedBox()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await tester.pumpWidget(buildApp(true));
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.pumpWidget(buildApp(false));
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
|
||||
expect(
|
||||
tester.getSize(find.byKey(visibilityKey)).width,
|
||||
allOf(greaterThan(0), lessThan(180)),
|
||||
);
|
||||
expect(tester.getSize(find.byKey(contentKey)).width, 180);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('rapid reversals never exceed the visible child width', (
|
||||
tester,
|
||||
) async {
|
||||
final visibilityKey = GlobalKey();
|
||||
|
||||
Widget buildApp(bool visible) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
AnimatedVisibility.sidebar(
|
||||
key: visibilityKey,
|
||||
visible: visible,
|
||||
child: const SizedBox(width: 180),
|
||||
),
|
||||
const Expanded(child: SizedBox()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await tester.pumpWidget(buildApp(true));
|
||||
expect(tester.getSize(find.byKey(visibilityKey)).width, 180);
|
||||
|
||||
await tester.pumpWidget(buildApp(false));
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
await tester.pumpWidget(buildApp(true));
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
await tester.pumpWidget(buildApp(false));
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
|
||||
expect(
|
||||
tester.getSize(find.byKey(visibilityKey)).width,
|
||||
lessThanOrEqualTo(180),
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('rapid reversal keeps a single child subtree', (tester) async {
|
||||
final contentKey = GlobalKey<_StatefulContentState>();
|
||||
|
||||
Widget buildApp(bool visible) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
AnimatedVisibility.sidebar(
|
||||
visible: visible,
|
||||
child: _StatefulContent(key: contentKey),
|
||||
),
|
||||
const Expanded(child: SizedBox()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await tester.pumpWidget(buildApp(true));
|
||||
final initialState = contentKey.currentState;
|
||||
await tester.pumpWidget(buildApp(false));
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
await tester.pumpWidget(buildApp(true));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(contentKey), findsOneWidget);
|
||||
expect(contentKey.currentState, same(initialState));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
class _StatefulContent extends StatefulWidget {
|
||||
const _StatefulContent({super.key});
|
||||
|
||||
@override
|
||||
State<_StatefulContent> createState() => _StatefulContentState();
|
||||
}
|
||||
|
||||
class _StatefulContentState extends State<_StatefulContent> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SizedBox(width: 180);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/common/theme.dart';
|
||||
import 'package:fl_clash/l10n/l10n.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/providers/state.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/views/dashboard/dashboard.dart';
|
||||
import 'package:fl_clash/widgets/grid.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('dashboard limits a wide grid to 12 centered columns', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(1600, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
dashboardStateProvider.overrideWithValue(
|
||||
const DashboardState(dashboardWidgets: []),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
globalState.container = container;
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const _TestApp(child: DashboardView()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final grid = find.byType(Grid);
|
||||
expect(tester.widget<Grid>(grid).crossAxisCount, 12);
|
||||
expect(tester.getSize(grid).width, 840);
|
||||
expect(tester.getTopLeft(grid).dx, 380);
|
||||
expect(tester.takeException(), null);
|
||||
});
|
||||
}
|
||||
|
||||
class _TestApp extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const _TestApp({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
navigatorKey: globalState.navigatorKey,
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: AppLocalizations.delegate.supportedLocales,
|
||||
builder: (context, child) {
|
||||
globalState.measure = Measure.of(context, 1);
|
||||
globalState.theme = CommonTheme.of(context, 1);
|
||||
return child!;
|
||||
},
|
||||
home: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'package:fl_clash/widgets/open_container.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('OpenContainer opens, closes, and returns a value', (
|
||||
tester,
|
||||
) async {
|
||||
String? result;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 160,
|
||||
height: 80,
|
||||
child: OpenContainer<String>(
|
||||
middleColor: Colors.orange,
|
||||
routeSettings: const RouteSettings(name: 'details'),
|
||||
transitionDuration: const Duration(milliseconds: 200),
|
||||
closedBuilder: (_, open) {
|
||||
return FilledButton(
|
||||
onPressed: open,
|
||||
child: const Text('Closed'),
|
||||
);
|
||||
},
|
||||
openBuilder: (_, close) {
|
||||
return Center(
|
||||
child: FilledButton(
|
||||
onPressed: () => close(returnValue: 'done'),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
);
|
||||
},
|
||||
onClosed: (value) {
|
||||
result = value;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Closed'));
|
||||
await tester.pump(const Duration(milliseconds: 80));
|
||||
expect(find.text('Close'), findsOneWidget);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Close'));
|
||||
await tester.pump(const Duration(milliseconds: 80));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(result, 'done');
|
||||
expect(find.text('Closed'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'fadeThrough opens by callback and supports interrupted reverse',
|
||||
(tester) async {
|
||||
String? result = 'unchanged';
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: OpenContainer<String>(
|
||||
tappable: false,
|
||||
useRootNavigator: true,
|
||||
transitionType: ContainerTransitionType.fadeThrough,
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
closedBuilder: (_, open) {
|
||||
return TextButton(
|
||||
onPressed: open,
|
||||
child: const Text('Open manually'),
|
||||
);
|
||||
},
|
||||
openBuilder: (_, close) {
|
||||
return TextButton(
|
||||
onPressed: () => close(),
|
||||
child: const Text('Reverse now'),
|
||||
);
|
||||
},
|
||||
onClosed: (value) {
|
||||
result = value;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open manually'));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.tap(find.text('Reverse now'));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump();
|
||||
|
||||
expect(result, isNull);
|
||||
expect(find.text('Reverse now'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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/l10n/l10n.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/providers/config.dart';
|
||||
import 'package:fl_clash/providers/database.dart';
|
||||
import 'package:fl_clash/providers/state.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/views/proxies/tab.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
late ProviderContainer globalContainer;
|
||||
late ProviderSubscription<Profile?> currentProfileSubscription;
|
||||
|
||||
setUp(() {
|
||||
final profile = Profile.normal().copyWith(currentGroupName: 'B');
|
||||
globalContainer = ProviderContainer(
|
||||
overrides: [
|
||||
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
|
||||
profilesProvider.overrideWith(() => _TestProfiles([profile])),
|
||||
currentGroupsStateProvider.overrideWithValue(
|
||||
GroupsState(value: [_group('A'), _group('B'), _group('C')]),
|
||||
),
|
||||
],
|
||||
);
|
||||
globalState.container = globalContainer;
|
||||
currentProfileSubscription = globalContainer.listen(
|
||||
currentProfileProvider,
|
||||
(_, _) {},
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
currentProfileSubscription.close();
|
||||
globalContainer.dispose();
|
||||
});
|
||||
|
||||
testWidgets('current group follows the rendered tab list', (tester) async {
|
||||
final key = GlobalKey<ProxiesTabViewState>();
|
||||
final renderedGroups = [_group('B'), _group('C')];
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
proxiesTabStateProvider.overrideWithValue(
|
||||
ProxiesTabState(
|
||||
groups: renderedGroups,
|
||||
currentGroupName: 'B',
|
||||
proxyCardType: ProxyCardType.expand,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: _TestApp(child: ProxiesTabView(key: key)),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(key.currentState?.currentGroup?.name, 'B');
|
||||
|
||||
final tabBar = tester.widget<TabBar>(find.byType(TabBar));
|
||||
tabBar.controller?.animateTo(1);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(key.currentState?.currentGroup?.name, 'C');
|
||||
expect(globalContainer.read(currentProfileProvider)?.currentGroupName, 'C');
|
||||
});
|
||||
}
|
||||
|
||||
Group _group(String name) {
|
||||
return Group(type: GroupType.Selector, name: name);
|
||||
}
|
||||
|
||||
class _TestProfiles extends Profiles {
|
||||
final List<Profile> initial;
|
||||
|
||||
_TestProfiles(this.initial);
|
||||
|
||||
@override
|
||||
List<Profile> build() => initial;
|
||||
|
||||
@override
|
||||
void put(Profile profile) {
|
||||
final next = List<Profile>.from(state);
|
||||
final index = next.indexWhere((item) => item.id == profile.id);
|
||||
if (index == -1) {
|
||||
next.add(profile);
|
||||
} else {
|
||||
next[index] = profile;
|
||||
}
|
||||
state = next;
|
||||
}
|
||||
}
|
||||
|
||||
class _TestApp extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const _TestApp({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
navigatorKey: globalState.navigatorKey,
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: AppLocalizations.delegate.supportedLocales,
|
||||
builder: (context, child) {
|
||||
globalState.measure = Measure.of(context, 1);
|
||||
globalState.theme = CommonTheme.of(context, 1);
|
||||
return child!;
|
||||
},
|
||||
home: Scaffold(body: child),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'package:fl_clash/widgets/grid.dart';
|
||||
import 'package:fl_clash/widgets/super_grid.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
GridItem _item(String label, {int crossAxisCellCount = 2}) {
|
||||
return GridItem(
|
||||
crossAxisCellCount: crossAxisCellCount,
|
||||
mainAxisCellCount: 1,
|
||||
child: SizedBox(
|
||||
key: ValueKey(label),
|
||||
height: 100,
|
||||
child: ColoredBox(
|
||||
color: Colors.blue,
|
||||
child: Center(child: Text(label)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('SuperGrid adds and deletes items while reporting updates', (
|
||||
tester,
|
||||
) async {
|
||||
final key = GlobalKey<SuperGridState>();
|
||||
var updates = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: SuperGrid(
|
||||
key: key,
|
||||
crossAxisCount: 4,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
onUpdate: () => updates++,
|
||||
children: [_item('A'), _item('B'), _item('C')],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(key.currentState!.length, 3);
|
||||
expect(find.byIcon(Icons.close), findsNWidgets(3));
|
||||
|
||||
key.currentState!.handleAdd(_item('D', crossAxisCellCount: 4));
|
||||
await tester.pump();
|
||||
expect(key.currentState!.length, 4);
|
||||
expect(find.byKey(const ValueKey('D')), findsOneWidget);
|
||||
|
||||
final deleteButton = tester.widget<IconButton>(
|
||||
find.ancestor(
|
||||
of: find.byIcon(Icons.close).at(1),
|
||||
matching: find.byType(IconButton),
|
||||
),
|
||||
);
|
||||
deleteButton.onPressed!();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 301));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 421));
|
||||
await tester.pump();
|
||||
expect(key.currentState!.length, 3);
|
||||
expect(find.byKey(const ValueKey('B')), findsNothing);
|
||||
expect(updates, greaterThanOrEqualTo(2));
|
||||
expect(tester.takeException(), null);
|
||||
});
|
||||
|
||||
testWidgets('SuperGrid handles a desktop drag and keeps items mounted', (
|
||||
tester,
|
||||
) async {
|
||||
final key = GlobalKey<SuperGridState>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: SuperGrid(
|
||||
key: key,
|
||||
crossAxisCount: 4,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
children: [_item('A'), _item('B'), _item('C'), _item('D')],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byKey(const ValueKey('A'))),
|
||||
);
|
||||
await tester.pump();
|
||||
await gesture.moveBy(const Offset(10, 0));
|
||||
await tester.pump();
|
||||
await gesture.moveTo(tester.getCenter(find.byKey(const ValueKey('D'))));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await gesture.up();
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
|
||||
expect(key.currentState!.length, 4);
|
||||
for (final label in ['A', 'B', 'C', 'D']) {
|
||||
expect(find.byKey(ValueKey(label)), findsOneWidget);
|
||||
}
|
||||
expect(tester.takeException(), null);
|
||||
});
|
||||
|
||||
testWidgets('SuperGrid completes a pending drop when disposed', (
|
||||
tester,
|
||||
) async {
|
||||
final key = GlobalKey<SuperGridState>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: SuperGrid(
|
||||
key: key,
|
||||
crossAxisCount: 4,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
children: [_item('A'), _item('B'), _item('C'), _item('D')],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byKey(const ValueKey('A'))),
|
||||
);
|
||||
await tester.pump();
|
||||
await gesture.moveBy(const Offset(10, 0));
|
||||
await tester.pump();
|
||||
await gesture.moveTo(tester.getCenter(find.byKey(const ValueKey('D'))));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
|
||||
final transformCompleted = key.currentState!.isTransformCompleter;
|
||||
await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink()));
|
||||
|
||||
expect(await transformCompleted, isFalse);
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
expect(tester.takeException(), null);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
Widget _action() {
|
||||
return CommonFloatingActionButton(
|
||||
key: const ValueKey('action'),
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.add),
|
||||
label: 'action',
|
||||
);
|
||||
}
|
||||
|
||||
Widget _content() {
|
||||
return ListView(
|
||||
children: [
|
||||
for (var index = 0; index < 20; index++)
|
||||
SizedBox(
|
||||
height: 80,
|
||||
child: TextButton(
|
||||
key: ValueKey('item$index'),
|
||||
onPressed: () {},
|
||||
child: Text('item $index'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
bool _isActionFocused() {
|
||||
final context = FocusManager.instance.primaryFocus?.context;
|
||||
return context?.findAncestorWidgetOfExactType<FloatingActionButton>() != null;
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('non-TV CommonScaffold keeps the Scaffold FAB', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: CommonScaffold(
|
||||
appBar: AppBar(title: const Text('page')),
|
||||
isTV: false,
|
||||
floatingActionButton: _action(),
|
||||
body: _content(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final scaffold = tester.widget<Scaffold>(find.byType(Scaffold));
|
||||
|
||||
expect(scaffold.floatingActionButton, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('CommonScaffold pins the FAB above scrollable content on TV', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: CommonScaffold(
|
||||
appBar: AppBar(title: const Text('page')),
|
||||
isTV: true,
|
||||
floatingActionButton: _action(),
|
||||
body: _content(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final scaffold = tester.widget<Scaffold>(find.byType(Scaffold));
|
||||
final action = find.byKey(const ValueKey('action'));
|
||||
final firstItem = find.byKey(const ValueKey('item0'));
|
||||
final initialActionTop = tester.getTopLeft(action).dy;
|
||||
|
||||
expect(scaffold.floatingActionButton, isNull);
|
||||
expect(initialActionTop, lessThan(tester.getTopLeft(firstItem).dy));
|
||||
|
||||
await tester.drag(firstItem, const Offset(0, -300));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.getTopLeft(action).dy, initialActionTop);
|
||||
});
|
||||
|
||||
testWidgets('TV top action is the first focus target in page content', (
|
||||
tester,
|
||||
) async {
|
||||
final outsideFocus = FocusNode();
|
||||
addTearDown(outsideFocus.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Column(
|
||||
children: [
|
||||
Focus(focusNode: outsideFocus, child: const SizedBox()),
|
||||
Expanded(
|
||||
child: CommonScaffold(
|
||||
appBar: AppBar(title: const Text('page')),
|
||||
isTV: true,
|
||||
floatingActionButton: _action(),
|
||||
body: _content(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
outsideFocus.requestFocus();
|
||||
await tester.pump();
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pump();
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
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/l10n/l10n.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/providers/app.dart';
|
||||
import 'package:fl_clash/providers/config.dart';
|
||||
import 'package:fl_clash/providers/database.dart';
|
||||
import 'package:fl_clash/providers/state.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/views/config/advanced.dart';
|
||||
import 'package:fl_clash/views/config/dns.dart';
|
||||
import 'package:fl_clash/views/config/general.dart';
|
||||
import 'package:fl_clash/views/config/network.dart';
|
||||
import 'package:fl_clash/views/config/on_demand.dart';
|
||||
import 'package:fl_clash/views/hotkey.dart';
|
||||
import 'package:fl_clash/views/profiles/overwrite/custom/groups.dart';
|
||||
import 'package:fl_clash/views/profiles/overwrite/custom/proxies.dart';
|
||||
import 'package:fl_clash/views/profiles/overwrite/custom/proxy_providers.dart';
|
||||
import 'package:fl_clash/views/profiles/overwrite/custom/rules.dart';
|
||||
import 'package:fl_clash/views/proxies/list.dart';
|
||||
import 'package:fl_clash/views/proxies/tab.dart';
|
||||
import 'package:fl_clash/views/theme.dart';
|
||||
import 'package:fl_clash/views/views.dart';
|
||||
import 'package:fl_clash/widgets/inherited.dart';
|
||||
import 'package:fl_clash/widgets/sheet.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smooth_sheets/smooth_sheets.dart';
|
||||
|
||||
void main() {
|
||||
final cases = <String, Widget>{
|
||||
'dashboard': const DashboardView(),
|
||||
'proxies': const ProxiesView(),
|
||||
'profiles': const ProfilesView(),
|
||||
'requests': const RequestsView(),
|
||||
'resources': const ResourcesView(),
|
||||
'logs': const LogsView(),
|
||||
'tools': const ToolsView(),
|
||||
'basic config': const ConfigView(),
|
||||
'dns config': const Scaffold(body: DnsListView()),
|
||||
'network config': const Scaffold(body: NetworkListView()),
|
||||
'advanced config': const AdvancedConfigView(),
|
||||
'on demand config': const OnDemandView(),
|
||||
'theme': const ThemeView(),
|
||||
'application settings': const ApplicationSettingView(),
|
||||
'backup and restore': const BackupAndRestore(),
|
||||
'hotkeys': const HotKeyView(),
|
||||
'access control': const AccessView(),
|
||||
};
|
||||
|
||||
for (final entry in cases.entries) {
|
||||
testWidgets('${entry.key} renders its default state', (tester) async {
|
||||
tester.view.physicalSize = const Size(1400, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [profilesProvider.overrideWith(_TestProfiles.new)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
globalState.container = container;
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: _TestApp(child: entry.value),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
if (entry.key == 'access control') {
|
||||
await tester.pump(const Duration(milliseconds: 301));
|
||||
}
|
||||
final scrollables = find.byType(Scrollable);
|
||||
if (scrollables.evaluate().isNotEmpty) {
|
||||
for (var index = 0; index < 8; index++) {
|
||||
await tester.drag(scrollables.first, const Offset(0, -700));
|
||||
await tester.pump();
|
||||
}
|
||||
}
|
||||
|
||||
expect(find.byWidget(entry.value), findsOneWidget);
|
||||
expect(tester.takeException(), null);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
});
|
||||
}
|
||||
|
||||
final toolDestinations = <String, Type>{
|
||||
'Theme': ThemeView,
|
||||
'Backup and Restore': BackupAndRestore,
|
||||
'Basic configuration': ConfigView,
|
||||
'Advanced configuration': AdvancedConfigView,
|
||||
'Application': ApplicationSettingView,
|
||||
};
|
||||
|
||||
for (final entry in toolDestinations.entries) {
|
||||
testWidgets('tools opens ${entry.key}', (tester) async {
|
||||
tester.view.physicalSize = const Size(1400, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [profilesProvider.overrideWith(_TestProfiles.new)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
globalState.container = container;
|
||||
container
|
||||
.read(viewSizeProvider.notifier)
|
||||
.update((_) => const Size(1400, 1000));
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const _TestApp(child: ToolsView()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final target = find.text(entry.key);
|
||||
await tester.scrollUntilVisible(
|
||||
target,
|
||||
500,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await tester.tap(target);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(entry.value), findsOneWidget);
|
||||
expect(tester.takeException(), null);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('user agent dialog applies a preset', (tester) async {
|
||||
tester.view.physicalSize = const Size(1000, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [profilesProvider.overrideWith(_TestProfiles.new)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
globalState.container = container;
|
||||
container
|
||||
.read(viewSizeProvider.notifier)
|
||||
.update((_) => const Size(1000, 800));
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: _TestApp(
|
||||
child: Scaffold(body: ListView(children: const [UaItem()])),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('User-Agent'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('clash-verge/v2.4.2'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('clash-verge/v2.4.2'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
container.read(patchClashConfigProvider).globalUa,
|
||||
'clash-verge/v2.4.2',
|
||||
);
|
||||
expect(tester.takeException(), null);
|
||||
});
|
||||
|
||||
testWidgets('DNS mode options update the patch configuration', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(1000, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [profilesProvider.overrideWith(_TestProfiles.new)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
globalState.container = container;
|
||||
container
|
||||
.read(viewSizeProvider.notifier)
|
||||
.update((_) => const Size(1000, 800));
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const _TestApp(child: Scaffold(body: DnsModeItem())),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('DNS mode'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('fakeIp'), findsWidgets);
|
||||
|
||||
await tester.tap(find.text('fakeIp').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
container.read(patchClashConfigProvider).dns.enhancedMode,
|
||||
DnsMode.fakeIp,
|
||||
);
|
||||
|
||||
final previousOverride = container.read(overrideDnsProvider);
|
||||
final previousDns = container.read(patchClashConfigProvider).dns;
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const _TestApp(
|
||||
child: Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
OverrideItem(),
|
||||
StatusItem(),
|
||||
PreferH3Item(),
|
||||
IPv6Item(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Override Dns'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Status'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('PreferH3'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('IPv6'));
|
||||
await tester.pump();
|
||||
|
||||
expect(container.read(overrideDnsProvider), !previousOverride);
|
||||
expect(
|
||||
container.read(patchClashConfigProvider).dns.enable,
|
||||
!previousDns.enable,
|
||||
);
|
||||
expect(
|
||||
container.read(patchClashConfigProvider).dns.preferH3,
|
||||
!previousDns.preferH3,
|
||||
);
|
||||
expect(
|
||||
container.read(patchClashConfigProvider).dns.ipv6,
|
||||
!previousDns.ipv6,
|
||||
);
|
||||
expect(tester.takeException(), null);
|
||||
});
|
||||
|
||||
testWidgets('proxies renders populated tab and list layouts', (tester) async {
|
||||
tester.view.physicalSize = const Size(1400, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final profile = Profile.normal().copyWith(
|
||||
currentGroupName: 'Selector',
|
||||
selectedMap: {'Selector': 'Proxy 1'},
|
||||
unfoldSet: {'Selector'},
|
||||
);
|
||||
final proxies = List.generate(
|
||||
24,
|
||||
(index) => Proxy(name: 'Proxy $index', type: 'Direct'),
|
||||
);
|
||||
final group = Group(
|
||||
name: 'Selector',
|
||||
type: GroupType.Selector,
|
||||
hidden: false,
|
||||
now: 'Proxy 1',
|
||||
all: proxies,
|
||||
);
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
profilesProvider.overrideWith(() => _TestProfiles([profile])),
|
||||
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
|
||||
currentGroupsStateProvider.overrideWithValue(
|
||||
GroupsState(value: [group]),
|
||||
),
|
||||
groupsProvider.overrideWithValue([group]),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
globalState.container = container;
|
||||
container
|
||||
.read(viewSizeProvider.notifier)
|
||||
.update((_) => const Size(1400, 1000));
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const _TestApp(child: ProxiesView()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(container.read(proxiesTabStateProvider).groups, [group]);
|
||||
expect(find.byType(ProxiesTabView), findsOneWidget);
|
||||
expect(find.byType(ProxyGroupView), findsOneWidget);
|
||||
|
||||
container
|
||||
.read(proxiesStyleSettingProvider.notifier)
|
||||
.update((state) => state.copyWith(type: ProxiesType.list));
|
||||
await tester.pump();
|
||||
expect(find.byType(ProxiesListView), findsOneWidget);
|
||||
|
||||
final scrollables = find.byType(Scrollable);
|
||||
for (var index = 0; index < 8; index++) {
|
||||
await tester.drag(
|
||||
scrollables.last,
|
||||
const Offset(0, -700),
|
||||
warnIfMissed: false,
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
expect(tester.takeException(), null);
|
||||
});
|
||||
|
||||
testWidgets('custom overwrite editors render populated data', (tester) async {
|
||||
tester.view.physicalSize = const Size(1400, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final profile = Profile.normal().copyWith(
|
||||
overwriteType: OverwriteType.custom,
|
||||
);
|
||||
final proxyGroups = List.generate(
|
||||
8,
|
||||
(index) => ProxyGroup(
|
||||
id: 100 + index,
|
||||
profileId: profile.id,
|
||||
name: 'Group $index',
|
||||
type: GroupType.Selector,
|
||||
proxies: const ['DIRECT'],
|
||||
),
|
||||
);
|
||||
final rules = List.generate(
|
||||
12,
|
||||
(index) => Rule(
|
||||
id: 200 + index,
|
||||
content: 'example$index.com',
|
||||
ruleTarget: 'DIRECT',
|
||||
order: index.toString(),
|
||||
),
|
||||
);
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
profilesProvider.overrideWith(() => _TestProfiles([profile])),
|
||||
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
|
||||
profileCustomRulesProvider.overrideWith2(
|
||||
(_) => _TestProfileCustomRules(rules),
|
||||
),
|
||||
proxyGroupsProvider.overrideWith2((_) => _TestProxyGroups(proxyGroups)),
|
||||
proxyGroupProvider.overrideWithBuild((_, _) => proxyGroups.first),
|
||||
clashConfigProvider(profile.id).overrideWithValue(
|
||||
const AsyncData(
|
||||
ClashConfig(
|
||||
proxies: [Proxy(name: 'DIRECT', type: 'Direct')],
|
||||
proxyProviders: ['provider'],
|
||||
),
|
||||
),
|
||||
),
|
||||
customOverwriteDateProvider(profile.id).overrideWithValue(
|
||||
CustomOverwriteDate(
|
||||
proxies: const [Proxy(name: 'DIRECT', type: 'Direct')],
|
||||
proxyGroups: proxyGroups,
|
||||
proxyProviders: const {'provider'},
|
||||
ruleTargets: {
|
||||
...RuleTarget.baseTargets,
|
||||
...proxyGroups.map((group) => group.name),
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
globalState.container = container;
|
||||
container
|
||||
.read(viewSizeProvider.notifier)
|
||||
.update((_) => const Size(1400, 1000));
|
||||
|
||||
final views = <Widget>[
|
||||
CustomRulesView(profile.id),
|
||||
CustomProxyGroupsView(profile.id),
|
||||
SheetProvider(
|
||||
type: SheetType.page,
|
||||
child: ProfileIdProvider(
|
||||
profileId: profile.id,
|
||||
child: const EditProxiesView(),
|
||||
),
|
||||
),
|
||||
SheetProvider(
|
||||
type: SheetType.page,
|
||||
child: ProfileIdProvider(
|
||||
profileId: profile.id,
|
||||
child: const EditProxyProvidersView(),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
for (final view in views) {
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: _TestApp(child: view),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byWidget(view), findsOneWidget);
|
||||
expect(tester.takeException(), null);
|
||||
|
||||
if (view is CustomRulesView) {
|
||||
final list = tester.widget<ReorderableListView>(
|
||||
find.byType(ReorderableListView),
|
||||
);
|
||||
list.onReorderItem!(0, 1);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byType(Checkbox).first);
|
||||
await tester.pump();
|
||||
expect(find.text('Select all'), findsOneWidget);
|
||||
await tester.tap(find.text('Select all'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Select all'));
|
||||
await tester.pump();
|
||||
expect(find.text('Add'), findsOneWidget);
|
||||
|
||||
container
|
||||
.read(viewSizeProvider.notifier)
|
||||
.update((_) => const Size(500, 1000));
|
||||
await tester.tap(find.text('Add'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(SheetViewport), findsOneWidget);
|
||||
globalState.navigatorKey.currentState!.pop();
|
||||
await tester.pumpAndSettle();
|
||||
container
|
||||
.read(viewSizeProvider.notifier)
|
||||
.update((_) => const Size(1400, 1000));
|
||||
}
|
||||
|
||||
if (view is CustomProxyGroupsView) {
|
||||
final list = tester.widget<ReorderableListView>(
|
||||
find.byType(ReorderableListView),
|
||||
);
|
||||
list.onReorderItem!(0, 1);
|
||||
await tester.pump();
|
||||
|
||||
container
|
||||
.read(viewSizeProvider.notifier)
|
||||
.update((_) => const Size(500, 1000));
|
||||
await tester.tap(find.text('Add'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(SheetViewport), findsOneWidget);
|
||||
globalState.navigatorKey.currentState!.pop();
|
||||
await tester.pumpAndSettle();
|
||||
container
|
||||
.read(viewSizeProvider.notifier)
|
||||
.update((_) => const Size(1400, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
});
|
||||
}
|
||||
|
||||
class _TestProfiles extends Profiles {
|
||||
final List<Profile> initial;
|
||||
|
||||
_TestProfiles([this.initial = const []]);
|
||||
|
||||
@override
|
||||
List<Profile> build() => initial;
|
||||
}
|
||||
|
||||
class _TestProfileCustomRules extends ProfileCustomRules {
|
||||
final List<Rule> initial;
|
||||
|
||||
_TestProfileCustomRules(this.initial);
|
||||
|
||||
@override
|
||||
Stream<List<Rule>> build(int profileId) => Stream.value(initial);
|
||||
|
||||
@override
|
||||
void order(int oldIndex, int newIndex) {}
|
||||
}
|
||||
|
||||
class _TestProxyGroups extends ProxyGroups {
|
||||
final List<ProxyGroup> initial;
|
||||
|
||||
_TestProxyGroups(this.initial);
|
||||
|
||||
@override
|
||||
Stream<List<ProxyGroup>> build(int profileId) => Stream.value(initial);
|
||||
|
||||
@override
|
||||
void order(int oldIndex, int newIndex) {}
|
||||
}
|
||||
|
||||
class _TestApp extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const _TestApp({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
navigatorKey: globalState.navigatorKey,
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: AppLocalizations.delegate.supportedLocales,
|
||||
builder: (context, child) {
|
||||
globalState.measure = Measure.of(context, 1);
|
||||
globalState.theme = CommonTheme.of(context, 1);
|
||||
return child!;
|
||||
},
|
||||
home: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user