feat(ui): add Liquid Glass and slide-to-pick popup menus
Changelog: Optional two-tier Liquid Glass and slide-to-pick menus
This commit is contained in:
@@ -213,6 +213,7 @@
|
||||
"layout": "Layout",
|
||||
"tight": "Tight",
|
||||
"standard": "Standard",
|
||||
"ultra": "Ultra",
|
||||
"loose": "Loose",
|
||||
"profilesSort": "Sort profiles",
|
||||
"start": "Start",
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"layout": "レイアウト",
|
||||
"tight": "コンパクト",
|
||||
"standard": "標準",
|
||||
"ultra": "最高",
|
||||
"loose": "ゆったり",
|
||||
"profilesSort": "プロファイルの並べ替え",
|
||||
"start": "開始",
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"layout": "Макет",
|
||||
"tight": "Плотный",
|
||||
"standard": "Стандартный",
|
||||
"ultra": "Максимум",
|
||||
"loose": "Свободный",
|
||||
"profilesSort": "Сортировка профилей",
|
||||
"start": "Старт",
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"layout": "布局",
|
||||
"tight": "紧凑",
|
||||
"standard": "标准",
|
||||
"ultra": "极致",
|
||||
"loose": "宽松",
|
||||
"profilesSort": "配置排序",
|
||||
"start": "启动",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/// Features whose code stays but that ship switched off. A disabled feature
|
||||
/// hides its setting and ignores the stored value, so switching it back on
|
||||
/// restores what the user had chosen.
|
||||
/// Build-time switches for features whose code stays either way, each
|
||||
/// defaulting to how it ships. A disabled feature with a setting hides it and
|
||||
/// ignores the stored value, so switching it back on restores what the user
|
||||
/// had chosen.
|
||||
class Feature {
|
||||
const Feature({
|
||||
this.sidebarBlur = const bool.fromEnvironment('FEATURE_SIDEBAR_BLUR'),
|
||||
|
||||
@@ -116,6 +116,8 @@ class _ListEditorPageState<T, K> extends ConsumerState<ListEditorPage<T, K>> {
|
||||
message: TextSpan(
|
||||
text: context.appLocalizations.deleteMultipTip(widget.title),
|
||||
),
|
||||
destructive: true,
|
||||
confirmText: context.appLocalizations.delete,
|
||||
);
|
||||
if (res != true || !mounted) {
|
||||
return;
|
||||
@@ -347,7 +349,6 @@ class ListEditorAddAction extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
return CommonPopupBox(
|
||||
anchorShift: Offset(0, context.isMobileView ? 0 : 20),
|
||||
targetBuilder: (open) => AppBarActionButton(
|
||||
data: IconButtonData(
|
||||
glyph: AppGlyphs.addCircle,
|
||||
|
||||
@@ -1645,6 +1645,11 @@ class AppLocalizations {
|
||||
return Intl.message('Standard', name: 'standard', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `Ultra`
|
||||
String get ultra {
|
||||
return Intl.message('Ultra', name: 'ultra', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `Loose`
|
||||
String get loose {
|
||||
return Intl.message('Loose', name: 'loose', desc: '', args: []);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/providers/action.dart';
|
||||
import 'package:fl_clash/providers/app.dart';
|
||||
import 'package:fl_clash/providers/config.dart';
|
||||
@@ -115,7 +116,6 @@ class ThemeManager extends ConsumerWidget {
|
||||
|
||||
final measure = Measure.of(context, textScaleFactor);
|
||||
globalState.measure = measure;
|
||||
UiTextScale.factor = textScaleFactor;
|
||||
return MeasureScope(
|
||||
measure: measure,
|
||||
child: AppViewScope(
|
||||
@@ -167,11 +167,18 @@ class _LiquidGlassSetting extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final mode = ref.watch(
|
||||
themeSettingProvider.select((state) => state.liquidGlass),
|
||||
);
|
||||
return LiquidGlassScope(
|
||||
enabled: ref.watch(
|
||||
themeSettingProvider.select((state) => state.liquidGlass),
|
||||
enabled: mode != LiquidGlassMode.none,
|
||||
quality: mode == LiquidGlassMode.ultra
|
||||
? LiquidGlassQuality.ultra
|
||||
: LiquidGlassQuality.standard,
|
||||
child: LiquidGlassHost(
|
||||
enabled: mode != LiquidGlassMode.none,
|
||||
child: child,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-12
@@ -29,11 +29,6 @@ class HomePage extends ConsumerWidget {
|
||||
.watch(currentNavigationItemsStateProvider)
|
||||
.value;
|
||||
final isMobile = ref.watch(isMobileViewProvider);
|
||||
final floating = ref.watch(
|
||||
appSettingProvider.select(
|
||||
(state) => state.floatingNavigationBar,
|
||||
),
|
||||
);
|
||||
return _HomePageView(
|
||||
navigationItems: navigationItems,
|
||||
pageBuilder: (_, index) {
|
||||
@@ -42,7 +37,6 @@ class HomePage extends ConsumerWidget {
|
||||
key: ValueKey(navigationItem.label),
|
||||
item: navigationItem,
|
||||
isMobile: isMobile,
|
||||
primaryActionInBar: !isMobile || floating,
|
||||
view: navigationItem.builder(context),
|
||||
);
|
||||
},
|
||||
@@ -185,20 +179,16 @@ class _NavigationPage extends StatelessWidget {
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.isMobile,
|
||||
required this.primaryActionInBar,
|
||||
required this.view,
|
||||
});
|
||||
|
||||
final NavigationItem item;
|
||||
final bool isMobile;
|
||||
final bool primaryActionInBar;
|
||||
final Widget view;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scopedView = PageFocusScope(
|
||||
child: PrimaryActionInBarScope(inBar: primaryActionInBar, child: view),
|
||||
);
|
||||
final scopedView = PageFocusScope(child: view);
|
||||
final keptView = KeepScope(
|
||||
key: ValueKey(item.label),
|
||||
keep: item.keep,
|
||||
@@ -413,7 +403,8 @@ class _FadeTabPage extends StatelessWidget {
|
||||
return AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (_, child) {
|
||||
final delta = enabled ? _delta : 0.0;
|
||||
final offset = _delta;
|
||||
final delta = enabled ? offset : 0.0;
|
||||
return FractionalTranslation(
|
||||
translation: Offset(-delta, 0),
|
||||
child: Opacity(opacity: 1 - delta.abs(), child: child),
|
||||
|
||||
+65
-15
@@ -7,6 +7,7 @@ import 'package:material_ui/material_ui.dart';
|
||||
import 'focus.dart';
|
||||
import 'popup.dart';
|
||||
import 'sortable.dart';
|
||||
import 'surface.dart';
|
||||
|
||||
const _touchDevices = {
|
||||
PointerDeviceKind.touch,
|
||||
@@ -30,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) {
|
||||
@@ -52,18 +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,
|
||||
anchorOf: () => point & Size.zero,
|
||||
avoid: avoid ? rowRect : null,
|
||||
builder: (_) => CommonPopupMenu(items: widget.menuItems!),
|
||||
),
|
||||
),
|
||||
navigator.push(menu).whenComplete(() {
|
||||
if (identical(_menu, menu)) {
|
||||
_menu = null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,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,
|
||||
|
||||
+357
-62
@@ -2,11 +2,14 @@ import 'dart:math' as math;
|
||||
|
||||
import 'package:fl_clash/ui/color.dart';
|
||||
import 'package:fl_clash/ui/context.dart';
|
||||
import 'package:fl_clash/ui/input.dart';
|
||||
import 'package:fl_clash/ui/liquid_glass.dart';
|
||||
import 'package:fl_clash/ui/motion.dart';
|
||||
import 'package:fl_clash/ui/navigation_dock.dart';
|
||||
import 'package:fl_clash/ui/shape.dart';
|
||||
import 'package:fl_clash/ui/surface.dart';
|
||||
import 'package:fl_clash/ui/typography.dart';
|
||||
import 'package:flutter/foundation.dart' show listEquals;
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
@@ -18,6 +21,10 @@ const double _alertWidth = 348;
|
||||
const double _contentMaxWidth = 300;
|
||||
// Material 3 sets a filled field's text 4 in past its content padding.
|
||||
const double _fieldInputGap = 4;
|
||||
const double _pressGrowth = 4;
|
||||
const double _pressPull = 3;
|
||||
const double _pressedFillShare = 0.4;
|
||||
const double _pressedDarkDefaultShare = 0.5;
|
||||
|
||||
// UIAlertController on iOS 27 grows in from 1.2 times its size and only fades
|
||||
// out, both on one critically damped spring that settles in 0.404 s.
|
||||
@@ -37,7 +44,7 @@ Future<T?> showFloatingDialog<T>({
|
||||
}) {
|
||||
return Navigator.of(context).push<T>(
|
||||
_AlertRoute<T>(
|
||||
barrierColor: context.colorScheme.modalScrim,
|
||||
barrierColor: context.colorScheme.alertScrim,
|
||||
barrierDismissible: dismissible,
|
||||
barrierLabel: dismissible
|
||||
? MaterialLocalizations.of(context).modalBarrierDismissLabel
|
||||
@@ -187,7 +194,7 @@ class _AlertTransitionState extends State<_AlertTransition> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FadeTransition(
|
||||
return LiquidGlassFade(
|
||||
opacity: widget.animation,
|
||||
child: AnimatedBuilder(
|
||||
animation: widget.animation,
|
||||
@@ -198,6 +205,118 @@ class _AlertTransitionState extends State<_AlertTransition> {
|
||||
}
|
||||
}
|
||||
|
||||
RenderPhysicalShape? _surfaceIn(RenderObject node) {
|
||||
if (node is RenderPhysicalShape) {
|
||||
return node;
|
||||
}
|
||||
RenderPhysicalShape? surface;
|
||||
node.visitChildren((child) => surface ??= _surfaceIn(child));
|
||||
return surface;
|
||||
}
|
||||
|
||||
Rect _surfaceRectIn(RenderBox box) {
|
||||
final surface = _surfaceIn(box);
|
||||
if (surface == null) {
|
||||
return Offset.zero & box.size;
|
||||
}
|
||||
return MatrixUtils.transformRect(
|
||||
surface.getTransformTo(box),
|
||||
Offset.zero & surface.size,
|
||||
);
|
||||
}
|
||||
|
||||
/// An [AlertDialog] takes only a color, so the pane goes where its [Material]
|
||||
/// lays out its [PhysicalShape].
|
||||
class _AlertGlass extends SingleChildRenderObjectWidget {
|
||||
const _AlertGlass({
|
||||
required this.style,
|
||||
required this.rim,
|
||||
required this.shadows,
|
||||
super.child,
|
||||
});
|
||||
|
||||
final LiquidGlassStyle style;
|
||||
final LiquidGlassRim rim;
|
||||
final List<BoxShadow> shadows;
|
||||
|
||||
@override
|
||||
_RenderAlertGlass createRenderObject(BuildContext context) {
|
||||
return _RenderAlertGlass(style, rim, shadows);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
BuildContext context,
|
||||
_RenderAlertGlass renderObject,
|
||||
) {
|
||||
renderObject
|
||||
..style = style
|
||||
..rim = rim
|
||||
..shadows = shadows;
|
||||
}
|
||||
}
|
||||
|
||||
class _RenderAlertGlass extends RenderProxyBox {
|
||||
_RenderAlertGlass(this._style, this._rim, this._shadows);
|
||||
|
||||
late final _pane = LiquidGlassPane(markNeedsPaint);
|
||||
|
||||
LiquidGlassStyle _style;
|
||||
set style(LiquidGlassStyle value) {
|
||||
if (value == _style) {
|
||||
return;
|
||||
}
|
||||
_style = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
LiquidGlassRim _rim;
|
||||
set rim(LiquidGlassRim value) {
|
||||
if (value == _rim) {
|
||||
return;
|
||||
}
|
||||
_rim = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
List<BoxShadow> _shadows;
|
||||
set shadows(List<BoxShadow> value) {
|
||||
if (listEquals(value, _shadows)) {
|
||||
return;
|
||||
}
|
||||
_shadows = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
@override
|
||||
bool get alwaysNeedsCompositing => true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pane.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
if (_surfaceIn(this) == null) {
|
||||
_pane.clear();
|
||||
} else {
|
||||
final rect = _surfaceRectIn(this).shift(offset);
|
||||
_pane.paint(
|
||||
context,
|
||||
rect,
|
||||
shape: LiquidGlassCorners.circular(AppCorner.alert),
|
||||
style: _style,
|
||||
shadows: _shadows,
|
||||
rim: _rim,
|
||||
);
|
||||
_rim.paintLine(context.canvas, rect, AppCorner.alert);
|
||||
}
|
||||
super.paint(context, offset);
|
||||
}
|
||||
}
|
||||
|
||||
class CommonDialog extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget? trailing;
|
||||
@@ -212,6 +331,9 @@ class CommonDialog extends StatelessWidget {
|
||||
/// Sets the fields in [child] as an iOS alert does: as wide as its buttons.
|
||||
final bool hasFields;
|
||||
|
||||
/// The last action destroys data: red on the plain fill, as in UIKit.
|
||||
final bool destructive;
|
||||
|
||||
const CommonDialog({
|
||||
super.key,
|
||||
required this.title,
|
||||
@@ -220,35 +342,53 @@ class CommonDialog extends StatelessWidget {
|
||||
this.child,
|
||||
this.padding,
|
||||
this.hasFields = false,
|
||||
this.destructive = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final actions = this.actions ?? const <Widget>[];
|
||||
final contentInset = hasFields ? _actionInset : _textInset;
|
||||
return AlertDialog(
|
||||
titleTextStyle: AppTextStyle.headline.of(context),
|
||||
final glass = LiquidGlassScope.of(context);
|
||||
final colorScheme = context.colorScheme;
|
||||
final fill = glass ? glassFillOf(colorScheme) : null;
|
||||
final dialog = AlertDialog(
|
||||
backgroundColor: glass ? Colors.transparent : null,
|
||||
titleTextStyle: hasFields
|
||||
? AppTextStyle.body.of(context)
|
||||
: AppTextStyle.headline.of(context),
|
||||
contentTextStyle: AppTextStyle.subheadline
|
||||
.of(context)
|
||||
.copyWith(color: context.colorScheme.onSurfaceVariant),
|
||||
title: trailing == null
|
||||
? Text(title)
|
||||
: Row(
|
||||
children: [
|
||||
Expanded(child: Text(title)),
|
||||
trailing!,
|
||||
],
|
||||
.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: colorScheme.brightness == Brightness.dark ? 1 : 0.85,
|
||||
),
|
||||
actions: actions.isEmpty ? null : [AlertActions(actions: actions)],
|
||||
),
|
||||
title: _buildTitle(),
|
||||
actions: actions.isEmpty
|
||||
? null
|
||||
: [
|
||||
AlertActions(
|
||||
actions: actions,
|
||||
fill: fill,
|
||||
destructive: destructive,
|
||||
),
|
||||
],
|
||||
actionsPadding: const EdgeInsets.fromLTRB(
|
||||
_actionInset,
|
||||
0,
|
||||
_actionInset,
|
||||
_actionInset,
|
||||
),
|
||||
titlePadding: const EdgeInsets.fromLTRB(_textInset, 24, _textInset, 0),
|
||||
titlePadding: const EdgeInsets.fromLTRB(_textInset, 21, _textInset, 0),
|
||||
contentPadding:
|
||||
padding ?? EdgeInsets.fromLTRB(contentInset, 16, contentInset, 24),
|
||||
padding ??
|
||||
EdgeInsets.fromLTRB(
|
||||
contentInset,
|
||||
hasFields ? 16 : 6,
|
||||
contentInset,
|
||||
20,
|
||||
),
|
||||
content: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: padding == null
|
||||
@@ -257,93 +397,247 @@ class CommonDialog extends StatelessWidget {
|
||||
),
|
||||
width: double.infinity,
|
||||
child: SingleChildScrollView(
|
||||
child: hasFields ? _AlertFieldTheme(child: child) : child,
|
||||
child: hasFields
|
||||
? FilledInputTheme(
|
||||
fillColor: fill,
|
||||
borderRadius: AppRadius.all(_actionHeight / 2),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: _textInset - _actionInset - _fieldInputGap,
|
||||
vertical: 12,
|
||||
),
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
)
|
||||
: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
return ElasticPress(
|
||||
pullPastEdges: true,
|
||||
maxGrowth: _pressGrowth,
|
||||
maxPull: _pressPull,
|
||||
panelOf: _surfaceRectIn,
|
||||
child: glass
|
||||
? _AlertGlass(
|
||||
style: LiquidGlassStyle.alert(colorScheme),
|
||||
rim: LiquidGlassRim.alert(colorScheme),
|
||||
shadows: glassMenuShadows(colorScheme),
|
||||
child: dialog,
|
||||
)
|
||||
: dialog,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTitle() {
|
||||
final trailing = this.trailing;
|
||||
if (!hasFields) {
|
||||
return trailing == null
|
||||
? Text(title)
|
||||
: Row(
|
||||
children: [
|
||||
Expanded(child: Text(title)),
|
||||
trailing,
|
||||
],
|
||||
);
|
||||
}
|
||||
final text = Text(title, textAlign: TextAlign.center);
|
||||
if (trailing == null) {
|
||||
return SizedBox(width: double.infinity, child: text);
|
||||
}
|
||||
return _CenteredTitle(title: text, trailing: trailing);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlertFieldTheme extends StatelessWidget {
|
||||
const _AlertFieldTheme({required this.child});
|
||||
|
||||
final Widget? child;
|
||||
/// A title centred on its whole width, beside a control at its end.
|
||||
class _CenteredTitle extends MultiChildRenderObjectWidget {
|
||||
_CenteredTitle({required Widget title, required Widget trailing})
|
||||
: super(children: [title, trailing]);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = context.colorScheme;
|
||||
final shape = AppShape.input.copyWith(
|
||||
borderRadius: AppRadius.all(_actionHeight / 2),
|
||||
_RenderCenteredTitle createRenderObject(BuildContext context) {
|
||||
return _RenderCenteredTitle(textDirection: Directionality.of(context));
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
BuildContext context,
|
||||
_RenderCenteredTitle renderObject,
|
||||
) {
|
||||
renderObject.textDirection = Directionality.of(context);
|
||||
}
|
||||
}
|
||||
|
||||
class _RenderCenteredTitle extends RenderBox
|
||||
with
|
||||
ContainerRenderObjectMixin<RenderBox, MultiChildLayoutParentData>,
|
||||
RenderBoxContainerDefaultsMixin<RenderBox, MultiChildLayoutParentData> {
|
||||
_RenderCenteredTitle({required TextDirection textDirection})
|
||||
: _textDirection = textDirection;
|
||||
|
||||
TextDirection _textDirection;
|
||||
|
||||
set textDirection(TextDirection value) {
|
||||
if (value == _textDirection) {
|
||||
return;
|
||||
}
|
||||
_textDirection = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
RenderBox get _title => firstChild!;
|
||||
|
||||
RenderBox get _trailing => lastChild!;
|
||||
|
||||
@override
|
||||
void setupParentData(RenderBox child) {
|
||||
if (child.parentData is! MultiChildLayoutParentData) {
|
||||
child.parentData = MultiChildLayoutParentData();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicWidth(double height) =>
|
||||
_title.getMinIntrinsicWidth(height) +
|
||||
_trailing.getMinIntrinsicWidth(height) * 2;
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicWidth(double height) =>
|
||||
_title.getMaxIntrinsicWidth(height) +
|
||||
_trailing.getMaxIntrinsicWidth(height) * 2;
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicHeight(double width) => math.max(
|
||||
_title.getMinIntrinsicHeight(width),
|
||||
_trailing.getMinIntrinsicHeight(width),
|
||||
);
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicHeight(double width) => math.max(
|
||||
_title.getMaxIntrinsicHeight(width),
|
||||
_trailing.getMaxIntrinsicHeight(width),
|
||||
);
|
||||
|
||||
double _widthOf(BoxConstraints constraints, double side) =>
|
||||
constraints.hasBoundedWidth
|
||||
? constraints.maxWidth
|
||||
: _title.getMaxIntrinsicWidth(double.infinity) + side * 2;
|
||||
|
||||
BoxConstraints _titleConstraints(double width, double side) =>
|
||||
BoxConstraints.tightFor(width: math.max(0.0, width - side * 2));
|
||||
|
||||
@override
|
||||
Size computeDryLayout(covariant BoxConstraints constraints) {
|
||||
final trailing = _trailing.getDryLayout(constraints.loosen());
|
||||
final width = _widthOf(constraints, trailing.width);
|
||||
final title = _title.getDryLayout(_titleConstraints(width, trailing.width));
|
||||
return constraints.constrain(
|
||||
Size(width, math.max(title.height, trailing.height)),
|
||||
);
|
||||
final none = shape.copyWith(borderSide: BorderSide.none);
|
||||
return InputDecorationTheme(
|
||||
data: InputDecorationTheme.of(context).copyWith(
|
||||
filled: true,
|
||||
fillColor: context.raisedSurfaceLevel.colorOf(colorScheme),
|
||||
hoverColor: Colors.transparent,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.never,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: _textInset - _actionInset - _fieldInputGap,
|
||||
vertical: 12,
|
||||
),
|
||||
border: none,
|
||||
enabledBorder: none,
|
||||
disabledBorder: none,
|
||||
errorBorder: none,
|
||||
focusedBorder: shape.copyWith(
|
||||
borderSide: BorderSide(color: colorScheme.primary, width: 2),
|
||||
),
|
||||
focusedErrorBorder: shape.copyWith(
|
||||
borderSide: BorderSide(color: colorScheme.error, width: 2),
|
||||
),
|
||||
),
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
}
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
_trailing.layout(constraints.loosen(), parentUsesSize: true);
|
||||
final side = _trailing.size.width;
|
||||
final width = _widthOf(constraints, side);
|
||||
_title.layout(_titleConstraints(width, side), parentUsesSize: true);
|
||||
size = constraints.constrain(
|
||||
Size(width, math.max(_title.size.height, _trailing.size.height)),
|
||||
);
|
||||
final rtl = _textDirection == TextDirection.rtl;
|
||||
(_title.parentData! as MultiChildLayoutParentData).offset = Offset(
|
||||
side,
|
||||
(size.height - _title.size.height) / 2,
|
||||
);
|
||||
(_trailing.parentData! as MultiChildLayoutParentData).offset = Offset(
|
||||
rtl ? 0 : size.width - side,
|
||||
(size.height - _trailing.size.height) / 2,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
defaultPaint(context, offset);
|
||||
}
|
||||
|
||||
@override
|
||||
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
|
||||
return defaultHitTestChildren(result, position: position);
|
||||
}
|
||||
}
|
||||
|
||||
/// Text buttons laid out and filled as [CommonDialog] sets its own.
|
||||
class AlertActions extends StatelessWidget {
|
||||
const AlertActions({super.key, required this.actions});
|
||||
const AlertActions({
|
||||
super.key,
|
||||
required this.actions,
|
||||
this.fill,
|
||||
this.destructive = false,
|
||||
});
|
||||
|
||||
final List<Widget> actions;
|
||||
|
||||
/// The plain buttons' fill, in place of the raised surface's.
|
||||
final Color? fill;
|
||||
|
||||
final bool destructive;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _AlertActions(
|
||||
children: [
|
||||
for (final (index, action) in actions.indexed)
|
||||
_AlertAction(isDefault: index == actions.length - 1, child: action),
|
||||
_AlertAction(
|
||||
isDefault: !destructive && index == actions.length - 1,
|
||||
destructive: destructive && index == actions.length - 1,
|
||||
fill: fill,
|
||||
child: action,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlertAction extends StatelessWidget {
|
||||
const _AlertAction({required this.isDefault, required this.child});
|
||||
const _AlertAction({
|
||||
required this.isDefault,
|
||||
required this.destructive,
|
||||
required this.fill,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final bool isDefault;
|
||||
final bool destructive;
|
||||
final Color? fill;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = context.colorScheme;
|
||||
final fill = context.raisedSurfaceLevel.colorOf(colorScheme);
|
||||
final fill = this.fill ?? context.raisedSurfaceLevel.colorOf(colorScheme);
|
||||
final foreground = isDefault
|
||||
? colorScheme.onPrimary
|
||||
: destructive
|
||||
? colorScheme.destructive
|
||||
: colorScheme.onSurface;
|
||||
final child = this.child;
|
||||
final background = isDefault ? colorScheme.primary : fill;
|
||||
final pressedShare = switch ((isDefault, colorScheme.brightness)) {
|
||||
(true, Brightness.dark) => _pressedDarkDefaultShare,
|
||||
(false, Brightness.dark) => 1.0,
|
||||
_ => _pressedFillShare,
|
||||
};
|
||||
return TextButtonTheme(
|
||||
data: TextButtonThemeData(
|
||||
style:
|
||||
TextButton.styleFrom(
|
||||
backgroundColor: isDefault ? colorScheme.primary : fill,
|
||||
foregroundColor: foreground,
|
||||
disabledBackgroundColor: fill,
|
||||
disabledForegroundColor: colorScheme.onSurface.withValues(
|
||||
alpha: 0.38,
|
||||
),
|
||||
textStyle: AppTextStyle.body.of(context, emphasized: isDefault),
|
||||
textStyle: isDefault
|
||||
? AppTextStyle.body.of(context, emphasized: true)
|
||||
: AppTextStyle.body
|
||||
.of(context)
|
||||
.copyWith(fontWeight: FontWeight.w500),
|
||||
minimumSize: const Size(0, _actionHeight),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
shape: AppShape.full,
|
||||
@@ -351,6 +645,13 @@ class _AlertAction extends StatelessWidget {
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
).copyWith(
|
||||
backgroundColor: WidgetStateProperty<Color?>.fromMap({
|
||||
WidgetState.disabled: fill,
|
||||
WidgetState.pressed: background.withValues(
|
||||
alpha: background.a * pressedShare,
|
||||
),
|
||||
WidgetState.any: background,
|
||||
}),
|
||||
overlayColor: WidgetStateProperty<Color?>.fromMap({
|
||||
WidgetState.focused: foreground.withValues(alpha: 0.1),
|
||||
WidgetState.pressed: Colors.transparent,
|
||||
@@ -359,13 +660,7 @@ class _AlertAction extends StatelessWidget {
|
||||
side: focusRingStyle(colorScheme, standsOff: isDefault).side,
|
||||
),
|
||||
),
|
||||
child: ElasticPress(
|
||||
enabled: switch (child) {
|
||||
ButtonStyleButton(:final enabled) => enabled,
|
||||
_ => true,
|
||||
},
|
||||
child: child,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -314,11 +314,15 @@ class FilledInputTheme extends StatelessWidget {
|
||||
super.key,
|
||||
this.borderRadius = AppRadius.full,
|
||||
this.contentPadding,
|
||||
this.fillColor,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final BorderRadius borderRadius;
|
||||
final EdgeInsetsGeometry? contentPadding;
|
||||
|
||||
/// In place of the raised surface's.
|
||||
final Color? fillColor;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
@@ -329,7 +333,7 @@ class FilledInputTheme extends StatelessWidget {
|
||||
return InputDecorationTheme(
|
||||
data: InputDecorationTheme.of(context).copyWith(
|
||||
filled: true,
|
||||
fillColor: context.raisedSurfaceLevel.colorOf(colorScheme),
|
||||
fillColor: fillColor ?? context.raisedSurfaceLevel.colorOf(colorScheme),
|
||||
hoverColor: Colors.transparent,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.never,
|
||||
contentPadding: contentPadding,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,967 @@
|
||||
part of 'liquid_glass.dart';
|
||||
|
||||
// A capture shrinks while a sigma of its frost still spans two of its own
|
||||
// pixels; at one, where g1455 records its regular finish, text under dark glass
|
||||
// shows blocks. g1455 measured no divisor past _maxDivisor.
|
||||
const double _captureFrost = 2;
|
||||
const int _maxDivisor = 8;
|
||||
const double _maxCaptureSide = 2048;
|
||||
// The blur of a downscaled capture read linearly, in its own pixels.
|
||||
const double _captureSoftness = 0.5;
|
||||
// How far content may paint past the bounds its layer was recorded for.
|
||||
const double _overflow = 64;
|
||||
// Impeller keeps the targets of layers and of their blurs by size for a few
|
||||
// frames: a layer resized as content moves would miss them every frame.
|
||||
const double _atlasStep = 32;
|
||||
// Past this many sigmas of its frost a pane takes under 1% of what it reads, so
|
||||
// one painted there does not raise it a level.
|
||||
const double _levelReach = 2.5;
|
||||
const double _heldFrostShare = 0.9;
|
||||
// Offsets into a pane's mapping, in the order of the shader's uniforms.
|
||||
const _hazeSample = 10;
|
||||
const _hazeShare = 18;
|
||||
const _noHaze = <double>[0, 0, 1, 1, 0, 0, 0, 0];
|
||||
|
||||
typedef LiquidGlassCapture = ({
|
||||
int snapshots,
|
||||
int keptBands,
|
||||
List<
|
||||
({
|
||||
Rect pane,
|
||||
Rect slot,
|
||||
Rect place,
|
||||
double scale,
|
||||
int level,
|
||||
ui.Image atlas,
|
||||
})
|
||||
>
|
||||
panes,
|
||||
});
|
||||
|
||||
/// Draws the panes below from one capture of what lies behind them, taken as
|
||||
/// the frame is composited, in place of each pane reading the backdrop
|
||||
/// (.agents/architecture.md). Only one may enclose a pane.
|
||||
class LiquidGlassHost extends StatelessWidget {
|
||||
const LiquidGlassHost({
|
||||
super.key,
|
||||
required this.enabled,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
final Widget child;
|
||||
|
||||
@visibleForTesting
|
||||
static LiquidGlassCapture? debugLastCapture;
|
||||
|
||||
@visibleForTesting
|
||||
static bool? debugCarryBandsOverride;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _LiquidGlassHostBox(
|
||||
enabled: enabled,
|
||||
devicePixelRatio: MediaQuery.devicePixelRatioOf(context),
|
||||
quality: LiquidGlassScope.qualityOf(context),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fades [child] as [FadeTransition] does, but as Core Animation fades a layer:
|
||||
/// the panes within still bend what lies behind it, and fade with the rest.
|
||||
class LiquidGlassFade extends SingleChildRenderObjectWidget {
|
||||
const LiquidGlassFade({super.key, required this.opacity, super.child});
|
||||
|
||||
final Animation<double> opacity;
|
||||
|
||||
/// [PaintingContext.pushOpacity] for a render object, fading as this does.
|
||||
static void push(
|
||||
PaintingContext context,
|
||||
Offset offset,
|
||||
int alpha,
|
||||
PaintingContextCallback painter,
|
||||
) {
|
||||
context.pushLayer(
|
||||
_LiquidGlassFadeLayer()
|
||||
..alpha = alpha
|
||||
..offset = offset,
|
||||
painter,
|
||||
Offset.zero,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) {
|
||||
return _RenderLiquidGlassFade(opacity);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, RenderObject renderObject) {
|
||||
(renderObject as _RenderLiquidGlassFade).opacity = opacity;
|
||||
}
|
||||
}
|
||||
|
||||
class _RenderLiquidGlassFade extends RenderProxyBox {
|
||||
_RenderLiquidGlassFade(this._opacity);
|
||||
|
||||
final _layer = LayerHandle<_LiquidGlassFadeLayer>();
|
||||
|
||||
Animation<double> _opacity;
|
||||
set opacity(Animation<double> value) {
|
||||
if (value == _opacity) {
|
||||
return;
|
||||
}
|
||||
if (attached) {
|
||||
_opacity.removeListener(markNeedsPaint);
|
||||
value.addListener(markNeedsPaint);
|
||||
}
|
||||
_opacity = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
int get _alpha =>
|
||||
ui.Color.getAlphaFromOpacity(_opacity.value.clamp(0.0, 1.0));
|
||||
|
||||
@override
|
||||
bool get alwaysNeedsCompositing => child != null;
|
||||
|
||||
@override
|
||||
void attach(PipelineOwner owner) {
|
||||
super.attach(owner);
|
||||
_opacity.addListener(markNeedsPaint);
|
||||
}
|
||||
|
||||
@override
|
||||
void detach() {
|
||||
_opacity.removeListener(markNeedsPaint);
|
||||
super.detach();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_layer.layer = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
final alpha = _alpha;
|
||||
if (child == null || alpha == 0) {
|
||||
_layer.layer = null;
|
||||
return;
|
||||
}
|
||||
final layer = _layer.layer ??= _LiquidGlassFadeLayer();
|
||||
layer
|
||||
..alpha = alpha
|
||||
..offset = offset;
|
||||
context.pushLayer(layer, super.paint, Offset.zero);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitChildrenForSemantics(RenderObjectVisitor visitor) {
|
||||
if (_alpha != 0) {
|
||||
super.visitChildrenForSemantics(visitor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _LiquidGlassFadeLayer extends OpacityLayer {}
|
||||
|
||||
class _LiquidGlassHostBox extends SingleChildRenderObjectWidget {
|
||||
const _LiquidGlassHostBox({
|
||||
required this.enabled,
|
||||
required this.devicePixelRatio,
|
||||
required this.quality,
|
||||
super.child,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
final double devicePixelRatio;
|
||||
final LiquidGlassQuality quality;
|
||||
|
||||
@override
|
||||
_RenderLiquidGlassHost createRenderObject(BuildContext context) {
|
||||
return _RenderLiquidGlassHost(
|
||||
enabled: enabled,
|
||||
devicePixelRatio: devicePixelRatio,
|
||||
quality: quality,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
BuildContext context,
|
||||
_RenderLiquidGlassHost renderObject,
|
||||
) {
|
||||
renderObject
|
||||
..enabled = enabled
|
||||
..devicePixelRatio = devicePixelRatio
|
||||
..quality = quality;
|
||||
}
|
||||
}
|
||||
|
||||
class _RenderLiquidGlassHost extends RenderProxyBox {
|
||||
_RenderLiquidGlassHost({
|
||||
required bool enabled,
|
||||
required double devicePixelRatio,
|
||||
required LiquidGlassQuality quality,
|
||||
}) : _enabled = enabled,
|
||||
_devicePixelRatio = devicePixelRatio,
|
||||
_quality = quality;
|
||||
|
||||
final _layer = LayerHandle<_LiquidGlassHostLayer>();
|
||||
|
||||
bool _enabled;
|
||||
set enabled(bool value) {
|
||||
if (value == _enabled) {
|
||||
return;
|
||||
}
|
||||
_enabled = value;
|
||||
markNeedsCompositingBitsUpdate();
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
double _devicePixelRatio;
|
||||
set devicePixelRatio(double value) {
|
||||
if (value == _devicePixelRatio) {
|
||||
return;
|
||||
}
|
||||
_devicePixelRatio = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
LiquidGlassQuality _quality;
|
||||
set quality(LiquidGlassQuality value) {
|
||||
if (value == _quality) {
|
||||
return;
|
||||
}
|
||||
_quality = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
@override
|
||||
bool get alwaysNeedsCompositing => _enabled;
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
if (!_enabled) {
|
||||
_release();
|
||||
super.paint(context, offset);
|
||||
return;
|
||||
}
|
||||
final layer = _layer.layer ??= _LiquidGlassHostLayer();
|
||||
layer
|
||||
..devicePixelRatio = _devicePixelRatio
|
||||
..quality = _quality
|
||||
..bounds = offset & size;
|
||||
context.pushLayer(layer, super.paint, offset);
|
||||
}
|
||||
|
||||
// A pane captured on a pass still current would keep drawing from it.
|
||||
void _release() {
|
||||
if (_layer.layer != null) {
|
||||
_layer.layer = null;
|
||||
_LiquidGlassHostLayer._pass++;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_release();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _LiquidGlassHostLayer extends ContainerLayer {
|
||||
static int _pass = 0;
|
||||
|
||||
double devicePixelRatio = 1;
|
||||
LiquidGlassQuality quality = LiquidGlassQuality.ultra;
|
||||
Rect bounds = Rect.zero;
|
||||
|
||||
final _atlases = <({ui.Image image, List<_Band> bands})>[];
|
||||
|
||||
@override
|
||||
void addToScene(ui.SceneBuilder builder) {
|
||||
_capture();
|
||||
super.addToScene(builder);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final atlas in _atlases) {
|
||||
atlas.image.dispose();
|
||||
}
|
||||
_atlases.clear();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// A pane over one painted before it in the same root sits a level above
|
||||
/// it, and reads a capture with that one drawn as glass.
|
||||
void _capture() {
|
||||
_pass++;
|
||||
final found = <_Behind>[];
|
||||
_gather(this, Matrix4.identity(), this, Matrix4.identity(), [], found);
|
||||
final placed = <_Behind>[];
|
||||
for (final entry in found) {
|
||||
if (_place(entry)) {
|
||||
placed.add(entry);
|
||||
if (entry.style.haze > 0) {
|
||||
final haze = _Behind.hazeOf(entry);
|
||||
if (_place(haze)) {
|
||||
entry.haze = haze;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
entry.pane._captureNothing(_pass);
|
||||
}
|
||||
}
|
||||
var levels = 0;
|
||||
for (final (index, entry) in placed.indexed) {
|
||||
for (final before in placed.take(index)) {
|
||||
if ((identical(entry.root, this) ||
|
||||
before.ancestors.contains(entry.root)) &&
|
||||
before.device.overlaps(entry.reach)) {
|
||||
entry.level = math.max(entry.level, before.level + 1);
|
||||
}
|
||||
}
|
||||
levels = math.max(levels, entry.level + 1);
|
||||
}
|
||||
var snapshots = 0;
|
||||
var keptBands = 0;
|
||||
for (var level = 0; level < levels; level++) {
|
||||
final members = [
|
||||
for (final entry in placed)
|
||||
if (entry.level == level) entry,
|
||||
];
|
||||
final (:atlas, :taken, :kept) = _snapshot(level, [
|
||||
for (final entry in members) ...[entry, ?entry.haze],
|
||||
]);
|
||||
if (taken) {
|
||||
snapshots++;
|
||||
keptBands += kept;
|
||||
}
|
||||
for (final entry in members) {
|
||||
entry.atlas = atlas;
|
||||
_publish(entry, atlas);
|
||||
}
|
||||
}
|
||||
while (_atlases.length > levels) {
|
||||
_atlases.removeLast().image.dispose();
|
||||
}
|
||||
assert(() {
|
||||
LiquidGlassHost.debugLastCapture = (
|
||||
snapshots: snapshots,
|
||||
keptBands: keptBands,
|
||||
panes: [
|
||||
for (final entry in placed)
|
||||
(
|
||||
pane: entry.device,
|
||||
slot: entry.slot,
|
||||
place: entry.place,
|
||||
scale: entry.texelsPerDp,
|
||||
level: entry.level,
|
||||
atlas: entry.atlas!,
|
||||
),
|
||||
],
|
||||
);
|
||||
return true;
|
||||
}());
|
||||
}
|
||||
|
||||
void _gather(
|
||||
ContainerLayer layer,
|
||||
Matrix4 toHost,
|
||||
ContainerLayer root,
|
||||
Matrix4 rootToHost,
|
||||
List<ContainerLayer> chain,
|
||||
List<_Behind> found,
|
||||
) {
|
||||
chain.add(layer);
|
||||
for (Layer? child = layer.firstChild; child != null;) {
|
||||
switch (child) {
|
||||
case final _LiquidGlassLayer pane:
|
||||
found.add(
|
||||
_Behind(
|
||||
pane,
|
||||
switch (quality) {
|
||||
LiquidGlassQuality.standard => pane.style._standard,
|
||||
LiquidGlassQuality.ultra => pane.style,
|
||||
},
|
||||
toHost,
|
||||
root,
|
||||
rootToHost,
|
||||
chain.toSet(),
|
||||
),
|
||||
);
|
||||
case final ContainerLayer container:
|
||||
final offscreen = _drawsOffscreen(container);
|
||||
_gather(
|
||||
container,
|
||||
_within(container, toHost),
|
||||
offscreen ? container : root,
|
||||
offscreen ? toHost : rootToHost,
|
||||
chain,
|
||||
found,
|
||||
);
|
||||
}
|
||||
child = child.nextSibling;
|
||||
}
|
||||
chain.removeLast();
|
||||
}
|
||||
|
||||
/// Snaps the slot to the capture's pixels, so that content holding still
|
||||
/// lands on the same ones while the pane moves. A pane with a hold keeps
|
||||
/// its slot while it fits inside at no more frost, nor much less, as a
|
||||
/// spring settling past its card would need, rather than glass fading.
|
||||
bool _place(_Behind entry) {
|
||||
final pane = entry.pane;
|
||||
final style = entry.style;
|
||||
final hazed = entry.hazed;
|
||||
final blur = hazed ? style.hazeBlur : style.blur;
|
||||
final storage = entry.toHost.storage;
|
||||
final stretch = math.max(storage[0].abs(), storage[5].abs());
|
||||
final device = entry.device;
|
||||
if (stretch == 0 || device.isEmpty || !device.overlaps(bounds)) {
|
||||
pane._held = null;
|
||||
return false;
|
||||
}
|
||||
final hold = hazed ? null : pane.hold;
|
||||
final sweep = hold == null
|
||||
? device
|
||||
: device.expandToInclude(MatrixUtils.transformRect(entry.toHost, hold));
|
||||
final bend = math.max(0.0, -style.refraction) + style.dispersion;
|
||||
final reach = (3 * (blur + style.rimBlur) + bend + 2) * stretch;
|
||||
entry.reach = sweep.inflate(
|
||||
(_levelReach * (blur + style.rimBlur) + bend) * stretch,
|
||||
);
|
||||
final needed = device.inflate(reach).intersect(bounds);
|
||||
final covered = sweep.inflate(reach).intersect(bounds);
|
||||
final longest = math.max(covered.width, covered.height) * devicePixelRatio;
|
||||
final divisor = math.max(
|
||||
math.min(
|
||||
_maxDivisor,
|
||||
math.max(
|
||||
1,
|
||||
(blur * stretch * devicePixelRatio / _captureFrost).floor(),
|
||||
),
|
||||
),
|
||||
(longest / _maxCaptureSide).ceil(),
|
||||
);
|
||||
final scale = devicePixelRatio / divisor;
|
||||
final frost = blur * stretch * scale;
|
||||
final softness = divisor > 1 ? _captureSoftness : 0.0;
|
||||
final blurSigma = frost > softness
|
||||
? math.sqrt(frost * frost - softness * softness)
|
||||
: 0.0;
|
||||
final held = pane._held;
|
||||
final _Placement placement;
|
||||
if (hold != null &&
|
||||
held != null &&
|
||||
held.texelsPerDp == scale &&
|
||||
held.blurSigma >= blurSigma &&
|
||||
blurSigma >= held.blurSigma * _heldFrostShare &&
|
||||
_encloses(held.slot, needed)) {
|
||||
placement = held;
|
||||
} else {
|
||||
final left = (covered.left * scale).floorToDouble();
|
||||
final top = (covered.top * scale).floorToDouble();
|
||||
final right = (covered.right * scale).ceilToDouble();
|
||||
final bottom = (covered.bottom * scale).ceilToDouble();
|
||||
placement = (
|
||||
slot: Rect.fromLTRB(
|
||||
left / scale,
|
||||
top / scale,
|
||||
right / scale,
|
||||
bottom / scale,
|
||||
),
|
||||
texels: Size(right - left, bottom - top),
|
||||
texelsPerDp: scale,
|
||||
heldFrost: math.max(frost, softness),
|
||||
blurSigma: blurSigma,
|
||||
);
|
||||
}
|
||||
if (!hazed) {
|
||||
pane._held = hold == null ? null : placement;
|
||||
}
|
||||
entry
|
||||
..texelsPerDp = placement.texelsPerDp
|
||||
..heldFrost = placement.heldFrost
|
||||
..blurSigma = placement.blurSigma
|
||||
..slot = placement.slot
|
||||
..texels = placement.texels;
|
||||
return true;
|
||||
}
|
||||
|
||||
({ui.Image atlas, bool taken, int kept}) _snapshot(
|
||||
int level,
|
||||
List<_Behind> members,
|
||||
) {
|
||||
// Slots that blur alike lie together, under one layer that blurs them;
|
||||
// one that does not blur lies alone, so it is kept or drawn on its own.
|
||||
final classes = <Object, List<_Behind>>{};
|
||||
for (final entry in members) {
|
||||
(classes[entry.blurSigma == 0 ? entry : entry.blurSigma] ??= []).add(
|
||||
entry,
|
||||
);
|
||||
}
|
||||
double step(double extent) => (extent / _atlasStep).ceil() * _atlasStep;
|
||||
final bands = <_Band>[];
|
||||
final pictures = <ui.Picture>[];
|
||||
var top = 0.0;
|
||||
for (final entries in classes.values) {
|
||||
var bottom = 0.0;
|
||||
var width = 0.0;
|
||||
for (final entry in entries) {
|
||||
entry.place = Offset(0, top + bottom) & entry.texels;
|
||||
bottom += entry.texels.height;
|
||||
width = math.max(width, entry.texels.width);
|
||||
}
|
||||
final rect = Rect.fromLTWH(0, top, step(width), step(bottom));
|
||||
final sigma = entries.first.blurSigma;
|
||||
final drawn = <Object?>[sigma, rect.size];
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = Canvas(recorder);
|
||||
for (final entry in entries) {
|
||||
final place = entry.place.shift(-rect.topLeft);
|
||||
drawn
|
||||
..add(place)
|
||||
..add(entry.slot)
|
||||
..addAll(entry.rootToHost.storage);
|
||||
canvas
|
||||
..save()
|
||||
..clipRect(place)
|
||||
..translate(place.left, place.top)
|
||||
..scale(entry.texelsPerDp)
|
||||
..translate(-entry.slot.left, -entry.slot.top)
|
||||
..transform(entry.rootToHost.storage);
|
||||
_Replay(canvas, drawn, entry).run();
|
||||
canvas.restore();
|
||||
}
|
||||
bands.add((rect: rect, sigma: sigma, drawn: drawn));
|
||||
pictures.add(recorder.endRecording());
|
||||
top = rect.bottom;
|
||||
}
|
||||
final size = Size(
|
||||
bands.fold(0.0, (width, band) => math.max(width, band.rect.width)),
|
||||
top,
|
||||
);
|
||||
final previous = level < _atlases.length ? _atlases[level] : null;
|
||||
final last = previous?.bands ?? const <_Band>[];
|
||||
if (previous != null &&
|
||||
previous.image.width == size.width &&
|
||||
previous.image.height == size.height &&
|
||||
last.length == bands.length &&
|
||||
bands.indexed.every(
|
||||
(band) =>
|
||||
band.$2.rect == last[band.$1].rect &&
|
||||
listEquals(band.$2.drawn, last[band.$1].drawn),
|
||||
)) {
|
||||
for (final picture in pictures) {
|
||||
picture.dispose();
|
||||
}
|
||||
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;
|
||||
if (old != null) {
|
||||
canvas.drawImageRect(
|
||||
previous!.image,
|
||||
old.rect,
|
||||
band.rect,
|
||||
Paint()
|
||||
..filterQuality = FilterQuality.none
|
||||
..blendMode = BlendMode.src,
|
||||
);
|
||||
kept++;
|
||||
} else {
|
||||
final sigma = band.sigma;
|
||||
canvas
|
||||
..save()
|
||||
..clipRect(band.rect);
|
||||
if (sigma > 0) {
|
||||
canvas.saveLayer(
|
||||
band.rect,
|
||||
Paint()
|
||||
..imageFilter = ui.ImageFilter.blur(
|
||||
sigmaX: sigma,
|
||||
sigmaY: sigma,
|
||||
tileMode: TileMode.clamp,
|
||||
),
|
||||
);
|
||||
}
|
||||
// Impeller filters a layer under the transform current at its
|
||||
// restore (flutter-upgrade.md).
|
||||
canvas
|
||||
..save()
|
||||
..translate(band.rect.left, band.rect.top)
|
||||
..drawPicture(picture)
|
||||
..restore();
|
||||
if (sigma > 0) {
|
||||
canvas.restore();
|
||||
}
|
||||
canvas.restore();
|
||||
}
|
||||
picture.dispose();
|
||||
}
|
||||
final picture = recorder.endRecording();
|
||||
final image = picture.toImageSync(size.width.toInt(), size.height.toInt());
|
||||
picture.dispose();
|
||||
if (previous == null) {
|
||||
_atlases.add((image: image, bands: bands));
|
||||
} else {
|
||||
previous.image.dispose();
|
||||
_atlases[level] = (image: image, bands: bands);
|
||||
}
|
||||
return (atlas: image, taken: true, kept: kept);
|
||||
}
|
||||
|
||||
void _publish(_Behind entry, ui.Image atlas) {
|
||||
final pane = entry.pane;
|
||||
final storage = entry.toHost.storage;
|
||||
final haze = entry.haze;
|
||||
pane._captureFrom(
|
||||
_pass,
|
||||
atlas,
|
||||
[
|
||||
..._sampling(entry),
|
||||
1 / (devicePixelRatio * math.max(storage[0].abs(), storage[5].abs())),
|
||||
entry.heldFrost,
|
||||
...haze == null ? _noHaze : _sampling(haze),
|
||||
if (haze == null) 0 else entry.style.haze,
|
||||
],
|
||||
identical(entry.root, this) ? BlendMode.srcOver : BlendMode.src,
|
||||
entry.style,
|
||||
);
|
||||
}
|
||||
|
||||
List<double> _sampling(_Behind slot) {
|
||||
final pane = slot.pane;
|
||||
final storage = slot.toHost.storage;
|
||||
final place = slot.place;
|
||||
final scale = slot.texelsPerDp;
|
||||
final texelsPerPaneX = storage[0] * scale;
|
||||
final texelsPerPaneY = storage[5] * scale;
|
||||
final originX =
|
||||
place.left +
|
||||
(storage[0] * pane.rect.left + storage[12] - slot.slot.left) * scale;
|
||||
final originY =
|
||||
place.top +
|
||||
(storage[5] * pane.rect.top + storage[13] - slot.slot.top) * scale;
|
||||
return [
|
||||
-originX / texelsPerPaneX,
|
||||
-originY / texelsPerPaneY,
|
||||
1 / texelsPerPaneX,
|
||||
1 / texelsPerPaneY,
|
||||
place.left + 0.5,
|
||||
place.top + 0.5,
|
||||
place.right - 0.5,
|
||||
place.bottom - 0.5,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
typedef _Placement = ({
|
||||
Rect slot,
|
||||
Size texels,
|
||||
double texelsPerDp,
|
||||
double heldFrost,
|
||||
double blurSigma,
|
||||
});
|
||||
|
||||
typedef _Band = ({Rect rect, double sigma, List<Object?> drawn});
|
||||
|
||||
bool _encloses(Rect outer, Rect inner) =>
|
||||
outer.left <= inner.left &&
|
||||
outer.top <= inner.top &&
|
||||
outer.right >= inner.right &&
|
||||
outer.bottom >= inner.bottom;
|
||||
|
||||
class _Behind {
|
||||
_Behind(
|
||||
this.pane,
|
||||
this.style,
|
||||
this.toHost,
|
||||
this.root,
|
||||
this.rootToHost,
|
||||
this.ancestors,
|
||||
) : hazed = false;
|
||||
|
||||
_Behind.hazeOf(_Behind owner)
|
||||
: pane = owner.pane,
|
||||
style = owner.style,
|
||||
toHost = owner.toHost,
|
||||
root = owner.root,
|
||||
rootToHost = owner.rootToHost,
|
||||
ancestors = owner.ancestors,
|
||||
hazed = true;
|
||||
|
||||
final _LiquidGlassLayer pane;
|
||||
final LiquidGlassStyle style;
|
||||
final Matrix4 toHost;
|
||||
|
||||
/// The host, or below it the nearest layer that draws offscreen: a backdrop
|
||||
/// filter there would read only that layer's texture.
|
||||
final ContainerLayer root;
|
||||
final Matrix4 rootToHost;
|
||||
final Set<ContainerLayer> ancestors;
|
||||
|
||||
/// Captures the backdrop again for the pane's [LiquidGlassStyle.haze].
|
||||
final bool hazed;
|
||||
|
||||
late final Rect device = MatrixUtils.transformRect(toHost, pane.rect);
|
||||
|
||||
/// Where a pane painted before this one would show in what it reads.
|
||||
Rect reach = Rect.zero;
|
||||
Rect slot = Rect.zero;
|
||||
double texelsPerDp = 1;
|
||||
double heldFrost = 0;
|
||||
double blurSigma = 0;
|
||||
Size texels = Size.zero;
|
||||
Rect place = Rect.zero;
|
||||
int level = 0;
|
||||
ui.Image? atlas;
|
||||
_Behind? haze;
|
||||
}
|
||||
|
||||
/// The transform a layer gives its children, from what it holds now:
|
||||
/// [Layer.applyTransform] may answer from the frame before.
|
||||
Matrix4? _geometry(ContainerLayer layer) {
|
||||
Matrix4 applied(Layer child) {
|
||||
final matrix = Matrix4.identity();
|
||||
layer.applyTransform(child, matrix);
|
||||
return matrix;
|
||||
}
|
||||
|
||||
return switch (layer) {
|
||||
TransformLayer(:final transform?, :final offset) =>
|
||||
offset == Offset.zero
|
||||
? transform
|
||||
: (Matrix4.translationValues(offset.dx, offset.dy, 0)
|
||||
..multiply(transform)),
|
||||
OffsetLayer(:final offset) || LeaderLayer(:final offset) =>
|
||||
offset == Offset.zero
|
||||
? null
|
||||
: Matrix4.translationValues(offset.dx, offset.dy, 0),
|
||||
FollowerLayer(:final firstChild?) => applied(firstChild),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
Matrix4 _within(ContainerLayer layer, Matrix4 toHost) {
|
||||
final geometry = _geometry(layer);
|
||||
return geometry == null ? toHost : (toHost.clone()..multiply(geometry));
|
||||
}
|
||||
|
||||
bool _drawsOffscreen(Layer layer) {
|
||||
return switch (layer) {
|
||||
_LiquidGlassFadeLayer() => false,
|
||||
ClipRectLayer(:final clipBehavior) ||
|
||||
ClipRRectLayer(:final clipBehavior) ||
|
||||
ClipRSuperellipseLayer(:final clipBehavior) ||
|
||||
ClipPathLayer(
|
||||
:final clipBehavior,
|
||||
) => clipBehavior == Clip.antiAliasWithSaveLayer,
|
||||
OpacityLayer(:final alpha) => alpha != null && alpha < 255,
|
||||
ColorFilterLayer() ||
|
||||
ImageFilterLayer() ||
|
||||
ShaderMaskLayer() ||
|
||||
BackdropFilterLayer() => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// Redraws onto a capture what a pane's root holds below the pane, as the
|
||||
/// engine composites it, but for the root's own effect, which the engine
|
||||
/// applies to the glass as well.
|
||||
class _Replay {
|
||||
_Replay(this.canvas, this.drawn, this.entry) : _toHost = [entry.rootToHost];
|
||||
|
||||
final Canvas canvas;
|
||||
final List<Object?> drawn;
|
||||
final _Behind entry;
|
||||
final List<Matrix4> _toHost;
|
||||
|
||||
// A layer's effects are applied only once something under it draws.
|
||||
final _entered = <(ContainerLayer, Matrix4?, bool)>[];
|
||||
final _saves = <int>[];
|
||||
|
||||
void run() => _walk(entry.root, root: true);
|
||||
|
||||
bool _walk(ContainerLayer layer, {required bool root}) {
|
||||
if (!root && !entry.ancestors.contains(layer) && !_mayShow(layer)) {
|
||||
return false;
|
||||
}
|
||||
final geometry = _geometry(layer);
|
||||
final outer = _toHost.last;
|
||||
_toHost.add(geometry == null ? outer : (outer.clone()..multiply(geometry)));
|
||||
_entered.add((layer, geometry, root));
|
||||
var reached = false;
|
||||
for (Layer? child = layer.firstChild; child != null && !reached;) {
|
||||
switch (child) {
|
||||
case _ when identical(child, entry.pane):
|
||||
reached = true;
|
||||
case final _LiquidGlassLayer pane
|
||||
when pane._capturedPass == _LiquidGlassHostLayer._pass:
|
||||
if (pane._captured case final picture? when _shows(pane.rect)) {
|
||||
_draw(picture);
|
||||
}
|
||||
case final PictureLayer recorded:
|
||||
if (recorded.picture case final picture?
|
||||
when _shows(recorded.canvasBounds.inflate(_overflow))) {
|
||||
_draw(picture);
|
||||
}
|
||||
case final ContainerLayer container:
|
||||
reached = _walk(container, root: false);
|
||||
}
|
||||
child = child.nextSibling;
|
||||
}
|
||||
_leave();
|
||||
_toHost.removeLast();
|
||||
return reached;
|
||||
}
|
||||
|
||||
bool _mayShow(ContainerLayer layer) {
|
||||
if (layer is OpacityLayer && layer.alpha == 0) {
|
||||
return false;
|
||||
}
|
||||
final clip = layer.describeClipBounds();
|
||||
return clip == null || _shows(clip);
|
||||
}
|
||||
|
||||
bool _shows(Rect bounds) =>
|
||||
MatrixUtils.transformRect(_toHost.last, bounds).overlaps(entry.slot);
|
||||
|
||||
void _draw(ui.Picture picture) {
|
||||
while (_saves.length < _entered.length) {
|
||||
_saves.add(_apply(_entered[_saves.length]));
|
||||
}
|
||||
canvas.drawPicture(picture);
|
||||
drawn.add(picture);
|
||||
}
|
||||
|
||||
int _apply((ContainerLayer, Matrix4?, bool) entered) {
|
||||
final (layer, geometry, root) = entered;
|
||||
var saves = 0;
|
||||
if (geometry != null) {
|
||||
canvas
|
||||
..save()
|
||||
..transform(geometry.storage);
|
||||
drawn.addAll(geometry.storage);
|
||||
saves++;
|
||||
}
|
||||
final effect = root
|
||||
? null
|
||||
: switch (layer) {
|
||||
OpacityLayer(:final alpha?) when alpha < 255 => (
|
||||
Paint()..color = Color.fromARGB(alpha, 0, 0, 0),
|
||||
alpha,
|
||||
),
|
||||
ColorFilterLayer(:final colorFilter?) => (
|
||||
Paint()..colorFilter = colorFilter,
|
||||
colorFilter,
|
||||
),
|
||||
ImageFilterLayer(:final imageFilter?) => (
|
||||
Paint()..imageFilter = imageFilter,
|
||||
imageFilter,
|
||||
),
|
||||
ShaderMaskLayer(:final shader) => (Paint(), shader),
|
||||
_ => null,
|
||||
};
|
||||
if (effect case (final paint, final key)?) {
|
||||
canvas.saveLayer(null, paint);
|
||||
drawn
|
||||
..add(layer.runtimeType)
|
||||
..add(key);
|
||||
saves++;
|
||||
}
|
||||
if (_clip(layer)) {
|
||||
saves++;
|
||||
}
|
||||
return saves;
|
||||
}
|
||||
|
||||
bool _clip(ContainerLayer layer) {
|
||||
final Object clip;
|
||||
switch (layer) {
|
||||
case ClipRectLayer(:final clipRect?, :final clipBehavior)
|
||||
when clipBehavior != Clip.none:
|
||||
canvas
|
||||
..save()
|
||||
..clipRect(clipRect, doAntiAlias: clipBehavior != Clip.hardEdge);
|
||||
clip = clipRect;
|
||||
case ClipRSuperellipseLayer(
|
||||
:final clipRSuperellipse?,
|
||||
:final clipBehavior,
|
||||
)
|
||||
when clipBehavior != Clip.none:
|
||||
canvas
|
||||
..save()
|
||||
..clipRSuperellipse(
|
||||
clipRSuperellipse,
|
||||
doAntiAlias: clipBehavior != Clip.hardEdge,
|
||||
);
|
||||
clip = clipRSuperellipse;
|
||||
case ClipPathLayer(:final clipPath?, :final clipBehavior)
|
||||
when clipBehavior != Clip.none:
|
||||
canvas
|
||||
..save()
|
||||
..clipPath(clipPath, doAntiAlias: clipBehavior != Clip.hardEdge);
|
||||
clip = clipPath;
|
||||
default:
|
||||
// Lib clips to no rounded rect, so one a package draws clips by its
|
||||
// bounds here.
|
||||
final bounds = layer.describeClipBounds();
|
||||
if (bounds == null) {
|
||||
return false;
|
||||
}
|
||||
canvas
|
||||
..save()
|
||||
..clipRect(bounds);
|
||||
clip = bounds;
|
||||
}
|
||||
drawn.add(clip);
|
||||
return true;
|
||||
}
|
||||
|
||||
void _leave() {
|
||||
final (layer, _, root) = _entered.removeLast();
|
||||
if (_saves.length <= _entered.length) {
|
||||
return;
|
||||
}
|
||||
final saves = _saves.removeLast();
|
||||
if (layer case ShaderMaskLayer(
|
||||
:final shader?,
|
||||
:final maskRect?,
|
||||
:final blendMode?,
|
||||
) when !root) {
|
||||
canvas
|
||||
..translate(maskRect.left, maskRect.top)
|
||||
..drawRect(
|
||||
Offset.zero & maskRect.size,
|
||||
Paint()
|
||||
..shader = shader
|
||||
..blendMode = blendMode,
|
||||
);
|
||||
drawn
|
||||
..add(maskRect)
|
||||
..add(blendMode);
|
||||
}
|
||||
for (var index = 0; index < saves; index++) {
|
||||
canvas.restore();
|
||||
}
|
||||
drawn.add(saves);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,12 @@ class CommonPopScope extends StatelessWidget {
|
||||
if (didPop) {
|
||||
return;
|
||||
}
|
||||
// BackLayerScope adds its entry a frame after this builds, which
|
||||
// rebuilds nothing here on a route with another below it.
|
||||
if (route?.willHandlePopInternally == true) {
|
||||
Navigator.of(context).pop();
|
||||
return;
|
||||
}
|
||||
final res = await onPop!(context);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
|
||||
+2703
File diff suppressed because it is too large
Load Diff
+221
-171
@@ -20,6 +20,7 @@ import 'package:flutter/services.dart';
|
||||
|
||||
import 'button.dart';
|
||||
import 'chip.dart';
|
||||
import 'dialog.dart';
|
||||
import 'inherited.dart';
|
||||
import 'liquid_glass.dart';
|
||||
import 'loading.dart';
|
||||
@@ -41,6 +42,7 @@ class CommonScaffold extends StatefulWidget {
|
||||
final Color? backgroundColor;
|
||||
final String? title;
|
||||
final Widget? titleWidget;
|
||||
final List<CommonPopupMenuItem> titleMenuItems;
|
||||
final bool isLoading;
|
||||
final List<Widget>? actions;
|
||||
|
||||
@@ -51,6 +53,10 @@ class CommonScaffold extends StatefulWidget {
|
||||
final List<IconButtonData> iconActions;
|
||||
final List<CommonPopupMenuItem> menuItems;
|
||||
|
||||
/// Answers a form: last in the bar, and in a form sheet the filled default
|
||||
/// at its foot beside cancel, where SwiftUI sets one on macOS.
|
||||
final IconButtonData? confirmationAction;
|
||||
|
||||
/// Folds [primaryAction] ahead of [iconActions], so a bar without search
|
||||
/// keeps the first of them in sight instead.
|
||||
final bool foldPrimaryAction;
|
||||
@@ -73,6 +79,7 @@ class CommonScaffold extends StatefulWidget {
|
||||
this.backgroundColor,
|
||||
this.title,
|
||||
this.titleWidget,
|
||||
this.titleMenuItems = const [],
|
||||
this.actions,
|
||||
this.backAction,
|
||||
this.isLoading = false,
|
||||
@@ -82,6 +89,7 @@ class CommonScaffold extends StatefulWidget {
|
||||
this.primaryAction,
|
||||
this.iconActions = const [],
|
||||
this.menuItems = const [],
|
||||
this.confirmationAction,
|
||||
this.foldPrimaryAction = false,
|
||||
this.onKeywordsUpdate,
|
||||
this.resizeToAvoidBottomInset,
|
||||
@@ -95,7 +103,6 @@ class CommonScaffold extends StatefulWidget {
|
||||
class CommonScaffoldState extends State<CommonScaffold> {
|
||||
late final ValueNotifier<AppBarState> _appBarState;
|
||||
final ValueNotifier<bool> _loadingNotifier = ValueNotifier(false);
|
||||
final ValueNotifier<bool> _isFabExtendedNotifier = ValueNotifier(true);
|
||||
final ValueNotifier<List<String>> _keywordsNotifier = ValueNotifier([]);
|
||||
final _textController = TextEditingController();
|
||||
final _searchFocusNode = FocusNode();
|
||||
@@ -109,6 +116,7 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
return widget.canSort ||
|
||||
widget.primaryAction != null ||
|
||||
widget.iconActions.isNotEmpty ||
|
||||
widget.confirmationAction != null ||
|
||||
widget.menuItems.isNotEmpty ||
|
||||
widget.actions?.isNotEmpty == true;
|
||||
}
|
||||
@@ -147,7 +155,7 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
!_isFrontmost) {
|
||||
return false;
|
||||
}
|
||||
if (_isSearch || context.isInBottomSheet) {
|
||||
if (_isSearch || context.isInCompactSheet) {
|
||||
_searchFocusNode.requestFocus();
|
||||
_textController.selection = TextSelection(
|
||||
baseOffset: 0,
|
||||
@@ -214,8 +222,9 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
void didUpdateWidget(CommonScaffold oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.searchState != widget.searchState) {
|
||||
final query = _appBarState.value.searchState?.query;
|
||||
_appBarState.value = _appBarState.value.copyWith(
|
||||
searchState: widget.searchState,
|
||||
searchState: widget.searchState?.copyWith(query: query),
|
||||
);
|
||||
}
|
||||
if (oldWidget.isLoading != widget.isLoading) {
|
||||
@@ -269,7 +278,6 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
_appBarState.dispose();
|
||||
_textController.dispose();
|
||||
_searchFocusNode.dispose();
|
||||
_isFabExtendedNotifier.dispose();
|
||||
_loadingNotifier.dispose();
|
||||
_keywordsNotifier.dispose();
|
||||
super.dispose();
|
||||
@@ -293,7 +301,6 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
Widget _buildDockedSearch(AppBarSearchState searchState) {
|
||||
final colorScheme = context.colorScheme;
|
||||
final glass = LiquidGlassScope.of(context);
|
||||
final shadows = dockShadows(colorScheme);
|
||||
final pill = AppShape.input.copyWith(borderRadius: AppRadius.full);
|
||||
final fieldPadding =
|
||||
(BottomInsetScope.dockedSearchHeight -
|
||||
@@ -330,19 +337,22 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
child: glass
|
||||
? LiquidGlass(
|
||||
style: LiquidGlassStyle.pill(colorScheme),
|
||||
shadows: shadows,
|
||||
shadows: dockGlassShadows(colorScheme),
|
||||
child: field,
|
||||
)
|
||||
: DecoratedBox(
|
||||
decoration: ShapeDecoration(
|
||||
shape: AppShape.full,
|
||||
shadows: shadows,
|
||||
shadows: dockShadows(colorScheme),
|
||||
),
|
||||
child: field,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
VoidCallback get _closeSheet =>
|
||||
SheetProvider.of(context)?.onClose ?? () => context.safeNestedPop();
|
||||
|
||||
Widget _buildSheetPopButton(
|
||||
VoidCallback? backAction, {
|
||||
required bool useCloseIcon,
|
||||
@@ -352,9 +362,7 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
data: useCloseIcon
|
||||
? IconButtonData(
|
||||
glyph: AppGlyphs.close,
|
||||
onPressed:
|
||||
SheetProvider.of(context)?.onClose ??
|
||||
() => context.safeNestedPop(),
|
||||
onPressed: _closeSheet,
|
||||
tooltip: localizations.close,
|
||||
)
|
||||
: IconButtonData(
|
||||
@@ -401,7 +409,7 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
);
|
||||
}
|
||||
if (pop != null) {
|
||||
return pop.asSuffix
|
||||
return pop.asSuffix || pop.atFoot
|
||||
? null
|
||||
: _buildSheetPopButton(backAction, useCloseIcon: pop.useCloseIcon);
|
||||
}
|
||||
@@ -424,7 +432,7 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
: const BackButton();
|
||||
}
|
||||
|
||||
Widget _buildTitle(AppBarSearchState? startState) {
|
||||
Widget _buildTitle(AppBarSearchState? startState, {required bool compact}) {
|
||||
final localizations = UiLocalizations.of(context);
|
||||
return _isSearch
|
||||
? TextField(
|
||||
@@ -441,7 +449,19 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
},
|
||||
decoration: InputDecoration(hintText: localizations.search),
|
||||
)
|
||||
: widget.titleWidget ?? Text(widget.title!);
|
||||
: widget.titleWidget ??
|
||||
(widget.titleMenuItems.isEmpty
|
||||
? Text(widget.title!)
|
||||
: CommonPopupBox(
|
||||
placement: PopupPlacement.overAnchorStart,
|
||||
targetBuilder: (open) => _TitleMenuButton(
|
||||
title: widget.title!,
|
||||
compact: compact,
|
||||
onPressed: open,
|
||||
),
|
||||
popupBuilder: (_) =>
|
||||
CommonPopupMenu(items: widget.titleMenuItems),
|
||||
));
|
||||
}
|
||||
|
||||
List<Widget> _buildActions(
|
||||
@@ -489,6 +509,7 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
(selection.isEmpty ? 0 : 1) + selectionSingles.length;
|
||||
final popAsSuffix = !_isSearch && pop?.asSuffix == true;
|
||||
final pairedPrimary = popAsSuffix ? primaryAction : null;
|
||||
final atFoot = pop?.atFoot == true;
|
||||
final fold = _isSearch
|
||||
? _foldBarActions(
|
||||
hasLead: true,
|
||||
@@ -508,6 +529,7 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
),
|
||||
...widget.iconActions,
|
||||
],
|
||||
confirmation: atFoot ? null : widget.confirmationAction,
|
||||
widgetCount:
|
||||
widgets.length +
|
||||
selectionCount +
|
||||
@@ -559,13 +581,31 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
], edge: AppBarActionEdge.container);
|
||||
}
|
||||
|
||||
Widget _buildAppBarWrap(Widget child) {
|
||||
final appBar = LiquidGlassGroup(
|
||||
child: TonalButtonTheme(
|
||||
glass: LiquidGlassScope.of(context),
|
||||
child: _isSearch ? _buildSearchingAppBarTheme(child) : child,
|
||||
Widget _buildFootActions(IconButtonData confirmation) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: AlertActions(
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _closeSheet,
|
||||
child: Text(UiLocalizations.of(context).cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: confirmation.isLoading ? null : confirmation.onPressed,
|
||||
child: confirmation.isLoading
|
||||
? CommonCircleLoading(size: TonalButtonSize.bar.icon)
|
||||
: Text(confirmation.tooltip!),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBarWrap(Widget child) {
|
||||
final appBar = TonalButtonTheme(
|
||||
glass: LiquidGlassScope.of(context),
|
||||
child: _isSearch ? _buildSearchingAppBarTheme(child) : child,
|
||||
);
|
||||
if (_isSearch || _sorting) {
|
||||
return BackLayerScope(onBack: _handleExitAppBarLayer, child: appBar);
|
||||
}
|
||||
@@ -584,25 +624,23 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
);
|
||||
}
|
||||
|
||||
PreferredSizeWidget _buildAppBar(
|
||||
VoidCallback? backAction,
|
||||
_SheetForm form, {
|
||||
required IconButtonData? primaryAction,
|
||||
}) {
|
||||
final isBottomSheet = form.isBottomSheet;
|
||||
// A pushed page on a phone centres a small title between its buttons, as
|
||||
// iOS does, so a long one is not squeezed against the back button.
|
||||
PreferredSizeWidget _buildAppBar(VoidCallback? backAction, _SheetForm form) {
|
||||
final isCompact = form.isCompact;
|
||||
final isMobileView = ViewScope.isMobileOf(context);
|
||||
final pushed =
|
||||
form.pop == null &&
|
||||
widget.titleWidget == null &&
|
||||
ViewScope.isMobileOf(context) &&
|
||||
isMobileView &&
|
||||
ModalRoute.of(context)?.impliesAppBarDismissal == true;
|
||||
final centered = isBottomSheet || pushed;
|
||||
final compactTitle = isCompact || pushed;
|
||||
final titleEndInset = appBarActionInset(
|
||||
_isCompactBar(context, isMobileView),
|
||||
);
|
||||
// Under a sheet the bar drops its actions, so none reads as the sheet's.
|
||||
final uncovered = ReverseAnimation(sheetCoverOf(context));
|
||||
return PreferredSize(
|
||||
preferredSize: Size.fromHeight(
|
||||
isBottomSheet ? sheetToolbarHeight : pageToolbarHeight,
|
||||
isCompact ? sheetToolbarHeight : pageToolbarHeight,
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
@@ -618,22 +656,32 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
automaticallyImplyLeading: false,
|
||||
animateColor: true,
|
||||
// A searching bar carries its own opaque theme.
|
||||
forceMaterialTransparency: isBottomSheet || !_isSearch,
|
||||
toolbarHeight: isBottomSheet ? sheetToolbarHeight : null,
|
||||
centerTitle: isBottomSheet || pushed && !_isSearch,
|
||||
forceMaterialTransparency: isCompact || !_isSearch,
|
||||
toolbarHeight: isCompact ? sheetToolbarHeight : null,
|
||||
centerTitle: isCompact,
|
||||
titleTextStyle: _isSearch
|
||||
? null
|
||||
: centered
|
||||
: compactTitle
|
||||
? AppTextStyle.headline.of(context)
|
||||
: AppTextStyle.pageTitle.of(context),
|
||||
leading: leading,
|
||||
// The leading slot already trails its button by a margin.
|
||||
titleSpacing: leading == null ? null : 0,
|
||||
title: _buildTitle(state.searchState),
|
||||
title: leading == null || isCompact || _isSearch
|
||||
? _buildTitle(state.searchState, compact: compactTitle)
|
||||
: Padding(
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
end: titleEndInset,
|
||||
),
|
||||
child: _buildTitle(
|
||||
state.searchState,
|
||||
compact: compactTitle,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
for (final action in _buildActions(
|
||||
state.searchState != null,
|
||||
primaryAction,
|
||||
widget.primaryAction,
|
||||
backAction,
|
||||
form,
|
||||
))
|
||||
@@ -641,7 +689,7 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
],
|
||||
),
|
||||
);
|
||||
return isBottomSheet
|
||||
return isCompact
|
||||
? appBar
|
||||
: _buildFloatingHeader(
|
||||
_WindowControlsClearance(child: appBar),
|
||||
@@ -670,25 +718,16 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
searchState: widget.searchState,
|
||||
hasActions: _hasActions,
|
||||
hasPrimaryAction: widget.primaryAction != null,
|
||||
hasConfirmationAction: widget.confirmationAction != null,
|
||||
);
|
||||
final isBottomSheet = form.isBottomSheet;
|
||||
final isCompact = form.isCompact;
|
||||
final bottomInset = BottomInsetScope.of(context);
|
||||
final primaryAction = widget.primaryAction;
|
||||
final actionInBar =
|
||||
form.isBottomSheet || PrimaryActionInBarScope.of(context);
|
||||
final fabSlot = actionInBar
|
||||
? null
|
||||
: primaryAction == null
|
||||
? null
|
||||
: _PrimaryActionFab(data: primaryAction);
|
||||
final hasFab = fabSlot != null;
|
||||
// A detent sheet fills the height its detent gives it and clips its own
|
||||
// corners; any other bottom sheet sizes to its content and brings both.
|
||||
final hugsContent =
|
||||
isBottomSheet && !SheetOverhangScope.fillsDetentOf(context);
|
||||
final hugsContent = isCompact && !SheetOverhangScope.fillsDetentOf(context);
|
||||
// The page's bar floats over its body, which leaves the bar's height at
|
||||
// its top through MediaQuery padding, as extendBodyBehindAppBar does.
|
||||
final barFloats = !isBottomSheet;
|
||||
final barFloats = !isCompact;
|
||||
final sortableBody = SortModeScope(sorting: _sorting, child: widget.body);
|
||||
final body = SafeArea(
|
||||
top: !barFloats,
|
||||
@@ -704,7 +743,7 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
final barInset = MediaQuery.paddingOf(context).top;
|
||||
final overlap = barFloats
|
||||
? barInset
|
||||
: isBottomSheet
|
||||
: isCompact
|
||||
? sheetAppBarHeight
|
||||
: 0.0;
|
||||
final content = Column(
|
||||
@@ -717,7 +756,7 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: isBottomSheet ? sheetAppBarHeight : 16,
|
||||
top: isCompact ? sheetAppBarHeight : 16,
|
||||
bottom: 16,
|
||||
),
|
||||
child: Wrap(
|
||||
@@ -760,65 +799,21 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
},
|
||||
),
|
||||
);
|
||||
final fabChild = ValueListenableBuilder<bool>(
|
||||
valueListenable: _isFabExtendedNotifier,
|
||||
builder: (_, isExtended, child) {
|
||||
return CommonScaffoldFabExtendedProvider(
|
||||
isExtended: isExtended,
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
child: fabSlot,
|
||||
);
|
||||
final appBar = _buildAppBar(
|
||||
backAction,
|
||||
form,
|
||||
primaryAction: actionInBar ? primaryAction : null,
|
||||
);
|
||||
final insetBody = form.hasDockedSearch
|
||||
final appBar = _buildAppBar(backAction, form);
|
||||
final content = form.hasDockedSearch
|
||||
? BottomInsetScope(
|
||||
inset: bottomInset + BottomInsetScope.dockedSearchInset,
|
||||
child: body,
|
||||
)
|
||||
: body;
|
||||
final content = NotificationListener<UserScrollNotification>(
|
||||
child: PrimaryActionInBarScope(
|
||||
inBar: true,
|
||||
child: hasFab
|
||||
? BottomInsetScope(
|
||||
inset: bottomInset + BottomInsetScope.floatingActionButtonInset,
|
||||
child: insetBody,
|
||||
)
|
||||
: insetBody,
|
||||
),
|
||||
onNotification: (notification) {
|
||||
if (notification.direction == ScrollDirection.reverse) {
|
||||
_isFabExtendedNotifier.value = false;
|
||||
} else if (notification.direction == ScrollDirection.forward) {
|
||||
_isFabExtendedNotifier.value = true;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
);
|
||||
final fab = hasFab && !_sorting
|
||||
? SheetOverhangLift(
|
||||
child: bottomInset > 0
|
||||
? Padding(
|
||||
padding: EdgeInsets.only(bottom: bottomInset),
|
||||
child: fabChild,
|
||||
)
|
||||
: fabChild,
|
||||
)
|
||||
: null;
|
||||
if (!isBottomSheet) {
|
||||
if (!isCompact) {
|
||||
return AppBarInsetScope(
|
||||
child: Scaffold(
|
||||
appBar: appBar,
|
||||
extendBodyBehindAppBar: barFloats,
|
||||
body: content,
|
||||
body: _BodyScope(child: content),
|
||||
resizeToAvoidBottomInset: widget.resizeToAvoidBottomInset,
|
||||
backgroundColor: widget.backgroundColor,
|
||||
floatingActionButton: fab,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -826,30 +821,44 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
backgroundColor:
|
||||
widget.backgroundColor ??
|
||||
SurfaceLevel.page.colorOf(context.colorScheme),
|
||||
header: SheetToolBar(appBar: appBar),
|
||||
header: SheetToolBar(appBar: appBar, dragHandle: form.hasDragHandle),
|
||||
footer: form.dockedSearch == null || _sorting
|
||||
? null
|
||||
: _buildDockedSearch(form.dockedSearch!),
|
||||
body: content,
|
||||
);
|
||||
final footed = form.pop?.atFoot == true
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
fit: hugsContent ? FlexFit.loose : FlexFit.tight,
|
||||
child: sheetBody,
|
||||
),
|
||||
_buildFootActions(widget.confirmationAction!),
|
||||
],
|
||||
)
|
||||
: sheetBody;
|
||||
if (!hugsContent) {
|
||||
return AppBarInsetScope(
|
||||
child: Scaffold(
|
||||
body: sheetBody,
|
||||
body: footed,
|
||||
resizeToAvoidBottomInset: widget.resizeToAvoidBottomInset,
|
||||
backgroundColor: widget.backgroundColor,
|
||||
floatingActionButton: fab,
|
||||
),
|
||||
);
|
||||
}
|
||||
final hugging = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [Flexible(child: footed)],
|
||||
);
|
||||
return AppBarInsetScope(
|
||||
child: ClipRSuperellipse(
|
||||
borderRadius: AppRadius.top(AppCorner.xxl),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [Flexible(child: sheetBody)],
|
||||
),
|
||||
),
|
||||
child: form.hasDragHandle
|
||||
? ClipRSuperellipse(
|
||||
borderRadius: AppRadius.top(AppCorner.xxl),
|
||||
child: hugging,
|
||||
)
|
||||
: hugging,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -873,6 +882,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 {
|
||||
@@ -947,57 +992,6 @@ class _FocusClearOfBarState extends State<_FocusClearOfBar> {
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
|
||||
/// Only a home page above the fixed navigation bar floats its action button.
|
||||
class PrimaryActionInBarScope extends InheritedWidget {
|
||||
const PrimaryActionInBarScope({
|
||||
super.key,
|
||||
required this.inBar,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
final bool inBar;
|
||||
|
||||
static bool of(BuildContext context) =>
|
||||
context
|
||||
.dependOnInheritedWidgetOfExactType<PrimaryActionInBarScope>()
|
||||
?.inBar ??
|
||||
true;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(PrimaryActionInBarScope oldWidget) =>
|
||||
inBar != oldWidget.inBar;
|
||||
}
|
||||
|
||||
class _PrimaryActionFab extends StatelessWidget {
|
||||
const _PrimaryActionFab({required this.data});
|
||||
|
||||
static const _duration = Duration(milliseconds: 400);
|
||||
|
||||
final IconButtonData data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isLoading = data.isLoading;
|
||||
return IgnorePointer(
|
||||
ignoring: isLoading,
|
||||
child: AnimatedScale(
|
||||
scale: isLoading ? 0 : 1,
|
||||
duration: _duration,
|
||||
curve: Curves.easeInOutBack,
|
||||
child: AnimatedOpacity(
|
||||
opacity: isLoading ? 0 : 1,
|
||||
duration: _duration,
|
||||
child: CommonFloatingActionButton(
|
||||
onPressed: data.onPressed,
|
||||
icon: GlyphIcon(data.glyph, fill: 1),
|
||||
label: data.tooltip ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _maxBarButtons = 3;
|
||||
const _maxGroupedActions = 2;
|
||||
|
||||
@@ -1007,13 +1001,15 @@ _foldBarActions({
|
||||
IconButtonData? primary,
|
||||
bool foldPrimary = false,
|
||||
List<IconButtonData> icons = const [],
|
||||
IconButtonData? confirmation,
|
||||
int widgetCount = 0,
|
||||
List<CommonPopupMenuItem> menuItems = const [],
|
||||
}) {
|
||||
final foldable = [?primary, ...icons];
|
||||
final fixed = (hasLead ? 1 : 0) + widgetCount;
|
||||
final fixed =
|
||||
(hasLead ? 1 : 0) + widgetCount + (confirmation == null ? 0 : 1);
|
||||
if (fixed + foldable.length + (menuItems.isEmpty ? 0 : 1) <= _maxBarButtons) {
|
||||
return (shown: foldable, overflow: menuItems);
|
||||
return (shown: [...foldable, ?confirmation], overflow: menuItems);
|
||||
}
|
||||
final slots = _maxBarButtons - 1 - fixed;
|
||||
final kept = (foldPrimary ? [...icons, ?primary] : foldable)
|
||||
@@ -1024,6 +1020,7 @@ _foldBarActions({
|
||||
shown: [
|
||||
for (final data in foldable)
|
||||
if (isKept(data)) data,
|
||||
?confirmation,
|
||||
],
|
||||
overflow: [
|
||||
for (final data in foldable)
|
||||
@@ -1047,7 +1044,6 @@ class _OverflowMenuButton extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CommonPopupBox(
|
||||
anchorShift: Offset(0, context.isMobileView ? 0 : 20),
|
||||
targetBuilder: (open) => IconButton(
|
||||
tooltip: UiLocalizations.of(context).more,
|
||||
onPressed: open,
|
||||
@@ -1094,7 +1090,8 @@ class AppBarActionButton extends StatelessWidget {
|
||||
/// foot instead of a button that takes the bar over.
|
||||
class _SheetForm {
|
||||
const _SheetForm({
|
||||
required this.isBottomSheet,
|
||||
required this.isCompact,
|
||||
required this.hasDragHandle,
|
||||
required this.dockedSearch,
|
||||
required this.pop,
|
||||
});
|
||||
@@ -1104,26 +1101,31 @@ class _SheetForm {
|
||||
required AppBarSearchState? searchState,
|
||||
required bool hasActions,
|
||||
required bool hasPrimaryAction,
|
||||
required bool hasConfirmationAction,
|
||||
}) {
|
||||
final provider = SheetProvider.of(context);
|
||||
final isModal = provider != null && provider.type != SheetType.page;
|
||||
final isBottomSheet = provider?.type == SheetType.bottomSheet;
|
||||
final isCompact = provider?.type.isCompact ?? false;
|
||||
return _SheetForm(
|
||||
isBottomSheet: isBottomSheet,
|
||||
dockedSearch: isBottomSheet ? searchState : null,
|
||||
isCompact: isCompact,
|
||||
hasDragHandle: provider?.type == SheetType.bottomSheet,
|
||||
dockedSearch: isCompact ? searchState : null,
|
||||
pop: !isModal
|
||||
? null
|
||||
: _SheetPop.of(
|
||||
context,
|
||||
provider,
|
||||
// A bottom sheet docks its search at its foot, not in the bar.
|
||||
hasActions: hasActions || searchState != null && !isBottomSheet,
|
||||
hasActions: hasActions || searchState != null && !isCompact,
|
||||
hasPrimaryAction: hasPrimaryAction,
|
||||
hasConfirmationAction:
|
||||
hasConfirmationAction && searchState == null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final bool isBottomSheet;
|
||||
final bool isCompact;
|
||||
final bool hasDragHandle;
|
||||
final AppBarSearchState? dockedSearch;
|
||||
final _SheetPop? pop;
|
||||
|
||||
@@ -1133,25 +1135,36 @@ class _SheetForm {
|
||||
/// How a sheet's own pop button reads: [useCloseIcon] where it dismisses the
|
||||
/// sheet, and [asSuffix] where it ends the bar, alone or with a primary action.
|
||||
class _SheetPop {
|
||||
const _SheetPop({required this.useCloseIcon, required this.asSuffix});
|
||||
const _SheetPop({
|
||||
required this.useCloseIcon,
|
||||
required this.asSuffix,
|
||||
required this.atFoot,
|
||||
});
|
||||
|
||||
factory _SheetPop.of(
|
||||
BuildContext context,
|
||||
SheetProvider provider, {
|
||||
required bool hasActions,
|
||||
required bool hasPrimaryAction,
|
||||
required bool hasConfirmationAction,
|
||||
}) {
|
||||
final useCloseIcon =
|
||||
provider.nestedNavigatorPop == null ||
|
||||
ModalRoute.of(context)?.impliesAppBarDismissal == false;
|
||||
final atFoot =
|
||||
useCloseIcon &&
|
||||
hasConfirmationAction &&
|
||||
provider.type == SheetType.formSheet;
|
||||
return _SheetPop(
|
||||
useCloseIcon: useCloseIcon,
|
||||
asSuffix: useCloseIcon && (!hasActions || hasPrimaryAction),
|
||||
asSuffix: !atFoot && useCloseIcon && (!hasActions || hasPrimaryAction),
|
||||
atFoot: atFoot,
|
||||
);
|
||||
}
|
||||
|
||||
final bool useCloseIcon;
|
||||
final bool asSuffix;
|
||||
final bool atFoot;
|
||||
}
|
||||
|
||||
const double appBarActionSpace = 4;
|
||||
@@ -1242,6 +1255,43 @@ class AppBarInsetScope extends StatelessWidget {
|
||||
|
||||
/// A route over the home page on the root navigator fills the window, with
|
||||
/// neither the window header nor the sidebar keeping it off the buttons.
|
||||
class _TitleMenuButton extends StatelessWidget {
|
||||
const _TitleMenuButton({
|
||||
required this.title,
|
||||
required this.compact,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final bool compact;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// No ink: the padding it needs would move the title off its place in the
|
||||
// bar, and a press turns the title into its menu at once.
|
||||
return InkWell(
|
||||
onTap: onPressed,
|
||||
customBorder: AppShape.sm,
|
||||
hoverColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: compact ? 2 : 4,
|
||||
children: [
|
||||
Flexible(child: Text(title)),
|
||||
GlyphIcon(
|
||||
AppGlyphs.chevronDown,
|
||||
size: compact ? 16 : 20,
|
||||
color: context.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WindowControlsClearance extends StatelessWidget {
|
||||
const _WindowControlsClearance({required this.child});
|
||||
|
||||
|
||||
+6
-6
@@ -11,6 +11,9 @@ abstract final class AppCorner {
|
||||
static const double lg = 20;
|
||||
static const double xl = 24;
|
||||
static const double xxl = 28;
|
||||
|
||||
/// UIAlertController's corner on iOS 27, rounder than a sheet's.
|
||||
static const double alert = 34;
|
||||
static const double full = 1000;
|
||||
|
||||
static double fit(double shortestSide) {
|
||||
@@ -201,7 +204,7 @@ class AppInputBorder extends InputBorder {
|
||||
}) {
|
||||
final outline = rect.deflate(borderSide.width / 2);
|
||||
if (gapStart == null || gapExtent <= 0.0 || gapPercentage == 0.0) {
|
||||
_shape.paint(canvas, outline, textDirection: textDirection);
|
||||
_shape.paint(canvas, rect, textDirection: textDirection);
|
||||
return;
|
||||
}
|
||||
final extent = lerpDouble(
|
||||
@@ -228,7 +231,7 @@ class AppInputBorder extends InputBorder {
|
||||
),
|
||||
clipOp: ClipOp.difference,
|
||||
);
|
||||
_shape.paint(canvas, outline, textDirection: textDirection);
|
||||
_shape.paint(canvas, rect, textDirection: textDirection);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
@@ -247,13 +250,10 @@ class AppInputBorder extends InputBorder {
|
||||
int get hashCode => Object.hash(borderSide, borderRadius, gapPadding);
|
||||
}
|
||||
|
||||
// UIAlertController's corner on iOS 27, rounder than a sheet's.
|
||||
const double _dialogCorner = 34;
|
||||
|
||||
extension AppShapeThemeExt on ThemeData {
|
||||
ThemeData get withAppShapes => copyWith(
|
||||
cardTheme: cardTheme.copyWith(shape: AppShape.md),
|
||||
dialogTheme: dialogTheme.copyWith(shape: AppShape.all(_dialogCorner)),
|
||||
dialogTheme: dialogTheme.copyWith(shape: AppShape.all(AppCorner.alert)),
|
||||
bottomSheetTheme: bottomSheetTheme.copyWith(
|
||||
shape: AppShape.top(AppCorner.xxl),
|
||||
),
|
||||
|
||||
+13
-1
@@ -114,12 +114,24 @@ class ElevatedSurfaceTheme extends StatelessWidget {
|
||||
|
||||
final Widget child;
|
||||
|
||||
static bool isIn(BuildContext context) =>
|
||||
context.getInheritedWidgetOfExactType<_ElevatedSurfaceScope>() != null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Theme(data: Theme.of(context).elevated, child: child);
|
||||
return _ElevatedSurfaceScope(
|
||||
child: Theme(data: Theme.of(context).elevated, child: child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ElevatedSurfaceScope extends InheritedWidget {
|
||||
const _ElevatedSurfaceScope({required super.child});
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(_ElevatedSurfaceScope oldWidget) => false;
|
||||
}
|
||||
|
||||
extension AppSurfaceThemeExt on ThemeData {
|
||||
ThemeData get elevated {
|
||||
final scheme = extension<ElevatedColorScheme>()?.colorScheme;
|
||||
|
||||
+5
-3
@@ -64,7 +64,8 @@ class EmojiText extends StatelessWidget {
|
||||
this.style,
|
||||
});
|
||||
|
||||
List<TextSpan> _buildTextSpans(String emojis) {
|
||||
/// The spans [EmojiText] lays [text] out in, emoji in their own font.
|
||||
static TextSpan spanOf(String text, TextStyle? style) {
|
||||
final List<TextSpan> spans = [];
|
||||
final matches = emojiRegex().allMatches(text);
|
||||
|
||||
@@ -92,13 +93,14 @@ class EmojiText extends StatelessWidget {
|
||||
spans.add(TextSpan(text: text.substring(lastMatchEnd), style: style));
|
||||
}
|
||||
|
||||
return spans;
|
||||
return TextSpan(children: spans);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text.rich(
|
||||
TextSpan(children: _buildTextSpans(text)),
|
||||
spanOf(text, style),
|
||||
style: style,
|
||||
maxLines: maxLines,
|
||||
overflow: overflow ?? TextOverflow.clip,
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/views/profiles/custom/icon.dart';
|
||||
import 'package:fl_clash/features/form/form.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -33,7 +34,8 @@ class _IconSetsViewState extends ConsumerState<IconSetsView> {
|
||||
}
|
||||
|
||||
Future<void> _handleImportFromUrl() async {
|
||||
final res = await dialogs.showNamedUrlInput(
|
||||
final res = await showNamedUrlForm(
|
||||
context,
|
||||
title: context.appLocalizations.importUrl,
|
||||
);
|
||||
if (res == null || !mounted) {
|
||||
@@ -80,7 +82,8 @@ class _IconSetsViewState extends ConsumerState<IconSetsView> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
final res = await dialogs.showNamedUrlInput(
|
||||
final res = await showNamedUrlForm(
|
||||
context,
|
||||
title: appLocalizations.options,
|
||||
label: iconSet.name,
|
||||
url: iconSet.url,
|
||||
@@ -104,6 +107,8 @@ class _IconSetsViewState extends ConsumerState<IconSetsView> {
|
||||
final res = await dialogs.showMessage(
|
||||
title: appLocalizations.tip,
|
||||
message: TextSpan(text: appLocalizations.deleteTip(iconSet.name)),
|
||||
destructive: true,
|
||||
confirmText: appLocalizations.delete,
|
||||
);
|
||||
if (res == true && mounted) {
|
||||
ref.read(iconSetsProvider.notifier).del(iconSet.id);
|
||||
@@ -220,7 +225,6 @@ class _IconSetsViewState extends ConsumerState<IconSetsView> {
|
||||
isLoading: ref.watch(loadingProvider(LoadingTag.iconSets)),
|
||||
actions: [
|
||||
CommonPopupBox(
|
||||
anchorShift: Offset(0, ref.watch(isMobileViewProvider) ? 0 : 20),
|
||||
targetBuilder: (open) {
|
||||
return IconButton(
|
||||
tooltip: appLocalizations.add,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/features/form/form.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/pages/editor.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
@@ -10,7 +11,6 @@ import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class ClashProvidersView extends ConsumerStatefulWidget {
|
||||
@@ -82,7 +82,8 @@ class _ClashProvidersViewState extends ConsumerState<ClashProvidersView> {
|
||||
|
||||
Future<void> _handleImportFromUrl() async {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final res = await dialogs.showNamedUrlsInput(
|
||||
final res = await showNamedUrlsForm(
|
||||
context,
|
||||
title: appLocalizations.importUrl,
|
||||
labelValidator: (value) => _validateLabel(value, optional: true),
|
||||
urlValidator: _validateProviderUrl,
|
||||
@@ -240,7 +241,7 @@ class _ClashProvidersViewState extends ConsumerState<ClashProvidersView> {
|
||||
}
|
||||
|
||||
Future<void> _handleEditOptions(ClashProvider provider) =>
|
||||
_showClashProviderOptions(ref, provider);
|
||||
_showClashProviderOptions(context, ref, provider);
|
||||
|
||||
Future<void> _handleDelete(ClashProvider provider) async {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
@@ -255,6 +256,8 @@ class _ClashProvidersViewState extends ConsumerState<ClashProvidersView> {
|
||||
users.map((profile) => profile.realLabel).join(', '),
|
||||
),
|
||||
),
|
||||
destructive: true,
|
||||
confirmText: appLocalizations.delete,
|
||||
);
|
||||
if (res != true) {
|
||||
return;
|
||||
@@ -385,7 +388,6 @@ class _ClashProvidersViewState extends ConsumerState<ClashProvidersView> {
|
||||
isLoading: ref.watch(loadingProvider(LoadingTag.ruleProviders)),
|
||||
actions: [
|
||||
CommonPopupBox(
|
||||
anchorShift: Offset(0, ref.watch(isMobileViewProvider) ? 0 : 20),
|
||||
targetBuilder: (open) {
|
||||
return IconButton(
|
||||
tooltip: appLocalizations.add,
|
||||
@@ -467,11 +469,13 @@ class _RuleSetUpdateTimeState extends ConsumerState<_RuleSetUpdateTime> {
|
||||
}
|
||||
|
||||
Future<void> _showClashProviderOptions(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ClashProvider provider,
|
||||
) async {
|
||||
final res = await dialogs.showCommonDialog<ClashProvider>(
|
||||
child: _ProviderDialog(
|
||||
final res = await showFormSheet<ClashProvider>(
|
||||
context: context,
|
||||
builder: (_) => _ProviderForm(
|
||||
provider: provider,
|
||||
reservedLabels: {
|
||||
for (final item
|
||||
@@ -506,20 +510,22 @@ String? _validateProviderUrl(String? value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
class _ProviderDialog extends StatefulWidget {
|
||||
const _ProviderDialog({required this.provider, required this.reservedLabels});
|
||||
enum _ProviderField { label, url }
|
||||
|
||||
class _ProviderForm extends StatefulWidget {
|
||||
const _ProviderForm({required this.provider, required this.reservedLabels});
|
||||
|
||||
final ClashProvider provider;
|
||||
final Set<String> reservedLabels;
|
||||
|
||||
@override
|
||||
State<_ProviderDialog> createState() => _ProviderDialogState();
|
||||
State<_ProviderForm> createState() => _ProviderFormState();
|
||||
}
|
||||
|
||||
class _ProviderDialogState extends State<_ProviderDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
class _ProviderFormState extends State<_ProviderForm> {
|
||||
late final TextEditingController _labelController;
|
||||
late final TextEditingController _urlController;
|
||||
bool _attempted = false;
|
||||
|
||||
ClashProvider get _provider => widget.provider;
|
||||
|
||||
@@ -537,20 +543,40 @@ class _ProviderDialogState extends State<_ProviderDialog> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String? _validateLabel(String? value) {
|
||||
({_ProviderField field, String message})? _issue() {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final label = value?.trim() ?? '';
|
||||
final label = _labelController.text.trim();
|
||||
if (label.isEmpty) {
|
||||
return appLocalizations.emptyTip(appLocalizations.name);
|
||||
return (
|
||||
field: _ProviderField.label,
|
||||
message: appLocalizations.emptyTip(appLocalizations.name),
|
||||
);
|
||||
}
|
||||
if (widget.reservedLabels.contains(label)) {
|
||||
return appLocalizations.existsTip(appLocalizations.name);
|
||||
return (
|
||||
field: _ProviderField.label,
|
||||
message: appLocalizations.existsTip(appLocalizations.name),
|
||||
);
|
||||
}
|
||||
if (_provider.isRemote) {
|
||||
if (_validateProviderUrl(_urlController.text) case final message?) {
|
||||
return (field: _ProviderField.url, message: message);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _handleChanged(String _) {
|
||||
if (_attempted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSubmit() {
|
||||
if (_formKey.currentState?.validate() == false) {
|
||||
if (_issue() != null) {
|
||||
setState(() {
|
||||
_attempted = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop(
|
||||
@@ -561,61 +587,51 @@ class _ProviderDialogState extends State<_ProviderDialog> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
FormFieldValidator<String>? validator,
|
||||
TextInputType? keyboardType,
|
||||
List<TextInputFormatter>? inputFormatters,
|
||||
}) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
validator: validator,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
decoration: InputDecoration(border: AppShape.input, labelText: label),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return CommonDialog(
|
||||
final issue = _attempted ? _issue() : null;
|
||||
return FormPage(
|
||||
title: appLocalizations.options,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(appLocalizations.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _handleSubmit,
|
||||
child: Text(appLocalizations.confirm),
|
||||
),
|
||||
],
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
child: Wrap(
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
_buildField(
|
||||
controller: _labelController,
|
||||
label: appLocalizations.name,
|
||||
validator: _validateLabel,
|
||||
inputFormatters: TextInputLimits.limit(TextInputLimits.name),
|
||||
onSave: _handleSubmit,
|
||||
error: issue?.message,
|
||||
children: [
|
||||
generateSection(
|
||||
items: [
|
||||
FormRow(
|
||||
title: appLocalizations.name,
|
||||
invalid: issue?.field == _ProviderField.label,
|
||||
trailing: FormRowTextField(
|
||||
controller: _labelController,
|
||||
hintText: appLocalizations.required,
|
||||
textInputAction: _provider.isRemote
|
||||
? TextInputAction.next
|
||||
: TextInputAction.done,
|
||||
inputFormatters: TextInputLimits.limit(TextInputLimits.name),
|
||||
onChanged: _handleChanged,
|
||||
onFieldSubmitted: _provider.isRemote
|
||||
? null
|
||||
: (_) => _handleSubmit(),
|
||||
),
|
||||
),
|
||||
if (_provider.isRemote)
|
||||
_buildField(
|
||||
controller: _urlController,
|
||||
label: appLocalizations.url,
|
||||
validator: _validateProviderUrl,
|
||||
keyboardType: TextInputType.url,
|
||||
FormRow(
|
||||
title: appLocalizations.url,
|
||||
invalid: issue?.field == _ProviderField.url,
|
||||
trailing: FormRowTextField(
|
||||
controller: _urlController,
|
||||
hintText: appLocalizations.required,
|
||||
keyboardType: TextInputType.url,
|
||||
textInputAction: TextInputAction.done,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
onChanged: _handleChanged,
|
||||
onFieldSubmitted: (_) => _handleSubmit(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ class _ScriptsViewState extends ConsumerState<ScriptsView> {
|
||||
message: TextSpan(
|
||||
text: appLocalizations.deleteTip(appLocalizations.script),
|
||||
),
|
||||
destructive: true,
|
||||
confirmText: appLocalizations.delete,
|
||||
);
|
||||
if (res != true || !mounted) {
|
||||
return;
|
||||
@@ -345,7 +347,6 @@ class _ScriptsViewState extends ConsumerState<ScriptsView> {
|
||||
isLoading: isLoading,
|
||||
actions: [
|
||||
CommonPopupBox(
|
||||
anchorShift: Offset(0, ref.watch(isMobileViewProvider) ? 0 : 20),
|
||||
targetBuilder: (open) {
|
||||
return IconButton(
|
||||
tooltip: appLocalizations.add,
|
||||
|
||||
+136
-66
@@ -1,9 +1,9 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/providers/config.dart';
|
||||
import 'package:fl_clash/providers/state.dart';
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/views/font_family.dart';
|
||||
import 'package:fl_clash/views/theme_preview.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
@@ -34,6 +34,8 @@ class ThemeView extends StatelessWidget {
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
const _TabAnimationItem(),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
const _LiquidGlassItem(),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
const _SidebarBlurItem(),
|
||||
const _FontFamilyItem(),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
@@ -81,13 +83,16 @@ class _ThemeModeItem extends ConsumerWidget {
|
||||
themeSettingProvider.select((state) => state.themeMode),
|
||||
);
|
||||
final floatingBar = ref.watch(_floatingBarProvider);
|
||||
final liquidGlass = ref.watch(_liquidGlassProvider);
|
||||
final light = MiniScreen(
|
||||
colorScheme: ref.watch(genColorSchemeProvider(Brightness.light)),
|
||||
floatingBar: floatingBar,
|
||||
liquidGlass: liquidGlass,
|
||||
);
|
||||
final dark = MiniScreen(
|
||||
colorScheme: ref.watch(genColorSchemeProvider(Brightness.dark)),
|
||||
floatingBar: floatingBar,
|
||||
liquidGlass: liquidGlass,
|
||||
);
|
||||
return SliverToBoxAdapter(
|
||||
child: PreviewChoiceGroup<ThemeMode>(
|
||||
@@ -167,7 +172,7 @@ class _PrimaryColorItemState extends ConsumerState<_PrimaryColorItem> {
|
||||
text: appLocalizations.deleteTip(appLocalizations.colorSchemes),
|
||||
),
|
||||
);
|
||||
if (res != true) {
|
||||
if (res != true || !mounted) {
|
||||
return;
|
||||
}
|
||||
ref.read(themeSettingProvider.notifier).update((state) {
|
||||
@@ -196,13 +201,13 @@ class _PrimaryColorItemState extends ConsumerState<_PrimaryColorItem> {
|
||||
final res = await dialogs.showCommonDialog<int>(
|
||||
child: const _PaletteDialog(),
|
||||
);
|
||||
if (res == null) {
|
||||
if (res == null || !mounted) {
|
||||
return;
|
||||
}
|
||||
final isExists = ref.read(
|
||||
themeSettingProvider.select((state) => state.primaryColors.contains(res)),
|
||||
);
|
||||
if (isExists && mounted) {
|
||||
if (isExists) {
|
||||
context.showNotifier(
|
||||
appLocalizations.existsTip(appLocalizations.colorSchemes),
|
||||
level: MessageLevel.warning,
|
||||
@@ -216,21 +221,7 @@ class _PrimaryColorItemState extends ConsumerState<_PrimaryColorItem> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleChangeSchemeVariant() async {
|
||||
final schemeVariant = ref.read(
|
||||
themeSettingProvider.select((state) => state.schemeVariant),
|
||||
);
|
||||
final value = await dialogs.showCommonDialog<DynamicSchemeVariant>(
|
||||
child: OptionsDialog<DynamicSchemeVariant>(
|
||||
title: context.appLocalizations.colorSchemes,
|
||||
options: DynamicSchemeVariant.values,
|
||||
textBuilder: (item) => item.label,
|
||||
value: schemeVariant,
|
||||
),
|
||||
);
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
void _handleChangeSchemeVariant(DynamicSchemeVariant value) {
|
||||
ref.read(themeSettingProvider.notifier).update((state) {
|
||||
return state.copyWith(schemeVariant: value);
|
||||
});
|
||||
@@ -277,10 +268,22 @@ class _PrimaryColorItemState extends ConsumerState<_PrimaryColorItem> {
|
||||
actions: [
|
||||
if (_removablePrimaryColor == null)
|
||||
ElasticButton(
|
||||
child: FilledButton.tonal(
|
||||
style: _headerButtonStyle,
|
||||
onPressed: _handleChangeSchemeVariant,
|
||||
child: Text(schemeVariant.label),
|
||||
child: CommonPopupBox(
|
||||
targetBuilder: (open) => FilledButton.tonal(
|
||||
style: _headerButtonStyle,
|
||||
onPressed: open,
|
||||
child: PopupValueText(schemeVariant.label),
|
||||
),
|
||||
popupBuilder: (_) => CommonPopupMenu(
|
||||
items: [
|
||||
for (final variant in DynamicSchemeVariant.values)
|
||||
CommonPopupMenuItem(
|
||||
label: variant.label,
|
||||
checked: variant == schemeVariant,
|
||||
onPressed: () => _handleChangeSchemeVariant(variant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_removablePrimaryColor != null)
|
||||
@@ -456,6 +459,10 @@ final _floatingBarProvider = appSettingProvider.select(
|
||||
(state) => state.floatingNavigationBar,
|
||||
);
|
||||
|
||||
final _liquidGlassProvider = themeSettingProvider.select(
|
||||
(state) => state.liquidGlass,
|
||||
);
|
||||
|
||||
class _PureBlackItem extends ConsumerWidget {
|
||||
const _PureBlackItem();
|
||||
|
||||
@@ -466,12 +473,14 @@ class _PureBlackItem extends ConsumerWidget {
|
||||
themeSettingProvider.select((state) => state.pureBlack),
|
||||
);
|
||||
final floatingBar = ref.watch(_floatingBarProvider);
|
||||
final liquidGlass = ref.watch(_liquidGlassProvider);
|
||||
Widget preview(bool pureBlack) => MiniScreenThumb(
|
||||
screen: MiniScreen(
|
||||
colorScheme: ref.watch(
|
||||
genColorSchemeProvider(Brightness.dark, pureBlack: pureBlack),
|
||||
),
|
||||
floatingBar: floatingBar,
|
||||
liquidGlass: liquidGlass,
|
||||
),
|
||||
);
|
||||
return SliverToBoxAdapter(
|
||||
@@ -510,6 +519,7 @@ class _NavigationBarItem extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final floatingBar = ref.watch(_floatingBarProvider);
|
||||
final liquidGlass = ref.watch(_liquidGlassProvider);
|
||||
final colorScheme = context.colorScheme;
|
||||
return SliverToBoxAdapter(
|
||||
child: PreviewChoiceGroup<bool>(
|
||||
@@ -523,7 +533,11 @@ class _NavigationBarItem extends ConsumerWidget {
|
||||
value: true,
|
||||
label: appLocalizations.floating,
|
||||
pictogram: MiniScreenThumb(
|
||||
screen: MiniScreen(colorScheme: colorScheme, floatingBar: true),
|
||||
screen: MiniScreen(
|
||||
colorScheme: colorScheme,
|
||||
floatingBar: true,
|
||||
liquidGlass: liquidGlass,
|
||||
),
|
||||
),
|
||||
),
|
||||
PreviewChoice(
|
||||
@@ -554,11 +568,13 @@ class _TabAnimationItem extends ConsumerWidget {
|
||||
appSettingProvider.select((state) => state.tabAnimation),
|
||||
);
|
||||
final floatingBar = ref.watch(_floatingBarProvider);
|
||||
final liquidGlass = ref.watch(_liquidGlassProvider);
|
||||
final colorScheme = context.colorScheme;
|
||||
Widget switching(TabAnimation tabAnimation) => MiniScreenThumb(
|
||||
screen: MiniScreen(
|
||||
colorScheme: colorScheme,
|
||||
floatingBar: floatingBar,
|
||||
liquidGlass: liquidGlass,
|
||||
selected: 1,
|
||||
previous: 0,
|
||||
progress: 0.55,
|
||||
@@ -594,6 +610,53 @@ class _TabAnimationItem extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _LiquidGlassItem extends ConsumerWidget {
|
||||
const _LiquidGlassItem();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final liquidGlass = ref.watch(_liquidGlassProvider);
|
||||
final colorScheme = context.colorScheme;
|
||||
// Floating either way, as a docked bar stays a fill, over the list page,
|
||||
// whose rows run on under the bar where glass shows them.
|
||||
Widget preview(bool liquidGlass) => MiniScreenThumb(
|
||||
screen: MiniScreen(
|
||||
colorScheme: colorScheme,
|
||||
floatingBar: true,
|
||||
liquidGlass: liquidGlass,
|
||||
selected: 1,
|
||||
),
|
||||
);
|
||||
return SliverToBoxAdapter(
|
||||
child: PreviewChoiceGroup<bool>(
|
||||
info: Info(
|
||||
label: appLocalizations.liquidGlass,
|
||||
glyph: AppGlyphs.liquidGlass,
|
||||
),
|
||||
value: liquidGlass,
|
||||
choices: [
|
||||
PreviewChoice(
|
||||
value: false,
|
||||
label: appLocalizations.standard,
|
||||
pictogram: preview(false),
|
||||
),
|
||||
PreviewChoice(
|
||||
value: true,
|
||||
label: appLocalizations.liquidGlass,
|
||||
pictogram: preview(true),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(themeSettingProvider.notifier)
|
||||
.update((state) => state.copyWith(liquidGlass: value));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SidebarBlurItem extends ConsumerWidget {
|
||||
const _SidebarBlurItem();
|
||||
|
||||
@@ -612,9 +675,9 @@ class _SidebarBlurItem extends ConsumerWidget {
|
||||
horizontalTitleGap: 12,
|
||||
title: Text(
|
||||
appLocalizations.sidebarBlur,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: context.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: AppTextStyle.subheadline
|
||||
.of(context, emphasized: true)
|
||||
.copyWith(color: context.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
subtitle: Text(appLocalizations.sidebarBlurDesc),
|
||||
value: sidebarBlur,
|
||||
@@ -635,7 +698,6 @@ class _FontFamilyItem extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final colorScheme = context.colorScheme;
|
||||
final textTheme = context.textTheme;
|
||||
final fontFamily = ref.watch(
|
||||
themeSettingProvider.select((state) => state.fontFamily),
|
||||
);
|
||||
@@ -646,17 +708,21 @@ class _FontFamilyItem extends ConsumerWidget {
|
||||
children: [
|
||||
Text(
|
||||
'Aa',
|
||||
style: textTheme.titleMedium?.copyWith(fontFamily: fontFamily),
|
||||
style: AppTextStyle.headline
|
||||
.of(context)
|
||||
.copyWith(fontFamily: fontFamily),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
fontFamily ?? appLocalizations.defaultText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: fontFamily,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: AppTextStyle.subheadline
|
||||
.of(context)
|
||||
.copyWith(
|
||||
fontFamily: fontFamily,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
GlyphIcon(
|
||||
@@ -667,8 +733,9 @@ class _FontFamilyItem extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
);
|
||||
final cardColor = context.raisedSurfaceLevel.colorOf(colorScheme);
|
||||
Widget card(VoidCallback onTap) => Material(
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
color: cardColor,
|
||||
shape: AppShape.lg,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(customBorder: AppShape.lg, onTap: onTap, child: preview),
|
||||
@@ -687,9 +754,8 @@ class _FontFamilyItem extends ConsumerWidget {
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: context.isMobileView
|
||||
? OpenContainer<void>(
|
||||
tappable: false,
|
||||
closedShape: AppShape.lg,
|
||||
closedColor: colorScheme.surfaceContainerLow,
|
||||
closedColor: cardColor,
|
||||
closedBuilder: (_, open) => card(open),
|
||||
openBuilder: (_, _) => const FontFamilyView(),
|
||||
)
|
||||
@@ -738,6 +804,7 @@ class _TextScaleFactorItemState extends ConsumerState<_TextScaleFactorItem> {
|
||||
).clamp(minTextScale, maxTextScale).toDouble();
|
||||
final scale = _draft ?? (textScale.enable ? textScale.scale : systemScale);
|
||||
final percent = '${(scale * 100).round()}%';
|
||||
final level = context.raisedSurfaceLevel;
|
||||
final divisions =
|
||||
((maxTextScale - minTextScale) / _TextScaleFactorItem._step).round();
|
||||
return SliverToBoxAdapter(
|
||||
@@ -783,7 +850,7 @@ class _TextScaleFactorItemState extends ConsumerState<_TextScaleFactorItem> {
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
decoration: ShapeDecoration(
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
color: level.colorOf(colorScheme),
|
||||
shape: AppShape.lg,
|
||||
),
|
||||
child: Column(
|
||||
@@ -808,12 +875,13 @@ class _TextScaleFactorItemState extends ConsumerState<_TextScaleFactorItem> {
|
||||
children: [
|
||||
Text(
|
||||
'Aa',
|
||||
style: context.textTheme.titleLarge,
|
||||
style: AppTextStyle.title3.of(context),
|
||||
),
|
||||
Text(
|
||||
appLocalizations.textScalePreview,
|
||||
style: context.textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
style: AppTextStyle.subheadline
|
||||
.of(context)
|
||||
.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
@@ -828,17 +896,19 @@ class _TextScaleFactorItemState extends ConsumerState<_TextScaleFactorItem> {
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: ShapeDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
color: level.next.colorOf(colorScheme),
|
||||
shape: AppShape.full,
|
||||
),
|
||||
child: Text(
|
||||
percent,
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontFeatures: const [
|
||||
FontFeature.tabularFigures(),
|
||||
],
|
||||
),
|
||||
style: AppTextStyle.subheadline
|
||||
.of(context, emphasized: true)
|
||||
.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontFeatures: const [
|
||||
FontFeature.tabularFigures(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -851,10 +921,11 @@ class _TextScaleFactorItemState extends ConsumerState<_TextScaleFactorItem> {
|
||||
ExcludeSemantics(
|
||||
child: Text(
|
||||
'A',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: AppTextStyle.footnote
|
||||
.of(context)
|
||||
.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
@@ -880,11 +951,11 @@ class _TextScaleFactorItemState extends ConsumerState<_TextScaleFactorItem> {
|
||||
ExcludeSemantics(
|
||||
child: Text(
|
||||
'A',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: AppTextStyle.title2
|
||||
.of(context)
|
||||
.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -925,7 +996,7 @@ class _SegmentedToggle extends StatelessWidget {
|
||||
height: _height,
|
||||
padding: const EdgeInsets.all(_inset),
|
||||
decoration: ShapeDecoration(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
color: context.raisedSurfaceLevel.colorOf(colorScheme),
|
||||
shape: AppShape.full,
|
||||
),
|
||||
child: Stack(
|
||||
@@ -941,7 +1012,7 @@ class _SegmentedToggle extends StatelessWidget {
|
||||
heightFactor: 1,
|
||||
child: DecoratedBox(
|
||||
decoration: ShapeDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
color: colorScheme.primary,
|
||||
shape: AppShape.full,
|
||||
),
|
||||
),
|
||||
@@ -967,14 +1038,13 @@ class _SegmentedToggle extends StatelessWidget {
|
||||
child: Center(
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
style: context.textTheme.labelLarge!.copyWith(
|
||||
color: option == value
|
||||
? colorScheme.onSecondaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
fontWeight: option == value
|
||||
? FontWeight.w600
|
||||
: FontWeight.w500,
|
||||
),
|
||||
style: AppTextStyle.subheadline
|
||||
.of(context, emphasized: option == value)
|
||||
.copyWith(
|
||||
color: option == value
|
||||
? colorScheme.onPrimary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
|
||||
+154
-143
@@ -1,4 +1,5 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'dart:ui' show ImageFilter;
|
||||
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/providers/config.dart';
|
||||
import 'package:fl_clash/providers/state.dart';
|
||||
@@ -40,16 +41,17 @@ class PreviewChoiceGroup<T> extends StatelessWidget {
|
||||
InfoHeader(info: info),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
child: generateSection(
|
||||
items: [
|
||||
for (final choice in choices)
|
||||
_ChoiceButton(
|
||||
label: choice.label,
|
||||
DecorationListItem(
|
||||
leading: ExcludeSemantics(child: choice.pictogram),
|
||||
title: Text(choice.label),
|
||||
isSelected: choice.value == value,
|
||||
trailing: choice.value == value
|
||||
? const SelectionCheck()
|
||||
: null,
|
||||
onPressed: () => onChanged(choice.value),
|
||||
pictogram: choice.pictogram,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -59,52 +61,6 @@ class PreviewChoiceGroup<T> extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ChoiceButton extends StatelessWidget {
|
||||
const _ChoiceButton({
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onPressed,
|
||||
required this.pictogram,
|
||||
});
|
||||
|
||||
static const double _height = 48;
|
||||
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onPressed;
|
||||
final Widget pictogram;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
selected: isSelected,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: _height),
|
||||
child: CommonCard(
|
||||
isSelected: isSelected,
|
||||
onPressed: onPressed,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
ExcludeSemantics(child: pictogram),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The foot of a [MiniScreen], where the options differ, drawn at the size of
|
||||
/// an icon.
|
||||
class MiniScreenThumb extends StatelessWidget {
|
||||
@@ -118,28 +74,21 @@ class MiniScreenThumb extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shape = AppShape.sm.copyWith(
|
||||
side: BorderSide(color: context.colorScheme.outlineVariant),
|
||||
);
|
||||
return SizedBox(
|
||||
height: _height,
|
||||
width: _height * _screenSize.width / _shownHeight,
|
||||
child: DecoratedBox(
|
||||
position: DecorationPosition.foreground,
|
||||
decoration: ShapeDecoration(shape: shape),
|
||||
child: ClipRSuperellipse(
|
||||
borderRadius: AppRadius.sm,
|
||||
child: FittedBox(
|
||||
child: SizedBox(
|
||||
width: _screenSize.width,
|
||||
height: _shownHeight,
|
||||
child: ClipRect(
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.bottomCenter,
|
||||
minHeight: _screenSize.height,
|
||||
maxHeight: _screenSize.height,
|
||||
child: screen,
|
||||
),
|
||||
child: ClipRSuperellipse(
|
||||
borderRadius: AppRadius.sm,
|
||||
child: FittedBox(
|
||||
child: SizedBox(
|
||||
width: _screenSize.width,
|
||||
height: _shownHeight,
|
||||
child: ClipRect(
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.bottomCenter,
|
||||
minHeight: _screenSize.height,
|
||||
maxHeight: _screenSize.height,
|
||||
child: screen,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -232,45 +181,41 @@ class _ThemeLivePreviewState extends ConsumerState<ThemeLivePreview>
|
||||
final tabAnimation = ref.watch(
|
||||
appSettingProvider.select((state) => state.tabAnimation),
|
||||
);
|
||||
final liquidGlass = ref.watch(
|
||||
themeSettingProvider.select((state) => state.liquidGlass),
|
||||
);
|
||||
final corner = AppCorner.fit(_phoneWidth);
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
alignment: Alignment.center,
|
||||
decoration: ShapeDecoration(
|
||||
color: context.colorScheme.surfaceContainerLow,
|
||||
color: context.raisedSurfaceLevel.colorOf(context.colorScheme),
|
||||
shape: AppShape.xxl,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: _phoneWidth,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 9 / 17,
|
||||
child: DecoratedBox(
|
||||
position: DecorationPosition.foreground,
|
||||
decoration: ShapeDecoration(
|
||||
shape: AppShape.all(corner).copyWith(
|
||||
side: BorderSide(color: context.colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: ClipRSuperellipse(
|
||||
borderRadius: AppRadius.all(corner),
|
||||
child: TweenAnimationBuilder<ColorScheme>(
|
||||
tween: _ColorSchemeTween(end: colorScheme),
|
||||
child: ClipRSuperellipse(
|
||||
borderRadius: AppRadius.all(corner),
|
||||
child: TweenAnimationBuilder<ColorScheme>(
|
||||
tween: _ColorSchemeTween(end: colorScheme),
|
||||
duration: _duration,
|
||||
builder: (_, colorScheme, _) => AnimatedSwitcher(
|
||||
duration: _duration,
|
||||
builder: (_, colorScheme, _) => AnimatedSwitcher(
|
||||
duration: _duration,
|
||||
child: AnimatedBuilder(
|
||||
key: ValueKey(floatingBar),
|
||||
animation: _slideCurve,
|
||||
builder: (_, _) => MiniScreen(
|
||||
colorScheme: colorScheme,
|
||||
floatingBar: floatingBar,
|
||||
selected: _selected,
|
||||
previous: _previous,
|
||||
progress: _previous == null ? 1 : _slideCurve.value,
|
||||
tabAnimation: tabAnimation,
|
||||
onSelect: _select,
|
||||
),
|
||||
child: AnimatedBuilder(
|
||||
key: ValueKey((floatingBar, liquidGlass)),
|
||||
animation: _slideCurve,
|
||||
builder: (_, _) => MiniScreen(
|
||||
colorScheme: colorScheme,
|
||||
floatingBar: floatingBar,
|
||||
liquidGlass: liquidGlass,
|
||||
selected: _selected,
|
||||
previous: _previous,
|
||||
progress: _previous == null ? 1 : _slideCurve.value,
|
||||
tabAnimation: tabAnimation,
|
||||
onSelect: _select,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -293,6 +238,7 @@ class MiniScreen extends StatelessWidget {
|
||||
super.key,
|
||||
required this.colorScheme,
|
||||
required this.floatingBar,
|
||||
this.liquidGlass = false,
|
||||
this.selected = 0,
|
||||
this.previous,
|
||||
this.progress = 1,
|
||||
@@ -304,6 +250,11 @@ class MiniScreen extends StatelessWidget {
|
||||
|
||||
final ColorScheme colorScheme;
|
||||
final bool floatingBar;
|
||||
|
||||
/// Draws a floating bar as glass; a docked one stays a fill, as the app's
|
||||
/// does.
|
||||
final bool liquidGlass;
|
||||
|
||||
final int selected;
|
||||
final int? previous;
|
||||
final double progress;
|
||||
@@ -322,6 +273,7 @@ class MiniScreen extends StatelessWidget {
|
||||
painter: _MiniScreenPainter(
|
||||
colorScheme: colorScheme,
|
||||
floatingBar: floatingBar,
|
||||
liquidGlass: liquidGlass,
|
||||
selected: selected,
|
||||
previous: previous,
|
||||
progress: progress,
|
||||
@@ -447,6 +399,7 @@ class _MiniScreenPainter extends CustomPainter {
|
||||
const _MiniScreenPainter({
|
||||
required this.colorScheme,
|
||||
required this.floatingBar,
|
||||
required this.liquidGlass,
|
||||
required this.selected,
|
||||
required this.previous,
|
||||
required this.progress,
|
||||
@@ -456,12 +409,15 @@ class _MiniScreenPainter extends CustomPainter {
|
||||
|
||||
final ColorScheme colorScheme;
|
||||
final bool floatingBar;
|
||||
final bool liquidGlass;
|
||||
final int selected;
|
||||
final int? previous;
|
||||
final double progress;
|
||||
final TabAnimation tabAnimation;
|
||||
final bool hasFab;
|
||||
|
||||
bool get _glass => floatingBar && liquidGlass;
|
||||
|
||||
static _MiniPage _pageOf(int index) =>
|
||||
index.isEven ? _MiniPage.cards : _MiniPage.list;
|
||||
|
||||
@@ -488,7 +444,41 @@ class _MiniScreenPainter extends CustomPainter {
|
||||
floatingBar: floatingBar,
|
||||
hasFab: hasFab,
|
||||
);
|
||||
canvas.drawRect(Offset.zero & size, Paint()..color = colorScheme.surface);
|
||||
_paintPages(canvas, geometry);
|
||||
final unit = geometry.unit;
|
||||
final dock = SurfaceLevel.floatingOf(colorScheme).colorOf(colorScheme);
|
||||
if (hasFab && !floatingBar) {
|
||||
_block(
|
||||
canvas,
|
||||
geometry.fab,
|
||||
colorScheme.primaryContainer,
|
||||
geometry.fabSize,
|
||||
);
|
||||
}
|
||||
final shadow = BoxShadow(
|
||||
color: colorScheme.shadow.withValues(alpha: 0.12),
|
||||
blurRadius: unit,
|
||||
offset: Offset(0, unit * 0.3),
|
||||
);
|
||||
if (_glass) {
|
||||
_paintGlassBar(canvas, geometry, shadow);
|
||||
} else if (floatingBar) {
|
||||
_fill(canvas, geometry.bar, dock, AppShape.full, shadows: [shadow]);
|
||||
} else {
|
||||
canvas.drawRect(geometry.bar, Paint()..color = dock);
|
||||
}
|
||||
_paintDestinations(canvas, geometry.bar, unit);
|
||||
if (hasFab && floatingBar) {
|
||||
_fill(canvas, geometry.fab, colorScheme.primaryContainer, AppShape.full);
|
||||
}
|
||||
}
|
||||
|
||||
void _paintPages(Canvas canvas, _MiniGeometry geometry) {
|
||||
final size = geometry.size;
|
||||
canvas.drawRect(
|
||||
Offset.zero & size,
|
||||
Paint()..color = SurfaceLevel.page.colorOf(colorScheme),
|
||||
);
|
||||
canvas.save();
|
||||
canvas.clipRect(Offset.zero & size, doAntiAlias: false);
|
||||
final previous = this.previous;
|
||||
@@ -513,39 +503,53 @@ class _MiniScreenPainter extends CustomPainter {
|
||||
);
|
||||
}
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
/// The page frosted under a tint, ringed with light and casting its shadow
|
||||
/// only outside, after what [LiquidGlass] does at full size.
|
||||
void _paintGlassBar(Canvas canvas, _MiniGeometry geometry, BoxShadow shadow) {
|
||||
final bar = geometry.bar;
|
||||
final unit = geometry.unit;
|
||||
if (hasFab && !floatingBar) {
|
||||
_block(
|
||||
canvas,
|
||||
geometry.fab,
|
||||
colorScheme.primaryContainer,
|
||||
geometry.fabSize,
|
||||
final path = AppShape.full.getOuterPath(bar);
|
||||
final style = LiquidGlassStyle.pill(colorScheme);
|
||||
final blur = unit * 0.4;
|
||||
canvas
|
||||
..save()
|
||||
..clipPath(
|
||||
Path()
|
||||
..fillType = PathFillType.evenOdd
|
||||
..addRect(bar.inflate(unit * 3))
|
||||
..addPath(path, Offset.zero),
|
||||
)
|
||||
..drawPath(path.shift(shadow.offset), shadow.toPaint())
|
||||
..restore()
|
||||
..save()
|
||||
..clipPath(path)
|
||||
..saveLayer(
|
||||
bar,
|
||||
Paint()..imageFilter = ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||
);
|
||||
}
|
||||
if (floatingBar) {
|
||||
_fill(
|
||||
canvas,
|
||||
geometry.bar,
|
||||
colorScheme.surfaceContainer,
|
||||
AppShape.full,
|
||||
shadows: [
|
||||
BoxShadow(
|
||||
color: colorScheme.shadow.withValues(alpha: 0.12),
|
||||
blurRadius: unit,
|
||||
offset: Offset(0, unit * 0.3),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
canvas.drawRect(
|
||||
geometry.bar,
|
||||
Paint()..color = colorScheme.surfaceContainer,
|
||||
);
|
||||
}
|
||||
_paintDestinations(canvas, geometry.bar, unit);
|
||||
if (hasFab && floatingBar) {
|
||||
_fill(canvas, geometry.fab, colorScheme.primaryContainer, AppShape.full);
|
||||
}
|
||||
_paintPages(canvas, geometry);
|
||||
final rimWidth = unit * 0.15;
|
||||
canvas
|
||||
..restore()
|
||||
..drawPath(path, Paint()..color = style.tint)
|
||||
..drawPath(
|
||||
AppShape.full.getOuterPath(bar.deflate(rimWidth / 2)),
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = rimWidth
|
||||
..shader = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: style.rim),
|
||||
Colors.white.withValues(alpha: style.rim * 0.15),
|
||||
Colors.white.withValues(alpha: style.rim * 0.5),
|
||||
],
|
||||
).createShader(bar),
|
||||
)
|
||||
..restore();
|
||||
}
|
||||
|
||||
void _paintPage(
|
||||
@@ -576,7 +580,7 @@ class _MiniScreenPainter extends CustomPainter {
|
||||
canvas.clipRect(content, doAntiAlias: false);
|
||||
final left = content.left;
|
||||
final width = content.width;
|
||||
final card = colorScheme.surfaceContainer;
|
||||
final card = SurfaceLevel.card.colorOf(colorScheme);
|
||||
final line = colorScheme.onSurfaceVariant.withValues(alpha: 0.4);
|
||||
var top = content.top;
|
||||
_fill(
|
||||
@@ -632,14 +636,19 @@ class _MiniScreenPainter extends CustomPainter {
|
||||
card,
|
||||
unit * 4,
|
||||
);
|
||||
_block(
|
||||
final chosen = Rect.fromLTWH(left + half + unit, top, half, unit * 4);
|
||||
_block(canvas, chosen, card, unit * 4);
|
||||
_fill(
|
||||
canvas,
|
||||
Rect.fromLTWH(left + half + unit, top, half, unit * 4),
|
||||
colorScheme.secondaryContainer,
|
||||
unit * 4,
|
||||
Rect.fromCircle(
|
||||
center: chosen.topRight + Offset(-unit * 1.3, unit * 1.3),
|
||||
radius: unit * 0.7,
|
||||
),
|
||||
colorScheme.primary,
|
||||
AppShape.circle,
|
||||
);
|
||||
top += unit * 4 + unit;
|
||||
for (var i = 0; i < 3; i++) {
|
||||
for (var i = 0; i < 4; i++) {
|
||||
_block(
|
||||
canvas,
|
||||
Rect.fromLTWH(left, top, width, unit * 4.5),
|
||||
@@ -650,7 +659,7 @@ class _MiniScreenPainter extends CustomPainter {
|
||||
}
|
||||
case _MiniPage.list:
|
||||
final lineLeft = left + unit * 2.2 + unit;
|
||||
for (var i = 0; i < 7; i++) {
|
||||
for (var i = 0; i < 9; i++) {
|
||||
_fill(
|
||||
canvas,
|
||||
Rect.fromLTWH(
|
||||
@@ -659,9 +668,7 @@ class _MiniScreenPainter extends CustomPainter {
|
||||
unit * 2.2,
|
||||
unit * 2.2,
|
||||
),
|
||||
i == 0
|
||||
? colorScheme.tertiaryContainer
|
||||
: colorScheme.secondaryContainer,
|
||||
SurfaceLevel.inset.colorOf(colorScheme),
|
||||
AppShape.circle,
|
||||
);
|
||||
final lineTop =
|
||||
@@ -707,13 +714,16 @@ class _MiniScreenPainter extends CustomPainter {
|
||||
_fill(canvas, dot, colorScheme.onSurfaceVariant, AppShape.circle);
|
||||
continue;
|
||||
}
|
||||
final dark = colorScheme.brightness == Brightness.dark;
|
||||
_fill(
|
||||
canvas,
|
||||
Rect.fromCenter(center: center, width: unit * 2.8, height: unit * 1.6),
|
||||
colorScheme.secondaryContainer,
|
||||
_glass
|
||||
? colorScheme.onSurface.withValues(alpha: dark ? 0.14 : 0.08)
|
||||
: SurfaceLevel.floatingOf(colorScheme).next.colorOf(colorScheme),
|
||||
AppShape.full,
|
||||
);
|
||||
_fill(canvas, dot, colorScheme.onSecondaryContainer, AppShape.circle);
|
||||
_fill(canvas, dot, colorScheme.primary, AppShape.circle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -721,6 +731,7 @@ class _MiniScreenPainter extends CustomPainter {
|
||||
bool shouldRepaint(_MiniScreenPainter oldDelegate) {
|
||||
return oldDelegate.colorScheme != colorScheme ||
|
||||
oldDelegate.floatingBar != floatingBar ||
|
||||
oldDelegate.liquidGlass != liquidGlass ||
|
||||
oldDelegate.selected != selected ||
|
||||
oldDelegate.previous != previous ||
|
||||
oldDelegate.progress != progress ||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -107,6 +107,8 @@ flutter:
|
||||
platforms: [ macos ]
|
||||
- path: assets/images/tray/windows/
|
||||
platforms: [ windows ]
|
||||
shaders:
|
||||
- shaders/liquid_glass.frag
|
||||
fonts:
|
||||
- family: JetBrainsMono
|
||||
fonts:
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
#version 460 core
|
||||
|
||||
#include <flutter/runtime_effect.glsl>
|
||||
|
||||
precision highp float;
|
||||
|
||||
// The texture's size; the order of the rest is the order in which
|
||||
// _LiquidGlassLayer in lib/ui/liquid_glass.dart writes them.
|
||||
uniform vec2 uSize;
|
||||
// Glass coordinates, in dp from its top left, of FlutterFragCoord: an origin,
|
||||
// then a scale per unit.
|
||||
uniform vec4 uFrame;
|
||||
// Glass coordinates of the texture's pixels, as uFrame.
|
||||
uniform vec4 uSample;
|
||||
// The first and last pixel centers the pane may read, as a capture holds the
|
||||
// backdrops of other panes beside its own.
|
||||
uniform vec4 uClamp;
|
||||
// Dp per device pixel, and the frost sigma in its own pixels the texture
|
||||
// already holds, as a capture is blurred before any pane reads it.
|
||||
uniform vec2 uTexels;
|
||||
// As uSample and uClamp, for a capture blurred by the style's haze, and the
|
||||
// share of the backdrop seen through it.
|
||||
uniform vec4 uHazeSample;
|
||||
uniform vec4 uHazeClamp;
|
||||
uniform float uHaze;
|
||||
// Width, height, bevel width, frost sigma.
|
||||
uniform vec4 uShape;
|
||||
// Corner radii: top left, top right, bottom right, bottom left.
|
||||
uniform vec4 uCorners;
|
||||
// The superellipse corners the pane is clipped to, three to a corner in the
|
||||
// order of uCorners: n, J and the arc's radius for the octant along the top or
|
||||
// bottom, the same for the one along the side, then both arcs' centers. A
|
||||
// sharp corner has n 0.
|
||||
uniform vec4 uCurves[12];
|
||||
// Refraction depth at the rim, saturation, brightness. A negative depth makes
|
||||
// a raised lens, which bends outward on UIKit's profile.
|
||||
uniform vec3 uOptics;
|
||||
// Straight alpha.
|
||||
uniform vec4 uTint;
|
||||
// Highlight along the top and bottom of the rim, the dp it fades over, how
|
||||
// much a lens's rounded ends darken.
|
||||
uniform vec3 uLight;
|
||||
// Glow center, glow strength, how far the tint thickens over content much
|
||||
// darker than it, or when negative much lighter.
|
||||
uniform vec4 uGlow;
|
||||
// How far the glow reaches, and the share of it spread over the whole pane.
|
||||
uniform vec2 uGlowSpread;
|
||||
// How much the dark line around the rim darkens the backdrop, how far apart in
|
||||
// dp the color channels part along the rim, and the frost sigma the rim adds
|
||||
// at its edge, easing off across the bevel.
|
||||
uniform vec3 uEdge;
|
||||
// The highlight's share in a faint tail kRimTail long, how far its fall
|
||||
// round rounded ends goes from the tab bar's to iOS 27 controls', and its
|
||||
// share added as light rather than screened; then the line at the sides.
|
||||
uniform vec4 uRim;
|
||||
// Above 0.5 the pane takes the outline of uProfile in place of its corners.
|
||||
uniform float uOutlined;
|
||||
// The outline's distance from the center along 64 even turns, the first
|
||||
// toward +x and the rest toward +y, four to a vector.
|
||||
uniform vec4 uProfile[16];
|
||||
// A menu's highlight (LiquidGlassRim): light added at eight turns from the
|
||||
// vertical through the center, rising from 0 to a right angle, and across
|
||||
// bands inward from the rim, where each ends in dp and its share.
|
||||
uniform vec4 uShineTurns[2];
|
||||
uniform vec4 uShineLit[2];
|
||||
uniform vec4 uShineEnds[3];
|
||||
uniform vec4 uShineShares[3];
|
||||
uniform sampler2D uTexture;
|
||||
|
||||
out vec4 fragColor;
|
||||
|
||||
const vec3 kLuma = vec3(0.2126, 0.7152, 0.0722);
|
||||
const float kTau = 6.28318530718;
|
||||
const float kTurns = 64.0;
|
||||
// Fewer taps leave a swirl over sharp text; 64 leave a faint one at 3x, too
|
||||
// fine to see. A count taken from a uniform costs as much as 96 constant taps,
|
||||
// so a narrower frost takes a constant count of its own, its taps no further
|
||||
// apart than 64 leave those of the widest.
|
||||
const int kFrostTaps = 64;
|
||||
const float kGoldenAngle = 2.39996322973;
|
||||
// 1.2 times the engine's kernel reach of (sigma - 0.5) * sqrt(3) pixels, which
|
||||
// on a Vogel disc fits its blur best.
|
||||
const float kFrostReach = 2.07846096908;
|
||||
// Measured on iOS 27: the width of the dark line in dp and the most it takes
|
||||
// off a channel of the backdrop.
|
||||
const float kLine = 0.67;
|
||||
const float kLineCap = 0.298;
|
||||
// The most the engine's superellipse corner lies inside a circular one, as a
|
||||
// share of the radius, with room to spare over the 1.8% it reaches.
|
||||
const float kCornerGap = 0.03;
|
||||
// How far in dp a lens's ends darken in from the rim.
|
||||
const float kEndShade = 3.0;
|
||||
// How far in dp from the rim the color channels part, and where they start
|
||||
// to close: iOS parts them nearly as far across a lens's band as at its rim.
|
||||
const float kPartReach = 10.0;
|
||||
const float kPartHold = 7.0;
|
||||
// Measured on iOS 27 controls: how far in dp the highlight's tail reaches.
|
||||
const float kRimTail = 4.5;
|
||||
// A frost this wide in dp reads the backdrop's luma once, as a haze does.
|
||||
const float kSteadyFrost = 8.0;
|
||||
|
||||
vec4 texel(vec2 pixel) {
|
||||
return texture(
|
||||
uTexture,
|
||||
clamp(pixel, uClamp.xy, min(uClamp.zw, uSize - 0.5)) / uSize
|
||||
);
|
||||
}
|
||||
|
||||
vec4 sampleGlass(vec2 point) {
|
||||
return texel((point - uSample.xy) / uSample.zw);
|
||||
}
|
||||
|
||||
vec4 sampleHaze(vec2 point) {
|
||||
vec2 pixel = (point - uHazeSample.xy) / uHazeSample.zw;
|
||||
return texture(
|
||||
uTexture,
|
||||
clamp(pixel, uHazeClamp.xy, min(uHazeClamp.zw, uSize - 0.5)) / uSize
|
||||
);
|
||||
}
|
||||
|
||||
#define FROST_DISC(taps) \
|
||||
float decay = exp(-falloff / float(taps)); \
|
||||
float weight = sqrt(decay); \
|
||||
vec2 turn = vec2(cos(kGoldenAngle), sin(kGoldenAngle)); \
|
||||
vec2 ray = vec2(1.0, 0.0); \
|
||||
vec4 sum = vec4(0.0); \
|
||||
float total = 0.0; \
|
||||
for (int i = 0; i < taps; i++) { \
|
||||
float spread = sqrt((float(i) + 0.5) / float(taps)); \
|
||||
sum += texel(center + ray * reach * spread) * weight; \
|
||||
total += weight; \
|
||||
weight *= decay; \
|
||||
ray = vec2(ray.x * turn.x - ray.y * turn.y, ray.x * turn.y + ray.y * turn.x); \
|
||||
} \
|
||||
return sum / total;
|
||||
|
||||
vec4 frostDisc16(vec2 center, vec2 reach, float falloff) {
|
||||
FROST_DISC(16)
|
||||
}
|
||||
|
||||
vec4 frostDisc32(vec2 center, vec2 reach, float falloff) {
|
||||
FROST_DISC(32)
|
||||
}
|
||||
|
||||
vec4 frostDisc64(vec2 center, vec2 reach, float falloff) {
|
||||
FROST_DISC(kFrostTaps)
|
||||
}
|
||||
|
||||
// The engine's Gaussian of a frost sigma in device pixels, past what the
|
||||
// texture holds, read on a Vogel disc.
|
||||
vec4 sampleFrosted(vec2 point, float frost) {
|
||||
vec2 sigma = frost / uSample.zw;
|
||||
sigma = sqrt(max(sigma * sigma - uTexels.y * uTexels.y, vec2(0.0)));
|
||||
vec2 reach = max(sigma - 0.5, 0.0) * kFrostReach;
|
||||
if (reach.x <= 0.0 || reach.y <= 0.0) {
|
||||
return sampleGlass(point);
|
||||
}
|
||||
vec2 ratio = reach / sigma;
|
||||
float falloff = 0.5 * ratio.x * ratio.y;
|
||||
vec2 center = (point - uSample.xy) / uSample.zw;
|
||||
float widest = max(sigma.x, sigma.y);
|
||||
if (widest <= 2.0) {
|
||||
return frostDisc16(center, reach, falloff);
|
||||
}
|
||||
if (widest <= 3.5) {
|
||||
return frostDisc32(center, reach, falloff);
|
||||
}
|
||||
return frostDisc64(center, reach, falloff);
|
||||
}
|
||||
|
||||
// Its taps sit a frost sigma apart, so that a glyph passing under one point
|
||||
// does not flicker the tint; a haze or a wide frost is blurred enough to read
|
||||
// once.
|
||||
vec4 sampleBehind(vec2 point) {
|
||||
if (uHaze > 0.0) {
|
||||
return sampleHaze(point);
|
||||
}
|
||||
if (uShape.w >= kSteadyFrost) {
|
||||
return sampleGlass(point);
|
||||
}
|
||||
vec2 spread = vec2(uShape.w, -uShape.w);
|
||||
return 0.25 * (sampleGlass(point + spread.xx)
|
||||
+ sampleGlass(point + spread.yy)
|
||||
+ sampleGlass(point + spread.xy)
|
||||
+ sampleGlass(point + spread.yx));
|
||||
}
|
||||
|
||||
float roundedBoxDistance(vec2 p, vec2 halfSize, float radius) {
|
||||
vec2 q = abs(p) - halfSize + radius;
|
||||
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius;
|
||||
}
|
||||
|
||||
vec2 roundedBoxNormal(vec2 p, vec2 halfSize, float radius) {
|
||||
vec2 q = abs(p) - halfSize + radius;
|
||||
vec2 n = q.x > 0.0 && q.y > 0.0
|
||||
? normalize(q)
|
||||
: (q.x > q.y ? vec2(1.0, 0.0) : vec2(0.0, 1.0));
|
||||
return n * vec2(p.x < 0.0 ? -1.0 : 1.0, p.y < 0.0 ? -1.0 : 1.0);
|
||||
}
|
||||
|
||||
// How far inside one octant of the engine's rounded superellipse a point lies,
|
||||
// in the octant's frame (impeller/geometry/round_superellipse_param.cc): a
|
||||
// superellipse of half side a up to J, then a circular arc. Close to exact
|
||||
// only within a few dp of the curve.
|
||||
float octantInset(vec2 point, float a, vec4 curve, vec2 center) {
|
||||
vec2 fromCenter = point - center;
|
||||
vec2 toJoint = curve.yz - center;
|
||||
if (toJoint.x * fromCenter.y < toJoint.y * fromCenter.x) {
|
||||
return curve.w - length(fromCenter);
|
||||
}
|
||||
float n = curve.x;
|
||||
vec2 s = max(point, 0.0) / a;
|
||||
float major = max(max(s.x, s.y), 1e-4);
|
||||
float norm = major * pow(1.0 + pow(min(s.x, s.y) / major, n), 1.0 / n);
|
||||
vec2 slope = pow(s / norm, vec2(n - 1.0));
|
||||
return a * (1.0 - norm) / max(length(slope), 1e-4);
|
||||
}
|
||||
|
||||
// A corner's quadrant lies about the pane's center, as the engine lays it when
|
||||
// all corners are alike, and its octants part along the diagonal through where
|
||||
// their arcs meet, half the difference of the sides off center.
|
||||
float cornerInset(vec2 p, vec2 halfSize) {
|
||||
vec2 side = step(0.0, p);
|
||||
vec4 along = mix(
|
||||
mix(uCurves[0], uCurves[3], side.x),
|
||||
mix(uCurves[9], uCurves[6], side.x),
|
||||
side.y
|
||||
);
|
||||
vec4 across = mix(
|
||||
mix(uCurves[1], uCurves[4], side.x),
|
||||
mix(uCurves[10], uCurves[7], side.x),
|
||||
side.y
|
||||
);
|
||||
vec4 centers = mix(
|
||||
mix(uCurves[2], uCurves[5], side.x),
|
||||
mix(uCurves[11], uCurves[8], side.x),
|
||||
side.y
|
||||
);
|
||||
vec2 q = abs(p);
|
||||
if (along.x < 2.0) {
|
||||
return min(halfSize.x - q.x, halfSize.y - q.y);
|
||||
}
|
||||
float c = halfSize.x - halfSize.y;
|
||||
if (q.y + c >= q.x) {
|
||||
return octantInset(vec2(q.x, q.y + c), halfSize.x, along, centers.xy);
|
||||
}
|
||||
return octantInset(vec2(q.y, q.x - c), halfSize.y, across, centers.zw);
|
||||
}
|
||||
|
||||
// Adds one sample of the profile to the radius at turn u and its slope, by
|
||||
// the Catmull-Rom (Keys) kernel, which crosses the samples smoothly.
|
||||
void addTurn(float u, float index, float radius, inout vec2 sum) {
|
||||
float x = u - index;
|
||||
x -= kTurns * floor(x / kTurns + 0.5);
|
||||
float d = abs(x);
|
||||
if (d >= 2.0) {
|
||||
return;
|
||||
}
|
||||
vec2 weight = d < 1.0
|
||||
? vec2((1.5 * d - 2.5) * d * d + 1.0, (4.5 * d - 5.0) * d)
|
||||
: vec2(((-0.5 * d + 2.5) * d - 4.0) * d + 2.0, (-1.5 * d + 5.0) * d - 4.0);
|
||||
sum += radius * vec2(weight.x, weight.y * sign(x));
|
||||
}
|
||||
|
||||
// The distance inward from a star-shaped outline and its outward normal,
|
||||
// both taken at the outline where the point's direction from the center
|
||||
// meets it, so they hold steady all the way in.
|
||||
void outlineGeometry(vec2 p, out float inset, out vec2 normal) {
|
||||
float len = max(length(p), 1e-3);
|
||||
float turn = atan(p.y, p.x) / kTau;
|
||||
float u = (turn - floor(turn)) * kTurns;
|
||||
vec2 sum = vec2(0.0);
|
||||
for (int i = 0; i < 16; i++) {
|
||||
vec4 radii = uProfile[i];
|
||||
float index = float(i) * 4.0;
|
||||
addTurn(u, index, radii.x, sum);
|
||||
addTurn(u, index + 1.0, radii.y, sum);
|
||||
addTurn(u, index + 2.0, radii.z, sum);
|
||||
addTurn(u, index + 3.0, radii.w, sum);
|
||||
}
|
||||
float reach = max(sum.x, 1e-3);
|
||||
float slope = sum.y * kTurns / kTau / reach;
|
||||
vec2 radial = p / len;
|
||||
vec2 gradient = radial - slope * vec2(-radial.y, radial.x);
|
||||
float stretch = length(gradient);
|
||||
inset = max((reach - len) / stretch, 0.0);
|
||||
normal = gradient / stretch;
|
||||
}
|
||||
|
||||
// The highlight's bands over depths a to b in from the rim, on average.
|
||||
float shineAcross(float a, float b) {
|
||||
float sum = 0.0;
|
||||
float start = 0.0;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
vec4 ends = uShineEnds[i];
|
||||
vec4 starts = vec4(start, ends.xyz);
|
||||
sum += dot(
|
||||
uShineShares[i],
|
||||
max(min(vec4(b), ends) - max(vec4(a), starts), 0.0)
|
||||
);
|
||||
start = ends.w;
|
||||
}
|
||||
return sum / max(b - a, 1e-4);
|
||||
}
|
||||
|
||||
// The highlight's brightness a turn from the vertical, as the sweep the
|
||||
// fallback draws it with lays it out.
|
||||
float shineAround(float turn) {
|
||||
vec4 startTurns = uShineTurns[0];
|
||||
vec4 lastTurns = uShineTurns[1];
|
||||
vec4 startLit = uShineLit[0];
|
||||
vec4 lastLit = uShineLit[1];
|
||||
vec4 firstEnds = vec4(startTurns.yzw, lastTurns.x);
|
||||
vec3 lastEnds = lastTurns.yzw;
|
||||
vec4 firstRise = vec4(startLit.yzw, lastLit.x) - startLit;
|
||||
vec3 lastRise = lastLit.yzw - lastLit.xyz;
|
||||
return startLit.x
|
||||
+ dot(firstRise, clamp(
|
||||
(turn - startTurns) / max(firstEnds - startTurns, 1e-5), 0.0, 1.0))
|
||||
+ dot(lastRise, clamp(
|
||||
(turn - lastTurns.xyz) / max(lastEnds - lastTurns.xyz, 1e-5),
|
||||
0.0,
|
||||
1.0
|
||||
));
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 point = uFrame.xy + FlutterFragCoord().xy * uFrame.zw;
|
||||
vec2 halfSize = uShape.xy * 0.5;
|
||||
float halfMinor = min(halfSize.x, halfSize.y);
|
||||
vec2 centered = point - halfSize;
|
||||
float inset;
|
||||
vec2 normal;
|
||||
// The inset of the line and the highlight. A pane is clipped to the engine's
|
||||
// superellipse, up to half a dp inside its circular corners where a long side
|
||||
// runs into them, and they keep to it; the optics read deeper than its inset
|
||||
// holds true, so they keep the circles. Only the band the line and the
|
||||
// highlight can reach works it out.
|
||||
float edge;
|
||||
if (uOutlined > 0.5) {
|
||||
outlineGeometry(centered, inset, normal);
|
||||
edge = inset;
|
||||
} else {
|
||||
vec2 side = step(0.0, centered);
|
||||
float radius = min(
|
||||
mix(
|
||||
mix(uCorners.x, uCorners.y, side.x),
|
||||
mix(uCorners.w, uCorners.z, side.x),
|
||||
side.y
|
||||
),
|
||||
halfMinor
|
||||
);
|
||||
inset = max(-roundedBoxDistance(centered, halfSize, radius), 0.0);
|
||||
normal = roundedBoxNormal(centered, halfSize, radius);
|
||||
edge = inset;
|
||||
bool lit = uEdge.x > 0.0 || uRim.w > 0.0 || uLight.x > 0.0;
|
||||
float traced = max(
|
||||
lit ? kLine + uLight.y : 0.0,
|
||||
uShineLit[0].x > 0.0 ? uShineEnds[2].w : 0.0
|
||||
);
|
||||
if (traced > 0.0 && inset < traced + uTexels.x + kCornerGap * radius) {
|
||||
edge = max(cornerInset(centered, halfSize), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
bool lens = uOptics.x < 0.0;
|
||||
float bevel = max(min(uShape.z, halfMinor * 0.5), 1e-3);
|
||||
float rise = 1.0 - clamp(inset / bevel, 0.0, 1.0);
|
||||
vec2 shift;
|
||||
if (lens) {
|
||||
// As measured on iOS 27, the raised lens reads outward along its normal
|
||||
// all around, by nothing at the rim and its full depth a bevel in, so its
|
||||
// outer band draws out what lies right under the rim; the depth then eases
|
||||
// off toward the middle.
|
||||
shift = normal * -uOptics.x * smoothstep(0.0, bevel, inset)
|
||||
* (1.0 - smoothstep(bevel, halfMinor, inset));
|
||||
} else {
|
||||
// A bevel of circular profile, fitted to small panes: flat where it meets
|
||||
// the face and steepening toward the rim, which reads from so far inside
|
||||
// that its outermost band shows the content squeezed and mirrored.
|
||||
float depth = min(uOptics.x, halfMinor) * (1.0 - sqrt(1.0 - rise * rise));
|
||||
shift = -normal * depth;
|
||||
}
|
||||
|
||||
float frost = uShape.w + uEdge.z * rise * rise;
|
||||
vec4 base = sampleFrosted(point + shift, frost);
|
||||
if (uEdge.y > 0.0 && inset < kPartReach) {
|
||||
// iOS 27 parts red from blue along the normal's mirror image, across it
|
||||
// at the sides and along it at the corners. Along the top and bottom the
|
||||
// parting narrows from the rim to what it holds around the ends. Over
|
||||
// clear glass a channel shows past the others, so the cover is the widest
|
||||
// of them.
|
||||
vec2 part = normal.yx * 0.5 * uEdge.y
|
||||
* (0.6 + 0.4 * abs(normal.y) * max(1.0 - inset / kPartHold, 0.0))
|
||||
* (1.0 - smoothstep(kPartHold, kPartReach, inset));
|
||||
vec4 red = sampleFrosted(point + shift + part, frost);
|
||||
vec4 blue = sampleFrosted(point + shift - part, frost);
|
||||
base = vec4(red.r, base.g, blue.b, max(base.a, max(red.a, blue.a)));
|
||||
}
|
||||
if (uHaze > 0.0) {
|
||||
base = mix(base, sampleHaze(point + shift), uHaze);
|
||||
}
|
||||
float alpha = base.a;
|
||||
vec3 color = base.rgb / max(alpha, 1e-4);
|
||||
color = mix(vec3(dot(color, kLuma)), color, uOptics.y) * uOptics.z;
|
||||
color *= 1.0 - uLight.z * abs(normal.x) * exp(-inset / kEndShade);
|
||||
|
||||
float clash = 0.0;
|
||||
if (uGlow.w != 0.0) {
|
||||
vec4 behind = sampleBehind(halfSize)
|
||||
+ sampleBehind(halfSize * vec2(0.4, 0.6))
|
||||
+ sampleBehind(halfSize * vec2(1.6, 0.6))
|
||||
+ sampleBehind(halfSize * vec2(0.4, 1.4))
|
||||
+ sampleBehind(halfSize * vec2(1.6, 1.4));
|
||||
float behindLuma = dot(behind.rgb / max(behind.a, 1e-4), kLuma);
|
||||
clash = clamp(
|
||||
(sign(uGlow.w) * (dot(uTint.rgb, kLuma) - behindLuma) - 0.3) / 0.5,
|
||||
0.0,
|
||||
1.0
|
||||
);
|
||||
}
|
||||
float cover = uTint.a + (1.0 - uTint.a) * clash * abs(uGlow.w);
|
||||
float coverage = alpha * (1.0 - cover) + cover;
|
||||
vec3 glass = color * alpha * (1.0 - cover) + uTint.rgb * cover;
|
||||
|
||||
// iOS lights the top and bottom of its glass, just inside the line, and on
|
||||
// its tab bar fades that around the rounded ends with the normal's upward
|
||||
// part squared, out only at their tips; its controls dim far faster. All
|
||||
// screened toward white, a pale tint would clip into a flat white ring.
|
||||
// The tail reads the circular inset, as it reaches past where the edge's is
|
||||
// worked out, and moves too slowly for the difference to show.
|
||||
float within = max(edge - kLine, 0.0);
|
||||
float facing;
|
||||
if (lens) {
|
||||
facing = smoothstep(0.65, 0.95, abs(normal.y));
|
||||
} else {
|
||||
float rise2 = normal.y * normal.y;
|
||||
float rise4 = rise2 * rise2;
|
||||
float rise16 = rise4 * rise4 * rise4 * rise4;
|
||||
facing = mix(rise2, 0.5 * rise2 * abs(normal.y) * (1.0 + rise16), uRim.y);
|
||||
}
|
||||
float band = clamp(1.0 - within / max(uLight.y, 1e-3), 0.0, 1.0);
|
||||
float tail = clamp(1.0 - max(inset - kLine, 0.0) / kRimTail, 0.0, 1.0);
|
||||
float shine = min(uLight.x * facing * mix(band, tail, uRim.x), 1.0);
|
||||
glass += (vec3(coverage) - (1.0 - uRim.z) * glass) * shine;
|
||||
|
||||
// iOS 27 outlines its glass with a dark line, which UIKit draws just outside
|
||||
// the bounds, over the backdrop, where the pane cannot draw, so it takes the
|
||||
// outermost pixels here. iOS draws it darkest at the sides; the dock keeps
|
||||
// it as faint all around as along the top, as its darker sides read as
|
||||
// brackets.
|
||||
float line = clamp((kLine - edge) / uTexels.x + 0.5, 0.0, 1.0);
|
||||
float across = abs(normal.x);
|
||||
float darken = mix(uEdge.x, uRim.w, across * across * across);
|
||||
if ((uEdge.x > 0.0 || uRim.w > 0.0) && line > 0.0) {
|
||||
vec4 under = sampleGlass(point);
|
||||
vec3 dark = max(under.rgb - kLineCap * under.a, 0.22 * under.rgb);
|
||||
glass = mix(glass, mix(under.rgb, dark, darken), line);
|
||||
coverage = mix(coverage, under.a, line);
|
||||
}
|
||||
|
||||
vec2 toGlow = point - uGlow.xy;
|
||||
float reach = max(uGlowSpread.x * uGlowSpread.x, 1.0);
|
||||
glass += uGlow.z * coverage
|
||||
* mix(exp(-dot(toGlow, toGlow) / reach), 1.0, uGlowSpread.y);
|
||||
|
||||
// A pixel the rim crosses takes the bands it covers, as the rasterizer
|
||||
// weighs the rest by its coverage.
|
||||
if (uShineLit[0].x > 0.0 && edge < uShineEnds[2].w + uTexels.x) {
|
||||
float turn = atan(abs(centered.x), abs(centered.y));
|
||||
float pixel = 0.5 * uTexels.x;
|
||||
glass += coverage * shineAround(turn)
|
||||
* shineAcross(max(edge - pixel, 0.0), edge + pixel);
|
||||
}
|
||||
|
||||
fragColor = vec4(min(glass, vec3(coverage)), coverage);
|
||||
}
|
||||
@@ -11,6 +11,8 @@ const _allowed = {
|
||||
'lib/ui/inherited.dart',
|
||||
// A drag on the editor's completion overlay must never move a sheet.
|
||||
'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',
|
||||
};
|
||||
|
||||
int _lineOf(String source, int offset) =>
|
||||
|
||||
@@ -803,7 +803,7 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('a non-floating bottom bar leaves primary actions floating', (
|
||||
testWidgets('a non-floating bottom bar leaves the page no bottom inset', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(500, 800);
|
||||
@@ -813,10 +813,8 @@ void main() {
|
||||
|
||||
Widget page(String label) {
|
||||
return Builder(
|
||||
builder: (context) => Text(
|
||||
'page:$label inBar:${PrimaryActionInBarScope.of(context)} '
|
||||
'inset:${BottomInsetScope.of(context)}',
|
||||
),
|
||||
builder: (context) =>
|
||||
Text('page:$label inset:${BottomInsetScope.of(context)}'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -856,7 +854,7 @@ void main() {
|
||||
await tester.pump();
|
||||
expect(find.byType(FloatingNavigationBar), findsNothing);
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
expect(find.text('page:dashboard inBar:false inset:0.0'), findsOneWidget);
|
||||
expect(find.text('page:dashboard inset:0.0'), findsOneWidget);
|
||||
|
||||
await tester.tap(_glyph(AppGlyphs.tools));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -872,7 +870,8 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
expect(find.byType(FloatingNavigationBar), findsOneWidget);
|
||||
expect(find.textContaining('page:tools inBar:true'), findsOneWidget);
|
||||
expect(find.textContaining('page:tools inset:'), findsOneWidget);
|
||||
expect(find.text('page:tools inset:0.0'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
|
||||
@@ -52,10 +52,10 @@ void main() {
|
||||
ThemeProps readTheme() => container.read(themeSettingProvider);
|
||||
|
||||
group('theme mode', () {
|
||||
testWidgets('defaults to the dark theme', (tester) async {
|
||||
testWidgets('defaults to following the system', (tester) async {
|
||||
await pumpThemeView(tester);
|
||||
|
||||
expect(readTheme().themeMode, ThemeMode.dark);
|
||||
expect(readTheme().themeMode, ThemeMode.system);
|
||||
});
|
||||
|
||||
testWidgets('switches to light and back to dark', (tester) async {
|
||||
@@ -126,6 +126,28 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('liquid glass', () {
|
||||
testWidgets('is off until chosen, and switches back', (tester) async {
|
||||
await pumpThemeView(tester);
|
||||
final group = find.ancestor(
|
||||
of: find.text('Liquid Glass').first,
|
||||
matching: find.byType(PreviewChoiceGroup<bool>),
|
||||
);
|
||||
Finder choice(String label) =>
|
||||
find.descendant(of: group, matching: find.text(label)).last;
|
||||
|
||||
expect(readTheme().liquidGlass, isFalse);
|
||||
|
||||
await tester.tap(choice('Liquid Glass'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(readTheme().liquidGlass, isTrue);
|
||||
|
||||
await tester.tap(choice('Standard'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(readTheme().liquidGlass, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('pure black', () {
|
||||
testWidgets('switches both ways', (tester) async {
|
||||
await pumpThemeView(tester);
|
||||
@@ -136,7 +158,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(readTheme().pureBlack, isTrue);
|
||||
|
||||
await tester.tap(find.text('Standard'));
|
||||
await tester.tap(find.text('Standard').first);
|
||||
await tester.pumpAndSettle();
|
||||
expect(readTheme().pureBlack, isFalse);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:fl_clash/providers/app.dart';
|
||||
import 'package:fl_clash/ui/ui.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
@@ -14,6 +15,7 @@ Future<void> _pumpDialog(
|
||||
List<String> labels, {
|
||||
TextDirection textDirection = TextDirection.ltr,
|
||||
ValueChanged<String>? onPressed,
|
||||
bool destructive = false,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
@@ -24,6 +26,7 @@ Future<void> _pumpDialog(
|
||||
body: Center(
|
||||
child: CommonDialog(
|
||||
title: 'Title',
|
||||
destructive: destructive,
|
||||
actions: [
|
||||
for (final label in labels)
|
||||
TextButton(
|
||||
@@ -43,6 +46,18 @@ Future<void> _pumpDialog(
|
||||
Rect _rectOf(WidgetTester tester, String label) =>
|
||||
tester.getRect(find.widgetWithText(TextButton, label));
|
||||
|
||||
Color? _labelColor(WidgetTester tester, String label) =>
|
||||
tester.renderObject<RenderParagraph>(find.text(label)).text.style?.color;
|
||||
|
||||
Color? _buttonFill(WidgetTester tester, String label) => tester
|
||||
.widget<Material>(
|
||||
find.descendant(
|
||||
of: find.widgetWithText(TextButton, label),
|
||||
matching: find.byType(Material),
|
||||
),
|
||||
)
|
||||
.color;
|
||||
|
||||
Future<void> _pumpLauncher(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
@@ -75,11 +90,11 @@ double _dialogScale(WidgetTester tester) => tester
|
||||
.entry(0, 0);
|
||||
|
||||
double _dialogOpacity(WidgetTester tester) => tester
|
||||
.widget<FadeTransition>(
|
||||
.widget<LiquidGlassFade>(
|
||||
find
|
||||
.ancestor(
|
||||
of: find.byType(CommonDialog),
|
||||
matching: find.byType(FadeTransition),
|
||||
matching: find.byType(LiquidGlassFade),
|
||||
)
|
||||
.first,
|
||||
)
|
||||
@@ -99,6 +114,20 @@ void main() {
|
||||
expect(no.right, lessThan(yes.left));
|
||||
});
|
||||
|
||||
testWidgets('a destructive last action is red on the plain fill', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpDialog(tester, ['Cancel', 'Delete'], destructive: true);
|
||||
final colorScheme = Theme.of(
|
||||
tester.element(find.text('Delete')),
|
||||
).colorScheme;
|
||||
|
||||
expect(_labelColor(tester, 'Delete'), colorScheme.destructive);
|
||||
expect(_labelColor(tester, 'Cancel'), colorScheme.onSurface);
|
||||
expect(_buttonFill(tester, 'Delete'), _buttonFill(tester, 'Cancel'));
|
||||
expect(_buttonFill(tester, 'Delete'), isNot(colorScheme.primary));
|
||||
});
|
||||
|
||||
testWidgets('a row follows the reading direction', (tester) async {
|
||||
await _pumpDialog(tester, ['No', 'Yes'], textDirection: TextDirection.rtl);
|
||||
|
||||
@@ -163,6 +192,48 @@ void main() {
|
||||
expect(find.byType(CommonDialog), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('on glass a pane lies under the surface and fades with it', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
builder: (_, child) => LiquidGlassScope(
|
||||
enabled: true,
|
||||
child: LiquidGlassHost(enabled: true, child: child!),
|
||||
),
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => showFloatingDialog<void>(
|
||||
context: context,
|
||||
child: const CommonDialog(title: 'Title', child: Text('Body')),
|
||||
),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(_dialogOpacity(tester), inExclusiveRange(0, 1));
|
||||
expect(LiquidGlassHost.debugLastCapture!.panes, hasLength(1));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
final surface = find
|
||||
.descendant(
|
||||
of: find.byType(AlertDialog),
|
||||
matching: find.byType(Material),
|
||||
)
|
||||
.first;
|
||||
expect(
|
||||
LiquidGlassHost.debugLastCapture!.panes.single.pane,
|
||||
rectMoreOrLessEquals(tester.getRect(surface)),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a tap on the scrim dismisses a dismissible dialog', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
const _red = Color(0xFFFF0000);
|
||||
const _green = Color(0xFF00FF00);
|
||||
const _blue = Color(0xFF0000FF);
|
||||
const _clear = LiquidGlassStyle(tint: Colors.transparent, blur: 0);
|
||||
|
||||
typedef _Pane = ({
|
||||
Rect pane,
|
||||
Rect slot,
|
||||
Rect place,
|
||||
double scale,
|
||||
int level,
|
||||
ui.Image atlas,
|
||||
});
|
||||
|
||||
Widget _app(
|
||||
Widget child, {
|
||||
bool enabled = true,
|
||||
LiquidGlassQuality quality = LiquidGlassQuality.ultra,
|
||||
}) => MaterialApp(
|
||||
home: LiquidGlassScope(
|
||||
enabled: true,
|
||||
quality: quality,
|
||||
child: LiquidGlassHost(enabled: enabled, child: child),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _pane(Rect rect, {LiquidGlassStyle style = _clear, Widget? child}) =>
|
||||
Positioned.fromRect(
|
||||
rect: rect,
|
||||
child: LiquidGlass(style: style, child: child),
|
||||
);
|
||||
|
||||
LiquidGlassCapture _capture() => LiquidGlassHost.debugLastCapture!;
|
||||
|
||||
class _HeldPane extends LeafRenderObjectWidget {
|
||||
const _HeldPane(this.rect, this.hold);
|
||||
|
||||
final Rect rect;
|
||||
final Rect hold;
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) =>
|
||||
_RenderHeldPane(rect, hold);
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, _RenderHeldPane renderObject) {
|
||||
renderObject
|
||||
..rect = rect
|
||||
..hold = hold
|
||||
..markNeedsPaint();
|
||||
}
|
||||
}
|
||||
|
||||
class _RenderHeldPane extends RenderBox {
|
||||
_RenderHeldPane(this.rect, this.hold);
|
||||
|
||||
Rect rect;
|
||||
Rect hold;
|
||||
late final _pane = LiquidGlassPane(markNeedsPaint);
|
||||
|
||||
@override
|
||||
bool get sizedByParent => true;
|
||||
|
||||
@override
|
||||
bool get alwaysNeedsCompositing => true;
|
||||
|
||||
@override
|
||||
Size computeDryLayout(BoxConstraints constraints) => constraints.biggest;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pane.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
_pane.paint(
|
||||
context,
|
||||
rect.shift(offset),
|
||||
shape: LiquidGlassCorners.circular(0),
|
||||
style: _clear,
|
||||
hold: hold.shift(offset),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Color> _behind(WidgetTester tester, _Pane pane, Offset point) async {
|
||||
final texel = pane.place.topLeft + (point - pane.slot.topLeft) * pane.scale;
|
||||
final bytes = (await tester.runAsync(pane.atlas.toByteData))!;
|
||||
final offset = (texel.dy.floor() * pane.atlas.width + texel.dx.floor()) * 4;
|
||||
return Color.fromARGB(
|
||||
bytes.getUint8(offset + 3),
|
||||
bytes.getUint8(offset),
|
||||
bytes.getUint8(offset + 1),
|
||||
bytes.getUint8(offset + 2),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() => LiquidGlassHost.debugLastCapture = null);
|
||||
|
||||
testWidgets('a pane reads what lies behind it and not what it holds', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: ColoredBox(color: _red)),
|
||||
_pane(
|
||||
const Rect.fromLTWH(100, 100, 200, 100),
|
||||
child: const ColoredBox(color: _blue),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final pane = _capture().panes.single;
|
||||
expect(pane.pane, const Rect.fromLTWH(100, 100, 200, 100));
|
||||
expect(pane.level, 0);
|
||||
expect(await _behind(tester, pane, const Offset(200, 150)), _red);
|
||||
});
|
||||
|
||||
testWidgets('a frame that leaves the backdrop as it was keeps the capture', (
|
||||
tester,
|
||||
) async {
|
||||
Widget scene(Color behind) => _app(
|
||||
Stack(
|
||||
children: [
|
||||
Positioned.fill(child: ColoredBox(color: behind)),
|
||||
_pane(const Rect.fromLTWH(100, 100, 200, 100)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(scene(_red));
|
||||
final first = _capture().panes.single.atlas;
|
||||
expect(_capture().snapshots, 1);
|
||||
|
||||
tester.binding.scheduleFrame();
|
||||
await tester.pump();
|
||||
expect(_capture().snapshots, 0);
|
||||
expect(_capture().panes.single.atlas, same(first));
|
||||
|
||||
await tester.pumpWidget(scene(_green));
|
||||
expect(_capture().snapshots, 1);
|
||||
final pane = _capture().panes.single;
|
||||
expect(await _behind(tester, pane, const Offset(200, 150)), _green);
|
||||
});
|
||||
|
||||
testWidgets('a pane moving within its hold keeps one capture', (
|
||||
tester,
|
||||
) async {
|
||||
const hold = Rect.fromLTWH(50, 50, 400, 300);
|
||||
Widget scene(Rect rect) => _app(
|
||||
Stack(
|
||||
children: [
|
||||
const Positioned.fill(
|
||||
child: RepaintBoundary(child: ColoredBox(color: _red)),
|
||||
),
|
||||
Positioned.fill(child: _HeldPane(rect, hold)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(scene(const Rect.fromLTWH(100, 100, 50, 50)));
|
||||
expect(_capture().snapshots, 1);
|
||||
final first = _capture().panes.single;
|
||||
expect(first.slot.contains(hold.bottomRight - const Offset(1, 1)), isTrue);
|
||||
|
||||
await tester.pumpWidget(scene(const Rect.fromLTWH(200, 150, 200, 150)));
|
||||
expect(_capture().snapshots, 0);
|
||||
expect(_capture().panes.single.slot, first.slot);
|
||||
expect(_capture().panes.single.atlas, same(first.atlas));
|
||||
|
||||
await tester.pumpWidget(scene(const Rect.fromLTWH(500, 500, 50, 50)));
|
||||
expect(_capture().snapshots, 1);
|
||||
});
|
||||
|
||||
testWidgets('a pane over another reads a capture with that one drawn in', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: ColoredBox(color: _red)),
|
||||
_pane(
|
||||
const Rect.fromLTWH(100, 100, 200, 100),
|
||||
style: const LiquidGlassStyle(tint: _blue, blur: 0),
|
||||
),
|
||||
_pane(const Rect.fromLTWH(250, 150, 100, 100)),
|
||||
_pane(const Rect.fromLTWH(500, 400, 100, 100)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final [lower, upper, apart] = _capture().panes;
|
||||
expect([lower.level, upper.level, apart.level], [0, 1, 0]);
|
||||
expect(upper.atlas, isNot(same(lower.atlas)));
|
||||
expect(apart.atlas, same(lower.atlas));
|
||||
final over = await _behind(tester, upper, const Offset(275, 175));
|
||||
expect(over.b, greaterThan(over.r));
|
||||
expect(await _behind(tester, upper, const Offset(325, 225)), _red);
|
||||
});
|
||||
|
||||
testWidgets('a pane stays on its level beside one past its frost', (
|
||||
tester,
|
||||
) async {
|
||||
const frosted = LiquidGlassStyle(tint: Colors.transparent);
|
||||
Future<int> levelBeside(double gap) async {
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: ColoredBox(color: _red)),
|
||||
_pane(const Rect.fromLTWH(100, 100, 200, 100), style: frosted),
|
||||
_pane(Rect.fromLTWH(300 + gap, 100, 100, 100), style: frosted),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
return _capture().panes.last.level;
|
||||
}
|
||||
|
||||
expect(await levelBeside(8), 0);
|
||||
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);
|
||||
|
||||
await tester.pumpWidget(bandsScene(_red));
|
||||
expect(_capture().snapshots, 1);
|
||||
expect(_capture().keptBands, 0);
|
||||
|
||||
await tester.pumpWidget(bandsScene(_green));
|
||||
expect(_capture().snapshots, 1);
|
||||
expect(_capture().keptBands, 1);
|
||||
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);
|
||||
});
|
||||
|
||||
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 {
|
||||
const hazed = LiquidGlassStyle(
|
||||
tint: Colors.transparent,
|
||||
blur: 0,
|
||||
haze: 0.72,
|
||||
hazeBlur: 15.6,
|
||||
);
|
||||
Future<double> scaleAt(LiquidGlassQuality quality) async {
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: ColoredBox(color: _red)),
|
||||
_pane(const Rect.fromLTWH(100, 100, 200, 100), style: hazed),
|
||||
],
|
||||
),
|
||||
quality: quality,
|
||||
),
|
||||
);
|
||||
return _capture().panes.single.scale;
|
||||
}
|
||||
|
||||
expect(
|
||||
await scaleAt(LiquidGlassQuality.ultra),
|
||||
tester.view.devicePixelRatio,
|
||||
);
|
||||
expect(await scaleAt(LiquidGlassQuality.standard), lessThan(1));
|
||||
});
|
||||
|
||||
testWidgets('a pane under a layer of its own reads only that layer', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: ColoredBox(color: _red)),
|
||||
Positioned.fill(
|
||||
child: Opacity(
|
||||
opacity: 0.5,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Positioned(
|
||||
left: 100,
|
||||
top: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
child: ColoredBox(color: _green),
|
||||
),
|
||||
_pane(const Rect.fromLTWH(100, 100, 200, 100)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final pane = _capture().panes.single;
|
||||
expect(await _behind(tester, pane, const Offset(150, 150)), _green);
|
||||
expect((await _behind(tester, pane, const Offset(250, 150))).a, 0);
|
||||
});
|
||||
|
||||
testWidgets('a pane under a glass fade reads through it', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: ColoredBox(color: _red)),
|
||||
Positioned.fill(
|
||||
child: LiquidGlassFade(
|
||||
opacity: const AlwaysStoppedAnimation(0.5),
|
||||
child: Stack(
|
||||
children: [
|
||||
const Positioned(
|
||||
left: 100,
|
||||
top: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
child: ColoredBox(color: _green),
|
||||
),
|
||||
_pane(const Rect.fromLTWH(100, 100, 200, 100)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final pane = _capture().panes.single;
|
||||
final faded = await _behind(tester, pane, const Offset(150, 150));
|
||||
expect(faded.r, inExclusiveRange(0, 1));
|
||||
expect(faded.g, inExclusiveRange(0, 1));
|
||||
expect(await _behind(tester, pane, const Offset(250, 150)), _red);
|
||||
});
|
||||
|
||||
testWidgets('a frosted pane reads a capture that holds its blur', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: ColoredBox(color: _red)),
|
||||
const Positioned(
|
||||
left: 200,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: ColoredBox(color: _blue),
|
||||
),
|
||||
_pane(
|
||||
const Rect.fromLTWH(100, 100, 200, 100),
|
||||
style: const LiquidGlassStyle(tint: Colors.transparent, blur: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final pane = _capture().panes.single;
|
||||
expect(pane.scale, lessThan(1));
|
||||
final near = await _behind(tester, pane, const Offset(194, 150));
|
||||
expect(near.b, inExclusiveRange(0.15, 0.5));
|
||||
expect(await _behind(tester, pane, const Offset(120, 150)), _red);
|
||||
});
|
||||
|
||||
testWidgets('a host turned off captures nothing', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: ColoredBox(color: _red)),
|
||||
_pane(const Rect.fromLTWH(100, 100, 200, 100)),
|
||||
],
|
||||
),
|
||||
enabled: false,
|
||||
),
|
||||
);
|
||||
|
||||
expect(LiquidGlassHost.debugLastCapture, isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
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';
|
||||
|
||||
void main() {
|
||||
test('a profile reads how far an outline reaches each way', () {
|
||||
final box = liquidGlassProfile(
|
||||
Path()..addRect(const Rect.fromLTWH(0, 0, 40, 20)),
|
||||
const Offset(20, 10),
|
||||
);
|
||||
expect(box[0], moreOrLessEquals(20, epsilon: 1e-6));
|
||||
expect(box[16], moreOrLessEquals(10, epsilon: 1e-6));
|
||||
expect(box[32], moreOrLessEquals(20, epsilon: 1e-6));
|
||||
expect(box[48], moreOrLessEquals(10, epsilon: 1e-6));
|
||||
expect(box[8], moreOrLessEquals(10 * math.sqrt2, epsilon: 1e-6));
|
||||
|
||||
final round = liquidGlassProfile(
|
||||
Path()..addOval(Rect.fromCircle(center: Offset.zero, radius: 30)),
|
||||
Offset.zero,
|
||||
);
|
||||
expect(round, everyElement(moreOrLessEquals(30, epsilon: 0.05)));
|
||||
});
|
||||
|
||||
test('corner curves lie on the superellipse the engine clips to', () {
|
||||
for (final (size, radius) in [
|
||||
(const Size(338, 62), 31.0),
|
||||
(const Size(84, 54), 27.0),
|
||||
(const Size(44, 44), 22.0),
|
||||
(const Size(270, 160), 34.0),
|
||||
(const Size(60, 200), 30.0),
|
||||
]) {
|
||||
final corners = BorderRadius.circular(radius);
|
||||
final path = Path()
|
||||
..addRSuperellipse(corners.toRSuperellipse(Offset.zero & size));
|
||||
final outline = [
|
||||
for (final metric in path.computeMetrics())
|
||||
for (var at = 0.0; at < metric.length; at += 0.02)
|
||||
metric.getTangentForOffset(at)!.position,
|
||||
];
|
||||
double offPath(Offset point) =>
|
||||
outline.map((on) => (on - point).distance).reduce(math.min);
|
||||
|
||||
final curves = liquidGlassCornerCurves(size, corners);
|
||||
final half = size.center(Offset.zero);
|
||||
final shift = half.dx - half.dy;
|
||||
final octants = [
|
||||
(
|
||||
a: half.dx,
|
||||
curve: curves.sublist(0, 4),
|
||||
center: Offset(curves[8], curves[9]),
|
||||
place: (Offset w) => half - Offset(w.dx, w.dy - shift),
|
||||
),
|
||||
(
|
||||
a: half.dy,
|
||||
curve: curves.sublist(4, 8),
|
||||
center: Offset(curves[10], curves[11]),
|
||||
place: (Offset w) => half - Offset(w.dy + shift, w.dx),
|
||||
),
|
||||
];
|
||||
for (final (:a, :curve, :center, :place) in octants) {
|
||||
final [n, jx, jy, arc] = curve;
|
||||
for (var x = 0.0; x <= jx; x += jx / 16) {
|
||||
final y = math.pow(1 - math.pow(x / a, n), 1 / n) * a;
|
||||
expect(offPath(place(Offset(x, y))), lessThan(0.06));
|
||||
}
|
||||
final from = (Offset(jx, jy) - center).direction;
|
||||
for (var step = 0; step <= 8; step++) {
|
||||
final turn = from - (from - math.pi / 4) * step / 8;
|
||||
final point = center + Offset.fromDirection(turn, arc);
|
||||
expect(offPath(place(point)), lessThan(0.06));
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(
|
||||
liquidGlassCornerCurves(const Size(40, 20), BorderRadius.zero),
|
||||
everyElement(0),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a pane shaped by a clipper keeps to its path', (tester) async {
|
||||
final key = GlobalKey();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Center(
|
||||
child: RepaintBoundary(
|
||||
key: key,
|
||||
child: const SizedBox.square(
|
||||
dimension: 40,
|
||||
child: LiquidGlass(
|
||||
style: LiquidGlassStyle(tint: Colors.blue),
|
||||
clipper: _Diamond(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final boundary =
|
||||
key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final image = boundary.toImageSync();
|
||||
addTearDown(image.dispose);
|
||||
|
||||
await tester.runAsync(() async {
|
||||
final bytes = (await image.toByteData())!;
|
||||
int alphaAt(int x, int y) =>
|
||||
bytes.getUint8((y * image.width + x) * 4 + 3);
|
||||
expect(alphaAt(5, 5), 0);
|
||||
expect(alphaAt(34, 34), 0);
|
||||
expect(alphaAt(20, 20), greaterThan(0));
|
||||
expect(alphaAt(20, 3), greaterThan(0));
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('a snapshot taken without panes holds only what stands on them', (
|
||||
tester,
|
||||
) async {
|
||||
final key = GlobalKey();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Center(
|
||||
child: RepaintBoundary(
|
||||
key: key,
|
||||
child: const SizedBox.square(
|
||||
dimension: 40,
|
||||
child: LiquidGlass(style: LiquidGlassStyle(tint: Colors.blue)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final boundary =
|
||||
key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final bare = LiquidGlass.withoutPanes(boundary.toImageSync);
|
||||
final glass = boundary.toImageSync();
|
||||
addTearDown(bare.dispose);
|
||||
addTearDown(glass.dispose);
|
||||
|
||||
Future<int> centerAlpha(ui.Image image) async {
|
||||
final bytes = (await image.toByteData())!;
|
||||
return bytes.getUint8(
|
||||
((image.height ~/ 2) * image.width + image.width ~/ 2) * 4 + 3,
|
||||
);
|
||||
}
|
||||
|
||||
await tester.runAsync(() async {
|
||||
expect(await centerAlpha(bare), 0);
|
||||
expect(await centerAlpha(glass), greaterThan(0));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _Diamond extends CustomClipper<Path> {
|
||||
const _Diamond();
|
||||
|
||||
@override
|
||||
Path getClip(Size size) => Path()
|
||||
..moveTo(size.width / 2, 0)
|
||||
..lineTo(size.width, size.height / 2)
|
||||
..lineTo(size.width / 2, size.height)
|
||||
..lineTo(0, size.height / 2)
|
||||
..close();
|
||||
|
||||
@override
|
||||
bool shouldReclip(_Diamond oldClipper) => false;
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:fl_clash/common/app_ports.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/pages/home.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/views/dashboard/widgets/start_button.dart';
|
||||
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';
|
||||
@@ -27,12 +27,11 @@ final _lens = find.descendant(
|
||||
);
|
||||
const _addLabel = 'Add';
|
||||
|
||||
IconButtonData _addAction({bool isLoading = false}) {
|
||||
IconButtonData _addAction() {
|
||||
return IconButtonData(
|
||||
glyph: AppGlyphs.add,
|
||||
onPressed: () {},
|
||||
tooltip: _addLabel,
|
||||
isLoading: isLoading,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,19 +123,13 @@ void main() {
|
||||
addTearDown(() => navigationPort = null);
|
||||
});
|
||||
|
||||
testWidgets('the start button holds the end of the dock on the dashboard', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('the dock stays centred alone on every page, the dashboard '
|
||||
'included', (tester) async {
|
||||
final container = await _pumpHome(tester);
|
||||
|
||||
final bar = tester.getRect(find.byType(FloatingNavigationBar));
|
||||
final button = tester.getRect(find.byType(FloatingActionButton));
|
||||
expect(find.byType(StartButton), findsOneWidget);
|
||||
expect(button.width, button.height);
|
||||
expect(button.height, bar.height);
|
||||
expect(button.center.dy, bar.center.dy);
|
||||
expect(button.left - bar.right, 8);
|
||||
expect((bar.left + button.right) / 2, _mobileSize.width / 2);
|
||||
expect(find.byType(FloatingActionButton), findsNothing);
|
||||
expect(bar.center.dx, _mobileSize.width / 2);
|
||||
|
||||
await tester.drag(
|
||||
find.byType(FloatingNavigationBar),
|
||||
@@ -145,7 +138,6 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(container.read(currentPageLabelProvider), PageLabel.tools);
|
||||
expect(find.byType(StartButton), findsNothing);
|
||||
final alone = tester.getRect(find.byType(FloatingNavigationBar));
|
||||
expect(alone.center.dx, _mobileSize.width / 2);
|
||||
expect(alone.width, lessThan(_mobileSize.width - 40));
|
||||
@@ -194,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));
|
||||
@@ -231,43 +252,79 @@ void main() {
|
||||
expect(tester.getRect(_lens), resting);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'the docked start button swells and follows a drag like the lens',
|
||||
(tester) async {
|
||||
await _pumpHome(tester);
|
||||
final press = find.ancestor(
|
||||
of: find.byType(FloatingActionButton),
|
||||
matching: find.byType(ElasticPress),
|
||||
);
|
||||
final resting = tester.getRect(press);
|
||||
Rect painted() => _painted(
|
||||
tester,
|
||||
tester.renderObject<RenderProxyBox>(press).child!,
|
||||
resting,
|
||||
);
|
||||
testWidgets('a quick tap lifts the lens all the way before it drops', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpHome(tester);
|
||||
final resting = tester.getRect(_lens);
|
||||
final hold = await tester.startGesture(resting.center);
|
||||
await tester.pumpAndSettle();
|
||||
final held = tester.getRect(_lens).height - resting.height;
|
||||
await hold.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final gesture = await tester.startGesture(resting.center);
|
||||
await tester.pumpAndSettle();
|
||||
final pressed = painted();
|
||||
expect(tester.getRect(press), resting);
|
||||
expect(pressed.center, resting.center);
|
||||
expect(pressed.width, greaterThan(resting.width + 8));
|
||||
expect(
|
||||
pressed.width / pressed.height,
|
||||
moreOrLessEquals(resting.width / resting.height),
|
||||
);
|
||||
final tap = await tester.startGesture(resting.center);
|
||||
await tester.pump(const Duration(milliseconds: 40));
|
||||
await tap.up();
|
||||
var lifted = 0.0;
|
||||
for (var i = 0; i < 30; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
lifted = math.max(lifted, tester.getRect(_lens).height - resting.height);
|
||||
}
|
||||
expect(lifted, greaterThan(held * 0.97));
|
||||
|
||||
await gesture.moveBy(const Offset(-60, 0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getRect(_lens), resting);
|
||||
});
|
||||
|
||||
for (final (quality, lifted) in [
|
||||
(LiquidGlassQuality.standard, 2),
|
||||
(LiquidGlassQuality.ultra, 3),
|
||||
]) {
|
||||
testWidgets('on ${quality.name} glass the dock and a lifted lens are panes '
|
||||
'of glass', (tester) async {
|
||||
tester.view.physicalSize = _mobileSize;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
homeBuilder: (child) => Scaffold(
|
||||
body: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: LiquidGlassScope(
|
||||
enabled: true,
|
||||
quality: quality,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: NavigationDock(
|
||||
destinations: const [
|
||||
NavigationDockDestination(glyph: AppGlyphs.dashboard, label: 'a'),
|
||||
NavigationDockDestination(glyph: AppGlyphs.tools, label: 'b'),
|
||||
],
|
||||
selectedIndex: 0,
|
||||
onSelected: (_) {},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final pulled = painted();
|
||||
expect(pulled.center.dx, lessThan(resting.center.dx - 4));
|
||||
expect(pulled.width / pulled.height, greaterThan(1));
|
||||
final glass = find.byType(LiquidGlass);
|
||||
expect(glass, findsOneWidget);
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.text('b')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(glass, findsNWidgets(lifted));
|
||||
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(painted(), resting);
|
||||
},
|
||||
);
|
||||
expect(glass, findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('the bar swells under a pointer and stretches past its end', (
|
||||
tester,
|
||||
@@ -341,8 +398,8 @@ void main() {
|
||||
expect(drawn, moreOrLessEquals(highlight / 2, epsilon: 0.5));
|
||||
expect(highlight, lessThan(30));
|
||||
expect(
|
||||
tester.getCenter(positioned.first).dx,
|
||||
moreOrLessEquals(tester.getCenter(positioned.last).dx),
|
||||
tester.getRect(positioned.first),
|
||||
rectMoreOrLessEquals(tester.getRect(positioned.last)),
|
||||
);
|
||||
|
||||
await mouse.moveTo(resting.center - const Offset(80, 0));
|
||||
@@ -397,93 +454,31 @@ void main() {
|
||||
expect(positioned, findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a phone page shows its primary action in its bar', (
|
||||
tester,
|
||||
) async {
|
||||
final container = await _pumpHome(tester);
|
||||
container
|
||||
.read(currentPageLabelProvider.notifier)
|
||||
.toPage(PageLabel.profiles);
|
||||
await tester.pumpAndSettle();
|
||||
for (final floating in [true, false]) {
|
||||
testWidgets('a phone page shows its primary action in its bar above a '
|
||||
'${floating ? 'floating' : 'fixed'} navigation bar', (tester) async {
|
||||
final container = await _pumpHome(tester);
|
||||
container
|
||||
.read(appSettingProvider.notifier)
|
||||
.update((state) => state.copyWith(floatingNavigationBar: floating));
|
||||
container
|
||||
.read(currentPageLabelProvider.notifier)
|
||||
.toPage(PageLabel.profiles);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final action = find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is AppBarActionButton && widget.data.tooltip == _addLabel,
|
||||
);
|
||||
expect(action, findsOneWidget);
|
||||
expect(
|
||||
find.ancestor(of: action, matching: find.byType(AppBar)),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(CommonScaffold),
|
||||
matching: find.byType(FloatingActionButton),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'the docked start button stays an icon-only circle while it runs',
|
||||
(tester) async {
|
||||
await _pumpHome(
|
||||
tester,
|
||||
settle: false,
|
||||
overrides: [
|
||||
runTimeProvider.overrideWithBuild(
|
||||
(_, _) => const Duration(
|
||||
hours: 2,
|
||||
minutes: 13,
|
||||
seconds: 8,
|
||||
).inMilliseconds,
|
||||
),
|
||||
],
|
||||
final action = find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is AppBarActionButton && widget.data.tooltip == _addLabel,
|
||||
);
|
||||
|
||||
final bar = tester.getRect(find.byType(FloatingNavigationBar));
|
||||
final button = tester.getRect(find.byType(FloatingActionButton));
|
||||
expect(button.width, button.height);
|
||||
expect(button.height, bar.height);
|
||||
expect(find.text('02:13:08'), findsNothing);
|
||||
expect(find.byType(RunTimeText), findsNothing);
|
||||
expect(action, findsOneWidget);
|
||||
expect(
|
||||
find.ancestor(of: action, matching: find.byType(AppBar)),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byType(FloatingActionButton), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('a page off the dock shows its primary action as a FAB', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
child: _page('page', primaryAction: _addAction()),
|
||||
),
|
||||
);
|
||||
|
||||
final fab = find.byType(FloatingActionButton);
|
||||
expect(fab, findsOneWidget);
|
||||
expect(find.text(_addLabel), findsOneWidget);
|
||||
expect(find.byType(AppBarActionButton), findsNothing);
|
||||
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
child: _page('page', primaryAction: _addAction(isLoading: true)),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester
|
||||
.widget<AnimatedScale>(
|
||||
find.ancestor(of: fab, matching: find.byType(AnimatedScale)),
|
||||
)
|
||||
.scale,
|
||||
0,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('four destinations keep to their width and five fill the row', (
|
||||
tester,
|
||||
@@ -528,7 +523,7 @@ void main() {
|
||||
testWidgets('a long label shrinks to fit, then ellipsizes behind a tooltip', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(360, 800);
|
||||
tester.view.physicalSize = const Size(340, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
@@ -580,6 +575,39 @@ void main() {
|
||||
expect(tester.widget<Tooltip>(tooltips).message, 'Configuration');
|
||||
});
|
||||
|
||||
testWidgets('a quick tap swells an elastic press all the way', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
const TestApp(
|
||||
child: Center(
|
||||
child: ElasticPress(
|
||||
child: ColoredBox(
|
||||
color: Colors.blue,
|
||||
child: SizedBox.square(dimension: 40),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final press = find.byType(ElasticPress);
|
||||
final resting = tester.getRect(press);
|
||||
Rect painted() => _painted(tester, tester.renderObject(press), resting);
|
||||
|
||||
final gesture = await tester.startGesture(resting.center);
|
||||
await tester.pump(const Duration(milliseconds: 40));
|
||||
await gesture.up();
|
||||
var swollen = resting.width;
|
||||
for (var i = 0; i < 30; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
swollen = math.max(swollen, painted().width);
|
||||
}
|
||||
expect(swollen, greaterThan(resting.width * 1.24));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(painted(), resting);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'an elastic button leaves a press to the swell and keeps the icon theme',
|
||||
(tester) async {
|
||||
|
||||
+1120
-56
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,112 @@
|
||||
import 'package:fl_clash/providers/app.dart';
|
||||
import 'package:fl_clash/ui/ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../helpers/test_app.dart';
|
||||
|
||||
ThemeData _theme(Brightness brightness) {
|
||||
final colorScheme = ColorScheme.fromSeed(
|
||||
seedColor: Colors.blue,
|
||||
brightness: brightness,
|
||||
);
|
||||
return ThemeData(
|
||||
colorScheme: colorScheme,
|
||||
extensions: [
|
||||
if (brightness == Brightness.dark)
|
||||
ElevatedColorScheme(colorScheme.elevated),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Color _floatingFill(ColorScheme colorScheme) =>
|
||||
SurfaceLevel.floatingOf(colorScheme).colorOf(colorScheme);
|
||||
|
||||
Future<ValueNotifier<ThemeMode>> _pumpPage(
|
||||
WidgetTester tester, {
|
||||
required WidgetBuilder body,
|
||||
}) async {
|
||||
const size = Size(400, 800);
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final mode = ValueNotifier(ThemeMode.light);
|
||||
addTearDown(mode.dispose);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [viewSizeProvider.overrideWithBuild((_, _) => size)],
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: mode,
|
||||
builder: (_, themeMode, _) => TestApp(
|
||||
theme: _theme(Brightness.light),
|
||||
darkTheme: _theme(Brightness.dark),
|
||||
themeMode: themeMode,
|
||||
child: CommonScaffold(
|
||||
title: 'Page',
|
||||
body: Builder(builder: body),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return mode;
|
||||
}
|
||||
|
||||
void main() {
|
||||
late PopupOpen open;
|
||||
final popupBox = CommonPopupBox(
|
||||
targetBuilder: (value) {
|
||||
open = value;
|
||||
return const SizedBox(width: 40, height: 40);
|
||||
},
|
||||
popupBuilder: (_) =>
|
||||
const CommonPopupMenu(items: [CommonPopupMenuItem(label: 'item')]),
|
||||
);
|
||||
|
||||
Color fillOf(WidgetTester tester) =>
|
||||
tester.widget<PopupMenuCard>(find.byType(PopupMenuCard)).fill;
|
||||
|
||||
final light = _theme(Brightness.light);
|
||||
final dark = _theme(Brightness.dark);
|
||||
|
||||
testWidgets('an open popup takes a new app theme', (tester) async {
|
||||
final mode = await _pumpPage(tester, body: (_) => Center(child: popupBox));
|
||||
open();
|
||||
await tester.pumpAndSettle();
|
||||
expect(fillOf(tester), _floatingFill(light.colorScheme));
|
||||
|
||||
mode.value = ThemeMode.dark;
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(fillOf(tester), _floatingFill(dark.colorScheme));
|
||||
});
|
||||
|
||||
testWidgets('a popup opened in a sheet keeps its lift in a new app theme', (
|
||||
tester,
|
||||
) async {
|
||||
final mode = await _pumpPage(
|
||||
tester,
|
||||
body: (context) => TextButton(
|
||||
onPressed: () => showSheet<void>(
|
||||
context: context,
|
||||
builder: (_) => Center(child: popupBox),
|
||||
),
|
||||
child: const Text('open'),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
open();
|
||||
await tester.pumpAndSettle();
|
||||
expect(fillOf(tester), _floatingFill(light.colorScheme));
|
||||
|
||||
mode.value = ThemeMode.dark;
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final elevated = dark.extension<ElevatedColorScheme>()!.colorScheme;
|
||||
expect(_floatingFill(elevated), isNot(_floatingFill(dark.colorScheme)));
|
||||
expect(fillOf(tester), _floatingFill(elevated));
|
||||
});
|
||||
}
|
||||
@@ -278,32 +278,58 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('only a page above the fixed navigation bar floats its primary '
|
||||
'action', (tester) async {
|
||||
Future<void> pump({required bool inBar}) => tester.pumpWidget(
|
||||
testWidgets('a page keeps its primary action in its bar', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
child: PrimaryActionInBarScope(
|
||||
inBar: inBar,
|
||||
child: CommonScaffold(
|
||||
title: 'Page',
|
||||
primaryAction: IconButtonData(
|
||||
glyph: AppGlyphs.add,
|
||||
onPressed: () {},
|
||||
tooltip: 'Add',
|
||||
),
|
||||
body: const SizedBox.expand(),
|
||||
child: CommonScaffold(
|
||||
title: 'Page',
|
||||
primaryAction: IconButtonData(
|
||||
glyph: AppGlyphs.add,
|
||||
onPressed: () {},
|
||||
tooltip: 'Add',
|
||||
),
|
||||
body: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await pump(inBar: true);
|
||||
expect(_barButtons(tester), ['Add']);
|
||||
expect(find.byType(FloatingActionButton), findsNothing);
|
||||
});
|
||||
|
||||
await pump(inBar: false);
|
||||
testWidgets('a title menu opens from the title start and runs its items', (
|
||||
tester,
|
||||
) async {
|
||||
final taps = <String>[];
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
child: CommonScaffold(
|
||||
title: 'Page',
|
||||
titleMenuItems: [
|
||||
CommonPopupMenuItem(
|
||||
label: 'Rename',
|
||||
onPressed: () => taps.add('Rename'),
|
||||
),
|
||||
],
|
||||
body: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(_inBar(find.byGlyph(AppGlyphs.chevronDown)), findsOneWidget);
|
||||
expect(_barButtons(tester), isEmpty);
|
||||
expect(find.byType(FloatingActionButton), findsOneWidget);
|
||||
final title = tester.getRect(_inBar(find.text('Page')));
|
||||
|
||||
await tester.tap(_inBar(find.text('Page')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.getRect(find.byType(PopupMenuCard)).left,
|
||||
moreOrLessEquals(title.left),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Rename'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(taps, ['Rename']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -136,4 +138,77 @@ void main() {
|
||||
expect(find.text('page action'), findsNothing);
|
||||
expect(find.byTooltip('search action'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('system back exits search on a pushed page before its own '
|
||||
'back handler', (tester) async {
|
||||
var pageBackCount = 0;
|
||||
await tester.pumpWidget(
|
||||
const TestApp(wrapInProviderScope: true, child: SizedBox.shrink()),
|
||||
);
|
||||
unawaited(
|
||||
tester
|
||||
.state<NavigatorState>(find.byType(Navigator))
|
||||
.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => CommonPopScope(
|
||||
onPop: (_) {
|
||||
pageBackCount++;
|
||||
return false;
|
||||
},
|
||||
child: _page(onSearch: (_) {}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byGlyph(AppGlyphs.search));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
expect(pageBackCount, 0);
|
||||
expect(find.text('Page'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a fresh search callback each build keeps search open', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
const TestApp(wrapInProviderScope: true, child: _RebuildingSearchPage()),
|
||||
);
|
||||
|
||||
await tester.tap(find.byGlyph(AppGlyphs.search));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField), 'a');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('query=a'), findsOneWidget);
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
class _RebuildingSearchPage extends StatefulWidget {
|
||||
const _RebuildingSearchPage();
|
||||
|
||||
@override
|
||||
State<_RebuildingSearchPage> createState() => _RebuildingSearchPageState();
|
||||
}
|
||||
|
||||
class _RebuildingSearchPageState extends State<_RebuildingSearchPage> {
|
||||
String _query = '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CommonScaffold(
|
||||
title: 'Page',
|
||||
searchState: AppBarSearchState(
|
||||
onSearch: (value) => setState(() => _query = value),
|
||||
),
|
||||
body: Text('query=$_query'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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