Compare commits

..

12 Commits

Author SHA1 Message Date
chen08209 a8655b0809 chore: bump version to 0.9.1+2026101701 2026-10-17 09:55:19 +08:00
chen08209 6c52d7a39a fix(ui): stop non-secret fields from opening a password keyboard
Changelog: Fix pasting and Chinese input in text fields
2026-10-17 09:55:10 +08:00
chen08209 4d68938d22 docs(agents): document surfaces, typography and the lib/ui layer 2026-10-17 09:54:29 +08:00
chen08209 213a13f62f feat(l10n): add French, Spanish and Korean
Changelog: French, Spanish and Korean translations
2026-10-17 09:54:29 +08:00
chen08209 fefbcf4598 feat(android): drop Firebase analytics and crash reporting
Changelog: No more Firebase analytics or crash reporting
2026-10-17 09:54:29 +08:00
chen08209 9f48781341 feat(network)!: set desktop TUN key by key over each profile's own
BREAKING CHANGE: Desktop TUN keys follow the profile unless picked; changed values carry over
Changelog: Per-key desktop TUN settings; system proxy and tray fixes
2026-10-17 09:54:29 +08:00
chen08209 dd952dd600 feat(theme): redesign theme colors with an iOS-style picker
Changelog: Redesigned theme colors and color styles
2026-10-17 09:54:29 +08:00
chen08209 bdbf4ab024 feat(dashboard): redesign the dashboard around the current exit
Changelog: A redesigned dashboard around the current exit
2026-10-17 09:54:29 +08:00
chen08209 347a74b940 feat(profiles): manage external resources from the profile details
Changelog: Profile-detail resources and include-all in custom groups
2026-10-17 09:54:29 +08:00
chen08209 eb47ee261f feat(proxies): show groups as cards and group tabs as chips
Changelog: Proxy groups as cards, group tabs as chips
2026-10-17 09:54:29 +08:00
chen08209 e82ebf785b feat(views): show connections, requests and DNS as tables
Changelog: Sortable tables for connections, requests and DNS
2026-10-17 09:54:29 +08:00
chen08209 ba15c313b4 feat(ui): add Liquid Glass and slide-to-pick popup menus
Changelog: Optional two-tier Liquid Glass and slide-to-pick menus
2026-10-17 09:54:29 +08:00
28 changed files with 445 additions and 153 deletions
+22 -3
View File
@@ -611,8 +611,10 @@ 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. 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. 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.
- 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
@@ -639,7 +641,8 @@ 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, tint, and a rim, with no refraction. `flutter test --enable-impeller` runs the shader, so `toImage`
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`
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
@@ -916,6 +919,22 @@ 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,6 +75,16 @@ 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,
-1
View File
@@ -36,7 +36,6 @@ 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,7 +288,6 @@ class _MapEntryFormState extends State<_MapEntryForm> {
hintText: appLocalizations.required,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
inputFormatters: keyMaxLength == null
? null
: TextInputLimits.limit(keyMaxLength),
@@ -305,7 +304,6 @@ 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,7 +296,6 @@ 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,7 +374,6 @@ class _RuleFormState extends State<RuleForm> {
controller: _contentController,
hintText: appLocalizations.inputRuleContent,
autocorrect: false,
enableSuggestions: false,
inputFormatters: TextInputLimits.limit(TextInputLimits.rule),
onChanged: (_) {
if (_attempted) {
+64 -16
View File
@@ -31,18 +31,67 @@ 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 (navigator == null ||
if (items == null ||
items.isEmpty ||
navigator == null ||
navigatorBox is! RenderBox ||
box is! RenderBox ||
!box.hasSize) {
@@ -53,19 +102,20 @@ 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(
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!),
),
),
navigator.push(menu).whenComplete(() {
if (identical(_menu, menu)) {
_menu = null;
}
}),
);
}
@@ -140,9 +190,7 @@ class _ContextMenuRegionState extends State<ContextMenuRegion> {
@override
Widget build(BuildContext context) {
final items = widget.menuItems;
final enabled =
items != null && items.isNotEmpty && !SortableItem.isSorting(context);
final enabled = _enabled;
return Focus(
canRequestFocus: false,
skipTraversal: true,
+19 -12
View File
@@ -1266,7 +1266,9 @@ class _LiquidGlassLayer extends Layer {
} else {
canvas.clipRSuperellipse(corners.toRSuperellipse(rect));
}
final saturate = ColorFilter.matrix(_saturation(style.saturation));
final saturate = ColorFilter.matrix(
_optics(style.saturation, style.brightness),
);
void drawSlot(int from, Paint paint) => canvas.drawImageRect(
atlas,
Rect.fromLTWH(
@@ -1285,7 +1287,7 @@ class _LiquidGlassLayer extends Layer {
if (haze > 0) {
drawSlot(_hazeSample, Paint()..color = Color.fromRGBO(0, 0, 0, haze));
}
_drawFrost(canvas);
_drawFrost(canvas, overCapture: true);
canvas.restore();
}
@@ -1308,10 +1310,14 @@ class _LiquidGlassLayer extends Layer {
return recorder.endRecording();
}
void _drawFrost(Canvas canvas) {
/// 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}) {
final tint = style.tint;
final fill = Paint()
..color = tint.withValues(alpha: math.min(1, tint.a + 0.2));
..color = overCapture
? tint
: tint.withValues(alpha: math.min(1, tint.a + 0.2));
final rim = style.rim;
final stroke = Paint()
..style = PaintingStyle.stroke
@@ -1479,15 +1485,16 @@ const _octantFits = [
return (ui.lerpDouble(n0, n1, t)!, 1 - 1 / ui.lerpDouble(k0, k1, t)!);
}
List<double> _saturation(double amount) {
const red = 0.2126;
const green = 0.7152;
const blue = 0.0722;
final rest = 1 - amount;
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;
return [
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,
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,
0, 0, 0, 1, 0,
];
}
+11 -3
View File
@@ -54,6 +54,9 @@ class LiquidGlassHost extends StatelessWidget {
@visibleForTesting
static LiquidGlassCapture? debugLastCapture;
@visibleForTesting
static bool? debugCarryBandsOverride;
@override
Widget build(BuildContext context) {
return _LiquidGlassHostBox(
@@ -560,14 +563,19 @@ 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 = last
.where((old) => listEquals(old.drawn, band.drawn))
.firstOrNull;
final old = carry
? last.where((old) => listEquals(old.drawn, band.drawn)).firstOrNull
: null;
if (old != null) {
canvas.drawImageRect(
previous!.image,
+7 -1
View File
@@ -370,7 +370,13 @@ class NavigationDock extends StatelessWidget {
),
),
);
return RepaintBoundary(child: dock);
// 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)),
);
}
}
+11 -8
View File
@@ -1544,6 +1544,7 @@ 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});
@@ -1551,6 +1552,7 @@ 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
@@ -1681,8 +1683,8 @@ class _CommonPopupMenuState extends State<CommonPopupMenu>
super.dispose();
}
/// 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.
/// 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.
bool _buildsLazily(_MenuLevel level) =>
widget.maxVisibleItems != null &&
level.owner == null &&
@@ -1694,8 +1696,8 @@ class _CommonPopupMenuState extends State<CommonPopupMenu>
return;
}
// Whole rows above, not centring, keep the half row a label cut at the foot.
final visible = widget.maxVisibleItems;
final alignment = visible == null ? 0.5 : (visible ~/ 2) / (visible - 0.5);
final visible = _maxVisibleItems;
final alignment = (visible ~/ 2) / (visible - 0.5);
Scrollable.maybeOf(
row,
)?.position.ensureVisible(row.findRenderObject()!, alignment: alignment);
@@ -1705,12 +1707,13 @@ 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) {
final visible = widget.maxVisibleItems;
if (visible == null || level.owner != null) {
if (level.owner != null) {
return double.infinity;
}
return 2 * _cardInset + (visible + 0.5) * _rowExtentOf(context);
return 2 * _cardInset + (_maxVisibleItems + 0.5) * _rowExtentOf(context);
}
static final _labelWidths = <(String, TextStyle, TextScaler), double>{};
@@ -1763,7 +1766,7 @@ class _CommonPopupMenuState extends State<CommonPopupMenu>
Widget _buildLazyContent(BuildContext context, _MenuLevel level) {
final items = level.items;
final extent = _rowExtentOf(context);
final visible = widget.maxVisibleItems!;
final visible = _maxVisibleItems;
final height = math.min(items.length, visible + 0.5) * extent;
final controller = _lazyController ??= () {
final checked = items.indexWhere((item) => item.checked == true);
+38 -1
View File
@@ -15,6 +15,7 @@ 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';
@@ -813,7 +814,7 @@ class CommonScaffoldState extends State<CommonScaffold> {
child: Scaffold(
appBar: appBar,
extendBodyBehindAppBar: barFloats,
body: content,
body: _BodyScope(child: content),
resizeToAvoidBottomInset: widget.resizeToAvoidBottomInset,
backgroundColor: widget.backgroundColor,
),
@@ -884,6 +885,42 @@ 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 {
-2
View File
@@ -441,7 +441,6 @@ class _WebDAVFormState extends ConsumerState<WebDAVForm> {
keyboardType: TextInputType.url,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
inputFormatters: TextInputLimits.limit(TextInputLimits.uri),
onChanged: _handleChanged,
),
@@ -454,7 +453,6 @@ class _WebDAVFormState extends ConsumerState<WebDAVForm> {
hintText: appLocalizations.required,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
inputFormatters: TextInputLimits.limit(
TextInputLimits.userName,
),
-1
View File
@@ -269,7 +269,6 @@ 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,7 +624,6 @@ class _ProviderFormState extends State<_ProviderForm> {
keyboardType: TextInputType.url,
textInputAction: TextInputAction.done,
autocorrect: false,
enableSuggestions: false,
onChanged: _handleChanged,
onFieldSubmitted: (_) => _handleSubmit(),
),
+17 -56
View File
@@ -17,9 +17,6 @@ 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;
@@ -42,49 +39,29 @@ 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, in the icon
/// style chosen there.
/// 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.
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 _filledInset = 0.15;
static const _plainScale = 0.65;
static const _inset = 0.15;
@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 * _filledInset;
final inset = size * _inset;
return Container(
width: size,
height: size,
@@ -108,7 +85,10 @@ class _GroupIcon extends StatelessWidget {
size: size - inset * 2,
color: colorScheme.onSurfaceVariant,
),
child: icon,
child: CommonTargetIcon(
src: group.icon,
fallback: GroupMonogram(group.name),
),
),
),
);
@@ -270,7 +250,6 @@ 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;
@@ -289,7 +268,6 @@ class ProxyGroupsCard extends ConsumerWidget {
height: tileHeight,
gap: tileGap,
iconSize: _figureMetricsOf(density).icon,
iconStyle: iconStyle,
bottom: padding.bottom,
);
return Column(
@@ -360,14 +338,14 @@ class _GroupsHeader extends StatelessWidget {
}
}
class _GroupsFigure extends ConsumerWidget {
class _GroupsFigure extends StatelessWidget {
const _GroupsFigure({required this.groups, required this.metrics});
final List<Group> groups;
final _FigureMetrics metrics;
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final count = Text.rich(
TextSpan(
children: [
@@ -383,9 +361,6 @@ class _GroupsFigure extends ConsumerWidget {
),
maxLines: 1,
);
if (_iconStyleOf(ref) != ProxiesIconStyle.filled) {
return count;
}
return DashboardCappedRow(
start: count,
end: _IconStack(
@@ -426,7 +401,6 @@ class _IconStack extends StatelessWidget {
start: index * (size - _overlap),
child: _GroupIcon(
group: group,
style: ProxiesIconStyle.filled,
size: size,
radius: DashboardCorner.badge,
ring: ring,
@@ -472,7 +446,6 @@ class _GroupTiles extends StatelessWidget {
required this.height,
required this.gap,
required this.iconSize,
required this.iconStyle,
required this.bottom,
});
@@ -481,7 +454,6 @@ class _GroupTiles extends StatelessWidget {
final double height;
final double gap;
final double iconSize;
final ProxiesIconStyle iconStyle;
final double bottom;
static const _minTileWidth = 172.0;
@@ -511,7 +483,6 @@ class _GroupTiles extends StatelessWidget {
itemBuilder: (context, index) => _GroupTile(
group: groups[index],
iconSize: iconSize,
iconStyle: iconStyle,
onTap: () =>
showProxiesSheet(context, groupName: groups[index].name),
),
@@ -526,38 +497,28 @@ 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: EdgeInsetsDirectional.fromSTEB(
showIcon ? _blockInset : 14,
0,
14,
0,
),
padding: const EdgeInsetsDirectional.fromSTEB(_blockInset, 0, 14, 0),
onTap: onTap,
semanticLabel: group.name,
child: Row(
spacing: 12,
children: [
if (showIcon)
_GroupIcon(
group: group,
style: iconStyle,
size: iconSize,
radius: DashboardCorner.block - _blockInset,
),
_GroupIcon(
group: group,
size: iconSize,
radius: DashboardCorner.block - _blockInset,
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
-1
View File
@@ -450,7 +450,6 @@ class _ProfileUrlField extends StatelessWidget {
keyboardType: TextInputType.url,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
inputFormatters: TextInputLimits.limit(TextInputLimits.url),
onChanged: onChanged,
),
+1 -1
View File
@@ -729,7 +729,7 @@ class _GroupIcon extends ConsumerWidget {
builder: (_, constraints) {
return IconTheme.merge(
data: IconThemeData(
size: constraints.maxHeight - 16.ap(context),
size: constraints.maxHeight,
color: context.colorScheme.primary,
),
child: CommonTargetIcon(src: src, fallback: GroupMonogram(name)),
-1
View File
@@ -703,7 +703,6 @@ 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+2026101602
version: 0.9.1+2026101701
environment:
sdk: '>=3.10.0 <4.0.0'
+2
View File
@@ -184,6 +184,8 @@ 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);
@@ -0,0 +1,37 @@
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'));
});
}
@@ -195,31 +195,29 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('the tiles draw icons in the style the proxies page uses', (
testWidgets('both cards draw icons whatever 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),
);
await pumpStyle(ProxiesIconStyle.filled);
expect(inTiles, findsNWidgets(2));
for (final style in ProxiesIconStyle.values) {
container.read(proxiesStyleSettingProvider.notifier).value = container
.read(proxiesStyleSettingProvider)
.copyWith(iconStyle: style);
await pumpStyle(ProxiesIconStyle.hidden);
expect(find.byType(GroupMonogram), findsNothing);
expect(name('N0'), findsOneWidget);
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);
}
expect(tester.takeException(), isNull);
});
+44 -27
View File
@@ -236,40 +236,43 @@ 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 {
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),
),
],
),
);
LiquidGlassHost.debugCarryBandsOverride = true;
addTearDown(() => LiquidGlassHost.debugCarryBandsOverride = null);
await tester.pumpWidget(scene(_red));
await tester.pumpWidget(bandsScene(_red));
expect(_capture().snapshots, 1);
expect(_capture().keptBands, 0);
await tester.pumpWidget(scene(_green));
await tester.pumpWidget(bandsScene(_green));
expect(_capture().snapshots, 1);
expect(_capture().keptBands, 1);
final [changed, kept] = _capture().panes;
@@ -277,6 +280,20 @@ 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,6 +11,7 @@ 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';
@@ -185,6 +186,35 @@ 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,6 +1475,26 @@ 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 {
+46
View File
@@ -0,0 +1,46 @@
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,6 +82,36 @@ 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
@@ -120,6 +150,30 @@ 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);