Compare commits

..

13 Commits

Author SHA1 Message Date
chen08209 a8655b0809 chore: bump version to 0.9.1+2026101701 2026-10-17 09:55:19 +08:00
chen08209 6c52d7a39a fix(ui): stop non-secret fields from opening a password keyboard
Changelog: Fix pasting and Chinese input in text fields
2026-10-17 09:55:10 +08:00
chen08209 4d68938d22 docs(agents): document surfaces, typography and the lib/ui layer 2026-10-17 09:54:29 +08:00
chen08209 213a13f62f feat(l10n): add French, Spanish and Korean
Changelog: French, Spanish and Korean translations
2026-10-17 09:54:29 +08:00
chen08209 fefbcf4598 feat(android): drop Firebase analytics and crash reporting
Changelog: No more Firebase analytics or crash reporting
2026-10-17 09:54:29 +08:00
chen08209 9f48781341 feat(network)!: set desktop TUN key by key over each profile's own
BREAKING CHANGE: Desktop TUN keys follow the profile unless picked; changed values carry over
Changelog: Per-key desktop TUN settings; system proxy and tray fixes
2026-10-17 09:54:29 +08:00
chen08209 dd952dd600 feat(theme): redesign theme colors with an iOS-style picker
Changelog: Redesigned theme colors and color styles
2026-10-17 09:54:29 +08:00
chen08209 bdbf4ab024 feat(dashboard): redesign the dashboard around the current exit
Changelog: A redesigned dashboard around the current exit
2026-10-17 09:54:29 +08:00
chen08209 347a74b940 feat(profiles): manage external resources from the profile details
Changelog: Profile-detail resources and include-all in custom groups
2026-10-17 09:54:29 +08:00
chen08209 eb47ee261f feat(proxies): show groups as cards and group tabs as chips
Changelog: Proxy groups as cards, group tabs as chips
2026-10-17 09:54:29 +08:00
chen08209 e82ebf785b feat(views): show connections, requests and DNS as tables
Changelog: Sortable tables for connections, requests and DNS
2026-10-17 09:54:29 +08:00
chen08209 ba15c313b4 feat(ui): add Liquid Glass and slide-to-pick popup menus
Changelog: Optional two-tier Liquid Glass and slide-to-pick menus
2026-10-17 09:54:29 +08:00
chen08209 568c49bb3a feat(ui): layer surfaces with fills and follow the iOS type scale
Changelog: iOS-style surfaces, alerts and sheets; clearer TV focus
2026-10-16 22:54:53 +08:00
45 changed files with 453 additions and 1117 deletions
-28
View File
@@ -1,33 +1,5 @@
# 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
View File
@@ -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 with IPv6 off it blocks this device's IPv6 connections.",
"strictRouteDesc": "Strict route keeps traffic such as DNS from bypassing TUN, and other devices can no longer reach this one.",
"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
View File
@@ -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, con IPv6 desactivado, bloquea las conexiones IPv6 de este dispositivo.",
"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.",
"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
View File
@@ -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, avec IPv6 désactivé, bloque les connexions IPv6 de cet appareil.",
"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.",
"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
View File
@@ -823,7 +823,7 @@
"keepAliveIdle": "TCP キープアライブ待機時間",
"disableKeepAlive": "TCP キープアライブを無効化",
"routingMark": "ルーティングマーク",
"strictRouteDesc": "厳格なルーティングは DNS などの通信が TUN を迂回するのを防ぎます。IPv6 がオフの場合、この端末の IPv6 通信は遮断されます。",
"strictRouteDesc": "厳格なルーティングは DNS などの通信が TUN を迂回するのを防ぎます。有効にすると他のデバイスからこの端末にアクセスできなくなります。",
"initFailed": "起動に失敗しました",
"initFailedTip": "FlClash の起動中にエラーが発生したため、続行できません。詳細をコピーして問題を報告できます。繰り返し発生する場合は、データを消去すると起動できるようになることがあります。",
"errorDetails": "エラーの詳細",
+1 -1
View File
@@ -823,7 +823,7 @@
"keepAliveIdle": "TCP 킵얼라이브 유휴 시간",
"disableKeepAlive": "TCP 킵얼라이브 사용 안 함",
"routingMark": "라우팅 마크",
"strictRouteDesc": "엄격한 라우팅은 DNS 등의 트래픽이 TUN을 우회하지 못하게 하며, IPv6가 꺼져 있으면 이 기기의 IPv6 연결을 차단합니다.",
"strictRouteDesc": "엄격한 라우팅은 DNS 등의 트래픽이 TUN을 우회하지 못하게 하며, 다른 기기는 이 기기에 접속할 수 없게 됩니다.",
"initFailed": "시작 실패",
"initFailedTip": "FlClash를 시작하는 중 오류가 발생하여 계속할 수 없습니다. 세부 정보를 복사하여 문제를 보고하세요. 문제가 계속되면 데이터를 지우면 대개 앱이 다시 시작됩니다.",
"errorDetails": "오류 세부 정보",
+1 -1
View File
@@ -823,7 +823,7 @@
"keepAliveIdle": "Простой TCP keep-alive",
"disableKeepAlive": "Отключить TCP keep-alive",
"routingMark": "Метка маршрутизации",
"strictRouteDesc": "Строгая маршрутизация не даёт трафику вроде DNS обходить TUN, а при выключенном IPv6 блокирует IPv6-соединения этого устройства.",
"strictRouteDesc": "Строгая маршрутизация не даёт трафику вроде DNS обходить TUN, но другие устройства перестают видеть это устройство.",
"initFailed": "Не удалось запустить",
"initFailedTip": "При запуске FlClash произошла ошибка, и работа не может быть продолжена. Скопируйте подробности, чтобы сообщить о проблеме. Если ошибка повторяется, очистка данных обычно позволяет снова запустить приложение.",
"errorDetails": "Подробности ошибки",
+1 -1
View File
@@ -823,7 +823,7 @@
"keepAliveIdle": "TCP 保持活动空闲时间",
"disableKeepAlive": "禁用 TCP 保持活动",
"routingMark": "路由标记",
"strictRouteDesc": "严格路由防止 DNS 等流量绕过虚拟网卡,关闭 IPv6 时会阻断本机的 IPv6 连接。",
"strictRouteDesc": "严格路由防止 DNS 等流量绕过虚拟网卡,开启后其他设备将无法访问本机。",
"initFailed": "启动失败",
"initFailedTip": "FlClash 启动时出错,无法继续运行。可以复制错误详情用于反馈;如果问题反复出现,清除数据后通常可以重新启动。",
"errorDetails": "错误详情",
-98
View File
@@ -1,104 +1,6 @@
{
"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",
+1 -1
View File
@@ -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 with IPv6 off it blocks this device\'s IPv6 connections.",
"Strict route keeps traffic such as DNS from bypassing TUN, and other devices can no longer reach this one.",
),
"style": MessageLookupByLibrary.simpleMessage("Style"),
"submit": MessageLookupByLibrary.simpleMessage("Submit"),
+1 -1
View File
@@ -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, con IPv6 desactivado, bloquea las conexiones IPv6 de este dispositivo.",
"La ruta estricta evita que el tráfico, como el DNS, eluda TUN, y los demás dispositivos ya no pueden acceder a este.",
),
"style": MessageLookupByLibrary.simpleMessage("Estilo"),
"submit": MessageLookupByLibrary.simpleMessage("Enviar"),
+1 -1
View File
@@ -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, avec IPv6 désactivé, bloque les connexions IPv6 de cet appareil.",
"La route stricte empêche le trafic tel que le DNS de contourner TUN, et les autres appareils ne peuvent plus joindre celui-ci.",
),
"style": MessageLookupByLibrary.simpleMessage("Style"),
"submit": MessageLookupByLibrary.simpleMessage("Valider"),
+1 -1
View File
@@ -1488,7 +1488,7 @@ class MessageLookup extends MessageLookupByLibrary {
"strategy": MessageLookupByLibrary.simpleMessage("戦略"),
"strictRoute": MessageLookupByLibrary.simpleMessage("厳格なルーティング"),
"strictRouteDesc": MessageLookupByLibrary.simpleMessage(
"厳格なルーティングは DNS などの通信が TUN を迂回するのを防ぎます。IPv6 がオフの場合、この端末の IPv6 通信は遮断されます。",
"厳格なルーティングは DNS などの通信が TUN を迂回するのを防ぎます。有効にすると他のデバイスからこの端末にアクセスできなくなります。",
),
"style": MessageLookupByLibrary.simpleMessage("スタイル"),
"submit": MessageLookupByLibrary.simpleMessage("送信"),
+1 -1
View File
@@ -1459,7 +1459,7 @@ class MessageLookup extends MessageLookupByLibrary {
"strategy": MessageLookupByLibrary.simpleMessage("전략"),
"strictRoute": MessageLookupByLibrary.simpleMessage("엄격한 라우팅"),
"strictRouteDesc": MessageLookupByLibrary.simpleMessage(
"엄격한 라우팅은 DNS 등의 트래픽이 TUN을 우회하지 못하게 하며, IPv6가 꺼져 있으면 이 기기의 IPv6 연결을 차단합니다.",
"엄격한 라우팅은 DNS 등의 트래픽이 TUN을 우회하지 못하게 하며, 다른 기기는 이 기기에 접속할 수 없게 됩니다.",
),
"style": MessageLookupByLibrary.simpleMessage("스타일"),
"submit": MessageLookupByLibrary.simpleMessage("제출"),
+1 -1
View File
@@ -1817,7 +1817,7 @@ class MessageLookup extends MessageLookupByLibrary {
"Строгая маршрутизация",
),
"strictRouteDesc": MessageLookupByLibrary.simpleMessage(
"Строгая маршрутизация не даёт трафику вроде DNS обходить TUN, а при выключенном IPv6 блокирует IPv6-соединения этого устройства.",
"Строгая маршрутизация не даёт трафику вроде DNS обходить TUN, но другие устройства перестают видеть это устройство.",
),
"style": MessageLookupByLibrary.simpleMessage("Стиль"),
"submit": MessageLookupByLibrary.simpleMessage("Отправить"),
+1 -1
View File
@@ -1341,7 +1341,7 @@ class MessageLookup extends MessageLookupByLibrary {
"strategy": MessageLookupByLibrary.simpleMessage("策略"),
"strictRoute": MessageLookupByLibrary.simpleMessage("严格路由"),
"strictRouteDesc": MessageLookupByLibrary.simpleMessage(
"严格路由防止 DNS 等流量绕过虚拟网卡,关闭 IPv6 时会阻断本机的 IPv6 连接。",
"严格路由防止 DNS 等流量绕过虚拟网卡,开启后其他设备将无法访问本机。",
),
"style": MessageLookupByLibrary.simpleMessage("风格"),
"submit": MessageLookupByLibrary.simpleMessage("提交"),
+2 -2
View File
@@ -6818,10 +6818,10 @@ class AppLocalizations {
);
}
/// `Strict route keeps traffic such as DNS from bypassing TUN, and with IPv6 off it blocks this device's IPv6 connections.`
/// `Strict route keeps traffic such as DNS from bypassing TUN, and other devices can no longer reach this one.`
String get strictRouteDesc {
return Intl.message(
'Strict route keeps traffic such as DNS from bypassing TUN, and with IPv6 off it blocks this device\'s IPv6 connections.',
'Strict route keeps traffic such as DNS from bypassing TUN, and other devices can no longer reach this one.',
name: 'strictRouteDesc',
desc: '',
args: [],
+1
View File
@@ -21,6 +21,7 @@ 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,
+23 -20
View File
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$NavigationItem {
Glyph get glyph; PageLabel get label; WidgetBuilder get builder; String? get path; List<NavigationItemMode> get modes;
Glyph get glyph; PageLabel get label; WidgetBuilder get builder; bool get keep; 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.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.keep, _this.keep) || other.keep == _this.keep)&&(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.path,const DeepCollectionEquality().hash(_this.modes));
return Object.hash(runtimeType,_this.glyph,_this.label,_this.builder,_this.keep,_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}, path: ${_this.path}, modes: ${_this.modes})';
return 'NavigationItem(glyph: ${_this.glyph}, label: ${_this.label}, builder: ${_this.builder}, keep: ${_this.keep}, 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, String? path, List<NavigationItemMode> modes
Glyph glyph, PageLabel label, WidgetBuilder builder, bool keep, String? path, List<NavigationItemMode> modes
});
@@ -68,12 +68,13 @@ 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? path = freezed,Object? modes = null,}) {
@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,}) {
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,path: freezed == path ? _self.path : path // 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 String?,modes: null == modes ? _self.modes : modes // ignore: cast_nullable_to_non_nullable
as List<NavigationItemMode>,
));
@@ -160,10 +161,10 @@ return $default(_that);case _:
/// }
/// ```
@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;
@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;
switch (_that) {
case _NavigationItem() when $default != null:
return $default(_that.glyph,_that.label,_that.builder,_that.path,_that.modes);case _:
return $default(_that.glyph,_that.label,_that.builder,_that.keep,_that.path,_that.modes);case _:
return orElse();
}
@@ -181,10 +182,10 @@ return $default(_that.glyph,_that.label,_that.builder,_that.path,_that.modes);ca
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( Glyph glyph, PageLabel label, WidgetBuilder builder, String? path, List<NavigationItemMode> modes) $default,) {final _that = this;
@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;
switch (_that) {
case _NavigationItem():
return $default(_that.glyph,_that.label,_that.builder,_that.path,_that.modes);case _:
return $default(_that.glyph,_that.label,_that.builder,_that.keep,_that.path,_that.modes);case _:
throw StateError('Unexpected subclass');
}
@@ -201,10 +202,10 @@ return $default(_that.glyph,_that.label,_that.builder,_that.path,_that.modes);ca
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( Glyph glyph, PageLabel label, WidgetBuilder builder, String? path, List<NavigationItemMode> modes)? $default,) {final _that = this;
@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;
switch (_that) {
case _NavigationItem() when $default != null:
return $default(_that.glyph,_that.label,_that.builder,_that.path,_that.modes);case _:
return $default(_that.glyph,_that.label,_that.builder,_that.keep,_that.path,_that.modes);case _:
return null;
}
@@ -216,12 +217,13 @@ return $default(_that.glyph,_that.label,_that.builder,_that.path,_that.modes);ca
class _NavigationItem implements NavigationItem {
const _NavigationItem({required this.glyph, required this.label, required this.builder, this.path, List<NavigationItemMode> modes = const [NavigationItemMode.mobile, NavigationItemMode.desktop]}): _modes = modes;
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;
@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 {
@@ -241,18 +243,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.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.keep, keep) || other.keep == keep)&&(identical(other.path, path) || other.path == path)&&const DeepCollectionEquality().equals(other.modes, _modes));
}
@override
int get hashCode {
return Object.hash(runtimeType,glyph,label,builder,path,const DeepCollectionEquality().hash(_modes));
return Object.hash(runtimeType,glyph,label,builder,keep,path,const DeepCollectionEquality().hash(_modes));
}
@override
String toString() {
return 'NavigationItem(glyph: $glyph, label: $label, builder: $builder, path: $path, modes: $modes)';
return 'NavigationItem(glyph: $glyph, label: $label, builder: $builder, keep: $keep, path: $path, modes: $modes)';
}
@@ -263,7 +265,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, String? path, List<NavigationItemMode> modes
Glyph glyph, PageLabel label, WidgetBuilder builder, bool keep, String? path, List<NavigationItemMode> modes
});
@@ -280,12 +282,13 @@ 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? path = freezed,Object? modes = null,}) {
@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,}) {
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,path: freezed == path ? _self.path : path // 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 String?,modes: null == modes ? _self._modes : modes // ignore: cast_nullable_to_non_nullable
as List<NavigationItemMode>,
));
+1
View File
@@ -191,6 +191,7 @@ class _NavigationPage extends StatelessWidget {
final scopedView = PageFocusScope(child: view);
final keptView = KeepScope(
key: ValueKey(item.label),
keep: item.keep,
child: isMobile
? scopedView
: Navigator(
+21 -39
View File
@@ -25,13 +25,6 @@ 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});
@@ -40,10 +33,7 @@ class ScanPage extends ConsumerStatefulWidget {
}
class _ScanPageState extends ConsumerState<ScanPage>
with
WidgetsBindingObserver,
SingleTickerProviderStateMixin,
RouteSettledMixin<ScanPage> {
with WidgetsBindingObserver, SingleTickerProviderStateMixin {
final _controller = MobileScannerController(
autoStart: false,
detectionSpeed: DetectionSpeed.noDuplicates,
@@ -73,6 +63,7 @@ class _ScanPageState extends ConsumerState<ScanPage>
_handleCapture,
onError: _handleDetectError,
);
unawaited(_start());
}
@override
@@ -82,15 +73,9 @@ 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.isRunning) {
_effectController.reset();
if (_disableAnimations || _controller.value.error != null) {
_effectController.stop();
} else if (!_effectController.isAnimating) {
_effectController.repeat();
}
@@ -100,7 +85,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 || !routeSettled || state.isStarting || state.isRunning) {
if (_completed || state.isStarting || state.isRunning) {
return;
}
try {
@@ -233,24 +218,10 @@ 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,
@@ -657,16 +628,27 @@ class ScannerOverlay extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final backgroundWithCutout = Path()
..fillType = PathFillType.evenOdd
..addRect(Offset.zero & size)
final backgroundPath = Path()..addRect(Rect.largest);
final cutoutPath = Path()
..addRSuperellipse(
RSuperellipse.fromRectAndRadius(
scanWindow,
Radius.circular(borderRadius),
),
);
canvas.drawPath(backgroundWithCutout, Paint()..color = color);
final backgroundPaint = Paint()
..color = color
..style = PaintingStyle.fill;
final backgroundWithCutout = Path.combine(
PathOperation.difference,
backgroundPath,
cutoutPath,
);
canvas.drawPath(backgroundWithCutout, backgroundPaint);
}
@override
-42
View File
@@ -2157,48 +2157,6 @@ 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._();
-12
View File
@@ -165,18 +165,6 @@ 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;
-7
View File
@@ -18,13 +18,6 @@ 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;
+3 -2
View File
@@ -2,8 +2,9 @@ import 'package:material_ui/material_ui.dart';
class KeepScope extends StatefulWidget {
final Widget child;
final bool keep;
const KeepScope({super.key, required this.child});
const KeepScope({super.key, required this.child, this.keep = true});
@override
State<KeepScope> createState() => _KeepContainerState();
@@ -18,5 +19,5 @@ class _KeepContainerState extends State<KeepScope>
}
@override
bool get wantKeepAlive => true;
bool get wantKeepAlive => widget.keep;
}
-3
View File
@@ -6,9 +6,6 @@ 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;
+20 -4
View File
@@ -1716,7 +1716,7 @@ class _CommonPopupMenuState extends State<CommonPopupMenu>
return 2 * _cardInset + (_maxVisibleItems + 0.5) * _rowExtentOf(context);
}
static final _labelWidths = <(String, TextStyle, TextScaler, bool), double>{};
static final _labelWidths = <(String, TextStyle, TextScaler), double>{};
/// What the rows would take at their intrinsic width, measured from the
/// labels as the rows lay them out.
@@ -1724,15 +1724,31 @@ 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 boldText = MediaQuery.boldTextOf(context);
final textDirection = Directionality.of(context);
final locale = Localizations.maybeLocaleOf(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, boldText)] ??=
EmojiText.widthOf(context, item.label, root);
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 checked = item.checked == true;
final row =
(checked ? _itemArrowPadding : _itemPadding).horizontal +
-21
View File
@@ -96,27 +96,6 @@ 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(
+9 -6
View File
@@ -111,12 +111,15 @@ class _AddedRulesViewState extends ConsumerState<AddedRulesView> {
),
];
return CommonPopScope(
onPop: isSelecting
? (_) {
ref.read(itemsProvider(_key).notifier).value = {};
return false;
}
: null,
onPop: (_) {
if (selectedRules.isNotEmpty) {
ref.read(itemsProvider(_key).notifier).value = {};
return false;
}
Navigator.of(context).pop();
return false;
},
child: CommonScaffold(
title: appLocalizations.addedRules,
searchState: AppBarSearchState(onSearch: _handleSearch),
+1
View File
@@ -15,6 +15,7 @@ class Navigation implements NavigationPort {
}) {
return [
NavigationItem(
keep: false,
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) =>
+10 -14
View File
@@ -237,13 +237,11 @@ class _EditProfileViewState extends ConsumerState<EditProfileView> {
},
);
final data = await BaseNavigator.push<String>(context, editorPage);
if (data == null || !mounted) {
if (data == null) {
return;
}
_rawText = data;
setState(() {
_fileData = Uint8List.fromList(utf8.encode(data));
});
_fileData = Uint8List.fromList(utf8.encode(data));
_fileInfoNotifier.value = _fileInfoNotifier.value?.copyWith(
size: _fileData?.length ?? 0,
lastModified: DateTime.now(),
@@ -253,13 +251,10 @@ class _EditProfileViewState extends ConsumerState<EditProfileView> {
Future<void> _uploadProfileFile() async {
final platformFile = await globalState.safeRun(picker.pickerFile);
if (platformFile == null) return;
final fileData = await platformFile.readBytes();
_fileData = await platformFile.readBytes();
if (!mounted) {
return;
}
setState(() {
_fileData = fileData;
});
_fileInfoNotifier.value = _fileInfoNotifier.value?.copyWith(
size: _fileData?.length ?? 0,
lastModified: DateTime.now(),
@@ -340,12 +335,13 @@ class _EditProfileViewState extends ConsumerState<EditProfileView> {
policy: PageTraversalPolicy(),
child: PageFocusScope(
child: CommonPopScope(
onPop: _fileData == null
? null
: (_) {
_handleBack();
return false;
},
onPop: (context) {
if (_fileData == null) {
return true;
}
_handleBack();
return false;
},
child: CommonScaffold(
title: appLocalizations.edit,
confirmationAction: IconButtonData(
+8 -6
View File
@@ -124,12 +124,14 @@ class _StandardContentState extends ConsumerState<StandardContent> {
final addedRules = ref.watch(profileRulesProvider(_profileId)).value ?? [];
final selectedRules = ref.watch(itemsProvider(_key));
return CommonPopScope(
onPop: selectedRules.isEmpty
? null
: (_) {
ref.read(itemsProvider(_key).notifier).value = {};
return false;
},
onPop: (_) {
if (selectedRules.isNotEmpty) {
ref.read(itemsProvider(_key).notifier).value = {};
return false;
}
Navigator.of(context).pop();
return false;
},
child: SliverMainAxisGroup(
slivers: [
const SliverToBoxAdapter(child: SizedBox(height: 24)),
+6 -10
View File
@@ -312,16 +312,12 @@ class _DelayTextState extends ConsumerState<_DelayText> {
size: measure.textHeight(AppTextStyle.caption2),
color: context.colorScheme.onSurfaceVariant.opacity38,
),
null => Tooltip(
message: context.appLocalizations.delayTest,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _handleTestCurrentDelay,
child: GlyphIcon(
AppGlyphs.bolt,
size: measure.textHeight(AppTextStyle.caption2),
),
),
null => IconButton(
tooltip: context.appLocalizations.delayTest,
icon: const GlyphIcon(AppGlyphs.bolt),
iconSize: measure.textHeight(AppTextStyle.caption2),
padding: EdgeInsets.zero,
onPressed: _handleTestCurrentDelay,
),
}),
)
-408
View File
@@ -1,408 +0,0 @@
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),
),
);
}
}
+26 -43
View File
@@ -508,66 +508,49 @@ 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. Every header is laid
/// out on every pass, so one beyond the cache area builds nothing.
/// otherwise show around its top corners and above it.
Widget _buildPinnedHeader(
bool isExpand,
Widget Function({double floating, double lift}) buildHeader,
) {
final columns = widget.columns;
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)));
final rowsExtent = isExpand
? _rowsExtent(
(widget.group.all.length + columns - 1) ~/ columns,
_rowExtent,
)
: 0.0;
(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 = 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) {
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) {
return header!;
}
headerState = state;
if (state == null) {
return header = PinnedHeaderSliver(
child: SizedBox(height: listHeaderHeight),
);
}
final (floating, lift, cover, pageColor) = state;
return header = PinnedHeaderSliver(
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),
],
),
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),
],
),
);
},
+11 -6
View File
@@ -23,10 +23,6 @@ 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) {
@@ -54,7 +50,16 @@ class _ProxiesViewState extends ConsumerState<ProxiesView> {
if (!_isTab) {
return null;
}
final isDelayTesting = ref.watch(currentGroupDelayTestingProvider);
final currentGroupName = ref.watch(
proxiesTabControllerStateProvider.select(
(state) => state.currentGroupName,
),
);
final isDelayTesting = ref.watch(
delayTestingGroupsProvider.select(
(state) => state.contains(currentGroupName),
),
);
return IconButtonData(
glyph: AppGlyphs.bolt,
onPressed: () {
@@ -114,7 +119,7 @@ class _ProxiesViewState extends ConsumerState<ProxiesView> {
title: context.appLocalizations.proxies,
searchState: AppBarSearchState(onSearch: _onSearch),
body: switch (proxiesType) {
ProxiesType.tab => _tabView,
ProxiesType.tab => ProxiesTabView(key: _proxiesTabKey),
ProxiesType.list => const ProxiesListView(),
},
);
+282 -2
View File
@@ -5,13 +5,14 @@ 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});
@@ -190,7 +191,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(
@@ -223,6 +224,285 @@ 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;
+8 -22
View File
@@ -6,7 +6,6 @@ abstract class EditViewState<W extends ConsumerStatefulWidget, T>
List<T> _entries = [];
late final List<T> _initialEntries;
final _selectionKey = uniqueId;
bool _routeTakesResult = false;
String get title;
@@ -36,16 +35,6 @@ 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(() {});
@@ -149,17 +138,14 @@ abstract class EditViewState<W extends ConsumerStatefulWidget, T>
final selected = ref.watch(itemsProvider(_selectionKey));
final isEditing = selected.isNotEmpty;
return CommonPopScope(
onPop: isEditing
? (_) {
ref.read(itemsProvider(_selectionKey).notifier).value = {};
return false;
}
: _routeTakesResult
? null
: (_) {
Navigator.of(context).pop(popResult(_entries));
return false;
},
onPop: (_) {
if (isEditing) {
ref.read(itemsProvider(_selectionKey).notifier).value = {};
return false;
}
Navigator.of(context).pop(popResult(_entries));
return false;
},
child: CommonScaffold(
title: title,
selectionActions: [
+1 -1
View File
@@ -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+2026101702
version: 0.9.1+2026101701
environment:
sdk: '>=3.10.0 <4.0.0'
@@ -13,8 +13,6 @@ 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) =>
+3 -55
View File
@@ -118,7 +118,7 @@ void main() {
addTearDown(platform.close);
});
/// The scan effect repeats for as long as the camera runs, so
/// The scan effect repeats for as long as the page is mounted, 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> pushScanPage(
Future<void> pumpScanPage(
WidgetTester tester, {
required void Function(String? result) onPopped,
VoidCallback? onBelowPopped,
@@ -162,20 +162,7 @@ void main() {
)
.then(onPopped),
);
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));
await pumpFrames(tester);
}
Future<void> sendLifecycle(WidgetTester tester, AppLifecycleState state) {
@@ -234,19 +221,6 @@ 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;
@@ -400,32 +374,6 @@ 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 {
+2 -50
View File
@@ -48,11 +48,7 @@ void main() {
late ProviderContainer container;
late _RecordingGlobalRules rules;
Future<void> pumpRules(
WidgetTester tester,
List<Rule> initial, {
bool pushed = false,
}) async {
Future<void> pumpRules(WidgetTester tester, List<Rule> initial) async {
tester.view.physicalSize = const Size(1200, 2400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
@@ -71,29 +67,10 @@ void main() {
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
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(),
),
child: const TestApp(child: 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 {
@@ -174,31 +151,6 @@ 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));
-69
View File
@@ -83,73 +83,4 @@ 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);
});
}
-48
View File
@@ -514,54 +514,6 @@ 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 {
+1 -83
View File
@@ -8,8 +8,6 @@ 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';
@@ -167,7 +165,7 @@ void main() {
await tester.pump();
final tabs = find.descendant(
of: find.byType(GroupTabs),
of: find.byType(SingleChildScrollView),
matching: find.byType(Scrollable),
);
final position = tester.state<ScrollableState>(tabs).position;
@@ -310,86 +308,6 @@ 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);