Optimize back navigation

This commit is contained in:
chen08209
2026-08-05 10:58:23 +08:00
parent 1cffeaa29e
commit 4551e57ff4
20 changed files with 1020 additions and 187 deletions
+165
View File
@@ -11,6 +11,7 @@ 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:fl_clash/widgets/widgets.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -547,6 +548,144 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('switching home pages exits a generic search layer', (
tester,
) async {
tester.view.physicalSize = const Size(500, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
var query = '';
final container = ProviderContainer(
overrides: [
navigationItemsStateProvider.overrideWithValue(
NavigationItemsState(
value: [
NavigationItem(
icon: const Icon(Icons.space_dashboard),
label: PageLabel.dashboard,
builder: (_) => CommonScaffold(
title: 'Search page',
searchState: AppBarSearchState(
onSearch: (value) {
query = value;
},
),
body: const SizedBox(),
),
),
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(500, 800);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const _TestApp(child: HomePage()),
),
);
await tester.pump();
await tester.tap(find.byIcon(Icons.search));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
await tester.enterText(find.byType(TextField), 'needle');
expect(query, 'needle');
await tester.tap(find.byIcon(Icons.construction));
await tester.pumpAndSettle();
expect(query, isEmpty);
await tester.tap(find.byIcon(Icons.space_dashboard));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsNothing);
});
testWidgets('desktop nested route inherits home page activity', (
tester,
) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
var query = '';
final container = ProviderContainer(
overrides: [
navigationItemsStateProvider.overrideWithValue(
NavigationItemsState(
value: [
NavigationItem(
icon: const Icon(Icons.space_dashboard),
label: PageLabel.dashboard,
builder: (_) => _NestedSearchLauncher(
onSearch: (value) {
query = value;
},
),
),
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();
await tester.tap(find.text('Open nested search'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.search));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'needle');
expect(query, 'needle');
final navigationRail = find.byType(NavigationRail);
await tester.tap(
find.descendant(
of: navigationRail,
matching: find.byIcon(Icons.construction),
),
);
await tester.pumpAndSettle();
expect(query, isEmpty);
await tester.tap(
find.descendant(
of: navigationRail,
matching: find.byIcon(Icons.space_dashboard),
),
);
await tester.pumpAndSettle();
expect(find.text('Nested search'), findsOneWidget);
expect(find.byType(TextField), findsNothing);
});
testWidgets(
'desktop tabbing past page content does not scroll the PageView',
(tester) async {
@@ -711,6 +850,32 @@ class _StatefulContentState extends State<_StatefulContent> {
}
}
class _NestedSearchLauncher extends StatelessWidget {
final ValueChanged<String> onSearch;
const _NestedSearchLauncher({required this.onSearch});
@override
Widget build(BuildContext context) {
return Center(
child: FilledButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => CommonScaffold(
title: 'Nested search',
searchState: AppBarSearchState(onSearch: onSearch),
body: const SizedBox(),
),
),
);
},
child: const Text('Open nested search'),
),
);
}
}
class _HomeTestProfiles extends Profiles {
final List<Profile> initial;
-14
View File
@@ -219,20 +219,6 @@ void main() {
});
});
group('BackBlock provider', () {
test('default is false', () {
expect(container.read(backBlockProvider), false);
});
test('can block and unblock back navigation', () {
container.read(backBlockProvider.notifier).backBlock();
expect(container.read(backBlockProvider), true);
container.read(backBlockProvider.notifier).unBackBlock();
expect(container.read(backBlockProvider), false);
});
});
group('Version provider', () {
test('default is 0', () {
expect(container.read(versionProvider), 0);
+453
View File
@@ -0,0 +1,453 @@
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/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/views/access.dart';
import 'package:fl_clash/views/dashboard/dashboard.dart';
import 'package:fl_clash/views/logs.dart';
import 'package:fl_clash/widgets/widgets.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('back layers are consumed from inner to outer', (tester) async {
var innerActive = true;
var outerActive = true;
var innerBackCount = 0;
var outerBackCount = 0;
var rootBackCount = 0;
await tester.pumpWidget(
MaterialApp(
home: CommonPopScope(
onPop: (_) {
rootBackCount++;
return false;
},
child: StatefulBuilder(
builder: (context, setState) {
Widget child = const SizedBox();
if (innerActive) {
child = BackLayerScope(
onBack: () {
innerBackCount++;
setState(() {
innerActive = false;
});
},
child: child,
);
}
if (outerActive) {
child = BackLayerScope(
onBack: () {
outerBackCount++;
setState(() {
outerActive = false;
});
},
child: child,
);
}
return child;
},
),
),
),
);
await tester.pumpAndSettle();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect((innerBackCount, outerBackCount, rootBackCount), (1, 0, 0));
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect((innerBackCount, outerBackCount, rootBackCount), (1, 1, 0));
await tester.binding.handlePopRoute();
await tester.pump();
expect((innerBackCount, outerBackCount, rootBackCount), (1, 1, 1));
});
testWidgets(
'a pending inactive sync is cancelled when the page reactivates',
(tester) async {
final isActive = ValueNotifier(true);
addTearDown(isActive.dispose);
final pendingCallbacks = <void Function(Duration)>[];
var backCount = 0;
var rootBackCount = 0;
await tester.pumpWidget(
MaterialApp(
home: CommonPopScope(
onPop: (_) {
rootBackCount++;
return false;
},
child: _PageActivityTestScope(
isActive: isActive,
child: BackLayerScope(
onBack: () {
backCount++;
},
schedulePostFrameCallback: pendingCallbacks.add,
child: const SizedBox(),
),
),
),
),
);
await tester.pump();
expect(pendingCallbacks, hasLength(1));
pendingCallbacks.removeAt(0)(Duration.zero);
await tester.pump();
isActive.value = false;
await tester.pump();
isActive.value = true;
await tester.pump();
expect(pendingCallbacks, hasLength(2));
for (final callback in pendingCallbacks.toList()) {
callback(Duration.zero);
}
pendingCallbacks.clear();
await tester.pump();
expect(backCount, 0);
await tester.binding.handlePopRoute();
await tester.pump();
expect(backCount, 1);
expect(rootBackCount, 0);
await tester.binding.handlePopRoute();
await tester.pump();
expect(backCount, 1);
expect(rootBackCount, 1);
},
);
testWidgets('disposing a back layer does not invoke its callback', (
tester,
) async {
final showLayer = ValueNotifier(true);
addTearDown(showLayer.dispose);
var backCount = 0;
await tester.pumpWidget(
MaterialApp(
home: ValueListenableBuilder(
valueListenable: showLayer,
builder: (_, value, _) {
if (!value) {
return const SizedBox();
}
return BackLayerScope(
onBack: () {
backCount++;
},
child: const SizedBox(),
);
},
),
),
);
await tester.pumpAndSettle();
showLayer.value = false;
await tester.pumpAndSettle();
expect(backCount, 0);
});
testWidgets('system back exits search without reaching the root fallback', (
tester,
) async {
final container = ProviderContainer();
addTearDown(container.dispose);
globalState.container = container;
var rootBackCount = 0;
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: AppLocalizations.delegate.supportedLocales,
home: CommonPopScope(
onPop: (_) {
rootBackCount++;
return false;
},
child: CommonScaffold(
title: 'Logs',
searchState: AppBarSearchState(onSearch: (_) {}),
body: const SizedBox(),
),
),
),
),
);
await tester.tap(find.byIcon(Icons.search));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
await tester.binding.handlePopRoute();
await tester.pump();
expect(find.byType(TextField), findsNothing);
expect(rootBackCount, 0);
});
testWidgets('inactive page scope exits the kept search layer', (
tester,
) async {
final container = ProviderContainer();
addTearDown(container.dispose);
globalState.container = container;
final isActive = ValueNotifier(true);
addTearDown(isActive.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: AppLocalizations.delegate.supportedLocales,
home: _PageActivityTestScope(
isActive: isActive,
child: const LogsView(),
),
),
),
);
await tester.tap(find.byIcon(Icons.search));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
isActive.value = false;
await tester.pumpAndSettle();
expect(find.byType(TextField), findsNothing);
});
testWidgets('inactive page scope exits dashboard edit layer', (tester) async {
final container = ProviderContainer(
overrides: [
dashboardStateProvider.overrideWithValue(
const DashboardState(dashboardWidgets: []),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
final isActive = ValueNotifier(true);
addTearDown(isActive.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: _DashboardTestApp(
child: _PageActivityTestScope(
isActive: isActive,
child: const DashboardView(),
),
),
),
);
await tester.pump();
await tester.tap(find.byKey(const ValueKey('edit-icon')));
await tester.pump(const Duration(milliseconds: 500));
expect(find.byKey(const ValueKey('save-icon')), findsOneWidget);
isActive.value = false;
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(find.byKey(const ValueKey('edit-icon')), findsOneWidget);
});
testWidgets('inactive page scope exits access search layer', (tester) async {
final container = ProviderContainer();
addTearDown(container.dispose);
globalState.container = container;
final isActive = ValueNotifier(true);
addTearDown(isActive.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: _DashboardTestApp(
child: _PageActivityTestScope(
isActive: isActive,
child: const AccessView(),
),
),
),
);
await tester.pump(const Duration(milliseconds: 301));
await tester.tap(find.byIcon(Icons.more_vert));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.search));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
isActive.value = false;
await tester.pumpAndSettle();
expect(find.byType(TextField), findsNothing);
});
testWidgets('save and system back cannot re-enter dashboard edit mode', (
tester,
) async {
final container = ProviderContainer(
overrides: [
dashboardStateProvider.overrideWithValue(
const DashboardState(dashboardWidgets: []),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const _DashboardTestApp(),
),
);
await tester.pump();
await tester.tap(find.byKey(const ValueKey('edit-icon')));
await tester.pump(const Duration(milliseconds: 500));
expect(find.byKey(const ValueKey('save-icon')), findsOneWidget);
tester.widget<IconButton>(find.byKey(const ValueKey(true))).onPressed!();
await tester.binding.handlePopRoute();
await tester.pump(const Duration(milliseconds: 500));
expect(find.byKey(const ValueKey('edit-icon')), findsOneWidget);
});
testWidgets('system back preserves a pending dashboard deletion', (
tester,
) async {
final container = ProviderContainer(
overrides: [
dashboardStateProvider.overrideWithValue(
const DashboardState(
dashboardWidgets: [
DashboardWidget.networkSpeed,
DashboardWidget.outboundModeV2,
],
),
),
],
);
addTearDown(container.dispose);
final appSettingSubscription = container.listen(
appSettingProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(appSettingSubscription.close);
globalState.container = container;
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const _DashboardTestApp(),
),
);
await tester.pump();
await tester.tap(find.byKey(const ValueKey('edit-icon')));
await tester.pump(const Duration(milliseconds: 500));
final deleteButton = find.ancestor(
of: find.byIcon(Icons.close).first,
matching: find.byType(IconButton),
);
tester.widget<IconButton>(deleteButton).onPressed!();
await tester.pump();
await tester.pump(const Duration(milliseconds: 301));
await tester.pump();
expect(
tester.state<SuperGridState>(find.byType(SuperGrid)).snapshotChildren,
[DashboardWidget.outboundModeV2.widget],
);
await tester.binding.handlePopRoute();
await tester.pump(const Duration(milliseconds: 500));
expect(find.byKey(const ValueKey('edit-icon')), findsOneWidget);
expect(container.read(appSettingProvider).dashboardWidgets, [
DashboardWidget.outboundModeV2,
]);
});
}
class _DashboardTestApp extends StatelessWidget {
final Widget child;
const _DashboardTestApp({this.child = const DashboardView()});
@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 _PageActivityTestScope extends StatelessWidget {
final ValueNotifier<bool> isActive;
final Widget child;
const _PageActivityTestScope({required this.isActive, required this.child});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: isActive,
builder: (_, value, child) {
return PageActivityScope(isActive: value, child: child!);
},
child: child,
);
}
}
+170
View File
@@ -0,0 +1,170 @@
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/manager/hotkey_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/widgets/widgets.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.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('desktop escape runs the back flow and exits search', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.macOS;
final container = ProviderContainer();
addTearDown(container.dispose);
globalState.container = container;
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
navigatorKey: globalState.navigatorKey,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: AppLocalizations.delegate.supportedLocales,
home: HotKeyManager(
child: CommonScaffold(
title: 'Logs',
searchState: AppBarSearchState(onSearch: (_) {}),
body: Center(
child: TextButton(
onPressed: () {},
child: const Text('focus target'),
),
),
),
),
),
),
);
await tester.tap(find.byIcon(Icons.search));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
// Move focus off the text field: EditableText's own shortcuts swallow
// escape on some platforms, so the escape binding applies outside the
// text input. (tap does not focus widgets inside a scope in tests.)
Focus.of(tester.element(find.text('focus target'))).requestFocus();
await tester.pump();
expect(
FocusManager.instance.primaryFocus?.context
?.findAncestorWidgetOfExactType<TextField>(),
isNull,
);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(
find.byType(TextField),
findsNothing,
reason: 'escape must exit search through the back flow',
);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('TV back exits search on the desktop nested route', (
tester,
) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
var query = '';
var closeCount = 0;
final container = ProviderContainer(
overrides: [
navigationItemsStateProvider.overrideWithValue(
NavigationItemsState(
value: [
NavigationItem(
icon: const Icon(Icons.space_dashboard),
label: PageLabel.dashboard,
builder: (_) => CommonScaffold(
key: const GlobalObjectKey(PageLabel.dashboard),
title: 'Search page',
searchState: AppBarSearchState(
onSearch: (value) {
query = value;
},
),
body: const SizedBox(),
),
),
NavigationItem(
icon: const Icon(Icons.construction),
label: PageLabel.tools,
builder: (_) => const SizedBox.shrink(),
),
],
),
),
systemActionProvider.overrideWith(
() => _TestSystemAction(() => closeCount++),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container.read(viewSizeProvider.notifier).value = const Size(1200, 800);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: AppLocalizations.delegate.supportedLocales,
home: const HomePage(),
),
),
);
await tester.pump();
expect(find.byType(NavigationRail), findsOneWidget);
await tester.tap(find.byIcon(Icons.search));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
await tester.enterText(find.byType(TextField), 'needle');
expect(query, 'needle');
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(closeCount, 0, reason: 'back must not fall through to app close');
expect(find.byType(TextField), findsNothing);
expect(query, isEmpty);
});
}
class _TestSystemAction extends SystemAction {
final int Function() closeCount;
_TestSystemAction(this.closeCount);
@override
void build() {}
@override
Future<void> handleClose([bool exit = true]) async {
closeCount();
}
}