Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d4f974494 | |||
| 5607f7047f | |||
| 766085995b | |||
| f43755c146 | |||
| bc72bba981 | |||
| cb080885b0 | |||
| 5eafd7ad38 | |||
| 9bd258f654 | |||
| 0bac50029a | |||
| 2ea52fdfdc | |||
| da6fb0a8a1 | |||
| 7c9627fffb | |||
| 9bd2286eb5 |
@@ -1,5 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
## v0.9.1 (2026-10-17)
|
||||
|
||||
**Breaking Changes**
|
||||
|
||||
- **network** Desktop TUN keys follow the profile unless picked; changed values carry over (cb08088)
|
||||
|
||||
**Features**
|
||||
|
||||
- **l10n** French, Spanish and Korean translations (f43755c)
|
||||
- **android** No more Firebase analytics or crash reporting (bc72bba)
|
||||
- **network** Per-key desktop TUN settings; system proxy and tray fixes (cb08088)
|
||||
- **theme** Redesigned theme colors and color styles (5eafd7a)
|
||||
- **dashboard** A redesigned dashboard around the current exit (9bd258f)
|
||||
- **profiles** Profile-detail resources and include-all in custom groups (0bac500)
|
||||
- **proxies** Proxy groups as cards, group tabs as chips (2ea52fd)
|
||||
- **views** Sortable tables for connections, requests and DNS (da6fb0a)
|
||||
- **ui** Optional two-tier Liquid Glass and slide-to-pick menus (7c9627f)
|
||||
- **ui** iOS-style surfaces, alerts and sheets; clearer TV focus (9bd2286)
|
||||
|
||||
**Bug Fixes**
|
||||
|
||||
- **ui** Fix pasting and Chinese input in text fields (5607f70)
|
||||
- **core** Large profiles no longer crash; downloads trust core CAs (5984c16)
|
||||
|
||||
**Performance**
|
||||
|
||||
- **android** Smoother Android UI while the proxy is busy (2201c19)
|
||||
|
||||
## v0.9.0 (2026-10-14)
|
||||
|
||||
**Breaking Changes**
|
||||
|
||||
+1
-1
@@ -823,7 +823,7 @@
|
||||
"keepAliveIdle": "TCP keep-alive idle",
|
||||
"disableKeepAlive": "Disable TCP keep-alive",
|
||||
"routingMark": "Routing mark",
|
||||
"strictRouteDesc": "Strict route keeps traffic such as DNS from bypassing TUN, and other devices can no longer reach this one.",
|
||||
"strictRouteDesc": "Strict route keeps traffic such as DNS from bypassing TUN, and with IPv6 off it blocks this device's IPv6 connections.",
|
||||
"initFailed": "Startup failed",
|
||||
"initFailedTip": "FlClash ran into an error while starting and can't continue. Copy the details to report the problem. If it keeps happening, clearing the data usually lets the app start again.",
|
||||
"errorDetails": "Error details",
|
||||
|
||||
+1
-1
@@ -823,7 +823,7 @@
|
||||
"keepAliveIdle": "Inactividad de keep-alive TCP",
|
||||
"disableKeepAlive": "Desactivar keep-alive TCP",
|
||||
"routingMark": "Marca de enrutamiento",
|
||||
"strictRouteDesc": "La ruta estricta evita que el tráfico, como el DNS, eluda TUN, y los demás dispositivos ya no pueden acceder a este.",
|
||||
"strictRouteDesc": "La ruta estricta evita que el tráfico, como el DNS, eluda TUN y, con IPv6 desactivado, bloquea las conexiones IPv6 de este dispositivo.",
|
||||
"initFailed": "Error al iniciar",
|
||||
"initFailedTip": "FlClash encontró un error al iniciarse y no puede continuar. Copia los detalles para informar del problema. Si sigue ocurriendo, borrar los datos suele permitir que la app vuelva a iniciarse.",
|
||||
"errorDetails": "Detalles del error",
|
||||
|
||||
+1
-1
@@ -823,7 +823,7 @@
|
||||
"keepAliveIdle": "Inactivité keep-alive TCP",
|
||||
"disableKeepAlive": "Désactiver le keep-alive TCP",
|
||||
"routingMark": "Marque de routage",
|
||||
"strictRouteDesc": "La route stricte empêche le trafic tel que le DNS de contourner TUN, et les autres appareils ne peuvent plus joindre celui-ci.",
|
||||
"strictRouteDesc": "La route stricte empêche le trafic tel que le DNS de contourner TUN et, avec IPv6 désactivé, bloque les connexions IPv6 de cet appareil.",
|
||||
"initFailed": "Échec du démarrage",
|
||||
"initFailedTip": "FlClash a rencontré une erreur au démarrage et ne peut pas continuer. Copiez les détails pour signaler le problème. Si cela se reproduit, effacer les données permet généralement de relancer l’app.",
|
||||
"errorDetails": "Détails de l’erreur",
|
||||
|
||||
+1
-1
@@ -823,7 +823,7 @@
|
||||
"keepAliveIdle": "TCP キープアライブ待機時間",
|
||||
"disableKeepAlive": "TCP キープアライブを無効化",
|
||||
"routingMark": "ルーティングマーク",
|
||||
"strictRouteDesc": "厳格なルーティングは DNS などの通信が TUN を迂回するのを防ぎます。有効にすると他のデバイスからこの端末にアクセスできなくなります。",
|
||||
"strictRouteDesc": "厳格なルーティングは DNS などの通信が TUN を迂回するのを防ぎます。IPv6 がオフの場合、この端末の IPv6 通信は遮断されます。",
|
||||
"initFailed": "起動に失敗しました",
|
||||
"initFailedTip": "FlClash の起動中にエラーが発生したため、続行できません。詳細をコピーして問題を報告できます。繰り返し発生する場合は、データを消去すると起動できるようになることがあります。",
|
||||
"errorDetails": "エラーの詳細",
|
||||
|
||||
+1
-1
@@ -823,7 +823,7 @@
|
||||
"keepAliveIdle": "TCP 킵얼라이브 유휴 시간",
|
||||
"disableKeepAlive": "TCP 킵얼라이브 사용 안 함",
|
||||
"routingMark": "라우팅 마크",
|
||||
"strictRouteDesc": "엄격한 라우팅은 DNS 등의 트래픽이 TUN을 우회하지 못하게 하며, 다른 기기는 이 기기에 접속할 수 없게 됩니다.",
|
||||
"strictRouteDesc": "엄격한 라우팅은 DNS 등의 트래픽이 TUN을 우회하지 못하게 하며, IPv6가 꺼져 있으면 이 기기의 IPv6 연결을 차단합니다.",
|
||||
"initFailed": "시작 실패",
|
||||
"initFailedTip": "FlClash를 시작하는 중 오류가 발생하여 계속할 수 없습니다. 세부 정보를 복사하여 문제를 보고하세요. 문제가 계속되면 데이터를 지우면 대개 앱이 다시 시작됩니다.",
|
||||
"errorDetails": "오류 세부 정보",
|
||||
|
||||
+1
-1
@@ -823,7 +823,7 @@
|
||||
"keepAliveIdle": "Простой TCP keep-alive",
|
||||
"disableKeepAlive": "Отключить TCP keep-alive",
|
||||
"routingMark": "Метка маршрутизации",
|
||||
"strictRouteDesc": "Строгая маршрутизация не даёт трафику вроде DNS обходить TUN, но другие устройства перестают видеть это устройство.",
|
||||
"strictRouteDesc": "Строгая маршрутизация не даёт трафику вроде DNS обходить TUN, а при выключенном IPv6 блокирует IPv6-соединения этого устройства.",
|
||||
"initFailed": "Не удалось запустить",
|
||||
"initFailedTip": "При запуске FlClash произошла ошибка, и работа не может быть продолжена. Скопируйте подробности, чтобы сообщить о проблеме. Если ошибка повторяется, очистка данных обычно позволяет снова запустить приложение.",
|
||||
"errorDetails": "Подробности ошибки",
|
||||
|
||||
+1
-1
@@ -823,7 +823,7 @@
|
||||
"keepAliveIdle": "TCP 保持活动空闲时间",
|
||||
"disableKeepAlive": "禁用 TCP 保持活动",
|
||||
"routingMark": "路由标记",
|
||||
"strictRouteDesc": "严格路由防止 DNS 等流量绕过虚拟网卡,开启后其他设备将无法访问本机。",
|
||||
"strictRouteDesc": "严格路由防止 DNS 等流量绕过虚拟网卡,关闭 IPv6 时会阻断本机的 IPv6 连接。",
|
||||
"initFailed": "启动失败",
|
||||
"initFailedTip": "FlClash 启动时出错,无法继续运行。可以复制错误详情用于反馈;如果问题反复出现,清除数据后通常可以重新启动。",
|
||||
"errorDetails": "错误详情",
|
||||
|
||||
@@ -1,6 +1,104 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.9.1",
|
||||
"tag": "v0.9.1",
|
||||
"date": "2026-10-17",
|
||||
"prerelease": false,
|
||||
"groups": [
|
||||
{
|
||||
"type": "breaking",
|
||||
"entries": [
|
||||
{
|
||||
"id": "cb08088",
|
||||
"scope": "network",
|
||||
"text": "Desktop TUN keys follow the profile unless picked; changed values carry over"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "feat",
|
||||
"entries": [
|
||||
{
|
||||
"id": "f43755c",
|
||||
"scope": "l10n",
|
||||
"text": "French, Spanish and Korean translations"
|
||||
},
|
||||
{
|
||||
"id": "bc72bba",
|
||||
"scope": "android",
|
||||
"text": "No more Firebase analytics or crash reporting"
|
||||
},
|
||||
{
|
||||
"id": "cb08088",
|
||||
"scope": "network",
|
||||
"text": "Per-key desktop TUN settings; system proxy and tray fixes"
|
||||
},
|
||||
{
|
||||
"id": "5eafd7a",
|
||||
"scope": "theme",
|
||||
"text": "Redesigned theme colors and color styles"
|
||||
},
|
||||
{
|
||||
"id": "9bd258f",
|
||||
"scope": "dashboard",
|
||||
"text": "A redesigned dashboard around the current exit"
|
||||
},
|
||||
{
|
||||
"id": "0bac500",
|
||||
"scope": "profiles",
|
||||
"text": "Profile-detail resources and include-all in custom groups"
|
||||
},
|
||||
{
|
||||
"id": "2ea52fd",
|
||||
"scope": "proxies",
|
||||
"text": "Proxy groups as cards, group tabs as chips"
|
||||
},
|
||||
{
|
||||
"id": "da6fb0a",
|
||||
"scope": "views",
|
||||
"text": "Sortable tables for connections, requests and DNS"
|
||||
},
|
||||
{
|
||||
"id": "7c9627f",
|
||||
"scope": "ui",
|
||||
"text": "Optional two-tier Liquid Glass and slide-to-pick menus"
|
||||
},
|
||||
{
|
||||
"id": "9bd2286",
|
||||
"scope": "ui",
|
||||
"text": "iOS-style surfaces, alerts and sheets; clearer TV focus"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "fix",
|
||||
"entries": [
|
||||
{
|
||||
"id": "5607f70",
|
||||
"scope": "ui",
|
||||
"text": "Fix pasting and Chinese input in text fields"
|
||||
},
|
||||
{
|
||||
"id": "5984c16",
|
||||
"scope": "core",
|
||||
"text": "Large profiles no longer crash; downloads trust core CAs"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "perf",
|
||||
"entries": [
|
||||
{
|
||||
"id": "2201c19",
|
||||
"scope": "android",
|
||||
"text": "Smoother Android UI while the proxy is busy"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.9.0",
|
||||
"tag": "v0.9.0",
|
||||
|
||||
@@ -1734,7 +1734,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"strategy": MessageLookupByLibrary.simpleMessage("Strategy"),
|
||||
"strictRoute": MessageLookupByLibrary.simpleMessage("Strict route"),
|
||||
"strictRouteDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"Strict route keeps traffic such as DNS from bypassing TUN, and other devices can no longer reach this one.",
|
||||
"Strict route keeps traffic such as DNS from bypassing TUN, and with IPv6 off it blocks this device\'s IPv6 connections.",
|
||||
),
|
||||
"style": MessageLookupByLibrary.simpleMessage("Style"),
|
||||
"submit": MessageLookupByLibrary.simpleMessage("Submit"),
|
||||
|
||||
@@ -1865,7 +1865,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"strategy": MessageLookupByLibrary.simpleMessage("Estrategia"),
|
||||
"strictRoute": MessageLookupByLibrary.simpleMessage("Ruta estricta"),
|
||||
"strictRouteDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"La ruta estricta evita que el tráfico, como el DNS, eluda TUN, y los demás dispositivos ya no pueden acceder a este.",
|
||||
"La ruta estricta evita que el tráfico, como el DNS, eluda TUN y, con IPv6 desactivado, bloquea las conexiones IPv6 de este dispositivo.",
|
||||
),
|
||||
"style": MessageLookupByLibrary.simpleMessage("Estilo"),
|
||||
"submit": MessageLookupByLibrary.simpleMessage("Enviar"),
|
||||
|
||||
@@ -1868,7 +1868,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"strategy": MessageLookupByLibrary.simpleMessage("Stratégie"),
|
||||
"strictRoute": MessageLookupByLibrary.simpleMessage("Route stricte"),
|
||||
"strictRouteDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"La route stricte empêche le trafic tel que le DNS de contourner TUN, et les autres appareils ne peuvent plus joindre celui-ci.",
|
||||
"La route stricte empêche le trafic tel que le DNS de contourner TUN et, avec IPv6 désactivé, bloque les connexions IPv6 de cet appareil.",
|
||||
),
|
||||
"style": MessageLookupByLibrary.simpleMessage("Style"),
|
||||
"submit": MessageLookupByLibrary.simpleMessage("Valider"),
|
||||
|
||||
@@ -1488,7 +1488,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"strategy": MessageLookupByLibrary.simpleMessage("戦略"),
|
||||
"strictRoute": MessageLookupByLibrary.simpleMessage("厳格なルーティング"),
|
||||
"strictRouteDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"厳格なルーティングは DNS などの通信が TUN を迂回するのを防ぎます。有効にすると他のデバイスからこの端末にアクセスできなくなります。",
|
||||
"厳格なルーティングは DNS などの通信が TUN を迂回するのを防ぎます。IPv6 がオフの場合、この端末の IPv6 通信は遮断されます。",
|
||||
),
|
||||
"style": MessageLookupByLibrary.simpleMessage("スタイル"),
|
||||
"submit": MessageLookupByLibrary.simpleMessage("送信"),
|
||||
|
||||
@@ -1459,7 +1459,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"strategy": MessageLookupByLibrary.simpleMessage("전략"),
|
||||
"strictRoute": MessageLookupByLibrary.simpleMessage("엄격한 라우팅"),
|
||||
"strictRouteDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"엄격한 라우팅은 DNS 등의 트래픽이 TUN을 우회하지 못하게 하며, 다른 기기는 이 기기에 접속할 수 없게 됩니다.",
|
||||
"엄격한 라우팅은 DNS 등의 트래픽이 TUN을 우회하지 못하게 하며, IPv6가 꺼져 있으면 이 기기의 IPv6 연결을 차단합니다.",
|
||||
),
|
||||
"style": MessageLookupByLibrary.simpleMessage("스타일"),
|
||||
"submit": MessageLookupByLibrary.simpleMessage("제출"),
|
||||
|
||||
@@ -1817,7 +1817,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"Строгая маршрутизация",
|
||||
),
|
||||
"strictRouteDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"Строгая маршрутизация не даёт трафику вроде DNS обходить TUN, но другие устройства перестают видеть это устройство.",
|
||||
"Строгая маршрутизация не даёт трафику вроде DNS обходить TUN, а при выключенном IPv6 блокирует IPv6-соединения этого устройства.",
|
||||
),
|
||||
"style": MessageLookupByLibrary.simpleMessage("Стиль"),
|
||||
"submit": MessageLookupByLibrary.simpleMessage("Отправить"),
|
||||
|
||||
@@ -1341,7 +1341,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"strategy": MessageLookupByLibrary.simpleMessage("策略"),
|
||||
"strictRoute": MessageLookupByLibrary.simpleMessage("严格路由"),
|
||||
"strictRouteDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"严格路由防止 DNS 等流量绕过虚拟网卡,开启后其他设备将无法访问本机。",
|
||||
"严格路由防止 DNS 等流量绕过虚拟网卡,关闭 IPv6 时会阻断本机的 IPv6 连接。",
|
||||
),
|
||||
"style": MessageLookupByLibrary.simpleMessage("风格"),
|
||||
"submit": MessageLookupByLibrary.simpleMessage("提交"),
|
||||
|
||||
+2
-2
@@ -6818,10 +6818,10 @@ class AppLocalizations {
|
||||
);
|
||||
}
|
||||
|
||||
/// `Strict route keeps traffic such as DNS from bypassing TUN, and other devices can no longer reach this one.`
|
||||
/// `Strict route keeps traffic such as DNS from bypassing TUN, and with IPv6 off it blocks this device's IPv6 connections.`
|
||||
String get strictRouteDesc {
|
||||
return Intl.message(
|
||||
'Strict route keeps traffic such as DNS from bypassing TUN, and other devices can no longer reach this one.',
|
||||
'Strict route keeps traffic such as DNS from bypassing TUN, and with IPv6 off it blocks this device\'s IPv6 connections.',
|
||||
name: 'strictRouteDesc',
|
||||
desc: '',
|
||||
args: [],
|
||||
|
||||
@@ -21,7 +21,6 @@ abstract class NavigationItem with _$NavigationItem {
|
||||
required Glyph glyph,
|
||||
required PageLabel label,
|
||||
required WidgetBuilder builder,
|
||||
@Default(true) bool keep,
|
||||
String? path,
|
||||
@Default([NavigationItemMode.mobile, NavigationItemMode.desktop])
|
||||
List<NavigationItemMode> modes,
|
||||
|
||||
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$NavigationItem {
|
||||
|
||||
Glyph get glyph; PageLabel get label; WidgetBuilder get builder; bool get keep; String? get path; List<NavigationItemMode> get modes;
|
||||
Glyph get glyph; PageLabel get label; WidgetBuilder get builder; String? get path; List<NavigationItemMode> get modes;
|
||||
/// Create a copy of NavigationItem
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -27,20 +27,20 @@ $NavigationItemCopyWith<NavigationItem> get copyWith => _$NavigationItemCopyWith
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
final _this = this as NavigationItem;
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is NavigationItem&&(identical(other.glyph, _this.glyph) || other.glyph == _this.glyph)&&(identical(other.label, _this.label) || other.label == _this.label)&&(identical(other.builder, _this.builder) || other.builder == _this.builder)&&(identical(other.keep, _this.keep) || other.keep == _this.keep)&&(identical(other.path, _this.path) || other.path == _this.path)&&const DeepCollectionEquality().equals(other.modes, _this.modes));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is NavigationItem&&(identical(other.glyph, _this.glyph) || other.glyph == _this.glyph)&&(identical(other.label, _this.label) || other.label == _this.label)&&(identical(other.builder, _this.builder) || other.builder == _this.builder)&&(identical(other.path, _this.path) || other.path == _this.path)&&const DeepCollectionEquality().equals(other.modes, _this.modes));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
final _this = this as NavigationItem;
|
||||
return Object.hash(runtimeType,_this.glyph,_this.label,_this.builder,_this.keep,_this.path,const DeepCollectionEquality().hash(_this.modes));
|
||||
return Object.hash(runtimeType,_this.glyph,_this.label,_this.builder,_this.path,const DeepCollectionEquality().hash(_this.modes));
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final _this = this as NavigationItem;
|
||||
return 'NavigationItem(glyph: ${_this.glyph}, label: ${_this.label}, builder: ${_this.builder}, keep: ${_this.keep}, path: ${_this.path}, modes: ${_this.modes})';
|
||||
return 'NavigationItem(glyph: ${_this.glyph}, label: ${_this.label}, builder: ${_this.builder}, path: ${_this.path}, modes: ${_this.modes})';
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ abstract mixin class $NavigationItemCopyWith<$Res> {
|
||||
factory $NavigationItemCopyWith(NavigationItem value, $Res Function(NavigationItem) _then) = _$NavigationItemCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
Glyph glyph, PageLabel label, WidgetBuilder builder, bool keep, String? path, List<NavigationItemMode> modes
|
||||
Glyph glyph, PageLabel label, WidgetBuilder builder, String? path, List<NavigationItemMode> modes
|
||||
});
|
||||
|
||||
|
||||
@@ -68,13 +68,12 @@ class _$NavigationItemCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of NavigationItem
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? glyph = null,Object? label = null,Object? builder = null,Object? keep = null,Object? path = freezed,Object? modes = null,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? glyph = null,Object? label = null,Object? builder = null,Object? path = freezed,Object? modes = null,}) {
|
||||
return _then(NavigationItem(
|
||||
glyph: null == glyph ? _self.glyph : glyph // ignore: cast_nullable_to_non_nullable
|
||||
as Glyph,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable
|
||||
as PageLabel,builder: null == builder ? _self.builder : builder // ignore: cast_nullable_to_non_nullable
|
||||
as WidgetBuilder,keep: null == keep ? _self.keep : keep // ignore: cast_nullable_to_non_nullable
|
||||
as bool,path: freezed == path ? _self.path : path // ignore: cast_nullable_to_non_nullable
|
||||
as WidgetBuilder,path: freezed == path ? _self.path : path // ignore: cast_nullable_to_non_nullable
|
||||
as String?,modes: null == modes ? _self.modes : modes // ignore: cast_nullable_to_non_nullable
|
||||
as List<NavigationItemMode>,
|
||||
));
|
||||
@@ -161,10 +160,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( Glyph glyph, PageLabel label, WidgetBuilder builder, bool keep, String? path, List<NavigationItemMode> modes)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( Glyph glyph, PageLabel label, WidgetBuilder builder, String? path, List<NavigationItemMode> modes)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _NavigationItem() when $default != null:
|
||||
return $default(_that.glyph,_that.label,_that.builder,_that.keep,_that.path,_that.modes);case _:
|
||||
return $default(_that.glyph,_that.label,_that.builder,_that.path,_that.modes);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
@@ -182,10 +181,10 @@ return $default(_that.glyph,_that.label,_that.builder,_that.keep,_that.path,_tha
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( Glyph glyph, PageLabel label, WidgetBuilder builder, bool keep, String? path, List<NavigationItemMode> modes) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( Glyph glyph, PageLabel label, WidgetBuilder builder, String? path, List<NavigationItemMode> modes) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _NavigationItem():
|
||||
return $default(_that.glyph,_that.label,_that.builder,_that.keep,_that.path,_that.modes);case _:
|
||||
return $default(_that.glyph,_that.label,_that.builder,_that.path,_that.modes);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
@@ -202,10 +201,10 @@ return $default(_that.glyph,_that.label,_that.builder,_that.keep,_that.path,_tha
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( Glyph glyph, PageLabel label, WidgetBuilder builder, bool keep, String? path, List<NavigationItemMode> modes)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( Glyph glyph, PageLabel label, WidgetBuilder builder, String? path, List<NavigationItemMode> modes)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _NavigationItem() when $default != null:
|
||||
return $default(_that.glyph,_that.label,_that.builder,_that.keep,_that.path,_that.modes);case _:
|
||||
return $default(_that.glyph,_that.label,_that.builder,_that.path,_that.modes);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
@@ -217,13 +216,12 @@ return $default(_that.glyph,_that.label,_that.builder,_that.keep,_that.path,_tha
|
||||
|
||||
|
||||
class _NavigationItem implements NavigationItem {
|
||||
const _NavigationItem({required this.glyph, required this.label, required this.builder, this.keep = true, this.path, List<NavigationItemMode> modes = const [NavigationItemMode.mobile, NavigationItemMode.desktop]}): _modes = modes;
|
||||
const _NavigationItem({required this.glyph, required this.label, required this.builder, this.path, List<NavigationItemMode> modes = const [NavigationItemMode.mobile, NavigationItemMode.desktop]}): _modes = modes;
|
||||
|
||||
|
||||
@override final Glyph glyph;
|
||||
@override final PageLabel label;
|
||||
@override final WidgetBuilder builder;
|
||||
@override@JsonKey() final bool keep;
|
||||
@override final String? path;
|
||||
final List<NavigationItemMode> _modes;
|
||||
@override@JsonKey() List<NavigationItemMode> get modes {
|
||||
@@ -243,18 +241,18 @@ _$NavigationItemCopyWith<_NavigationItem> get copyWith => __$NavigationItemCopyW
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _NavigationItem&&(identical(other.glyph, glyph) || other.glyph == glyph)&&(identical(other.label, label) || other.label == label)&&(identical(other.builder, builder) || other.builder == builder)&&(identical(other.keep, keep) || other.keep == keep)&&(identical(other.path, path) || other.path == path)&&const DeepCollectionEquality().equals(other.modes, _modes));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _NavigationItem&&(identical(other.glyph, glyph) || other.glyph == glyph)&&(identical(other.label, label) || other.label == label)&&(identical(other.builder, builder) || other.builder == builder)&&(identical(other.path, path) || other.path == path)&&const DeepCollectionEquality().equals(other.modes, _modes));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(runtimeType,glyph,label,builder,keep,path,const DeepCollectionEquality().hash(_modes));
|
||||
return Object.hash(runtimeType,glyph,label,builder,path,const DeepCollectionEquality().hash(_modes));
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'NavigationItem(glyph: $glyph, label: $label, builder: $builder, keep: $keep, path: $path, modes: $modes)';
|
||||
return 'NavigationItem(glyph: $glyph, label: $label, builder: $builder, path: $path, modes: $modes)';
|
||||
}
|
||||
|
||||
|
||||
@@ -265,7 +263,7 @@ abstract mixin class _$NavigationItemCopyWith<$Res> implements $NavigationItemCo
|
||||
factory _$NavigationItemCopyWith(_NavigationItem value, $Res Function(_NavigationItem) _then) = __$NavigationItemCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
Glyph glyph, PageLabel label, WidgetBuilder builder, bool keep, String? path, List<NavigationItemMode> modes
|
||||
Glyph glyph, PageLabel label, WidgetBuilder builder, String? path, List<NavigationItemMode> modes
|
||||
});
|
||||
|
||||
|
||||
@@ -282,13 +280,12 @@ class __$NavigationItemCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of NavigationItem
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? glyph = null,Object? label = null,Object? builder = null,Object? keep = null,Object? path = freezed,Object? modes = null,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? glyph = null,Object? label = null,Object? builder = null,Object? path = freezed,Object? modes = null,}) {
|
||||
return _then(_NavigationItem(
|
||||
glyph: null == glyph ? _self.glyph : glyph // ignore: cast_nullable_to_non_nullable
|
||||
as Glyph,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable
|
||||
as PageLabel,builder: null == builder ? _self.builder : builder // ignore: cast_nullable_to_non_nullable
|
||||
as WidgetBuilder,keep: null == keep ? _self.keep : keep // ignore: cast_nullable_to_non_nullable
|
||||
as bool,path: freezed == path ? _self.path : path // ignore: cast_nullable_to_non_nullable
|
||||
as WidgetBuilder,path: freezed == path ? _self.path : path // ignore: cast_nullable_to_non_nullable
|
||||
as String?,modes: null == modes ? _self._modes : modes // ignore: cast_nullable_to_non_nullable
|
||||
as List<NavigationItemMode>,
|
||||
));
|
||||
|
||||
@@ -191,7 +191,6 @@ class _NavigationPage extends StatelessWidget {
|
||||
final scopedView = PageFocusScope(child: view);
|
||||
final keptView = KeepScope(
|
||||
key: ValueKey(item.label),
|
||||
keep: item.keep,
|
||||
child: isMobile
|
||||
? scopedView
|
||||
: Navigator(
|
||||
|
||||
+39
-21
@@ -25,6 +25,13 @@ const _systemUiOverlayStyle = SystemUiOverlayStyle(
|
||||
|
||||
const _invalidHintDuration = Duration(seconds: 2);
|
||||
|
||||
const _previewRevealDuration = Duration(milliseconds: 600);
|
||||
const _previewRevealAwaitingFirstFrame = Interval(
|
||||
0.4,
|
||||
1,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
|
||||
class ScanPage extends ConsumerStatefulWidget {
|
||||
const ScanPage({super.key});
|
||||
|
||||
@@ -33,7 +40,10 @@ class ScanPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _ScanPageState extends ConsumerState<ScanPage>
|
||||
with WidgetsBindingObserver, SingleTickerProviderStateMixin {
|
||||
with
|
||||
WidgetsBindingObserver,
|
||||
SingleTickerProviderStateMixin,
|
||||
RouteSettledMixin<ScanPage> {
|
||||
final _controller = MobileScannerController(
|
||||
autoStart: false,
|
||||
detectionSpeed: DetectionSpeed.noDuplicates,
|
||||
@@ -63,7 +73,6 @@ class _ScanPageState extends ConsumerState<ScanPage>
|
||||
_handleCapture,
|
||||
onError: _handleDetectError,
|
||||
);
|
||||
unawaited(_start());
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -73,9 +82,15 @@ class _ScanPageState extends ConsumerState<ScanPage>
|
||||
_syncEffect();
|
||||
}
|
||||
|
||||
// Android binds the camera on the platform thread, which the UI shares.
|
||||
@override
|
||||
void didSettleRoute() {
|
||||
unawaited(_start());
|
||||
}
|
||||
|
||||
void _syncEffect() {
|
||||
if (_disableAnimations || _controller.value.error != null) {
|
||||
_effectController.stop();
|
||||
if (_disableAnimations || !_controller.value.isRunning) {
|
||||
_effectController.reset();
|
||||
} else if (!_effectController.isAnimating) {
|
||||
_effectController.repeat();
|
||||
}
|
||||
@@ -85,7 +100,7 @@ class _ScanPageState extends ConsumerState<ScanPage>
|
||||
// the first start is still pending, and a second `start` would throw.
|
||||
Future<void> _start() async {
|
||||
final state = _controller.value;
|
||||
if (_completed || state.isStarting || state.isRunning) {
|
||||
if (_completed || !routeSettled || state.isStarting || state.isRunning) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -218,10 +233,24 @@ class _ScanPageState extends ConsumerState<ScanPage>
|
||||
Positioned.fill(
|
||||
child: MobileScanner(
|
||||
controller: _controller,
|
||||
scanWindow: layout.scanWindow,
|
||||
errorBuilder: (_, _) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: ValueListenableBuilder<MobileScannerState>(
|
||||
valueListenable: _controller,
|
||||
builder: (context, state, _) => AnimatedOpacity(
|
||||
opacity: state.isRunning || state.error != null
|
||||
? 0
|
||||
: 1,
|
||||
duration: _disableAnimations || !state.isRunning
|
||||
? Duration.zero
|
||||
: _previewRevealDuration,
|
||||
curve: _previewRevealAwaitingFirstFrame,
|
||||
child: const ColoredBox(color: Colors.black),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: ValueListenableBuilder<MobileScannerState>(
|
||||
valueListenable: _controller,
|
||||
@@ -628,27 +657,16 @@ class ScannerOverlay extends CustomPainter {
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final backgroundPath = Path()..addRect(Rect.largest);
|
||||
|
||||
final cutoutPath = Path()
|
||||
final backgroundWithCutout = Path()
|
||||
..fillType = PathFillType.evenOdd
|
||||
..addRect(Offset.zero & size)
|
||||
..addRSuperellipse(
|
||||
RSuperellipse.fromRectAndRadius(
|
||||
scanWindow,
|
||||
Radius.circular(borderRadius),
|
||||
),
|
||||
);
|
||||
|
||||
final backgroundPaint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final backgroundWithCutout = Path.combine(
|
||||
PathOperation.difference,
|
||||
backgroundPath,
|
||||
cutoutPath,
|
||||
);
|
||||
|
||||
canvas.drawPath(backgroundWithCutout, backgroundPaint);
|
||||
canvas.drawPath(backgroundWithCutout, Paint()..color = color);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -2157,6 +2157,48 @@ final class ProxiesTabControllerStateProvider
|
||||
String _$proxiesTabControllerStateHash() =>
|
||||
r'548db61efef2c47c2694c855436276fcd3529956';
|
||||
|
||||
@ProviderFor(currentGroupDelayTesting)
|
||||
final currentGroupDelayTestingProvider = CurrentGroupDelayTestingProvider._();
|
||||
|
||||
final class CurrentGroupDelayTestingProvider
|
||||
extends $FunctionalProvider<bool, bool, bool>
|
||||
with $Provider<bool> {
|
||||
CurrentGroupDelayTestingProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'currentGroupDelayTestingProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$currentGroupDelayTestingHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
bool create(Ref ref) {
|
||||
return currentGroupDelayTesting(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$currentGroupDelayTestingHash() =>
|
||||
r'8eff21356efb7ad6b208b0badfc9081d94aa2fb4';
|
||||
|
||||
@ProviderFor(realTestUrl)
|
||||
final realTestUrlProvider = RealTestUrlFamily._();
|
||||
|
||||
|
||||
@@ -165,6 +165,18 @@ ProxiesTabControllerState proxiesTabControllerState(Ref ref) {
|
||||
);
|
||||
}
|
||||
|
||||
@riverpod
|
||||
bool currentGroupDelayTesting(Ref ref) {
|
||||
final currentGroupName = ref.watch(
|
||||
proxiesTabControllerStateProvider.select((state) => state.currentGroupName),
|
||||
);
|
||||
return ref.watch(
|
||||
delayTestingGroupsProvider.select(
|
||||
(state) => state.contains(currentGroupName),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@riverpod
|
||||
String realTestUrl(Ref ref, [String? testUrl]) {
|
||||
final currentTestUrl = ref.watch(appSettingProvider).testUrl;
|
||||
|
||||
@@ -18,6 +18,13 @@ const Animation<Offset> _noSlide = AlwaysStoppedAnimation(Offset.zero);
|
||||
mixin DragBackRouteMixin<T> on ModalRoute<T> {
|
||||
bool _dragBackActive = false;
|
||||
|
||||
/// What a page hands back to its opener when a pop brings no result of its
|
||||
/// own, as a drag back's does, so the page need not veto pops to answer.
|
||||
ValueGetter<Object?>? pageResult;
|
||||
|
||||
@override
|
||||
T? get currentResult => pageResult?.call() as T?;
|
||||
|
||||
/// While true the route's own transition should follow the finger linearly,
|
||||
/// through the release until the route settles.
|
||||
bool get isDragBackActive => _dragBackActive;
|
||||
|
||||
@@ -2,9 +2,8 @@ import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class KeepScope extends StatefulWidget {
|
||||
final Widget child;
|
||||
final bool keep;
|
||||
|
||||
const KeepScope({super.key, required this.child, this.keep = true});
|
||||
const KeepScope({super.key, required this.child});
|
||||
|
||||
@override
|
||||
State<KeepScope> createState() => _KeepContainerState();
|
||||
@@ -19,5 +18,5 @@ class _KeepContainerState extends State<KeepScope>
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => widget.keep;
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import 'inherited.dart';
|
||||
|
||||
class CommonPopScope extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
/// Any handler vetoes the pop up front, which also turns off the drag back,
|
||||
/// so pass one only while there is something to intercept.
|
||||
final FutureOr<bool> Function(BuildContext context)? onPop;
|
||||
final FutureOr<void> Function()? onPopSuccess;
|
||||
|
||||
|
||||
+4
-20
@@ -1716,7 +1716,7 @@ class _CommonPopupMenuState extends State<CommonPopupMenu>
|
||||
return 2 * _cardInset + (_maxVisibleItems + 0.5) * _rowExtentOf(context);
|
||||
}
|
||||
|
||||
static final _labelWidths = <(String, TextStyle, TextScaler), double>{};
|
||||
static final _labelWidths = <(String, TextStyle, TextScaler, bool), double>{};
|
||||
|
||||
/// What the rows would take at their intrinsic width, measured from the
|
||||
/// labels as the rows lay them out.
|
||||
@@ -1724,31 +1724,15 @@ class _CommonPopupMenuState extends State<CommonPopupMenu>
|
||||
final style = AppTextStyle.subheadline.of(context);
|
||||
final root = DefaultTextStyle.of(context).style.merge(style);
|
||||
final textScaler = MediaQuery.textScalerOf(context);
|
||||
final textDirection = Directionality.of(context);
|
||||
final locale = Localizations.maybeLocaleOf(context);
|
||||
final boldText = MediaQuery.boldTextOf(context);
|
||||
const limit = _menuMaxWidth - 2 * _cardInset;
|
||||
var width = 0.0;
|
||||
for (final item in items) {
|
||||
if (_labelWidths.length >= 1024) {
|
||||
_labelWidths.clear();
|
||||
}
|
||||
final label = _labelWidths[(item.label, root, textScaler)] ??= () {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(
|
||||
style: root,
|
||||
children: [EmojiText.spanOf(item.label, style)],
|
||||
),
|
||||
textDirection: textDirection,
|
||||
textScaler: textScaler,
|
||||
locale: locale,
|
||||
maxLines: 1,
|
||||
)..layout();
|
||||
try {
|
||||
return painter.maxIntrinsicWidth;
|
||||
} finally {
|
||||
painter.dispose();
|
||||
}
|
||||
}();
|
||||
final label = _labelWidths[(item.label, root, textScaler, boldText)] ??=
|
||||
EmojiText.widthOf(context, item.label, root);
|
||||
final checked = item.checked == true;
|
||||
final row =
|
||||
(checked ? _itemArrowPadding : _itemPadding).horizontal +
|
||||
|
||||
@@ -96,6 +96,27 @@ class EmojiText extends StatelessWidget {
|
||||
return TextSpan(children: spans);
|
||||
}
|
||||
|
||||
/// The width [EmojiText] lays [text] out at on one line under [style].
|
||||
static double widthOf(BuildContext context, String text, TextStyle style) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(
|
||||
style: MediaQuery.boldTextOf(context)
|
||||
? style.merge(const TextStyle(fontWeight: FontWeight.bold))
|
||||
: style,
|
||||
children: [spanOf(text, null)],
|
||||
),
|
||||
textDirection: Directionality.of(context),
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
locale: Localizations.maybeLocaleOf(context),
|
||||
maxLines: 1,
|
||||
)..layout();
|
||||
try {
|
||||
return painter.maxIntrinsicWidth;
|
||||
} finally {
|
||||
painter.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text.rich(
|
||||
|
||||
@@ -111,15 +111,12 @@ class _AddedRulesViewState extends ConsumerState<AddedRulesView> {
|
||||
),
|
||||
];
|
||||
return CommonPopScope(
|
||||
onPop: (_) {
|
||||
if (selectedRules.isNotEmpty) {
|
||||
ref.read(itemsProvider(_key).notifier).value = {};
|
||||
return false;
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
return false;
|
||||
},
|
||||
|
||||
onPop: isSelecting
|
||||
? (_) {
|
||||
ref.read(itemsProvider(_key).notifier).value = {};
|
||||
return false;
|
||||
}
|
||||
: null,
|
||||
child: CommonScaffold(
|
||||
title: appLocalizations.addedRules,
|
||||
searchState: AppBarSearchState(onSearch: _handleSearch),
|
||||
|
||||
@@ -15,7 +15,6 @@ class Navigation implements NavigationPort {
|
||||
}) {
|
||||
return [
|
||||
NavigationItem(
|
||||
keep: false,
|
||||
glyph: AppGlyphs.dashboard,
|
||||
label: PageLabel.dashboard,
|
||||
builder: (_) =>
|
||||
|
||||
@@ -237,11 +237,13 @@ class _EditProfileViewState extends ConsumerState<EditProfileView> {
|
||||
},
|
||||
);
|
||||
final data = await BaseNavigator.push<String>(context, editorPage);
|
||||
if (data == null) {
|
||||
if (data == null || !mounted) {
|
||||
return;
|
||||
}
|
||||
_rawText = data;
|
||||
_fileData = Uint8List.fromList(utf8.encode(data));
|
||||
setState(() {
|
||||
_fileData = Uint8List.fromList(utf8.encode(data));
|
||||
});
|
||||
_fileInfoNotifier.value = _fileInfoNotifier.value?.copyWith(
|
||||
size: _fileData?.length ?? 0,
|
||||
lastModified: DateTime.now(),
|
||||
@@ -251,10 +253,13 @@ class _EditProfileViewState extends ConsumerState<EditProfileView> {
|
||||
Future<void> _uploadProfileFile() async {
|
||||
final platformFile = await globalState.safeRun(picker.pickerFile);
|
||||
if (platformFile == null) return;
|
||||
_fileData = await platformFile.readBytes();
|
||||
final fileData = await platformFile.readBytes();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_fileData = fileData;
|
||||
});
|
||||
_fileInfoNotifier.value = _fileInfoNotifier.value?.copyWith(
|
||||
size: _fileData?.length ?? 0,
|
||||
lastModified: DateTime.now(),
|
||||
@@ -335,13 +340,12 @@ class _EditProfileViewState extends ConsumerState<EditProfileView> {
|
||||
policy: PageTraversalPolicy(),
|
||||
child: PageFocusScope(
|
||||
child: CommonPopScope(
|
||||
onPop: (context) {
|
||||
if (_fileData == null) {
|
||||
return true;
|
||||
}
|
||||
_handleBack();
|
||||
return false;
|
||||
},
|
||||
onPop: _fileData == null
|
||||
? null
|
||||
: (_) {
|
||||
_handleBack();
|
||||
return false;
|
||||
},
|
||||
child: CommonScaffold(
|
||||
title: appLocalizations.edit,
|
||||
confirmationAction: IconButtonData(
|
||||
|
||||
@@ -124,14 +124,12 @@ class _StandardContentState extends ConsumerState<StandardContent> {
|
||||
final addedRules = ref.watch(profileRulesProvider(_profileId)).value ?? [];
|
||||
final selectedRules = ref.watch(itemsProvider(_key));
|
||||
return CommonPopScope(
|
||||
onPop: (_) {
|
||||
if (selectedRules.isNotEmpty) {
|
||||
ref.read(itemsProvider(_key).notifier).value = {};
|
||||
return false;
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
return false;
|
||||
},
|
||||
onPop: selectedRules.isEmpty
|
||||
? null
|
||||
: (_) {
|
||||
ref.read(itemsProvider(_key).notifier).value = {};
|
||||
return false;
|
||||
},
|
||||
child: SliverMainAxisGroup(
|
||||
slivers: [
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||
|
||||
@@ -312,12 +312,16 @@ class _DelayTextState extends ConsumerState<_DelayText> {
|
||||
size: measure.textHeight(AppTextStyle.caption2),
|
||||
color: context.colorScheme.onSurfaceVariant.opacity38,
|
||||
),
|
||||
null => IconButton(
|
||||
tooltip: context.appLocalizations.delayTest,
|
||||
icon: const GlyphIcon(AppGlyphs.bolt),
|
||||
iconSize: measure.textHeight(AppTextStyle.caption2),
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: _handleTestCurrentDelay,
|
||||
null => Tooltip(
|
||||
message: context.appLocalizations.delayTest,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _handleTestCurrentDelay,
|
||||
child: GlyphIcon(
|
||||
AppGlyphs.bolt,
|
||||
size: measure.textHeight(AppTextStyle.caption2),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/models/common.dart';
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
const _stripInset = 16.0;
|
||||
const _tabSpacing = 8.0;
|
||||
const _tabPadding = 16.0;
|
||||
|
||||
/// The group chips over the proxies tab view. Only the chips in view are
|
||||
/// built: each chip's width comes from its label, so the strip can scroll to
|
||||
/// one it has not built.
|
||||
class GroupTabs extends StatefulWidget {
|
||||
const GroupTabs({super.key, required this.controller, required this.groups});
|
||||
|
||||
final TabController controller;
|
||||
final List<Group> groups;
|
||||
|
||||
@override
|
||||
State<GroupTabs> createState() => _GroupTabsState();
|
||||
}
|
||||
|
||||
class _GroupTabsState extends State<GroupTabs> {
|
||||
static const _fadeExtent = 24.0;
|
||||
|
||||
// A sheet's controller would hand the strip's drags to the sheet.
|
||||
final _scrollController = ScrollController();
|
||||
final _hasMore = ValueNotifier(false);
|
||||
final _fade = ValueNotifier(0.0);
|
||||
late final _selected = ValueNotifier(widget.controller.index);
|
||||
_TabExtents? _extents;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_follow(widget.controller);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(GroupTabs oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.controller != widget.controller) {
|
||||
oldWidget.controller.animation?.removeListener(_handleAnimation);
|
||||
_follow(widget.controller);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.animation?.removeListener(_handleAnimation);
|
||||
_scrollController.dispose();
|
||||
_hasMore.dispose();
|
||||
_fade.dispose();
|
||||
_selected.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _follow(TabController controller) {
|
||||
_selected.value = controller.index;
|
||||
controller.animation!.addListener(_handleAnimation);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _reveal(jump: true));
|
||||
}
|
||||
|
||||
// A tap animates across every tab between; only a swipe's passes count.
|
||||
void _handleAnimation() {
|
||||
final controller = widget.controller;
|
||||
final index = controller.indexIsChanging
|
||||
? controller.index
|
||||
: controller.animation!.value.round();
|
||||
if (index == _selected.value) {
|
||||
return;
|
||||
}
|
||||
_selected.value = index;
|
||||
_reveal();
|
||||
}
|
||||
|
||||
void _reveal({bool jump = false}) {
|
||||
final extents = _extents;
|
||||
if (!mounted || extents == null || !_scrollController.hasClients) {
|
||||
return;
|
||||
}
|
||||
final position = _scrollController.position;
|
||||
final index = _selected.value;
|
||||
if (!position.hasContentDimensions ||
|
||||
index < 0 ||
|
||||
index >= extents.widths.length) {
|
||||
return;
|
||||
}
|
||||
final center =
|
||||
_stripInset + extents.starts[index] + extents.widths[index] / 2;
|
||||
final target = (center - position.viewportDimension / 2).clamp(
|
||||
position.minScrollExtent,
|
||||
position.maxScrollExtent,
|
||||
);
|
||||
if (jump) {
|
||||
position.jumpTo(target);
|
||||
} else {
|
||||
position.animateTo(
|
||||
target,
|
||||
duration: commonDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _select(int index) {
|
||||
widget.controller.animateTo(index);
|
||||
}
|
||||
|
||||
bool _handleMetrics(ScrollMetrics metrics) {
|
||||
_fade.value = (metrics.extentAfter / _fadeExtent).clamp(0.0, 1.0);
|
||||
final hasMore = metrics.maxScrollExtent > 0;
|
||||
if (hasMore != _hasMore.value) {
|
||||
_hasMore.value = hasMore;
|
||||
// The more button narrows the tabs it overflows.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _reveal(jump: true));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Shader _buildEdgeFade(Rect bounds, double fade) {
|
||||
return LinearGradient(
|
||||
begin: AlignmentDirectional.centerStart,
|
||||
end: AlignmentDirectional.centerEnd,
|
||||
colors: [
|
||||
Colors.black,
|
||||
Colors.black.withValues(alpha: 1 - fade),
|
||||
],
|
||||
stops: [max(0.0, 1 - _fadeExtent / bounds.width), 1],
|
||||
).createShader(bounds, textDirection: Directionality.of(context));
|
||||
}
|
||||
|
||||
Widget _buildMoreButton() {
|
||||
return TonalButtonTheme(
|
||||
size: TonalButtonSize.section,
|
||||
child: ElasticPress(
|
||||
child: CommonPopupBox(
|
||||
targetBuilder: (open) => IconButton(
|
||||
tooltip: context.appLocalizations.proxyGroup,
|
||||
onPressed: open,
|
||||
icon: const GlyphIcon(AppGlyphs.chevronDown),
|
||||
),
|
||||
popupBuilder: (_) => CommonPopupMenu(
|
||||
maxVisibleItems: 7,
|
||||
items: [
|
||||
for (final (index, group) in widget.groups.indexed)
|
||||
CommonPopupMenuItem(
|
||||
label: group.name,
|
||||
checked: index == widget.controller.index,
|
||||
onPressed: () => _select(index),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabs(_TabExtents extents, bool hasMore) {
|
||||
final groups = widget.groups;
|
||||
final last = groups.length - 1;
|
||||
return ListView.custom(
|
||||
controller: _scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
start: _stripInset,
|
||||
end: hasMore ? 0 : _stripInset,
|
||||
),
|
||||
itemExtentBuilder: (index, _) => index < extents.widths.length
|
||||
? extents.widths[index] + (index < last ? _tabSpacing : 0)
|
||||
: null,
|
||||
childrenDelegate: _TabsDelegate(
|
||||
(_, index) => Padding(
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
end: index < last ? _tabSpacing : 0,
|
||||
),
|
||||
child: _GroupTab(
|
||||
name: groups[index].name,
|
||||
index: index,
|
||||
selected: _selected,
|
||||
onPressed: () => _select(index),
|
||||
),
|
||||
),
|
||||
childCount: groups.length,
|
||||
extent: extents.total,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final extents = _extents = _TabExtents.of(context, widget.groups, _extents);
|
||||
return SizedBox(
|
||||
height: TonalButtonSize.section.button,
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: _hasMore,
|
||||
builder: (_, hasMore, moreButton) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: NotificationListener<ScrollMetricsNotification>(
|
||||
onNotification: (notification) =>
|
||||
_handleMetrics(notification.metrics),
|
||||
child: NotificationListener<ScrollUpdateNotification>(
|
||||
onNotification: (notification) =>
|
||||
_handleMetrics(notification.metrics),
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: _fade,
|
||||
builder: (_, fade, child) => ShaderMask(
|
||||
blendMode: BlendMode.dstIn,
|
||||
shaderCallback: (bounds) =>
|
||||
_buildEdgeFade(bounds, fade),
|
||||
child: child,
|
||||
),
|
||||
child: _buildTabs(extents, hasMore),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasMore)
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: 8, end: 16),
|
||||
child: moreButton,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: _buildMoreButton(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The width each chip lays its label out at, worked out the way a
|
||||
/// [CommonCard]'s button sizes itself.
|
||||
class _TabExtents {
|
||||
_TabExtents._(this._key, this.widths) : starts = _startsOf(widths);
|
||||
|
||||
final Object _key;
|
||||
final List<double> widths;
|
||||
final List<double> starts;
|
||||
|
||||
double get total => widths.isEmpty ? 0 : starts.last + widths.last;
|
||||
|
||||
static List<double> _startsOf(List<double> widths) {
|
||||
final starts = <double>[];
|
||||
var start = 0.0;
|
||||
for (final width in widths) {
|
||||
starts.add(start);
|
||||
start += width + _tabSpacing;
|
||||
}
|
||||
return starts;
|
||||
}
|
||||
|
||||
static _TabExtents of(
|
||||
BuildContext context,
|
||||
List<Group> groups,
|
||||
_TabExtents? previous,
|
||||
) {
|
||||
final style = AppTextStyle.subheadline.of(context, emphasized: true);
|
||||
final buttonStyle = FilledButtonTheme.of(context).style;
|
||||
final defaults = const FilledButton(
|
||||
onPressed: null,
|
||||
child: null,
|
||||
).defaultStyleOf(context);
|
||||
final minimumSize = (buttonStyle?.minimumSize ?? defaults.minimumSize)!
|
||||
.resolve(const {})!;
|
||||
final density = (buttonStyle?.visualDensity ?? defaults.visualDensity)!;
|
||||
final adjustment = density.baseSizeAdjustment.dx;
|
||||
final minWidth = max(0.0, minimumSize.width + adjustment);
|
||||
final padding = 2 * (_tabPadding + max(0.0, adjustment));
|
||||
final names = [for (final group in groups) group.name];
|
||||
final key = (
|
||||
_NamesKey(names),
|
||||
style,
|
||||
MediaQuery.boldTextOf(context),
|
||||
MediaQuery.textScalerOf(context),
|
||||
Directionality.of(context),
|
||||
Localizations.maybeLocaleOf(context),
|
||||
minWidth,
|
||||
padding,
|
||||
);
|
||||
if (previous != null && previous._key == key) {
|
||||
return previous;
|
||||
}
|
||||
return _TabExtents._(key, [
|
||||
for (final name in names)
|
||||
max(
|
||||
minWidth,
|
||||
EmojiText.widthOf(context, name, style).ceilToDouble() + padding,
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports the strip's whole extent, which a list of varied extents would
|
||||
/// otherwise extrapolate from the chips it has built.
|
||||
class _TabsDelegate extends SliverChildBuilderDelegate {
|
||||
_TabsDelegate(
|
||||
super.builder, {
|
||||
required super.childCount,
|
||||
required this.extent,
|
||||
}) : super(addAutomaticKeepAlives: false);
|
||||
|
||||
final double extent;
|
||||
|
||||
@override
|
||||
double? estimateMaxScrollOffset(
|
||||
int firstIndex,
|
||||
int lastIndex,
|
||||
double leadingScrollOffset,
|
||||
double trailingScrollOffset,
|
||||
) => extent;
|
||||
}
|
||||
|
||||
class _NamesKey {
|
||||
const _NamesKey(this.names);
|
||||
|
||||
final List<String> names;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is _NamesKey && stringListEquality.equals(names, other.names);
|
||||
|
||||
@override
|
||||
int get hashCode => stringListEquality.hash(names);
|
||||
}
|
||||
|
||||
/// Rebuilds only when it gains or loses the selection, so a swipe across
|
||||
/// hundreds of groups rebuilds two tabs per step instead of the strip.
|
||||
class _GroupTab extends StatefulWidget {
|
||||
const _GroupTab({
|
||||
required this.name,
|
||||
required this.index,
|
||||
required this.selected,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final int index;
|
||||
final ValueListenable<int> selected;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
State<_GroupTab> createState() => _GroupTabState();
|
||||
}
|
||||
|
||||
class _GroupTabState extends State<_GroupTab> {
|
||||
late bool _isSelected;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isSelected = widget.selected.value == widget.index;
|
||||
widget.selected.addListener(_handleSelected);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_GroupTab oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.selected != widget.selected) {
|
||||
oldWidget.selected.removeListener(_handleSelected);
|
||||
widget.selected.addListener(_handleSelected);
|
||||
}
|
||||
_isSelected = widget.selected.value == widget.index;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.selected.removeListener(_handleSelected);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleSelected() {
|
||||
final isSelected = widget.selected.value == widget.index;
|
||||
if (isSelected != _isSelected) {
|
||||
setState(() => _isSelected = isSelected);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selected = _isSelected;
|
||||
final colorScheme = context.colorScheme;
|
||||
return CommonCard(
|
||||
isSelected: selected,
|
||||
fillsSelection: true,
|
||||
shape: AppShape.full,
|
||||
padding: const EdgeInsets.symmetric(horizontal: _tabPadding),
|
||||
onPressed: widget.onPressed,
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: midDuration,
|
||||
style: AppTextStyle.subheadline
|
||||
.of(context, emphasized: true)
|
||||
.copyWith(
|
||||
color: selected
|
||||
? colorScheme.onPrimary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
child: EmojiText(widget.name, maxLines: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+43
-26
@@ -508,49 +508,66 @@ class _GroupSliverState extends ConsumerState<_GroupSliver>
|
||||
/// Rounds the header and lifts it as its own rows scroll under it, and lets
|
||||
/// the shadow go before the end of the group pushes it out. The page color
|
||||
/// behind its upper half hides the rows that went under it, which would
|
||||
/// otherwise show around its top corners and above it.
|
||||
/// otherwise show around its top corners and above it. Every header is laid
|
||||
/// out on every pass, so one beyond the cache area builds nothing.
|
||||
Widget _buildPinnedHeader(
|
||||
bool isExpand,
|
||||
Widget Function({double floating, double lift}) buildHeader,
|
||||
) {
|
||||
final columns = widget.columns;
|
||||
final rowsExtent = isExpand
|
||||
? _rowsExtent(
|
||||
(widget.group.all.length + columns - 1) ~/ columns,
|
||||
_rowExtent,
|
||||
)
|
||||
: 0.0;
|
||||
final rowCount = (widget.group.all.length + columns - 1) ~/ columns;
|
||||
final rowsExtent = isExpand ? _rowsExtent(rowCount, _rowExtent) : 0.0;
|
||||
final groupExtent =
|
||||
listHeaderHeight +
|
||||
(_controller.isDismissed
|
||||
? 0.0
|
||||
: _rowsExtent(rowCount, getRowExtent(widget.cardType)));
|
||||
(double, double, double, Color)? headerState;
|
||||
Widget? header;
|
||||
return SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final beyondCache =
|
||||
constraints.remainingCacheExtent <= 0 ||
|
||||
constraints.scrollOffset + constraints.cacheOrigin >= groupExtent;
|
||||
final under = isExpand
|
||||
? constraints.scrollOffset + constraints.overlap
|
||||
: 0.0;
|
||||
final state = (
|
||||
(under / _liftDistance).clamp(0.0, 1.0),
|
||||
(min(under, rowsExtent - under) / _liftDistance).clamp(0.0, 1.0),
|
||||
isExpand ? constraints.overlap : 0.0,
|
||||
context.surfaceLevel.colorOf(context.colorScheme),
|
||||
);
|
||||
if (state == headerState) {
|
||||
final state = beyondCache
|
||||
? null
|
||||
: (
|
||||
(under / _liftDistance).clamp(0.0, 1.0),
|
||||
(min(under, rowsExtent - under) / _liftDistance).clamp(
|
||||
0.0,
|
||||
1.0,
|
||||
),
|
||||
isExpand ? constraints.overlap : 0.0,
|
||||
context.surfaceLevel.colorOf(context.colorScheme),
|
||||
);
|
||||
if (header != null && state == headerState) {
|
||||
return header!;
|
||||
}
|
||||
headerState = state;
|
||||
if (state == null) {
|
||||
return header = PinnedHeaderSliver(
|
||||
child: SizedBox(height: listHeaderHeight),
|
||||
);
|
||||
}
|
||||
final (floating, lift, cover, pageColor) = state;
|
||||
return header = PinnedHeaderSliver(
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
top: -cover,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: cover + listHeaderHeight / 2,
|
||||
child: ColoredBox(color: pageColor),
|
||||
),
|
||||
buildHeader(floating: floating, lift: lift),
|
||||
],
|
||||
child: RepaintBoundary(
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
top: -cover,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: cover + listHeaderHeight / 2,
|
||||
child: ColoredBox(color: pageColor),
|
||||
),
|
||||
buildHeader(floating: floating, lift: lift),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -23,6 +23,10 @@ class ProxiesView extends ConsumerStatefulWidget {
|
||||
|
||||
class _ProxiesViewState extends ConsumerState<ProxiesView> {
|
||||
final GlobalKey<ProxiesTabViewState> _proxiesTabKey = GlobalKey();
|
||||
|
||||
// The same instance on every build, so the bar changing with the current
|
||||
// group or its delay test leaves the page alone.
|
||||
late final _tabView = ProxiesTabView(key: _proxiesTabKey);
|
||||
bool _isTab = false;
|
||||
|
||||
List<CommonPopupMenuItem> _buildMenuItems(BuildContext context) {
|
||||
@@ -50,16 +54,7 @@ class _ProxiesViewState extends ConsumerState<ProxiesView> {
|
||||
if (!_isTab) {
|
||||
return null;
|
||||
}
|
||||
final currentGroupName = ref.watch(
|
||||
proxiesTabControllerStateProvider.select(
|
||||
(state) => state.currentGroupName,
|
||||
),
|
||||
);
|
||||
final isDelayTesting = ref.watch(
|
||||
delayTestingGroupsProvider.select(
|
||||
(state) => state.contains(currentGroupName),
|
||||
),
|
||||
);
|
||||
final isDelayTesting = ref.watch(currentGroupDelayTestingProvider);
|
||||
return IconButtonData(
|
||||
glyph: AppGlyphs.bolt,
|
||||
onPressed: () {
|
||||
@@ -119,7 +114,7 @@ class _ProxiesViewState extends ConsumerState<ProxiesView> {
|
||||
title: context.appLocalizations.proxies,
|
||||
searchState: AppBarSearchState(onSearch: _onSearch),
|
||||
body: switch (proxiesType) {
|
||||
ProxiesType.tab => ProxiesTabView(key: _proxiesTabKey),
|
||||
ProxiesType.tab => _tabView,
|
||||
ProxiesType.list => const ProxiesListView(),
|
||||
},
|
||||
);
|
||||
|
||||
+2
-282
@@ -5,14 +5,13 @@ import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/common.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'card.dart';
|
||||
import 'common.dart';
|
||||
import 'group_tabs.dart';
|
||||
|
||||
class ProxiesTabView extends ConsumerStatefulWidget {
|
||||
const ProxiesTabView({super.key});
|
||||
@@ -191,7 +190,7 @@ class ProxiesTabViewState extends ConsumerState<ProxiesTabView>
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4, bottom: 16),
|
||||
child: _GroupTabs(controller: controller, groups: groups),
|
||||
child: GroupTabs(controller: controller, groups: groups),
|
||||
),
|
||||
Expanded(
|
||||
child: WidthBuilder(
|
||||
@@ -224,285 +223,6 @@ class ProxiesTabViewState extends ConsumerState<ProxiesTabView>
|
||||
}
|
||||
}
|
||||
|
||||
class _GroupTabs extends StatefulWidget {
|
||||
const _GroupTabs({required this.controller, required this.groups});
|
||||
|
||||
final TabController controller;
|
||||
final List<Group> groups;
|
||||
|
||||
@override
|
||||
State<_GroupTabs> createState() => _GroupTabsState();
|
||||
}
|
||||
|
||||
class _GroupTabsState extends State<_GroupTabs> {
|
||||
static const _fadeExtent = 24.0;
|
||||
|
||||
final _hasMore = ValueNotifier(false);
|
||||
final _fade = ValueNotifier(0.0);
|
||||
final Map<String, GlobalKey> _tabKeys = {};
|
||||
late final _selected = ValueNotifier(widget.controller.index);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_follow(widget.controller);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_GroupTabs oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.controller != widget.controller) {
|
||||
oldWidget.controller.animation?.removeListener(_handleAnimation);
|
||||
_follow(widget.controller);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.animation?.removeListener(_handleAnimation);
|
||||
_hasMore.dispose();
|
||||
_fade.dispose();
|
||||
_selected.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _follow(TabController controller) {
|
||||
_selected.value = controller.index;
|
||||
controller.animation!.addListener(_handleAnimation);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _reveal(jump: true));
|
||||
}
|
||||
|
||||
// A tap animates across every tab between; only a swipe's passes count.
|
||||
void _handleAnimation() {
|
||||
final controller = widget.controller;
|
||||
final index = controller.indexIsChanging
|
||||
? controller.index
|
||||
: controller.animation!.value.round();
|
||||
if (index == _selected.value) {
|
||||
return;
|
||||
}
|
||||
_selected.value = index;
|
||||
_reveal();
|
||||
}
|
||||
|
||||
// The strip owns no controller: a sheet's would hand its drags to the sheet.
|
||||
void _reveal({bool jump = false}) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final name = widget.groups.elementAtOrNull(_selected.value)?.name;
|
||||
final tab = _tabKeys[name]?.currentContext;
|
||||
final position = tab == null ? null : Scrollable.maybeOf(tab)?.position;
|
||||
if (tab == null || position == null) {
|
||||
return;
|
||||
}
|
||||
position.ensureVisible(
|
||||
tab.findRenderObject()!,
|
||||
alignment: 0.5,
|
||||
duration: jump ? Duration.zero : commonDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
|
||||
void _select(int index) {
|
||||
widget.controller.animateTo(index);
|
||||
}
|
||||
|
||||
bool _handleMetrics(ScrollMetrics metrics) {
|
||||
_fade.value = (metrics.extentAfter / _fadeExtent).clamp(0.0, 1.0);
|
||||
final hasMore = metrics.maxScrollExtent > 0;
|
||||
if (hasMore != _hasMore.value) {
|
||||
_hasMore.value = hasMore;
|
||||
// The more button narrows the tabs it overflows.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _reveal(jump: true));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Shader _buildEdgeFade(Rect bounds, double fade) {
|
||||
return LinearGradient(
|
||||
begin: AlignmentDirectional.centerStart,
|
||||
end: AlignmentDirectional.centerEnd,
|
||||
colors: [
|
||||
Colors.black,
|
||||
Colors.black.withValues(alpha: 1 - fade),
|
||||
],
|
||||
stops: [max(0.0, 1 - _fadeExtent / bounds.width), 1],
|
||||
).createShader(bounds, textDirection: Directionality.of(context));
|
||||
}
|
||||
|
||||
Widget _buildMoreButton() {
|
||||
return TonalButtonTheme(
|
||||
size: TonalButtonSize.section,
|
||||
child: ElasticPress(
|
||||
child: CommonPopupBox(
|
||||
targetBuilder: (open) => IconButton(
|
||||
tooltip: context.appLocalizations.proxyGroup,
|
||||
onPressed: open,
|
||||
icon: const GlyphIcon(AppGlyphs.chevronDown),
|
||||
),
|
||||
popupBuilder: (_) => CommonPopupMenu(
|
||||
maxVisibleItems: 7,
|
||||
items: [
|
||||
for (final (index, group) in widget.groups.indexed)
|
||||
CommonPopupMenuItem(
|
||||
label: group.name,
|
||||
checked: index == widget.controller.index,
|
||||
onPressed: () => _select(index),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final names = {for (final group in widget.groups) group.name};
|
||||
_tabKeys.removeWhere((name, _) => !names.contains(name));
|
||||
final tabs = Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final (index, group) in widget.groups.indexed)
|
||||
_GroupTab(
|
||||
key: _tabKeys.putIfAbsent(group.name, GlobalKey.new),
|
||||
name: group.name,
|
||||
index: index,
|
||||
selected: _selected,
|
||||
onPressed: () => _select(index),
|
||||
),
|
||||
],
|
||||
);
|
||||
return SizedBox(
|
||||
height: TonalButtonSize.section.button,
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: _hasMore,
|
||||
builder: (_, hasMore, moreButton) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: NotificationListener<ScrollMetricsNotification>(
|
||||
onNotification: (notification) =>
|
||||
_handleMetrics(notification.metrics),
|
||||
child: NotificationListener<ScrollUpdateNotification>(
|
||||
onNotification: (notification) =>
|
||||
_handleMetrics(notification.metrics),
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: _fade,
|
||||
builder: (_, fade, child) => ShaderMask(
|
||||
blendMode: BlendMode.dstIn,
|
||||
shaderCallback: (bounds) =>
|
||||
_buildEdgeFade(bounds, fade),
|
||||
child: child,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
start: 16,
|
||||
end: hasMore ? 0 : 16,
|
||||
),
|
||||
child: tabs,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasMore)
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: 8, end: 16),
|
||||
child: moreButton,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: _buildMoreButton(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuilds only when it gains or loses the selection, so a swipe across
|
||||
/// hundreds of groups rebuilds two tabs per step instead of the strip.
|
||||
class _GroupTab extends StatefulWidget {
|
||||
const _GroupTab({
|
||||
super.key,
|
||||
required this.name,
|
||||
required this.index,
|
||||
required this.selected,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final int index;
|
||||
final ValueListenable<int> selected;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
State<_GroupTab> createState() => _GroupTabState();
|
||||
}
|
||||
|
||||
class _GroupTabState extends State<_GroupTab> {
|
||||
late bool _isSelected;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isSelected = widget.selected.value == widget.index;
|
||||
widget.selected.addListener(_handleSelected);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_GroupTab oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.selected != widget.selected) {
|
||||
oldWidget.selected.removeListener(_handleSelected);
|
||||
widget.selected.addListener(_handleSelected);
|
||||
}
|
||||
_isSelected = widget.selected.value == widget.index;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.selected.removeListener(_handleSelected);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleSelected() {
|
||||
final isSelected = widget.selected.value == widget.index;
|
||||
if (isSelected != _isSelected) {
|
||||
setState(() => _isSelected = isSelected);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selected = _isSelected;
|
||||
final name = widget.name;
|
||||
final onPressed = widget.onPressed;
|
||||
final colorScheme = context.colorScheme;
|
||||
return CommonCard(
|
||||
isSelected: selected,
|
||||
fillsSelection: true,
|
||||
shape: AppShape.full,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
onPressed: onPressed,
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: midDuration,
|
||||
style: AppTextStyle.subheadline
|
||||
.of(context, emphasized: true)
|
||||
.copyWith(
|
||||
color: selected
|
||||
? colorScheme.onPrimary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
child: EmojiText(name, maxLines: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProxyGroupView extends ConsumerWidget {
|
||||
final Group group;
|
||||
final ScrollController controller;
|
||||
|
||||
@@ -6,6 +6,7 @@ abstract class EditViewState<W extends ConsumerStatefulWidget, T>
|
||||
List<T> _entries = [];
|
||||
late final List<T> _initialEntries;
|
||||
final _selectionKey = uniqueId;
|
||||
bool _routeTakesResult = false;
|
||||
|
||||
String get title;
|
||||
|
||||
@@ -35,6 +36,16 @@ abstract class EditViewState<W extends ConsumerStatefulWidget, T>
|
||||
_initialEntries = List<T>.from(_entries);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final route = ModalRoute.of(context);
|
||||
_routeTakesResult = route is DragBackRouteMixin;
|
||||
if (route is DragBackRouteMixin) {
|
||||
route.pageResult = () => popResult(_entries);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleReorder(int oldIndex, int newIndex) {
|
||||
_entries = _entries.copyAndReorder(oldIndex, newIndex);
|
||||
setState(() {});
|
||||
@@ -138,14 +149,17 @@ abstract class EditViewState<W extends ConsumerStatefulWidget, T>
|
||||
final selected = ref.watch(itemsProvider(_selectionKey));
|
||||
final isEditing = selected.isNotEmpty;
|
||||
return CommonPopScope(
|
||||
onPop: (_) {
|
||||
if (isEditing) {
|
||||
ref.read(itemsProvider(_selectionKey).notifier).value = {};
|
||||
return false;
|
||||
}
|
||||
Navigator.of(context).pop(popResult(_entries));
|
||||
return false;
|
||||
},
|
||||
onPop: isEditing
|
||||
? (_) {
|
||||
ref.read(itemsProvider(_selectionKey).notifier).value = {};
|
||||
return false;
|
||||
}
|
||||
: _routeTakesResult
|
||||
? null
|
||||
: (_) {
|
||||
Navigator.of(context).pop(popResult(_entries));
|
||||
return false;
|
||||
},
|
||||
child: CommonScaffold(
|
||||
title: title,
|
||||
selectionActions: [
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: fl_clash
|
||||
description: A multi-platform proxy client based on ClashMeta, simple and easy to use, open-source and ad-free.
|
||||
publish_to: 'none'
|
||||
version: 0.9.1+2026101701
|
||||
version: 0.9.1+2026101702
|
||||
environment:
|
||||
sdk: '>=3.10.0 <4.0.0'
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ const _allowed = {
|
||||
'lib/features/editor/completion_popup.dart',
|
||||
// Nor a drag on a popup menu, which stands over the sheet it opened from.
|
||||
'lib/ui/popup.dart',
|
||||
// Nor a drag along the proxies group chips, which run across the sheet.
|
||||
'lib/views/proxies/group_tabs.dart',
|
||||
};
|
||||
|
||||
int _lineOf(String source, int offset) =>
|
||||
|
||||
@@ -118,7 +118,7 @@ void main() {
|
||||
addTearDown(platform.close);
|
||||
});
|
||||
|
||||
/// The scan effect repeats for as long as the page is mounted, so
|
||||
/// The scan effect repeats for as long as the camera runs, so
|
||||
/// `pumpAndSettle` only returns once the page has been popped or unmounted.
|
||||
Future<void> pumpFrames(WidgetTester tester) async {
|
||||
await tester.pump();
|
||||
@@ -127,7 +127,7 @@ void main() {
|
||||
|
||||
/// Pushes the page onto a route so `Navigator.pop` from `_handleBarcode` has
|
||||
/// something to pop, and reports what the page popped with.
|
||||
Future<void> pumpScanPage(
|
||||
Future<void> pushScanPage(
|
||||
WidgetTester tester, {
|
||||
required void Function(String? result) onPopped,
|
||||
VoidCallback? onBelowPopped,
|
||||
@@ -162,7 +162,20 @@ void main() {
|
||||
)
|
||||
.then(onPopped),
|
||||
);
|
||||
await pumpFrames(tester);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
Future<void> pumpScanPage(
|
||||
WidgetTester tester, {
|
||||
required void Function(String? result) onPopped,
|
||||
VoidCallback? onBelowPopped,
|
||||
}) async {
|
||||
await pushScanPage(
|
||||
tester,
|
||||
onPopped: onPopped,
|
||||
onBelowPopped: onBelowPopped,
|
||||
);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
}
|
||||
|
||||
Future<void> sendLifecycle(WidgetTester tester, AppLifecycleState state) {
|
||||
@@ -221,6 +234,19 @@ void main() {
|
||||
});
|
||||
|
||||
group('ScanPage barcode handling', () {
|
||||
testWidgets(
|
||||
'the whole preview scans, since the platform takes a code first seen '
|
||||
'outside a scan window for a duplicate once it is inside',
|
||||
(tester) async {
|
||||
await pumpScanPage(tester, onPopped: (_) {});
|
||||
|
||||
expect(
|
||||
tester.widget<MobileScanner>(find.byType(MobileScanner)).scanWindow,
|
||||
isNull,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('a url barcode pops its raw value', (tester) async {
|
||||
String? result;
|
||||
var popped = false;
|
||||
@@ -374,6 +400,32 @@ void main() {
|
||||
});
|
||||
|
||||
group('ScanPage app lifecycle', () {
|
||||
testWidgets('the camera starts only once the push has settled', (
|
||||
tester,
|
||||
) async {
|
||||
await pushScanPage(tester, onPopped: (_) {});
|
||||
await sendLifecycle(tester, AppLifecycleState.resumed);
|
||||
await tester.pump();
|
||||
|
||||
expect(platform.startCalls, 0);
|
||||
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
expect(platform.startCalls, 1);
|
||||
});
|
||||
|
||||
testWidgets('the scan effect waits for the camera to run', (tester) async {
|
||||
final gate = Completer<void>();
|
||||
platform.startGate = gate;
|
||||
await pumpScanPage(tester, onPopped: (_) {});
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
gate.complete();
|
||||
await tester.pump();
|
||||
|
||||
expect(tester.hasRunningAnimations, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('a resume during a pending start does not start again', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -48,7 +48,11 @@ void main() {
|
||||
late ProviderContainer container;
|
||||
late _RecordingGlobalRules rules;
|
||||
|
||||
Future<void> pumpRules(WidgetTester tester, List<Rule> initial) async {
|
||||
Future<void> pumpRules(
|
||||
WidgetTester tester,
|
||||
List<Rule> initial, {
|
||||
bool pushed = false,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1200, 2400);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
@@ -67,10 +71,29 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const TestApp(child: AddedRulesView()),
|
||||
child: TestApp(
|
||||
child: pushed
|
||||
? Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
CommonRoute<void>(
|
||||
builder: (_) => const AddedRulesView(),
|
||||
),
|
||||
),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const AddedRulesView(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
if (pushed) {
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('shows the empty state without any rule', (tester) async {
|
||||
@@ -151,6 +174,31 @@ void main() {
|
||||
]);
|
||||
});
|
||||
|
||||
testWidgets('drags back to its opener unless rules are selected', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpRules(tester, [_rule(1, 'a.com')], pushed: true);
|
||||
Future<void> dragBack() async {
|
||||
await tester.timedDrag(
|
||||
find.text('a.com'),
|
||||
const Offset(800, 0),
|
||||
const Duration(seconds: 1),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
await tester.tap(find.byType(CommonCheckBox));
|
||||
await tester.pumpAndSettle();
|
||||
await dragBack();
|
||||
expect(find.text('a.com'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byType(CommonCheckBox));
|
||||
await tester.pumpAndSettle();
|
||||
await dragBack();
|
||||
expect(find.text('a.com'), findsNothing);
|
||||
expect(find.text('open'), findsOneWidget);
|
||||
});
|
||||
|
||||
group('search', () {
|
||||
Future<void> search(WidgetTester tester, String query) async {
|
||||
await tester.tap(find.byGlyph(AppGlyphs.search));
|
||||
|
||||
@@ -83,4 +83,73 @@ void main() {
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('builds only the headers near the view and pins a far one '
|
||||
'once its rows reach the bar', (tester) async {
|
||||
tester.view.physicalSize = const Size(500, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final profile = Profile.normal().copyWith(unfoldSet: {'Far'});
|
||||
final groups = [
|
||||
for (var index = 0; index < 40; index++)
|
||||
Group(name: 'G$index', type: GroupType.Selector, hidden: false),
|
||||
Group(
|
||||
name: 'Far',
|
||||
type: GroupType.Selector,
|
||||
hidden: false,
|
||||
all: List.generate(
|
||||
160,
|
||||
(index) => Proxy(name: 'Proxy $index', type: 'Direct'),
|
||||
),
|
||||
),
|
||||
];
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
profilesProvider.overrideWith(() => TestProfiles([profile])),
|
||||
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
|
||||
currentGroupsStateProvider.overrideWithValue(
|
||||
GroupsState(value: groups),
|
||||
),
|
||||
groupsProvider.overrideWithValue(groups),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
globalState.container = container;
|
||||
container
|
||||
.read(viewSizeProvider.notifier)
|
||||
.update((_) => const Size(500, 1000));
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const TestApp(child: ProxiesView()),
|
||||
),
|
||||
);
|
||||
container
|
||||
.read(proxiesStyleSettingProvider.notifier)
|
||||
.update((state) => state.copyWith(type: ProxiesType.list));
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
find.byType(ListHeader, skipOffstage: false).evaluate().length,
|
||||
lessThan(groups.length ~/ 2),
|
||||
);
|
||||
|
||||
final position = tester
|
||||
.state<ScrollableState>(find.byType(Scrollable).first)
|
||||
.position;
|
||||
position.jumpTo(position.maxScrollExtent - 1000);
|
||||
await tester.pump();
|
||||
|
||||
final barBottom = tester.getRect(find.byType(AppBar)).bottom;
|
||||
final far = find.ancestor(
|
||||
of: find.text('Far'),
|
||||
matching: find.byType(ListHeader),
|
||||
);
|
||||
expect(tester.getRect(far).top, barBottom + 8);
|
||||
expect(find.text('G0', skipOffstage: false), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -514,6 +514,54 @@ void main() {
|
||||
expect(find.text('No data'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('ListEditView hands its items back to a drag back', (
|
||||
tester,
|
||||
) async {
|
||||
Object? result;
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
child: TestApp(
|
||||
overrides: [_viewSizeOverride],
|
||||
child: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () async {
|
||||
result = await Navigator.of(context).push<Object?>(
|
||||
CommonRoute<Object?>(
|
||||
builder: (_) => const ListEditView(
|
||||
title: 'Items',
|
||||
items: ['a', 'b'],
|
||||
titleBuilder: _textBuilder,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip('Add'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextFormField), 'c');
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.timedDrag(
|
||||
find.text('a'),
|
||||
const Offset(800, 0),
|
||||
const Duration(seconds: 1),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('open'), findsOneWidget);
|
||||
expect(result, ['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
testWidgets('MapEditView adds, reorders, selects, and deletes entries', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -8,6 +8,8 @@ import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/ui/ui.dart';
|
||||
import 'package:fl_clash/views/proxies/card.dart';
|
||||
import 'package:fl_clash/views/proxies/group_tabs.dart';
|
||||
import 'package:fl_clash/views/proxies/proxies.dart';
|
||||
import 'package:fl_clash/views/proxies/tab.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -165,7 +167,7 @@ void main() {
|
||||
await tester.pump();
|
||||
|
||||
final tabs = find.descendant(
|
||||
of: find.byType(SingleChildScrollView),
|
||||
of: find.byType(GroupTabs),
|
||||
matching: find.byType(Scrollable),
|
||||
);
|
||||
final position = tester.state<ScrollableState>(tabs).position;
|
||||
@@ -308,6 +310,86 @@ void main() {
|
||||
expect(tester.widget(card), same(before));
|
||||
});
|
||||
|
||||
testWidgets('lays each tab out at the width its label takes', (tester) async {
|
||||
const names = ['A', '🚀 节点选择', 'Group with a long name'];
|
||||
globalContainer
|
||||
.read(_tabStateProvider.notifier)
|
||||
.set(_tabState([for (final name in names) _group(name)], current: 'A'));
|
||||
await pumpTabView(tester);
|
||||
|
||||
for (final name in names) {
|
||||
final tab = tabOf(name);
|
||||
expect(
|
||||
tester.getSize(tab).width,
|
||||
closeTo(
|
||||
tester
|
||||
.renderObject<RenderBox>(tab)
|
||||
.getMaxIntrinsicWidth(double.infinity),
|
||||
1,
|
||||
),
|
||||
reason: name,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('builds only the tabs in view and opens on a far current one', (
|
||||
tester,
|
||||
) async {
|
||||
globalContainer
|
||||
.read(_tabStateProvider.notifier)
|
||||
.set(
|
||||
_tabState([
|
||||
for (var i = 0; i < 200; i++) _group('Group $i'),
|
||||
], current: 'Group 180'),
|
||||
);
|
||||
await pumpTabView(tester);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byType(GroupTabs),
|
||||
matching: find.byType(CommonCard),
|
||||
skipOffstage: false,
|
||||
)
|
||||
.evaluate()
|
||||
.length,
|
||||
lessThan(30),
|
||||
);
|
||||
final current = tester.getRect(tabOf('Group 180'));
|
||||
final more = tester.getRect(find.byGlyph(AppGlyphs.chevronDown));
|
||||
expect(isTabSelected(tester, 'Group 180'), isTrue);
|
||||
expect(current.left, greaterThanOrEqualTo(0));
|
||||
expect(current.right, lessThan(more.left));
|
||||
});
|
||||
|
||||
testWidgets('the bar following the current group leaves the page built', (
|
||||
tester,
|
||||
) async {
|
||||
globalContainer
|
||||
.read(proxiesStyleSettingProvider.notifier)
|
||||
.update((state) => state.copyWith(type: ProxiesType.tab));
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: globalContainer,
|
||||
child: const TestApp(child: ProxiesView()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
final tabs = tester.widget(find.byType(GroupTabs));
|
||||
|
||||
final state = globalContainer.read(_tabStateProvider);
|
||||
globalContainer
|
||||
.read(_tabStateProvider.notifier)
|
||||
.set(state.copyWith(currentGroupName: 'C'));
|
||||
globalContainer.read(delayTestingGroupsProvider.notifier).start('C');
|
||||
await tester.pump();
|
||||
|
||||
expect(globalContainer.read(currentGroupDelayTestingProvider), isTrue);
|
||||
expect(tester.widget(find.byType(GroupTabs)), same(tabs));
|
||||
});
|
||||
|
||||
testWidgets('rebuilds the tab bar when groups return', (tester) async {
|
||||
final key = await pumpTabView(tester);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user