Optimize back navigation

This commit is contained in:
chen08209
2026-08-05 10:58:23 +08:00
parent 497cf9b391
commit bd60dd40d8
20 changed files with 1020 additions and 187 deletions
+3
View File
@@ -11,6 +11,7 @@ import 'package:fl_clash/plugins/app.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.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';
@@ -46,6 +47,7 @@ class ApplicationState extends ConsumerState<Application> {
@override
void initState() {
super.initState();
SystemNavigator.setFrameworkHandlesBack(true);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
if (globalState.navigatorKey.currentContext != null) {
await globalState.attach();
@@ -141,6 +143,7 @@ class ApplicationState extends ConsumerState<Application> {
return MaterialApp(
debugShowCheckedModeBanner: false,
navigatorKey: globalState.navigatorKey,
onNavigationNotification: (_) => true,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
+5
View File
@@ -78,6 +78,8 @@ class _HotKeyManagerState extends ConsumerState<HotKeyManager> {
shortcuts: {
utils.controlSingleActivator(LogicalKeyboardKey.keyW):
const CloseWindowIntent(),
const SingleActivator(LogicalKeyboardKey.escape):
const EscapeBackIntent(),
},
child: Actions(
actions: {
@@ -86,6 +88,9 @@ class _HotKeyManagerState extends ConsumerState<HotKeyManager> {
.read(systemActionProvider.notifier)
.handleClose(false),
),
EscapeBackIntent: CallbackAction<EscapeBackIntent>(
onInvoke: (_) => globalState.navigatorKey.currentState?.maybePop(),
),
DoNothingIntent: CallbackAction<DoNothingIntent>(
onInvoke: (_) => null,
),
-1
View File
@@ -14,7 +14,6 @@ typedef DelayMap = Map<String, Map<String, int?>>;
abstract class AppState with _$AppState {
const factory AppState({
@Default(false) bool isInit,
@Default(false) bool backBlock,
@Default(PageLabel.dashboard) PageLabel pageLabel,
@Default([]) List<Package> packages,
@Default(0) int sortNum,
+4
View File
@@ -581,6 +581,10 @@ class CloseWindowIntent extends Intent {
const CloseWindowIntent();
}
class EscapeBackIntent extends Intent {
const EscapeBackIntent();
}
@freezed
abstract class Result<T> with _$Result<T> {
const factory Result({
+16 -19
View File
@@ -14,7 +14,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$AppState {
bool get isInit; bool get backBlock; PageLabel get pageLabel; List<Package> get packages; int get sortNum; Size get viewSize; double get sideWidth; DelayMap get delayMap; List<Group> get groups; int get checkIpNum; Brightness get brightness; int? get runTime; List<ExternalProvider> get providers; String? get localIp; FixedList<TrackerInfo> get requests; int get version; FixedList<Log> get logs; FixedList<Traffic> get traffics; Traffic get totalTraffic; TunAuthorizationState get authorizedTunEnable; bool get loading; SystemUiOverlayStyle get systemUiOverlayStyle; CoreStatus get coreStatus;
bool get isInit; PageLabel get pageLabel; List<Package> get packages; int get sortNum; Size get viewSize; double get sideWidth; DelayMap get delayMap; List<Group> get groups; int get checkIpNum; Brightness get brightness; int? get runTime; List<ExternalProvider> get providers; String? get localIp; FixedList<TrackerInfo> get requests; int get version; FixedList<Log> get logs; FixedList<Traffic> get traffics; Traffic get totalTraffic; TunAuthorizationState get authorizedTunEnable; bool get loading; SystemUiOverlayStyle get systemUiOverlayStyle; CoreStatus get coreStatus;
/// Create a copy of AppState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -25,12 +25,12 @@ $AppStateCopyWith<AppState> get copyWith => _$AppStateCopyWithImpl<AppState>(thi
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is AppState&&(identical(other.isInit, isInit) || other.isInit == isInit)&&(identical(other.backBlock, backBlock) || other.backBlock == backBlock)&&(identical(other.pageLabel, pageLabel) || other.pageLabel == pageLabel)&&const DeepCollectionEquality().equals(other.packages, packages)&&(identical(other.sortNum, sortNum) || other.sortNum == sortNum)&&(identical(other.viewSize, viewSize) || other.viewSize == viewSize)&&(identical(other.sideWidth, sideWidth) || other.sideWidth == sideWidth)&&const DeepCollectionEquality().equals(other.delayMap, delayMap)&&const DeepCollectionEquality().equals(other.groups, groups)&&(identical(other.checkIpNum, checkIpNum) || other.checkIpNum == checkIpNum)&&(identical(other.brightness, brightness) || other.brightness == brightness)&&(identical(other.runTime, runTime) || other.runTime == runTime)&&const DeepCollectionEquality().equals(other.providers, providers)&&(identical(other.localIp, localIp) || other.localIp == localIp)&&(identical(other.requests, requests) || other.requests == requests)&&(identical(other.version, version) || other.version == version)&&(identical(other.logs, logs) || other.logs == logs)&&(identical(other.traffics, traffics) || other.traffics == traffics)&&(identical(other.totalTraffic, totalTraffic) || other.totalTraffic == totalTraffic)&&(identical(other.authorizedTunEnable, authorizedTunEnable) || other.authorizedTunEnable == authorizedTunEnable)&&(identical(other.loading, loading) || other.loading == loading)&&(identical(other.systemUiOverlayStyle, systemUiOverlayStyle) || other.systemUiOverlayStyle == systemUiOverlayStyle)&&(identical(other.coreStatus, coreStatus) || other.coreStatus == coreStatus));
return identical(this, other) || (other.runtimeType == runtimeType&&other is AppState&&(identical(other.isInit, isInit) || other.isInit == isInit)&&(identical(other.pageLabel, pageLabel) || other.pageLabel == pageLabel)&&const DeepCollectionEquality().equals(other.packages, packages)&&(identical(other.sortNum, sortNum) || other.sortNum == sortNum)&&(identical(other.viewSize, viewSize) || other.viewSize == viewSize)&&(identical(other.sideWidth, sideWidth) || other.sideWidth == sideWidth)&&const DeepCollectionEquality().equals(other.delayMap, delayMap)&&const DeepCollectionEquality().equals(other.groups, groups)&&(identical(other.checkIpNum, checkIpNum) || other.checkIpNum == checkIpNum)&&(identical(other.brightness, brightness) || other.brightness == brightness)&&(identical(other.runTime, runTime) || other.runTime == runTime)&&const DeepCollectionEquality().equals(other.providers, providers)&&(identical(other.localIp, localIp) || other.localIp == localIp)&&(identical(other.requests, requests) || other.requests == requests)&&(identical(other.version, version) || other.version == version)&&(identical(other.logs, logs) || other.logs == logs)&&(identical(other.traffics, traffics) || other.traffics == traffics)&&(identical(other.totalTraffic, totalTraffic) || other.totalTraffic == totalTraffic)&&(identical(other.authorizedTunEnable, authorizedTunEnable) || other.authorizedTunEnable == authorizedTunEnable)&&(identical(other.loading, loading) || other.loading == loading)&&(identical(other.systemUiOverlayStyle, systemUiOverlayStyle) || other.systemUiOverlayStyle == systemUiOverlayStyle)&&(identical(other.coreStatus, coreStatus) || other.coreStatus == coreStatus));
}
@override
int get hashCode => Object.hashAll([runtimeType,isInit,backBlock,pageLabel,const DeepCollectionEquality().hash(packages),sortNum,viewSize,sideWidth,const DeepCollectionEquality().hash(delayMap),const DeepCollectionEquality().hash(groups),checkIpNum,brightness,runTime,const DeepCollectionEquality().hash(providers),localIp,requests,version,logs,traffics,totalTraffic,authorizedTunEnable,loading,systemUiOverlayStyle,coreStatus]);
int get hashCode => Object.hashAll([runtimeType,isInit,pageLabel,const DeepCollectionEquality().hash(packages),sortNum,viewSize,sideWidth,const DeepCollectionEquality().hash(delayMap),const DeepCollectionEquality().hash(groups),checkIpNum,brightness,runTime,const DeepCollectionEquality().hash(providers),localIp,requests,version,logs,traffics,totalTraffic,authorizedTunEnable,loading,systemUiOverlayStyle,coreStatus]);
@@ -41,7 +41,7 @@ abstract mixin class $AppStateCopyWith<$Res> {
factory $AppStateCopyWith(AppState value, $Res Function(AppState) _then) = _$AppStateCopyWithImpl;
@useResult
$Res call({
bool isInit, bool backBlock, PageLabel pageLabel, List<Package> packages, int sortNum, Size viewSize, double sideWidth, DelayMap delayMap, List<Group> groups, int checkIpNum, Brightness brightness, int? runTime, List<ExternalProvider> providers, String? localIp, FixedList<TrackerInfo> requests, int version, FixedList<Log> logs, FixedList<Traffic> traffics, Traffic totalTraffic, TunAuthorizationState authorizedTunEnable, bool loading, SystemUiOverlayStyle systemUiOverlayStyle, CoreStatus coreStatus
bool isInit, PageLabel pageLabel, List<Package> packages, int sortNum, Size viewSize, double sideWidth, DelayMap delayMap, List<Group> groups, int checkIpNum, Brightness brightness, int? runTime, List<ExternalProvider> providers, String? localIp, FixedList<TrackerInfo> requests, int version, FixedList<Log> logs, FixedList<Traffic> traffics, Traffic totalTraffic, TunAuthorizationState authorizedTunEnable, bool loading, SystemUiOverlayStyle systemUiOverlayStyle, CoreStatus coreStatus
});
@@ -58,10 +58,9 @@ class _$AppStateCopyWithImpl<$Res>
/// Create a copy of AppState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? isInit = null,Object? backBlock = null,Object? pageLabel = null,Object? packages = null,Object? sortNum = null,Object? viewSize = null,Object? sideWidth = null,Object? delayMap = null,Object? groups = null,Object? checkIpNum = null,Object? brightness = null,Object? runTime = freezed,Object? providers = null,Object? localIp = freezed,Object? requests = null,Object? version = null,Object? logs = null,Object? traffics = null,Object? totalTraffic = null,Object? authorizedTunEnable = null,Object? loading = null,Object? systemUiOverlayStyle = null,Object? coreStatus = null,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? isInit = null,Object? pageLabel = null,Object? packages = null,Object? sortNum = null,Object? viewSize = null,Object? sideWidth = null,Object? delayMap = null,Object? groups = null,Object? checkIpNum = null,Object? brightness = null,Object? runTime = freezed,Object? providers = null,Object? localIp = freezed,Object? requests = null,Object? version = null,Object? logs = null,Object? traffics = null,Object? totalTraffic = null,Object? authorizedTunEnable = null,Object? loading = null,Object? systemUiOverlayStyle = null,Object? coreStatus = null,}) {
return _then(_self.copyWith(
isInit: null == isInit ? _self.isInit : isInit // ignore: cast_nullable_to_non_nullable
as bool,backBlock: null == backBlock ? _self.backBlock : backBlock // ignore: cast_nullable_to_non_nullable
as bool,pageLabel: null == pageLabel ? _self.pageLabel : pageLabel // ignore: cast_nullable_to_non_nullable
as PageLabel,packages: null == packages ? _self.packages : packages // ignore: cast_nullable_to_non_nullable
as List<Package>,sortNum: null == sortNum ? _self.sortNum : sortNum // ignore: cast_nullable_to_non_nullable
@@ -177,10 +176,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool isInit, bool backBlock, PageLabel pageLabel, List<Package> packages, int sortNum, Size viewSize, double sideWidth, DelayMap delayMap, List<Group> groups, int checkIpNum, Brightness brightness, int? runTime, List<ExternalProvider> providers, String? localIp, FixedList<TrackerInfo> requests, int version, FixedList<Log> logs, FixedList<Traffic> traffics, Traffic totalTraffic, TunAuthorizationState authorizedTunEnable, bool loading, SystemUiOverlayStyle systemUiOverlayStyle, CoreStatus coreStatus)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool isInit, PageLabel pageLabel, List<Package> packages, int sortNum, Size viewSize, double sideWidth, DelayMap delayMap, List<Group> groups, int checkIpNum, Brightness brightness, int? runTime, List<ExternalProvider> providers, String? localIp, FixedList<TrackerInfo> requests, int version, FixedList<Log> logs, FixedList<Traffic> traffics, Traffic totalTraffic, TunAuthorizationState authorizedTunEnable, bool loading, SystemUiOverlayStyle systemUiOverlayStyle, CoreStatus coreStatus)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _AppState() when $default != null:
return $default(_that.isInit,_that.backBlock,_that.pageLabel,_that.packages,_that.sortNum,_that.viewSize,_that.sideWidth,_that.delayMap,_that.groups,_that.checkIpNum,_that.brightness,_that.runTime,_that.providers,_that.localIp,_that.requests,_that.version,_that.logs,_that.traffics,_that.totalTraffic,_that.authorizedTunEnable,_that.loading,_that.systemUiOverlayStyle,_that.coreStatus);case _:
return $default(_that.isInit,_that.pageLabel,_that.packages,_that.sortNum,_that.viewSize,_that.sideWidth,_that.delayMap,_that.groups,_that.checkIpNum,_that.brightness,_that.runTime,_that.providers,_that.localIp,_that.requests,_that.version,_that.logs,_that.traffics,_that.totalTraffic,_that.authorizedTunEnable,_that.loading,_that.systemUiOverlayStyle,_that.coreStatus);case _:
return orElse();
}
@@ -198,10 +197,10 @@ return $default(_that.isInit,_that.backBlock,_that.pageLabel,_that.packages,_tha
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool isInit, bool backBlock, PageLabel pageLabel, List<Package> packages, int sortNum, Size viewSize, double sideWidth, DelayMap delayMap, List<Group> groups, int checkIpNum, Brightness brightness, int? runTime, List<ExternalProvider> providers, String? localIp, FixedList<TrackerInfo> requests, int version, FixedList<Log> logs, FixedList<Traffic> traffics, Traffic totalTraffic, TunAuthorizationState authorizedTunEnable, bool loading, SystemUiOverlayStyle systemUiOverlayStyle, CoreStatus coreStatus) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool isInit, PageLabel pageLabel, List<Package> packages, int sortNum, Size viewSize, double sideWidth, DelayMap delayMap, List<Group> groups, int checkIpNum, Brightness brightness, int? runTime, List<ExternalProvider> providers, String? localIp, FixedList<TrackerInfo> requests, int version, FixedList<Log> logs, FixedList<Traffic> traffics, Traffic totalTraffic, TunAuthorizationState authorizedTunEnable, bool loading, SystemUiOverlayStyle systemUiOverlayStyle, CoreStatus coreStatus) $default,) {final _that = this;
switch (_that) {
case _AppState():
return $default(_that.isInit,_that.backBlock,_that.pageLabel,_that.packages,_that.sortNum,_that.viewSize,_that.sideWidth,_that.delayMap,_that.groups,_that.checkIpNum,_that.brightness,_that.runTime,_that.providers,_that.localIp,_that.requests,_that.version,_that.logs,_that.traffics,_that.totalTraffic,_that.authorizedTunEnable,_that.loading,_that.systemUiOverlayStyle,_that.coreStatus);case _:
return $default(_that.isInit,_that.pageLabel,_that.packages,_that.sortNum,_that.viewSize,_that.sideWidth,_that.delayMap,_that.groups,_that.checkIpNum,_that.brightness,_that.runTime,_that.providers,_that.localIp,_that.requests,_that.version,_that.logs,_that.traffics,_that.totalTraffic,_that.authorizedTunEnable,_that.loading,_that.systemUiOverlayStyle,_that.coreStatus);case _:
throw StateError('Unexpected subclass');
}
@@ -218,10 +217,10 @@ return $default(_that.isInit,_that.backBlock,_that.pageLabel,_that.packages,_tha
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool isInit, bool backBlock, PageLabel pageLabel, List<Package> packages, int sortNum, Size viewSize, double sideWidth, DelayMap delayMap, List<Group> groups, int checkIpNum, Brightness brightness, int? runTime, List<ExternalProvider> providers, String? localIp, FixedList<TrackerInfo> requests, int version, FixedList<Log> logs, FixedList<Traffic> traffics, Traffic totalTraffic, TunAuthorizationState authorizedTunEnable, bool loading, SystemUiOverlayStyle systemUiOverlayStyle, CoreStatus coreStatus)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool isInit, PageLabel pageLabel, List<Package> packages, int sortNum, Size viewSize, double sideWidth, DelayMap delayMap, List<Group> groups, int checkIpNum, Brightness brightness, int? runTime, List<ExternalProvider> providers, String? localIp, FixedList<TrackerInfo> requests, int version, FixedList<Log> logs, FixedList<Traffic> traffics, Traffic totalTraffic, TunAuthorizationState authorizedTunEnable, bool loading, SystemUiOverlayStyle systemUiOverlayStyle, CoreStatus coreStatus)? $default,) {final _that = this;
switch (_that) {
case _AppState() when $default != null:
return $default(_that.isInit,_that.backBlock,_that.pageLabel,_that.packages,_that.sortNum,_that.viewSize,_that.sideWidth,_that.delayMap,_that.groups,_that.checkIpNum,_that.brightness,_that.runTime,_that.providers,_that.localIp,_that.requests,_that.version,_that.logs,_that.traffics,_that.totalTraffic,_that.authorizedTunEnable,_that.loading,_that.systemUiOverlayStyle,_that.coreStatus);case _:
return $default(_that.isInit,_that.pageLabel,_that.packages,_that.sortNum,_that.viewSize,_that.sideWidth,_that.delayMap,_that.groups,_that.checkIpNum,_that.brightness,_that.runTime,_that.providers,_that.localIp,_that.requests,_that.version,_that.logs,_that.traffics,_that.totalTraffic,_that.authorizedTunEnable,_that.loading,_that.systemUiOverlayStyle,_that.coreStatus);case _:
return null;
}
@@ -233,11 +232,10 @@ return $default(_that.isInit,_that.backBlock,_that.pageLabel,_that.packages,_tha
class _AppState implements AppState {
const _AppState({this.isInit = false, this.backBlock = false, this.pageLabel = PageLabel.dashboard, final List<Package> packages = const [], this.sortNum = 0, required this.viewSize, this.sideWidth = 0, final DelayMap delayMap = const {}, final List<Group> groups = const [], this.checkIpNum = 0, required this.brightness, this.runTime, final List<ExternalProvider> providers = const [], this.localIp, required this.requests, required this.version, required this.logs, required this.traffics, required this.totalTraffic, this.authorizedTunEnable = TunAuthorizationState.none, this.loading = false, required this.systemUiOverlayStyle, this.coreStatus = CoreStatus.connecting}): _packages = packages,_delayMap = delayMap,_groups = groups,_providers = providers;
const _AppState({this.isInit = false, this.pageLabel = PageLabel.dashboard, final List<Package> packages = const [], this.sortNum = 0, required this.viewSize, this.sideWidth = 0, final DelayMap delayMap = const {}, final List<Group> groups = const [], this.checkIpNum = 0, required this.brightness, this.runTime, final List<ExternalProvider> providers = const [], this.localIp, required this.requests, required this.version, required this.logs, required this.traffics, required this.totalTraffic, this.authorizedTunEnable = TunAuthorizationState.none, this.loading = false, required this.systemUiOverlayStyle, this.coreStatus = CoreStatus.connecting}): _packages = packages,_delayMap = delayMap,_groups = groups,_providers = providers;
@override@JsonKey() final bool isInit;
@override@JsonKey() final bool backBlock;
@override@JsonKey() final PageLabel pageLabel;
final List<Package> _packages;
@override@JsonKey() List<Package> get packages {
@@ -294,12 +292,12 @@ _$AppStateCopyWith<_AppState> get copyWith => __$AppStateCopyWithImpl<_AppState>
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppState&&(identical(other.isInit, isInit) || other.isInit == isInit)&&(identical(other.backBlock, backBlock) || other.backBlock == backBlock)&&(identical(other.pageLabel, pageLabel) || other.pageLabel == pageLabel)&&const DeepCollectionEquality().equals(other._packages, _packages)&&(identical(other.sortNum, sortNum) || other.sortNum == sortNum)&&(identical(other.viewSize, viewSize) || other.viewSize == viewSize)&&(identical(other.sideWidth, sideWidth) || other.sideWidth == sideWidth)&&const DeepCollectionEquality().equals(other._delayMap, _delayMap)&&const DeepCollectionEquality().equals(other._groups, _groups)&&(identical(other.checkIpNum, checkIpNum) || other.checkIpNum == checkIpNum)&&(identical(other.brightness, brightness) || other.brightness == brightness)&&(identical(other.runTime, runTime) || other.runTime == runTime)&&const DeepCollectionEquality().equals(other._providers, _providers)&&(identical(other.localIp, localIp) || other.localIp == localIp)&&(identical(other.requests, requests) || other.requests == requests)&&(identical(other.version, version) || other.version == version)&&(identical(other.logs, logs) || other.logs == logs)&&(identical(other.traffics, traffics) || other.traffics == traffics)&&(identical(other.totalTraffic, totalTraffic) || other.totalTraffic == totalTraffic)&&(identical(other.authorizedTunEnable, authorizedTunEnable) || other.authorizedTunEnable == authorizedTunEnable)&&(identical(other.loading, loading) || other.loading == loading)&&(identical(other.systemUiOverlayStyle, systemUiOverlayStyle) || other.systemUiOverlayStyle == systemUiOverlayStyle)&&(identical(other.coreStatus, coreStatus) || other.coreStatus == coreStatus));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppState&&(identical(other.isInit, isInit) || other.isInit == isInit)&&(identical(other.pageLabel, pageLabel) || other.pageLabel == pageLabel)&&const DeepCollectionEquality().equals(other._packages, _packages)&&(identical(other.sortNum, sortNum) || other.sortNum == sortNum)&&(identical(other.viewSize, viewSize) || other.viewSize == viewSize)&&(identical(other.sideWidth, sideWidth) || other.sideWidth == sideWidth)&&const DeepCollectionEquality().equals(other._delayMap, _delayMap)&&const DeepCollectionEquality().equals(other._groups, _groups)&&(identical(other.checkIpNum, checkIpNum) || other.checkIpNum == checkIpNum)&&(identical(other.brightness, brightness) || other.brightness == brightness)&&(identical(other.runTime, runTime) || other.runTime == runTime)&&const DeepCollectionEquality().equals(other._providers, _providers)&&(identical(other.localIp, localIp) || other.localIp == localIp)&&(identical(other.requests, requests) || other.requests == requests)&&(identical(other.version, version) || other.version == version)&&(identical(other.logs, logs) || other.logs == logs)&&(identical(other.traffics, traffics) || other.traffics == traffics)&&(identical(other.totalTraffic, totalTraffic) || other.totalTraffic == totalTraffic)&&(identical(other.authorizedTunEnable, authorizedTunEnable) || other.authorizedTunEnable == authorizedTunEnable)&&(identical(other.loading, loading) || other.loading == loading)&&(identical(other.systemUiOverlayStyle, systemUiOverlayStyle) || other.systemUiOverlayStyle == systemUiOverlayStyle)&&(identical(other.coreStatus, coreStatus) || other.coreStatus == coreStatus));
}
@override
int get hashCode => Object.hashAll([runtimeType,isInit,backBlock,pageLabel,const DeepCollectionEquality().hash(_packages),sortNum,viewSize,sideWidth,const DeepCollectionEquality().hash(_delayMap),const DeepCollectionEquality().hash(_groups),checkIpNum,brightness,runTime,const DeepCollectionEquality().hash(_providers),localIp,requests,version,logs,traffics,totalTraffic,authorizedTunEnable,loading,systemUiOverlayStyle,coreStatus]);
int get hashCode => Object.hashAll([runtimeType,isInit,pageLabel,const DeepCollectionEquality().hash(_packages),sortNum,viewSize,sideWidth,const DeepCollectionEquality().hash(_delayMap),const DeepCollectionEquality().hash(_groups),checkIpNum,brightness,runTime,const DeepCollectionEquality().hash(_providers),localIp,requests,version,logs,traffics,totalTraffic,authorizedTunEnable,loading,systemUiOverlayStyle,coreStatus]);
@@ -310,7 +308,7 @@ abstract mixin class _$AppStateCopyWith<$Res> implements $AppStateCopyWith<$Res>
factory _$AppStateCopyWith(_AppState value, $Res Function(_AppState) _then) = __$AppStateCopyWithImpl;
@override @useResult
$Res call({
bool isInit, bool backBlock, PageLabel pageLabel, List<Package> packages, int sortNum, Size viewSize, double sideWidth, DelayMap delayMap, List<Group> groups, int checkIpNum, Brightness brightness, int? runTime, List<ExternalProvider> providers, String? localIp, FixedList<TrackerInfo> requests, int version, FixedList<Log> logs, FixedList<Traffic> traffics, Traffic totalTraffic, TunAuthorizationState authorizedTunEnable, bool loading, SystemUiOverlayStyle systemUiOverlayStyle, CoreStatus coreStatus
bool isInit, PageLabel pageLabel, List<Package> packages, int sortNum, Size viewSize, double sideWidth, DelayMap delayMap, List<Group> groups, int checkIpNum, Brightness brightness, int? runTime, List<ExternalProvider> providers, String? localIp, FixedList<TrackerInfo> requests, int version, FixedList<Log> logs, FixedList<Traffic> traffics, Traffic totalTraffic, TunAuthorizationState authorizedTunEnable, bool loading, SystemUiOverlayStyle systemUiOverlayStyle, CoreStatus coreStatus
});
@@ -327,10 +325,9 @@ class __$AppStateCopyWithImpl<$Res>
/// Create a copy of AppState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? isInit = null,Object? backBlock = null,Object? pageLabel = null,Object? packages = null,Object? sortNum = null,Object? viewSize = null,Object? sideWidth = null,Object? delayMap = null,Object? groups = null,Object? checkIpNum = null,Object? brightness = null,Object? runTime = freezed,Object? providers = null,Object? localIp = freezed,Object? requests = null,Object? version = null,Object? logs = null,Object? traffics = null,Object? totalTraffic = null,Object? authorizedTunEnable = null,Object? loading = null,Object? systemUiOverlayStyle = null,Object? coreStatus = null,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? isInit = null,Object? pageLabel = null,Object? packages = null,Object? sortNum = null,Object? viewSize = null,Object? sideWidth = null,Object? delayMap = null,Object? groups = null,Object? checkIpNum = null,Object? brightness = null,Object? runTime = freezed,Object? providers = null,Object? localIp = freezed,Object? requests = null,Object? version = null,Object? logs = null,Object? traffics = null,Object? totalTraffic = null,Object? authorizedTunEnable = null,Object? loading = null,Object? systemUiOverlayStyle = null,Object? coreStatus = null,}) {
return _then(_AppState(
isInit: null == isInit ? _self.isInit : isInit // ignore: cast_nullable_to_non_nullable
as bool,backBlock: null == backBlock ? _self.backBlock : backBlock // ignore: cast_nullable_to_non_nullable
as bool,pageLabel: null == pageLabel ? _self.pageLabel : pageLabel // ignore: cast_nullable_to_non_nullable
as PageLabel,packages: null == packages ? _self._packages : packages // ignore: cast_nullable_to_non_nullable
as List<Package>,sortNum: null == sortNum ? _self.sortNum : sortNum // ignore: cast_nullable_to_non_nullable
+19 -12
View File
@@ -110,7 +110,24 @@ class HomePage extends ConsumerWidget {
onDidRemovePage: (_) {},
),
);
return view;
return Consumer(
key: ValueKey(navigationItem.label),
builder: (_, ref, child) {
final isActive = ref.watch(
currentPageLabelProvider.select(
(label) => label == navigationItem.label,
),
);
return PageActivityScope(
isActive: isActive,
child: ExcludeFocus(
excluding: !isActive,
child: child!,
),
);
},
child: view,
);
},
);
},
@@ -204,12 +221,6 @@ class _HomePageViewState extends ConsumerState<_HomePageView> {
final itemCount = ref.watch(
currentNavigationItemsStateProvider.select((state) => state.value.length),
);
final currentIndex = ref.watch(
currentPageLabelProvider.select(
(label) =>
widget.navigationItems.indexWhere((item) => item.label == label),
),
);
return PageView.builder(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
@@ -224,11 +235,7 @@ class _HomePageViewState extends ConsumerState<_HomePageView> {
return index == -1 ? null : index;
},
itemBuilder: (context, index) {
return ExcludeFocus(
key: ValueKey(widget.navigationItems[index].label),
excluding: index != currentIndex,
child: widget.pageBuilder(context, index),
);
return widget.pageBuilder(context, index);
},
);
}
-3
View File
@@ -594,9 +594,6 @@ class SystemAction extends _$SystemAction {
}
Future<void> handleClose([bool exit = true]) async {
if (!system.isDesktop) {
if (ref.read(backBlockProvider)) return;
}
if (ref.read(appSettingProvider).minimizeOnExit || !exit) {
if (system.isDesktop) {
await preferences.saveConfig(ref.read(configProvider));
-17
View File
@@ -217,22 +217,6 @@ class CheckIpNum extends _$CheckIpNum with AutoDisposeNotifierMixin {
int add() => state++;
}
@Riverpod(keepAlive: true)
class BackBlock extends _$BackBlock with AutoDisposeNotifierMixin {
@override
bool build() {
return false;
}
void backBlock() {
value = true;
}
void unBackBlock() {
value = false;
}
}
@Riverpod(keepAlive: true)
class Version extends _$Version with AutoDisposeNotifierMixin {
@override
@@ -464,7 +448,6 @@ class LocationPermissions extends _$LocationPermissions
List<Override> buildAppStateOverrides(AppState appState) {
return [
initProvider.overrideWithBuild((_, _) => appState.isInit),
backBlockProvider.overrideWithBuild((_, _) => appState.backBlock),
currentPageLabelProvider.overrideWithBuild((_, _) => appState.pageLabel),
packagesProvider.overrideWithBuild((_, _) => appState.packages),
sortNumProvider.overrideWithBuild((_, _) => appState.sortNum),
+1 -1
View File
@@ -244,7 +244,7 @@ final class SystemActionProvider extends $NotifierProvider<SystemAction, void> {
}
}
String _$systemActionHash() => r'04f09af2702925aff276423a9c224fd8d3aca061';
String _$systemActionHash() => r'fe481ef6812b1aad42ed3f1a8a2459c9bbd289d9';
abstract class _$SystemAction extends $Notifier<void> {
void build();
-51
View File
@@ -999,57 +999,6 @@ abstract class _$CheckIpNum extends $Notifier<int> {
}
}
@ProviderFor(BackBlock)
final backBlockProvider = BackBlockProvider._();
final class BackBlockProvider extends $NotifierProvider<BackBlock, bool> {
BackBlockProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'backBlockProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$backBlockHash();
@$internal
@override
BackBlock create() => BackBlock();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$backBlockHash() => r'c867df920425f9b063807ca758dff8d849ca069e';
abstract class _$BackBlock extends $Notifier<bool> {
bool build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<bool, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<bool, bool>,
bool,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@ProviderFor(Version)
final versionProvider = VersionProvider._();
+57 -25
View File
@@ -69,12 +69,12 @@ class _DashboardViewState extends ConsumerState<DashboardView> {
? IconButton(
key: const ValueKey(true),
icon: const Icon(Icons.save, key: ValueKey('save-icon')),
onPressed: _handleUpdateIsEdit,
onPressed: _handleSaveAndExit,
)
: IconButton(
key: const ValueKey(false),
icon: const Icon(Icons.edit, key: ValueKey('edit-icon')),
onPressed: _handleUpdateIsEdit,
onPressed: _handleEnterEdit,
),
),
];
@@ -102,11 +102,32 @@ class _DashboardViewState extends ConsumerState<DashboardView> {
);
}
Future<void> _handleUpdateIsEdit() async {
if (_isEditNotifier.value == true) {
await _handleSave();
void _handleEnterEdit() {
if (_isEditNotifier.value) {
return;
}
_isEditNotifier.value = true;
}
void _handleExitEdit() {
if (!_isEditNotifier.value) {
return;
}
final dashboardWidgets = _getDashboardWidgets(key.currentState);
if (dashboardWidgets != null) {
_saveDashboardWidgets(dashboardWidgets);
}
_isEditNotifier.value = false;
}
Future<void> _handleSaveAndExit() async {
if (!_isEditNotifier.value) {
return;
}
await _handleSave();
if (mounted) {
_isEditNotifier.value = false;
}
_isEditNotifier.value = !_isEditNotifier.value;
}
Future<void> _handleSave() async {
@@ -114,7 +135,7 @@ class _DashboardViewState extends ConsumerState<DashboardView> {
if (currentState == null) {
return;
}
if (!mounted || currentState.children.isEmpty) {
if (!mounted || currentState.snapshotChildren.isEmpty) {
return;
}
final transformCompleted = await currentState.isTransformCompleter;
@@ -124,9 +145,25 @@ class _DashboardViewState extends ConsumerState<DashboardView> {
!identical(key.currentState, currentState)) {
return;
}
final dashboardWidgets = currentState.children
.map((item) => DashboardWidget.getDashboardWidget(item))
.toList();
final dashboardWidgets = _getDashboardWidgets(currentState);
if (dashboardWidgets == null) {
return;
}
_saveDashboardWidgets(dashboardWidgets);
}
List<DashboardWidget>? _getDashboardWidgets(SuperGridState? currentState) {
if (currentState == null) {
return null;
}
final children = currentState.snapshotChildren;
if (children.isEmpty) {
return null;
}
return children.map(DashboardWidget.getDashboardWidget).toList();
}
void _saveDashboardWidgets(List<DashboardWidget> dashboardWidgets) {
ref
.read(appSettingProvider.notifier)
.update((state) => state.copyWith(dashboardWidgets: dashboardWidgets));
@@ -173,21 +210,16 @@ class _DashboardViewState extends ConsumerState<DashboardView> {
_maxCrossAxisCount,
);
return isEdit
? SystemBackBlock(
child: CommonPopScope(
child: SuperGrid(
key: key,
crossAxisCount: columns,
crossAxisSpacing: spacing,
mainAxisSpacing: spacing,
children: children,
onUpdate: () {
_handleSave();
},
),
onPop: (context) {
_handleUpdateIsEdit();
return false;
? BackLayerScope(
onBack: _handleExitEdit,
child: SuperGrid(
key: key,
crossAxisCount: columns,
crossAxisSpacing: spacing,
mainAxisSpacing: spacing,
children: children,
onUpdate: () {
_handleSave();
},
),
)
-10
View File
@@ -20,7 +20,6 @@ class ProxiesView extends ConsumerStatefulWidget {
}
class _ProxiesViewState extends ConsumerState<ProxiesView> {
final GlobalKey<CommonScaffoldState> _scaffoldKey = GlobalKey();
final GlobalKey<ProxiesTabViewState> _proxiesTabKey = GlobalKey();
bool _hasProviders = false;
bool _isTab = false;
@@ -122,14 +121,6 @@ class _ProxiesViewState extends ConsumerState<ProxiesView> {
},
fireImmediately: true,
);
ref.listenManual(
currentPageLabelProvider.select((state) => state == PageLabel.proxies),
(prev, next) {
if (prev != next && next == false) {
_scaffoldKey.currentState?.handleExitSearching();
}
},
);
}
@override
@@ -139,7 +130,6 @@ class _ProxiesViewState extends ConsumerState<ProxiesView> {
);
final isLoading = ref.watch(loadingProvider(LoadingTag.proxies));
return CommonScaffold(
key: _scaffoldKey,
isLoading: isLoading,
resizeToAvoidBottomInset: false,
floatingActionButton: _buildFAB(),
+22
View File
@@ -2,6 +2,28 @@ import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/widgets/sheet.dart';
import 'package:flutter/material.dart';
class PageActivityScope extends InheritedWidget {
final bool isActive;
const PageActivityScope({
super.key,
required this.isActive,
required super.child,
});
static bool isActiveOf(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<PageActivityScope>()
?.isActive ??
true;
}
@override
bool updateShouldNotify(PageActivityScope oldWidget) {
return isActive != oldWidget.isActive;
}
}
class CommonScaffoldBackActionProvider extends InheritedWidget {
final VoidCallback? backAction;
+76 -15
View File
@@ -1,9 +1,8 @@
import 'dart:async';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/state.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'inherited.dart';
class CommonPopScope extends StatelessWidget {
final Widget child;
@@ -19,8 +18,10 @@ class CommonPopScope extends StatelessWidget {
@override
Widget build(BuildContext context) {
final route = ModalRoute.of(context);
final hasBackLayer = route?.willHandlePopInternally == true;
return PopScope(
canPop: onPop == null ? true : false,
canPop: onPop == null || hasBackLayer,
onPopInvokedWithResult: onPop == null
? null
: (didPop, _) async {
@@ -44,30 +45,90 @@ class CommonPopScope extends StatelessWidget {
}
}
class SystemBackBlock extends ConsumerStatefulWidget {
class BackLayerScope extends StatefulWidget {
final Widget child;
final VoidCallback onBack;
@visibleForTesting
final void Function(void Function(Duration) callback)?
schedulePostFrameCallback;
const SystemBackBlock({super.key, required this.child});
const BackLayerScope({
super.key,
required this.onBack,
required this.child,
@visibleForTesting this.schedulePostFrameCallback,
});
@override
ConsumerState<SystemBackBlock> createState() => _SystemBackBlockState();
State<BackLayerScope> createState() => _BackLayerScopeState();
}
class _SystemBackBlockState extends ConsumerState<SystemBackBlock> {
class _BackLayerScopeState extends State<BackLayerScope> {
ModalRoute<dynamic>? _route;
LocalHistoryEntry? _entry;
bool _isDetaching = false;
bool _isPageActive = true;
int _syncRevision = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
globalState.container.read(backBlockProvider.notifier).backBlock();
void didChangeDependencies() {
super.didChangeDependencies();
final route = ModalRoute.of(context);
final isPageActive = PageActivityScope.isActiveOf(context);
if (identical(_route, route) && _isPageActive == isPageActive) {
return;
}
_detach();
_route = route;
_isPageActive = isPageActive;
final revision = ++_syncRevision;
final schedulePostFrameCallback =
widget.schedulePostFrameCallback ??
WidgetsBinding.instance.addPostFrameCallback;
schedulePostFrameCallback((_) {
if (!mounted || revision != _syncRevision) {
return;
}
if (!_isPageActive) {
widget.onBack();
return;
}
if (route == null) {
return;
}
final entry = LocalHistoryEntry(
impliesAppBarDismissal: false,
onRemove: _handleRemove,
);
_entry = entry;
route.addLocalHistoryEntry(entry);
});
}
void _handleRemove() {
_entry = null;
if (!_isDetaching && mounted) {
widget.onBack();
}
}
void _detach() {
final entry = _entry;
if (entry == null) {
return;
}
_entry = null;
_isDetaching = true;
entry.remove();
_isDetaching = false;
}
@override
void dispose() {
_syncRevision++;
_detach();
_route = null;
super.dispose();
WidgetsBinding.instance.addPostFrameCallback((_) {
globalState.container.read(backBlockProvider.notifier).unBackBlock();
});
}
@override
+18 -16
View File
@@ -139,7 +139,7 @@ class CommonScaffoldState extends State<CommonScaffold> {
_handleClearInput();
return;
}
_updateSearchState((state) => state?.copyWith(query: null));
_popAppBarLayer();
}
void handleExitSearching() {
@@ -150,6 +150,20 @@ class CommonScaffoldState extends State<CommonScaffold> {
_updateSearchState((state) => state?.copyWith(query: null));
}
void _handleExitAppBarLayer() {
handleExitSearching();
if (_isEdit) {
_appBarState.value.editState?.onExit();
}
}
void _popAppBarLayer() {
if (!_isEdit && !_isSearch) {
return;
}
Navigator.of(context).pop();
}
@override
void dispose() {
_appBarState.dispose();
@@ -177,13 +191,13 @@ class CommonScaffoldState extends State<CommonScaffold> {
Widget? _buildLeading(VoidCallback? backAction) {
if (_isEdit) {
return IconButton(
onPressed: _appBarState.value.editState?.onExit,
onPressed: _popAppBarLayer,
icon: const Icon(Icons.close),
);
}
if (_isSearch) {
return IconButton(
onPressed: handleExitSearching,
onPressed: _popAppBarLayer,
icon: const Icon(Icons.arrow_back),
);
}
@@ -244,19 +258,7 @@ class CommonScaffoldState extends State<CommonScaffold> {
Widget _buildAppBarWrap(Widget child) {
final appBar = _isSearch ? _buildSearchingAppBarTheme(child) : child;
if (_isEdit || _isSearch) {
return SystemBackBlock(
child: CommonPopScope(
onPop: (context) {
if (_isEdit || _isSearch) {
handleExitSearching();
_appBarState.value.editState?.onExit();
return false;
}
return true;
},
child: appBar,
),
);
return BackLayerScope(onBack: _handleExitAppBarLayer, child: appBar);
}
return appBar;
}
+11 -3
View File
@@ -37,6 +37,10 @@ class SuperGridState extends State<SuperGrid> with TickerProviderStateMixin {
late final ValueNotifier<List<GridItem>> _childrenNotifier;
List<GridItem> children = [];
List<GridItem>? _pendingChildren;
List<GridItem> get snapshotChildren =>
List<GridItem>.unmodifiable(_pendingChildren ?? children);
int get length => _childrenNotifier.value.length;
List<int> _tempIndexList = [];
@@ -355,13 +359,16 @@ class SuperGridState extends State<SuperGrid> with TickerProviderStateMixin {
_preTransformState();
final indexWhere = _tempIndexList.indexWhere((i) => i == index);
_tempIndexList.removeAt(indexWhere);
final nextChildren = List<GridItem>.from(_childrenNotifier.value)
..removeAt(index);
_pendingChildren = nextChildren;
final completed = await _transform();
if (!completed || !mounted) {
_pendingChildren = null;
return;
}
final children = List<GridItem>.from(_childrenNotifier.value);
children.removeAt(index);
_childrenNotifier.value = children;
_childrenNotifier.value = nextChildren;
_pendingChildren = null;
_initState();
}
@@ -606,6 +613,7 @@ class SuperGridState extends State<SuperGrid> with TickerProviderStateMixin {
_childrenNotifier.removeListener(_handleChildrenChanged);
_childrenNotifier.value = const [];
children = const [];
_pendingChildren = null;
_itemContexts = [];
_sizes = [];
_offsets = [];
+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();
}
}