Compare commits

..

13 Commits

Author SHA1 Message Date
chen08209 b405e90ca6 chore: bump version to 0.9.1+2026101602 2026-10-16 22:58:29 +08:00
chen08209 af4827bc8c chore: bump version to 0.9.1+2026101601 2026-10-16 22:58:12 +08:00
chen08209 b6eae7b474 docs(agents): document surfaces, typography and the lib/ui layer 2026-10-16 22:54:53 +08:00
chen08209 555c350d83 feat(l10n): add French, Spanish and Korean
Changelog: French, Spanish and Korean translations
2026-10-16 22:54:53 +08:00
chen08209 2fae5b6b6d feat(android): drop Firebase analytics and crash reporting
Changelog: No more Firebase analytics or crash reporting
2026-10-16 22:54:53 +08:00
chen08209 bd2a295914 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-16 22:54:53 +08:00
chen08209 8a8627a853 feat(theme): redesign theme colors with an iOS-style picker
Changelog: Redesigned theme colors and color styles
2026-10-16 22:54:53 +08:00
chen08209 fea949adb1 feat(dashboard): redesign the dashboard around the current exit
Changelog: A redesigned dashboard around the current exit
2026-10-16 22:54:53 +08:00
chen08209 90eec411a5 feat(profiles): manage external resources from the profile details
Changelog: Profile-detail resources and include-all in custom groups
2026-10-16 22:54:53 +08:00
chen08209 9d17f50594 feat(proxies): show groups as cards and group tabs as chips
Changelog: Proxy groups as cards, group tabs as chips
2026-10-16 22:54:53 +08:00
chen08209 54633d8e0c feat(views): show connections, requests and DNS as tables
Changelog: Sortable tables for connections, requests and DNS
2026-10-16 22:54:53 +08:00
chen08209 53f0c28d56 feat(ui): add Liquid Glass and slide-to-pick popup menus
Changelog: Optional two-tier Liquid Glass and slide-to-pick menus
2026-10-16 22:54:53 +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
69 changed files with 605 additions and 1561 deletions
+3 -22
View File
@@ -611,10 +611,8 @@ the button pressed last over the neighbours it swells past. `lib/ui/liquid_glass
effects, stays the same, so a still screen redraws its glass without a capture and content scrolling under glass
captures once a frame. Within an image each band of slots that blur alike keeps its pixels the same way, copied
from the last image rather than drawn and blurred again, and a slot without frost is a band of its own: dragging
the lens redraws only its copy's slot, not the dock's haze or the buttons' frost. Not on Skia: an image there keeps
what it was drawn from as long as it lives, so each capture copied from the last would hold every one before it,
and memory grows by a capture a frame. The dark line along the rim darkens the blurred capture there rather than the
sharp backdrop, a pixel or two wide.
the lens redraws only its copy's slot, not the dock's haze or the buttons' frost. The dark line along the rim
darkens the blurred capture there rather than the sharp backdrop, a pixel or two wide.
- Measured on an Android emulator rendering in software (SwiftShader, 1080×2400 at density 420), as raster-thread time a
frame counting the captures that FrameTiming's raster span leaves out, median of three runs, each capture against
none in a run of its own: scrolling Tools takes 43 ms with none, 6 more with a sigma over one pixel of the capture and
@@ -641,8 +639,7 @@ the button pressed last over the neighbours it swells past. `lib/ui/liquid_glass
button's cookie does. The shader reads that path as its reach from the pane's center in 64 directions, so it must
meet every ray from the center once.
- Without Impeller's shader filters (Skia on the Intel macOS build, `flutter test`) a pane frosts from its capture:
blur, saturation, brightness, tint, and a rim, with no refraction; the tint is not thickened there, as it is where a
pane has no capture, since the capture holds the blur. `flutter test --enable-impeller` runs the shader, so `toImage`
blur, saturation, tint, and a rim, with no refraction. `flutter test --enable-impeller` runs the shader, so `toImage`
shows the real glass.
- A popup grows out of its anchor on a platter (`CommonPopupRoute`). On glass the platter is a `LiquidGlassPane`
easing from the button's `regular` glass to the thicker `menu` glass, and the menu's cards stand `bare` while it
@@ -919,22 +916,6 @@ which runs every one of those rebuilds inside the layout phase; in a profile tra
`InputDecoration.collapsed` included, and each frame of it lays the decorator out and, inside a sheet, the whole
form, through the first frames of the keyboard.
## Rebuild Scopes
A `LayoutBuilder` runs the rebuilds of everything under it inside its own layout, so a widget rebuilding there marks
the builder for layout, and that climbs to the nearest relayout boundary. Flutter marks every render object it lays
out for paint, so the nearest repaint boundary above that path repaints too. Under the material `Scaffold`'s body
`LayoutBuilder` (it has one with `extendBodyBehindAppBar`) that was the page and its background, and under the home
shell's `LayoutBuilder` the whole home route: a spinner turning in a page, or the dock's lens moving, relaid out and
repainted the page on every frame, and the glass over it captured every band again, the bar's included, though
nothing under the bar had changed.
So the body and the dock each take their rebuilds in a scope of their own: a `LayoutBuilder` under a repaint boundary
that is also a relayout boundary. `CommonScaffold`'s `_BodyScope` sizes itself to the most the scaffold allows, which
the scaffold never reads back, and lays the body out under it as before; `NavigationDock` sizes itself as it always
was, full width and its bar's height, and lays its boundary out tight. A rebuild inside either relays out and
repaints only that layer.
## Bottom Sheet Motion
`SnapSheetRoute` follows `UISheetPresentationController` as measured on the iOS 27 simulator (a UIKit probe app driven
-10
View File
@@ -75,16 +75,6 @@ clear of the button on either side.
`test/lint/typography_test.dart` fails on `textTheme` anywhere in `lib/` but `typography.dart`, and on `fontSize:` or
`fontWeight:` outside `lib/ui/`, except in the code editor, whose text follows the editor font-size setting.
### Text Field Suggestions
On Android, Flutter maps `enableSuggestions: false` to the visible-password input type, not only to "no suggestions"
(`TextInputPlugin.inputTypeFromTextInputType` in the engine). Input methods then treat the field as a password: no
clipboard panel, no Chinese layouts, and on some systems a secure keyboard. With the app's clipboard permission denied,
that leaves no way to paste a link at all. Only the secret fields, `SecretTextFormField` and `FormRowSecretTextField`,
ask for a password keyboard; a link, key, rule or regex field that should not be corrected sets `autocorrect: false`
alone. `test/lint/text_field_suggestions_test.dart` fails on `enableSuggestions: false` or
`TextInputType.visiblePassword` anywhere else in `lib/`.
### Length Units
Flutter lengths are logical pixels, the unit Android calls dp. Comments, test names, docs and reports give them in dp,
-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
View File
@@ -36,6 +36,7 @@ class BatchSection<T> extends StatelessWidget {
maxLines: null,
keyboardType: TextInputType.multiline,
autocorrect: false,
enableSuggestions: false,
onChanged: onChanged,
decoration: InputDecoration.collapsed(
border: const NoInputBorder(),
+2
View File
@@ -288,6 +288,7 @@ class _MapEntryFormState extends State<_MapEntryForm> {
hintText: appLocalizations.required,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
inputFormatters: keyMaxLength == null
? null
: TextInputLimits.limit(keyMaxLength),
@@ -304,6 +305,7 @@ class _MapEntryFormState extends State<_MapEntryForm> {
hintText: appLocalizations.required,
textInputAction: TextInputAction.done,
autocorrect: false,
enableSuggestions: false,
inputFormatters: valueMaxLength == null
? null
: TextInputLimits.limit(valueMaxLength),
+1
View File
@@ -296,6 +296,7 @@ class _NamedUrlFormState extends State<NamedUrlForm> {
? TextInputAction.next
: TextInputAction.done,
autocorrect: false,
enableSuggestions: false,
inputFormatters: TextInputLimits.limit(TextInputLimits.url),
onChanged: _handleChanged,
onFieldSubmitted: (_) {
+1
View File
@@ -374,6 +374,7 @@ class _RuleFormState extends State<RuleForm> {
controller: _contentController,
hintText: appLocalizations.inputRuleContent,
autocorrect: false,
enableSuggestions: false,
inputFormatters: TextInputLimits.limit(TextInputLimits.rule),
onChanged: (_) {
if (_attempted) {
+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;
+16 -64
View File
@@ -31,67 +31,18 @@ class ContextMenuRegion extends StatefulWidget {
class _ContextMenuRegionState extends State<ContextMenuRegion> {
Timer? _holdTimer;
LogicalKeyboardKey? _heldKey;
CommonPopupRoute<void>? _menu;
bool get _enabled {
final items = widget.menuItems;
return items != null &&
items.isNotEmpty &&
!SortableItem.isSorting(context);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_closeUnlessEnabled();
}
@override
void didUpdateWidget(ContextMenuRegion oldWidget) {
super.didUpdateWidget(oldWidget);
_closeUnlessEnabled();
}
@override
void dispose() {
_holdTimer?.cancel();
_closeMenu();
super.dispose();
}
/// A row withdraws its menu while its item updates, which the open menu
/// would otherwise still act on.
void _closeUnlessEnabled() {
if (_enabled) {
return;
}
_holdTimer?.cancel();
_heldKey = null;
_closeMenu();
}
void _closeMenu() {
final menu = _menu;
if (menu == null) {
return;
}
_menu = null;
// This runs while the tree builds or unmounts, when nothing may pop.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (menu.isCurrent) {
menu.navigator!.pop();
}
});
}
void _open(Offset globalPosition, {bool avoid = false}) {
final items = widget.menuItems;
final navigator = Navigator.maybeOf(context);
final navigatorBox = navigator?.context.findRenderObject();
final box = context.findRenderObject();
if (items == null ||
items.isEmpty ||
navigator == null ||
if (navigator == null ||
navigatorBox is! RenderBox ||
box is! RenderBox ||
!box.hasSize) {
@@ -102,20 +53,19 @@ class _ContextMenuRegionState extends State<ContextMenuRegion> {
box.getTransformTo(navigatorBox),
Offset.zero & box.size,
);
final menu = _menu = CommonPopupRoute<void>(
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
placement: PopupPlacement.belowPoint,
elevated: ElevatedSurfaceTheme.isIn(context),
anchorOf: () => point & Size.zero,
avoid: avoid ? rowRect : null,
builder: (_) => CommonPopupMenu(items: items),
);
unawaited(
navigator.push(menu).whenComplete(() {
if (identical(_menu, menu)) {
_menu = null;
}
}),
navigator.push(
CommonPopupRoute<void>(
barrierLabel: MaterialLocalizations.of(
context,
).modalBarrierDismissLabel,
placement: PopupPlacement.belowPoint,
elevated: ElevatedSurfaceTheme.isIn(context),
anchorOf: () => point & Size.zero,
avoid: avoid ? rowRect : null,
builder: (_) => CommonPopupMenu(items: widget.menuItems!),
),
),
);
}
@@ -190,7 +140,9 @@ class _ContextMenuRegionState extends State<ContextMenuRegion> {
@override
Widget build(BuildContext context) {
final enabled = _enabled;
final items = widget.menuItems;
final enabled =
items != null && items.isNotEmpty && !SortableItem.isSorting(context);
return Focus(
canRequestFocus: false,
skipTraversal: true,
-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;
}
+12 -19
View File
@@ -1266,9 +1266,7 @@ class _LiquidGlassLayer extends Layer {
} else {
canvas.clipRSuperellipse(corners.toRSuperellipse(rect));
}
final saturate = ColorFilter.matrix(
_optics(style.saturation, style.brightness),
);
final saturate = ColorFilter.matrix(_saturation(style.saturation));
void drawSlot(int from, Paint paint) => canvas.drawImageRect(
atlas,
Rect.fromLTWH(
@@ -1287,7 +1285,7 @@ class _LiquidGlassLayer extends Layer {
if (haze > 0) {
drawSlot(_hazeSample, Paint()..color = Color.fromRGBO(0, 0, 0, haze));
}
_drawFrost(canvas, overCapture: true);
_drawFrost(canvas);
canvas.restore();
}
@@ -1310,14 +1308,10 @@ class _LiquidGlassLayer extends Layer {
return recorder.endRecording();
}
/// Thickens the tint to stand in for the frost, unless the pane lies
/// [overCapture], which holds the blur.
void _drawFrost(Canvas canvas, {bool overCapture = false}) {
void _drawFrost(Canvas canvas) {
final tint = style.tint;
final fill = Paint()
..color = overCapture
? tint
: tint.withValues(alpha: math.min(1, tint.a + 0.2));
..color = tint.withValues(alpha: math.min(1, tint.a + 0.2));
final rim = style.rim;
final stroke = Paint()
..style = PaintingStyle.stroke
@@ -1485,16 +1479,15 @@ const _octantFits = [
return (ui.lerpDouble(n0, n1, t)!, 1 - 1 / ui.lerpDouble(k0, k1, t)!);
}
List<double> _optics(double saturation, double brightness) {
final red = 0.2126 * brightness;
final green = 0.7152 * brightness;
final blue = 0.0722 * brightness;
final rest = 1 - saturation;
final kept = saturation * brightness;
List<double> _saturation(double amount) {
const red = 0.2126;
const green = 0.7152;
const blue = 0.0722;
final rest = 1 - amount;
return [
red * rest + kept, green * rest, blue * rest, 0, 0, //
red * rest, green * rest + kept, blue * rest, 0, 0,
red * rest, green * rest, blue * rest + kept, 0, 0,
red * rest + amount, green * rest, blue * rest, 0, 0, //
red * rest, green * rest + amount, blue * rest, 0, 0,
red * rest, green * rest, blue * rest + amount, 0, 0,
0, 0, 0, 1, 0,
];
}
+3 -11
View File
@@ -54,9 +54,6 @@ class LiquidGlassHost extends StatelessWidget {
@visibleForTesting
static LiquidGlassCapture? debugLastCapture;
@visibleForTesting
static bool? debugCarryBandsOverride;
@override
Widget build(BuildContext context) {
return _LiquidGlassHostBox(
@@ -563,19 +560,14 @@ class _LiquidGlassHostLayer extends ContainerLayer {
}
return (atlas: previous.image, taken: false, kept: 0);
}
// Skia keeps what an image was drawn from for as long as the image lives,
// so a capture copied from the last would hold every one before it.
final carry =
LiquidGlassHost.debugCarryBandsOverride ??
ui.ImageFilter.isShaderFilterSupported;
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
var kept = 0;
for (final (index, band) in bands.indexed) {
final picture = pictures[index];
final old = carry
? last.where((old) => listEquals(old.drawn, band.drawn)).firstOrNull
: null;
final old = last
.where((old) => listEquals(old.drawn, band.drawn))
.firstOrNull;
if (old != null) {
canvas.drawImageRect(
previous!.image,
+1 -7
View File
@@ -370,13 +370,7 @@ class NavigationDock extends StatelessWidget {
),
),
);
// The bar rebuilds every frame its lens or a press moves; a scope of its
// own keeps that to the dock (.agents/architecture.md, Rebuild Scopes).
return SizedBox(
width: double.infinity,
height: _shadowRoom + height + _bottomMarginOf(context),
child: RepaintBoundary(child: LayoutBuilder(builder: (_, _) => dock)),
);
return RepaintBoundary(child: dock);
}
}
-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;
+28 -15
View File
@@ -1544,7 +1544,6 @@ class CommonPopupMenuItem {
const _menuMinWidth = 160.0;
const _menuMaxWidth = 280.0;
const _defaultMaxVisibleItems = 7;
class CommonPopupMenu extends StatefulWidget {
const CommonPopupMenu({super.key, required this.items, this.maxVisibleItems});
@@ -1552,7 +1551,6 @@ class CommonPopupMenu extends StatefulWidget {
final List<CommonPopupMenuItem> items;
/// Half of the next row shows past these, so the cut tells the menu scrolls.
/// Setting them builds only the rows in view, which needs rows of one height.
final int? maxVisibleItems;
@override
@@ -1683,8 +1681,8 @@ class _CommonPopupMenuState extends State<CommonPopupMenu>
super.dispose();
}
/// A menu given its own cap builds only the rows in view, since building
/// them all costs its first frame a row each; its rows share one height.
/// A menu that scrolls builds only the rows in view, since building them
/// all costs its first frame a row each; its rows share one height.
bool _buildsLazily(_MenuLevel level) =>
widget.maxVisibleItems != null &&
level.owner == null &&
@@ -1696,8 +1694,8 @@ class _CommonPopupMenuState extends State<CommonPopupMenu>
return;
}
// Whole rows above, not centring, keep the half row a label cut at the foot.
final visible = _maxVisibleItems;
final alignment = (visible ~/ 2) / (visible - 0.5);
final visible = widget.maxVisibleItems;
final alignment = visible == null ? 0.5 : (visible ~/ 2) / (visible - 0.5);
Scrollable.maybeOf(
row,
)?.position.ensureVisible(row.findRenderObject()!, alignment: alignment);
@@ -1707,16 +1705,15 @@ class _CommonPopupMenuState extends State<CommonPopupMenu>
_itemPadding.vertical +
MediaQuery.textScalerOf(context).scale(AppTextStyle.subheadline.leading);
int get _maxVisibleItems => widget.maxVisibleItems ?? _defaultMaxVisibleItems;
double _maxHeightOf(BuildContext context, _MenuLevel level) {
if (level.owner != null) {
final visible = widget.maxVisibleItems;
if (visible == null || level.owner != null) {
return double.infinity;
}
return 2 * _cardInset + (_maxVisibleItems + 0.5) * _rowExtentOf(context);
return 2 * _cardInset + (visible + 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 +1721,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 +
@@ -1750,7 +1763,7 @@ class _CommonPopupMenuState extends State<CommonPopupMenu>
Widget _buildLazyContent(BuildContext context, _MenuLevel level) {
final items = level.items;
final extent = _rowExtentOf(context);
final visible = _maxVisibleItems;
final visible = widget.maxVisibleItems!;
final height = math.min(items.length, visible + 0.5) * extent;
final controller = _lazyController ??= () {
final checked = items.indexWhere((item) => item.checked == true);
+1 -38
View File
@@ -15,7 +15,6 @@ import 'package:fl_clash/ui/surface.dart';
import 'package:fl_clash/ui/typography.dart';
import 'package:fl_clash/ui/view_scope.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'button.dart';
@@ -814,7 +813,7 @@ class CommonScaffoldState extends State<CommonScaffold> {
child: Scaffold(
appBar: appBar,
extendBodyBehindAppBar: barFloats,
body: _BodyScope(child: content),
body: content,
resizeToAvoidBottomInset: widget.resizeToAvoidBottomInset,
backgroundColor: widget.backgroundColor,
),
@@ -885,42 +884,6 @@ class AppBarClearance extends StatelessWidget {
}
}
/// Keeps rebuilds in the body to its own layer (.agents/architecture.md).
class _BodyScope extends StatelessWidget {
const _BodyScope({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return _BodyBoundary(child: LayoutBuilder(builder: (_, _) => child));
}
}
class _BodyBoundary extends SingleChildRenderObjectWidget {
const _BodyBoundary({super.child});
@override
RenderObject createRenderObject(BuildContext context) =>
_RenderBodyBoundary();
}
class _RenderBodyBoundary extends RenderProxyBox {
@override
bool get isRepaintBoundary => true;
@override
bool get sizedByParent => true;
@override
Size computeDryLayout(BoxConstraints constraints) => constraints.biggest;
@override
void performLayout() {
child?.layout(constraints);
}
}
/// Flutter reveals a focused control against the viewport alone, which runs
/// under a floating bar, so keyboard and remote traversal would park it there.
class _FocusClearOfBar extends StatefulWidget {
-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(
+2
View File
@@ -441,6 +441,7 @@ class _WebDAVFormState extends ConsumerState<WebDAVForm> {
keyboardType: TextInputType.url,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
inputFormatters: TextInputLimits.limit(TextInputLimits.uri),
onChanged: _handleChanged,
),
@@ -453,6 +454,7 @@ class _WebDAVFormState extends ConsumerState<WebDAVForm> {
hintText: appLocalizations.required,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
inputFormatters: TextInputLimits.limit(
TextInputLimits.userName,
),
+1
View File
@@ -269,6 +269,7 @@ class _FilterFormState extends State<_FilterForm> {
controller: _regexController,
hintText: r'(?i)hk|hong\s*kong',
autocorrect: false,
enableSuggestions: false,
textInputAction: TextInputAction.done,
inputFormatters: TextInputLimits.limit(TextInputLimits.filter),
onChanged: (_) {
+1
View File
@@ -624,6 +624,7 @@ class _ProviderFormState extends State<_ProviderForm> {
keyboardType: TextInputType.url,
textInputAction: TextInputAction.done,
autocorrect: false,
enableSuggestions: false,
onChanged: _handleChanged,
onFieldSubmitted: (_) => _handleSubmit(),
),
+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),
+56 -17
View File
@@ -17,6 +17,9 @@ import 'current_outbound.dart';
const _stackedIcons = 4;
ProxiesIconStyle _iconStyleOf(WidgetRef ref) =>
ref.watch(proxiesStyleSettingProvider.select((state) => state.iconStyle));
/// The inset of a block's corner, so the icon tile inside it is concentric.
const _blockInset = 10.0;
@@ -39,29 +42,49 @@ Future<void> showProxiesSheet(BuildContext context, {String? groupName}) async {
container.read(query.notifier).value = pageQuery;
}
/// Drawn in the proportions of the proxies page's group header, always
/// filled: the icon style chosen there is that page's own, and the dashboard
/// would look bare without icons.
/// Drawn in the proportions of the proxies page's group header, in the icon
/// style chosen there.
class _GroupIcon extends StatelessWidget {
const _GroupIcon({
required this.group,
required this.style,
required this.size,
required this.radius,
this.ring,
});
final Group group;
final ProxiesIconStyle style;
final double size;
final double radius;
final Color? ring;
static const _inset = 0.15;
static const _filledInset = 0.15;
static const _plainScale = 0.65;
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
final icon = CommonTargetIcon(
src: group.icon,
fallback: GroupMonogram(group.name),
);
if (style == ProxiesIconStyle.plain) {
return SizedBox.square(
dimension: size,
child: Center(
child: IconTheme.merge(
data: IconThemeData(
size: size * _plainScale,
color: colorScheme.onSurfaceVariant,
),
child: icon,
),
),
);
}
final ring = this.ring;
final inset = size * _inset;
final inset = size * _filledInset;
return Container(
width: size,
height: size,
@@ -85,10 +108,7 @@ class _GroupIcon extends StatelessWidget {
size: size - inset * 2,
color: colorScheme.onSurfaceVariant,
),
child: CommonTargetIcon(
src: group.icon,
fallback: GroupMonogram(group.name),
),
child: icon,
),
),
);
@@ -250,6 +270,7 @@ class ProxyGroupsCard extends ConsumerWidget {
final compact = density.isCompact;
final scale = DashboardDensityScope.scaleOf(context);
final groups = ref.watch(visibleGroupsStateProvider).value;
final iconStyle = _iconStyleOf(ref);
final padding = dashboardCardPadding(density);
final tileHeight = (compact ? 52 : 60) * scale;
final tileGap = compact ? 6.0 : 8.0;
@@ -268,6 +289,7 @@ class ProxyGroupsCard extends ConsumerWidget {
height: tileHeight,
gap: tileGap,
iconSize: _figureMetricsOf(density).icon,
iconStyle: iconStyle,
bottom: padding.bottom,
);
return Column(
@@ -338,14 +360,14 @@ class _GroupsHeader extends StatelessWidget {
}
}
class _GroupsFigure extends StatelessWidget {
class _GroupsFigure extends ConsumerWidget {
const _GroupsFigure({required this.groups, required this.metrics});
final List<Group> groups;
final _FigureMetrics metrics;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final count = Text.rich(
TextSpan(
children: [
@@ -361,6 +383,9 @@ class _GroupsFigure extends StatelessWidget {
),
maxLines: 1,
);
if (_iconStyleOf(ref) != ProxiesIconStyle.filled) {
return count;
}
return DashboardCappedRow(
start: count,
end: _IconStack(
@@ -401,6 +426,7 @@ class _IconStack extends StatelessWidget {
start: index * (size - _overlap),
child: _GroupIcon(
group: group,
style: ProxiesIconStyle.filled,
size: size,
radius: DashboardCorner.badge,
ring: ring,
@@ -446,6 +472,7 @@ class _GroupTiles extends StatelessWidget {
required this.height,
required this.gap,
required this.iconSize,
required this.iconStyle,
required this.bottom,
});
@@ -454,6 +481,7 @@ class _GroupTiles extends StatelessWidget {
final double height;
final double gap;
final double iconSize;
final ProxiesIconStyle iconStyle;
final double bottom;
static const _minTileWidth = 172.0;
@@ -483,6 +511,7 @@ class _GroupTiles extends StatelessWidget {
itemBuilder: (context, index) => _GroupTile(
group: groups[index],
iconSize: iconSize,
iconStyle: iconStyle,
onTap: () =>
showProxiesSheet(context, groupName: groups[index].name),
),
@@ -497,28 +526,38 @@ class _GroupTile extends StatelessWidget {
const _GroupTile({
required this.group,
required this.iconSize,
required this.iconStyle,
required this.onTap,
});
final Group group;
final double iconSize;
final ProxiesIconStyle iconStyle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final showIcon = iconStyle != ProxiesIconStyle.hidden;
return DashboardBlock(
radius: DashboardCorner.block,
padding: const EdgeInsetsDirectional.fromSTEB(_blockInset, 0, 14, 0),
padding: EdgeInsetsDirectional.fromSTEB(
showIcon ? _blockInset : 14,
0,
14,
0,
),
onTap: onTap,
semanticLabel: group.name,
child: Row(
spacing: 12,
children: [
_GroupIcon(
group: group,
size: iconSize,
radius: DashboardCorner.block - _blockInset,
),
if (showIcon)
_GroupIcon(
group: group,
style: iconStyle,
size: iconSize,
radius: DashboardCorner.block - _blockInset,
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
+1
View File
@@ -15,6 +15,7 @@ class Navigation implements NavigationPort {
}) {
return [
NavigationItem(
keep: false,
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) =>
+11 -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(
@@ -454,6 +450,7 @@ class _ProfileUrlField extends StatelessWidget {
keyboardType: TextInputType.url,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
inputFormatters: TextInputLimits.limit(TextInputLimits.url),
onChanged: onChanged,
),
+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),
),
);
}
}
+27 -44
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),
],
),
);
},
@@ -746,7 +729,7 @@ class _GroupIcon extends ConsumerWidget {
builder: (_, constraints) {
return IconTheme.merge(
data: IconThemeData(
size: constraints.maxHeight,
size: constraints.maxHeight - 16.ap(context),
color: context.colorScheme.primary,
),
child: CommonTargetIcon(src: src, fallback: GroupMonogram(name)),
+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
View File
@@ -703,6 +703,7 @@ class _HexColorFieldState extends State<HexColorField> {
child: TextField(
controller: _controller,
autocorrect: false,
enableSuggestions: false,
textCapitalization: TextCapitalization.characters,
style: const TextStyle(fontFeatures: [FontFeature.tabularFigures()]),
decoration: InputDecoration(
+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+2026101602
environment:
sdk: '>=3.10.0 <4.0.0'
-2
View File
@@ -184,8 +184,6 @@ void main() {
await tester.enterText(find.byType(TextFormField), 'example.com');
await tester.tap(find.text(l10n.splitStrategy));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text(l10n.matchTarget).last);
await tester.pumpAndSettle();
await tester.tap(find.text(l10n.matchTarget).last);
await tester.pumpAndSettle();
expect(find.text(l10n.matchTarget), findsOneWidget);
@@ -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) =>
@@ -1,37 +0,0 @@
import 'dart:io';
import 'package:test/test.dart';
final _passwordInputType = RegExp(
r'\benableSuggestions:\s*false\b|\bTextInputType\.visiblePassword\b',
);
const _secretFields = {'lib/ui/input.dart', 'lib/features/form/form_row.dart'};
void main() {
test('only secret fields ask Android for a password keyboard', () {
final offenders = <String>[];
for (final entity in Directory('lib').listSync(recursive: true)) {
if (entity is! File ||
!entity.path.endsWith('.dart') ||
entity.path.contains('/generated/') ||
_secretFields.contains(entity.path)) {
continue;
}
final lines = entity.readAsLinesSync();
for (var i = 0; i < lines.length; i++) {
final match = _passwordInputType.firstMatch(lines[i]);
if (match != null) {
offenders.add(
'${entity.path}:${i + 1} — ${match[0]} makes Android input '
'methods treat the field as a password, with no clipboard or '
'Chinese input; keep autocorrect: false alone.',
);
}
}
}
expect(offenders, isEmpty, reason: offenders.join('\n'));
});
}
+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));
@@ -195,29 +195,31 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('both cards draw icons whatever style the proxies page uses', (
testWidgets('the tiles draw icons in the style the proxies page uses', (
tester,
) async {
container.read(groupsProvider.notifier).value = [
_group('G0', ['N0'], now: 'N0'),
_group('G1', ['N1'], now: 'N1'),
];
Future<void> pumpStyle(ProxiesIconStyle style) async {
container.read(proxiesStyleSettingProvider.notifier).value = container
.read(proxiesStyleSettingProvider)
.copyWith(iconStyle: style);
await pumpCard(tester, height: 200);
}
final inTiles = find.descendant(
of: find.byType(DashboardBlock),
matching: find.byType(GroupMonogram),
);
for (final style in ProxiesIconStyle.values) {
container.read(proxiesStyleSettingProvider.notifier).value = container
.read(proxiesStyleSettingProvider)
.copyWith(iconStyle: style);
await pumpStyle(ProxiesIconStyle.filled);
expect(inTiles, findsNWidgets(2));
await pumpCard(tester, height: 200);
expect(inTiles, findsNWidgets(2), reason: style.name);
await pumpCard(tester, density: DashboardDensity.mobile);
expect(find.byType(GroupMonogram), findsNWidgets(2), reason: style.name);
}
await pumpStyle(ProxiesIconStyle.hidden);
expect(find.byType(GroupMonogram), findsNothing);
expect(name('N0'), findsOneWidget);
expect(tester.takeException(), isNull);
});
-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 {
+27 -44
View File
@@ -236,43 +236,40 @@ void main() {
expect(await levelBeside(4), 1);
});
Widget bandsScene(Color changing) => _app(
Stack(
children: [
Positioned(
left: 0,
top: 0,
width: 400,
height: 300,
child: RepaintBoundary(child: ColoredBox(color: changing)),
),
const Positioned(
left: 0,
top: 300,
width: 400,
height: 300,
child: RepaintBoundary(child: ColoredBox(color: _blue)),
),
_pane(const Rect.fromLTWH(100, 100, 200, 100)),
_pane(
const Rect.fromLTWH(100, 420, 200, 100),
style: const LiquidGlassStyle(tint: Colors.transparent, blur: 12),
),
],
),
);
testWidgets('a level redraws only the bands whose backdrop changed', (
tester,
) async {
LiquidGlassHost.debugCarryBandsOverride = true;
addTearDown(() => LiquidGlassHost.debugCarryBandsOverride = null);
Widget scene(Color changing) => _app(
Stack(
children: [
Positioned(
left: 0,
top: 0,
width: 400,
height: 300,
child: RepaintBoundary(child: ColoredBox(color: changing)),
),
const Positioned(
left: 0,
top: 300,
width: 400,
height: 300,
child: RepaintBoundary(child: ColoredBox(color: _blue)),
),
_pane(const Rect.fromLTWH(100, 100, 200, 100)),
_pane(
const Rect.fromLTWH(100, 420, 200, 100),
style: const LiquidGlassStyle(tint: Colors.transparent, blur: 12),
),
],
),
);
await tester.pumpWidget(bandsScene(_red));
await tester.pumpWidget(scene(_red));
expect(_capture().snapshots, 1);
expect(_capture().keptBands, 0);
await tester.pumpWidget(bandsScene(_green));
await tester.pumpWidget(scene(_green));
expect(_capture().snapshots, 1);
expect(_capture().keptBands, 1);
final [changed, kept] = _capture().panes;
@@ -280,20 +277,6 @@ void main() {
expect(await _behind(tester, kept, const Offset(200, 470)), _blue);
});
testWidgets(
'without Impeller a level redraws every band, copying from no capture',
(tester) async {
await tester.pumpWidget(bandsScene(_red));
await tester.pumpWidget(bandsScene(_green));
expect(_capture().snapshots, 1);
expect(_capture().keptBands, 0);
final [changed, kept] = _capture().panes;
expect(await _behind(tester, changed, const Offset(200, 150)), _green);
expect(await _behind(tester, kept, const Offset(200, 470)), _blue);
},
skip: ui.ImageFilter.isShaderFilterSupported,
);
testWidgets('the standard tier frosts a hazed pane through its haze alone', (
tester,
) async {
-30
View File
@@ -11,7 +11,6 @@ import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/views/navigation.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
@@ -186,35 +185,6 @@ void main() {
await tester.pumpAndSettle();
});
testWidgets('the lens moving repaints the dock and not the page', (
tester,
) async {
await _pumpHome(tester);
final page = tester.renderObject<RenderRepaintBoundary>(
find
.ancestor(
of: find.byType(HomePage),
matching: find.byType(RepaintBoundary),
)
.first,
);
final bar = tester.getRect(find.byType(FloatingNavigationBar));
final extent = (bar.width - 8) / 3;
var finger = Offset(bar.left + 4 + extent / 2, bar.center.dy);
final gesture = await tester.startGesture(finger);
await tester.pump(const Duration(milliseconds: 16));
page.debugResetMetrics();
for (var i = 0; i < 10; i++) {
finger += Offset(extent / 10, 0);
await gesture.moveTo(finger);
await tester.pump(const Duration(milliseconds: 16));
}
expect(page.debugSymmetricPaintCount + page.debugAsymmetricPaintCount, 0);
await gesture.up();
await tester.pumpAndSettle();
});
testWidgets('a flick carries the lens on to the next item', (tester) async {
final container = await _pumpHome(tester);
final bar = tester.getRect(find.byType(FloatingNavigationBar));
-20
View File
@@ -1475,26 +1475,6 @@ void main() {
expect(cut.bottom, greaterThan(menu.bottom));
});
testWidgets('a long menu caps itself well short of a tall window', (
tester,
) async {
tester.view.physicalSize = const Size(800, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final open = await pumpMenu(tester, [
for (var i = 0; i < 30; i++)
CommonPopupMenuItem(label: 'Item $i', onPressed: () {}),
]);
open();
await tester.pumpAndSettle();
final menu = tester.getRect(find.byType(PopupMenuCard));
final cut = tester.getRect(find.text('Item 7'));
expect(menu.height, lessThan(800));
expect(cut.top, lessThan(menu.bottom));
expect(cut.bottom, greaterThan(menu.bottom));
});
testWidgets('a capped menu is as wide as its rows laid out in full', (
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);
-46
View File
@@ -1,46 +0,0 @@
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import '../helpers/test_app.dart';
void main() {
testWidgets('a rebuild in the body repaints neither the bar nor the page', (
tester,
) async {
final count = ValueNotifier(0);
addTearDown(count.dispose);
const pageKey = Key('page');
await tester.pumpWidget(
TestApp(
wrapInProviderScope: true,
child: RepaintBoundary(
key: pageKey,
child: CommonScaffold(
title: 'Page',
body: Column(
children: [
ValueListenableBuilder(
valueListenable: count,
builder: (_, value, _) =>
RepaintBoundary(child: Text('$value')),
),
],
),
),
),
),
);
final page = tester.renderObject<RenderRepaintBoundary>(
find.byKey(pageKey),
);
page.debugResetMetrics();
count.value++;
await tester.pump();
expect(find.text('1'), findsOneWidget);
expect(page.debugSymmetricPaintCount + page.debugAsymmetricPaintCount, 0);
});
}
@@ -82,36 +82,6 @@ Future<List<String>> _pump(
return pressed;
}
/// A row whose sync withdraws its menu, as a row does while its item updates,
/// rebuilding the app above the navigator with it.
Future<ValueNotifier<bool>> _pumpSyncing(WidgetTester tester) async {
final syncing = ValueNotifier(false);
addTearDown(syncing.dispose);
await tester.pumpWidget(
ValueListenableBuilder(
valueListenable: syncing,
builder: (_, value, _) => TestApp(
child: Scaffold(
body: ContextMenuRegion(
menuItems: value
? null
: [
CommonPopupMenuItem(
glyph: AppGlyphs.sync,
label: 'Sync',
onPressed: () => syncing.value = true,
),
],
child: const DecorationListItem(title: Text('a')),
),
),
),
),
);
await tester.pumpAndSettle();
return syncing;
}
List<String> _order(WidgetTester tester, List<String> items) =>
[...items]..sort(
(a, b) => tester
@@ -150,30 +120,6 @@ void main() {
expect(pressed, isEmpty);
});
testWidgets('an item that withdraws the menu lets the menu close', (
tester,
) async {
await _pumpSyncing(tester);
await _rightClick(tester, 'a');
await tester.tap(find.text('Sync'));
await tester.pumpAndSettle();
expect(find.text('Sync'), findsNothing);
});
testWidgets('a row that withdraws its menu closes the open one', (
tester,
) async {
final syncing = await _pumpSyncing(tester);
await _rightClick(tester, 'a');
syncing.value = true;
await tester.pumpAndSettle();
expect(find.text('Sync'), findsNothing);
});
testWidgets('a mouse long press opens nothing', (tester) async {
await _pump(tester);