Optimize app layout

This commit is contained in:
chen08209
2026-08-05 10:56:40 +08:00
parent 425d32b50d
commit c493df386d
27 changed files with 3639 additions and 737 deletions
+647
View File
@@ -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;
}
+384
View File
@@ -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;
}
}
+433
View File
@@ -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);
}
}
+73
View File
@@ -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,
);
}
}
+108
View File
@@ -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);
},
);
}
+123
View File
@@ -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),
);
}
}
+152
View File
@@ -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);
});
}
+139
View File
@@ -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);
});
}
+532
View File
@@ -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,
);
}
}