Compare commits
12 Commits
v0.9.1-pre.3
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| a8655b0809 | |||
| 6c52d7a39a | |||
| 4d68938d22 | |||
| 213a13f62f | |||
| fefbcf4598 | |||
| 9f48781341 | |||
| dd952dd600 | |||
| bdbf4ab024 | |||
| 347a74b940 | |||
| eb47ee261f | |||
| e82ebf785b | |||
| ba15c313b4 |
+22
-3
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: (_) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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: (_) {
|
||||
|
||||
@@ -624,7 +624,6 @@ class _ProviderFormState extends State<_ProviderForm> {
|
||||
keyboardType: TextInputType.url,
|
||||
textInputAction: TextInputAction.done,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
onChanged: _handleChanged,
|
||||
onFieldSubmitted: (_) => _handleSubmit(),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user