644 lines
23 KiB
Dart
644 lines
23 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:forui/forui.dart';
|
|
|
|
import 'package:pj_supermarket_cloud/common/index.dart';
|
|
|
|
/// 全局业务弹框的默认遮罩透明度,用于隔离背景但保留终端上下文。
|
|
const double appDialogDefaultScrimOpacity = 0.45;
|
|
|
|
/// 全局业务弹框的默认进场时长,兼顾终端反馈速度与空间连续性。
|
|
const Duration appDialogDefaultTransitionDuration = Duration(milliseconds: 180);
|
|
|
|
/// 禁止关闭反馈的最短触发间隔,避免连续点击导致设备持续震动。
|
|
const Duration appDialogBlockedFeedbackThrottle = Duration(milliseconds: 700);
|
|
|
|
/// 默认弹框与系统顶部安全区之间的间隔,避免表单弹框紧贴状态栏。
|
|
const double appDialogDefaultTopSpacing = 24;
|
|
|
|
/// 弹框与左右、底部可用区域边缘的最小间隔,用于触控和拖动边界保护。
|
|
const double appDialogDefaultEdgeSpacing = 8;
|
|
|
|
/// 展示使用统一遮罩、返回拦截、关闭反馈和动效的业务弹框。
|
|
///
|
|
/// [builder] 应返回实际弹框表面,推荐使用 [AppDialog]。路由始终自行管理
|
|
/// ModalBarrier,因此禁止关闭时也能给出视觉和触觉反馈。默认位置位于顶部
|
|
/// 安全区下方,输入法出现后会自动缩入键盘上方的可用区域。
|
|
Future<T?> showAppDialog<T>({
|
|
required BuildContext context,
|
|
required WidgetBuilder builder,
|
|
bool barrierDismissible = true,
|
|
bool canPop = true,
|
|
bool blockedDismissFeedback = true,
|
|
bool useRootNavigator = true,
|
|
String barrierLabel = '关闭弹框',
|
|
Color? barrierColor,
|
|
Alignment alignment = Alignment.topCenter,
|
|
Duration transitionDuration = appDialogDefaultTransitionDuration,
|
|
RouteSettings? routeSettings,
|
|
}) {
|
|
return showGeneralDialog<T>(
|
|
context: context,
|
|
useRootNavigator: useRootNavigator,
|
|
barrierDismissible: false,
|
|
barrierLabel: barrierLabel,
|
|
barrierColor: Colors.transparent,
|
|
transitionDuration: transitionDuration,
|
|
routeSettings: routeSettings,
|
|
pageBuilder: (dialogContext, _, __) => _AppDialogRouteHost<T>(
|
|
barrierDismissible: barrierDismissible,
|
|
canPop: canPop,
|
|
blockedDismissFeedback: blockedDismissFeedback,
|
|
barrierLabel: barrierLabel,
|
|
barrierColor: barrierColor ??
|
|
Colors.black.withValues(alpha: appDialogDefaultScrimOpacity),
|
|
alignment: alignment,
|
|
child: builder(dialogContext),
|
|
),
|
|
transitionBuilder: (dialogContext, animation, _, child) {
|
|
if (MediaQuery.disableAnimationsOf(dialogContext)) return child;
|
|
final curved = CurvedAnimation(
|
|
parent: animation,
|
|
curve: Curves.easeOutCubic,
|
|
reverseCurve: Curves.easeInCubic,
|
|
);
|
|
return FadeTransition(
|
|
opacity: curved,
|
|
child: ScaleTransition(
|
|
scale: Tween<double>(begin: 0.985, end: 1).animate(curved),
|
|
child: child,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// 全局弹框的 ForUI 风格表面,统一标题栏、内容区、操作区和拖动行为。
|
|
class AppDialog extends StatefulWidget {
|
|
/// 创建一个可配置尺寸、关闭入口和拖动能力的业务弹框表面。
|
|
const AppDialog({
|
|
super.key,
|
|
required this.title,
|
|
required this.content,
|
|
this.subtitle,
|
|
this.leading,
|
|
this.actions = const [],
|
|
this.width,
|
|
this.height,
|
|
this.minWidth = 280,
|
|
this.maxWidth = 720,
|
|
this.contentPadding,
|
|
this.draggable = false,
|
|
this.showCloseButton = true,
|
|
this.closeButtonKey,
|
|
this.onClose,
|
|
this.headerTrailing,
|
|
this.semanticLabel,
|
|
this.clipBehavior = Clip.antiAlias,
|
|
});
|
|
|
|
/// 标题栏主标题,承担弹框的首要语义。
|
|
final Widget title;
|
|
|
|
/// 标题下方的辅助说明;为空时标题栏采用单行居中布局。
|
|
final Widget? subtitle;
|
|
|
|
/// 标题左侧图标或状态标识;为空时不预留位置。
|
|
final Widget? leading;
|
|
|
|
/// 弹框的核心业务内容,由调用方管理其业务状态和生命周期。
|
|
final Widget content;
|
|
|
|
/// 底部操作按钮;为空时不渲染操作区和顶部分隔线。
|
|
final List<Widget> actions;
|
|
|
|
/// 弹框期望宽度,单位为逻辑像素;为空时由内容和最大宽度约束决定。
|
|
final double? width;
|
|
|
|
/// 弹框固定高度,单位为逻辑像素;为空时随内容高度收缩。
|
|
final double? height;
|
|
|
|
/// 内容较少时允许的最小宽度,单位为逻辑像素。
|
|
final double minWidth;
|
|
|
|
/// 当前视口允许的最大宽度,单位为逻辑像素。
|
|
final double maxWidth;
|
|
|
|
/// 内容区内边距;为空时使用终端 8dp 节奏的默认间距。
|
|
final EdgeInsetsGeometry? contentPadding;
|
|
|
|
/// 是否允许从标题栏拖动;内容区手势不会触发拖动。
|
|
final bool draggable;
|
|
|
|
/// 是否展示标题栏关闭按钮;业务进行中仍可由 [onClose] 决定具体行为。
|
|
final bool showCloseButton;
|
|
|
|
/// 标题栏关闭按钮的可选稳定键,供大型业务弹框和 Widget 测试定位。
|
|
final Key? closeButtonKey;
|
|
|
|
/// 点击标题栏关闭按钮时的业务回调;为空时关闭按钮自动不可用。
|
|
final VoidCallback? onClose;
|
|
|
|
/// 标题栏右侧、关闭按钮之前的附加状态或操作。
|
|
final Widget? headerTrailing;
|
|
|
|
/// 无障碍服务播报的弹框名称;为空时由标题文本自身提供语义。
|
|
final String? semanticLabel;
|
|
|
|
/// 弹框内容超出圆角表面时的裁剪策略。
|
|
final Clip clipBehavior;
|
|
|
|
/// 创建弹框的拖动、反馈高亮和表面布局状态。
|
|
@override
|
|
State<AppDialog> createState() => _AppDialogState();
|
|
}
|
|
|
|
/// [AppDialog] 的拖动位置和禁止关闭反馈状态。
|
|
class _AppDialogState extends State<AppDialog> {
|
|
/// 实际弹框表面的布局键,用于计算可拖动边界。
|
|
final GlobalKey _surfaceKey = GlobalKey();
|
|
|
|
/// 相对初始对齐位置的拖动偏移。
|
|
Offset _dragOffset = Offset.zero;
|
|
|
|
/// 上一次构建使用的键盘遮挡高度,用于在输入法变化时清理失效拖动位置。
|
|
double _lastKeyboardInset = 0;
|
|
|
|
/// 当前拖动开始时指针的全局位置;未拖动时为空。
|
|
Offset? _dragStartGlobalPosition;
|
|
|
|
/// 当前拖动开始时已经累计的表面偏移,用于按一次手势的累计位移计算目标位置。
|
|
Offset? _dragStartOffset;
|
|
|
|
/// 当前拖动开始时表面的全局矩形,边界计算期间保持不变以避免边缘来回修正。
|
|
Rect? _dragStartSurfaceRect;
|
|
|
|
/// 减少动态效果时显示的短暂边框高亮状态。
|
|
bool _blockedHighlight = false;
|
|
|
|
/// 当前路由宿主提供的禁止关闭反馈信号。
|
|
ValueNotifier<int>? _blockedFeedbackSignal;
|
|
|
|
/// 清理短暂高亮的延迟任务;释放弹框时必须取消。
|
|
Timer? _highlightTimer;
|
|
|
|
/// 绑定当前路由的禁止关闭反馈信号,支持弹框跨主题重建。
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
final keyboardInset = MediaQuery.viewInsetsOf(context).bottom;
|
|
if (keyboardInset != _lastKeyboardInset) {
|
|
_lastKeyboardInset = keyboardInset;
|
|
// 键盘会改变可用区域,旧拖动偏移不再具有合法的屏幕坐标语义。
|
|
_dragOffset = Offset.zero;
|
|
_handleDragEnd();
|
|
}
|
|
final signal = _AppDialogBlockedFeedbackScope.maybeOf(context);
|
|
if (identical(signal, _blockedFeedbackSignal)) return;
|
|
_blockedFeedbackSignal?.removeListener(_handleBlockedFeedback);
|
|
_blockedFeedbackSignal = signal;
|
|
_blockedFeedbackSignal?.addListener(_handleBlockedFeedback);
|
|
}
|
|
|
|
/// 取消信号监听和高亮任务,避免已关闭弹框继续刷新状态。
|
|
@override
|
|
void dispose() {
|
|
_highlightTimer?.cancel();
|
|
_blockedFeedbackSignal?.removeListener(_handleBlockedFeedback);
|
|
super.dispose();
|
|
}
|
|
|
|
/// 在减少动态效果模式下用边框高亮替代位置抖动。
|
|
void _handleBlockedFeedback() {
|
|
if (!mounted || !MediaQuery.disableAnimationsOf(context)) return;
|
|
_highlightTimer?.cancel();
|
|
setState(() => _blockedHighlight = true);
|
|
_highlightTimer = Timer(const Duration(milliseconds: 240), () {
|
|
if (mounted) setState(() => _blockedHighlight = false);
|
|
});
|
|
}
|
|
|
|
/// 记录本次标题栏拖动的稳定基准,避免使用已变换矩形逐帧累加产生边缘抖动。
|
|
void _handleDragStart(DragStartDetails details) {
|
|
if (!widget.draggable) return;
|
|
final renderBox =
|
|
_surfaceKey.currentContext?.findRenderObject() as RenderBox?;
|
|
if (renderBox == null || !renderBox.hasSize) return;
|
|
_dragStartGlobalPosition = details.globalPosition;
|
|
_dragStartOffset = _dragOffset;
|
|
_dragStartSurfaceRect =
|
|
renderBox.localToGlobal(Offset.zero) & renderBox.size;
|
|
}
|
|
|
|
/// 根据当前手势累计位移更新位置,并限制整个弹框始终位于系统安全区域内。
|
|
void _handleDragUpdate(DragUpdateDetails details) {
|
|
if (!widget.draggable) return;
|
|
final dragStartGlobalPosition = _dragStartGlobalPosition;
|
|
final dragStartOffset = _dragStartOffset;
|
|
final dragStartSurfaceRect = _dragStartSurfaceRect;
|
|
if (dragStartGlobalPosition == null ||
|
|
dragStartOffset == null ||
|
|
dragStartSurfaceRect == null) {
|
|
return;
|
|
}
|
|
final viewport = MediaQuery.sizeOf(context);
|
|
// SafeArea 会把后代的 padding 清零,viewPadding 才能稳定保留系统栏原始占用。
|
|
final padding = MediaQuery.viewPaddingOf(context);
|
|
final keyboardInset = MediaQuery.viewInsetsOf(context).bottom;
|
|
final bottomObstruction =
|
|
keyboardInset > padding.bottom ? keyboardInset : padding.bottom;
|
|
final safeRect = Rect.fromLTRB(
|
|
padding.left + appDialogDefaultEdgeSpacing,
|
|
padding.top + appDialogDefaultEdgeSpacing,
|
|
viewport.width - padding.right - appDialogDefaultEdgeSpacing,
|
|
viewport.height - bottomObstruction - appDialogDefaultEdgeSpacing,
|
|
);
|
|
final requestedDelta = details.globalPosition - dragStartGlobalPosition;
|
|
final clampedDelta = Offset(
|
|
_clampDragAxis(
|
|
requestedDelta.dx,
|
|
safeRect.left - dragStartSurfaceRect.left,
|
|
safeRect.right - dragStartSurfaceRect.right,
|
|
),
|
|
_clampDragAxis(
|
|
requestedDelta.dy,
|
|
safeRect.top - dragStartSurfaceRect.top,
|
|
safeRect.bottom - dragStartSurfaceRect.bottom,
|
|
),
|
|
);
|
|
final nextOffset = dragStartOffset + clampedDelta;
|
|
if (nextOffset == _dragOffset) return;
|
|
setState(() => _dragOffset = nextOffset);
|
|
}
|
|
|
|
/// 在拖动结束或取消时释放手势基准,下一次拖动会从当前表面位置重新计算。
|
|
void _handleDragEnd() {
|
|
_dragStartGlobalPosition = null;
|
|
_dragStartOffset = null;
|
|
_dragStartSurfaceRect = null;
|
|
}
|
|
|
|
/// 将单轴位移限制到合法区间;极小窗口放不下弹框时使用区间中点保持位置稳定。
|
|
double _clampDragAxis(double value, double minimum, double maximum) {
|
|
if (minimum > maximum) return (minimum + maximum) / 2;
|
|
return value.clamp(minimum, maximum).toDouble();
|
|
}
|
|
|
|
/// 构建使用现有 ForUI 主题指标的标题、内容和操作区。
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = FTheme.of(context);
|
|
final requestedWidth = widget.width ?? widget.maxWidth;
|
|
final viewport = MediaQuery.sizeOf(context);
|
|
final safePadding = MediaQuery.viewPaddingOf(context);
|
|
final safeMaxWidth = (viewport.width - safePadding.horizontal - 16)
|
|
.clamp(0, double.infinity);
|
|
final widthUpperBound = safeMaxWidth < widget.maxWidth
|
|
? safeMaxWidth.toDouble()
|
|
: widget.maxWidth;
|
|
final widthLowerBound = widget.minWidth.clamp(0, widthUpperBound);
|
|
final actualWidth = requestedWidth.clamp(
|
|
widthLowerBound,
|
|
widthUpperBound,
|
|
);
|
|
final safeMaxHeight =
|
|
(viewport.height - safePadding.vertical - 16).clamp(0, double.infinity);
|
|
final actualHeight = widget.height?.clamp(0, safeMaxHeight).toDouble();
|
|
final surface = Container(
|
|
key: _surfaceKey,
|
|
width: actualWidth.toDouble(),
|
|
height: actualHeight,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius),
|
|
boxShadow: XfjDesignTokens.surfaceShadow,
|
|
),
|
|
child: Material(
|
|
color: XfjDesignTokens.surface,
|
|
shape: RoundedRectangleBorder(
|
|
side: BorderSide(
|
|
color: _blockedHighlight
|
|
? XfjDesignTokens.warning
|
|
: XfjDesignTokens.line,
|
|
width: _blockedHighlight ? 2 : 1,
|
|
),
|
|
borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius),
|
|
),
|
|
clipBehavior: widget.clipBehavior,
|
|
child: <Widget>[
|
|
_buildHeader(theme),
|
|
const Divider(height: 1, color: XfjDesignTokens.line),
|
|
widget.height == null
|
|
? Padding(
|
|
padding: widget.contentPadding ?? EdgeInsets.all(20.w),
|
|
child: widget.content,
|
|
)
|
|
: Expanded(
|
|
child: Padding(
|
|
padding: widget.contentPadding ?? EdgeInsets.all(20.w),
|
|
child: widget.content,
|
|
),
|
|
),
|
|
if (widget.actions.isNotEmpty) ...[
|
|
const Divider(height: 1, color: XfjDesignTokens.line),
|
|
_buildActions(),
|
|
],
|
|
].toColumn(mainAxisSize: MainAxisSize.min),
|
|
),
|
|
);
|
|
return Semantics(
|
|
scopesRoute: true,
|
|
namesRoute: true,
|
|
explicitChildNodes: true,
|
|
label: widget.semanticLabel,
|
|
child: Transform.translate(offset: _dragOffset, child: surface),
|
|
);
|
|
}
|
|
|
|
/// 构建仅标题栏可拖动的区域,并保留 48dp 关闭触控目标。
|
|
Widget _buildHeader(FThemeData theme) {
|
|
final titleBlock = <Widget>[
|
|
DefaultTextStyle(
|
|
style: theme.typography.lg.copyWith(
|
|
color: XfjDesignTokens.ink,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
child: widget.title,
|
|
),
|
|
if (widget.subtitle != null)
|
|
DefaultTextStyle(
|
|
style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted),
|
|
child: widget.subtitle!,
|
|
),
|
|
].toColumn(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
);
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onPanStart: widget.draggable ? _handleDragStart : null,
|
|
onPanUpdate: widget.draggable ? _handleDragUpdate : null,
|
|
onPanEnd: widget.draggable ? (_) => _handleDragEnd() : null,
|
|
onPanCancel: widget.draggable ? _handleDragEnd : null,
|
|
child: <Widget>[
|
|
if (widget.leading != null) widget.leading!,
|
|
if (widget.leading != null) SizedBox(width: 12.w),
|
|
titleBlock.expanded(),
|
|
if (widget.draggable) ...[
|
|
FIcon(
|
|
FAssets.icons.gripHorizontal,
|
|
size: 20.w,
|
|
color: XfjDesignTokens.muted,
|
|
),
|
|
SizedBox(width: 8.w),
|
|
],
|
|
if (widget.headerTrailing != null) ...[
|
|
widget.headerTrailing!,
|
|
SizedBox(width: 8.w),
|
|
],
|
|
if (widget.showCloseButton)
|
|
Semantics(
|
|
button: true,
|
|
enabled: widget.onClose != null,
|
|
label: '关闭弹框',
|
|
child: InkWell(
|
|
key: widget.closeButtonKey,
|
|
onTap: widget.onClose,
|
|
borderRadius: BorderRadius.circular(
|
|
XfjDesignTokens.controlRadius,
|
|
),
|
|
child: FIcon(
|
|
FAssets.icons.x,
|
|
size: 22.w,
|
|
color: widget.onClose == null
|
|
? XfjDesignTokens.line
|
|
: XfjDesignTokens.muted,
|
|
).center().width(48.w).height(48.h),
|
|
),
|
|
),
|
|
].toRow(crossAxisAlignment: CrossAxisAlignment.center).padding(
|
|
horizontal: 20.w,
|
|
vertical: 10.h,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 构建统一右对齐的底部操作区,按钮本身由业务调用方决定层级。
|
|
Widget _buildActions() {
|
|
return Wrap(
|
|
alignment: WrapAlignment.end,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
spacing: 12.w,
|
|
runSpacing: 8.h,
|
|
// ForUI 按钮内部使用默认占满主轴的 Row,直接放入 Wrap 会让每个按钮
|
|
// 各自占满一行;先按固有宽度测量,才能恢复桌面端横向操作区语义。
|
|
children: widget.actions
|
|
.map((action) => IntrinsicWidth(child: action))
|
|
.toList(growable: false),
|
|
).padding(horizontal: 20.w, vertical: 14.h);
|
|
}
|
|
}
|
|
|
|
/// 全局弹框路由宿主,负责区分遮罩点击、系统返回和实际内容点击。
|
|
class _AppDialogRouteHost<T> extends StatefulWidget {
|
|
/// 创建一层只在业务允许时关闭的弹框路由。
|
|
const _AppDialogRouteHost({
|
|
required this.barrierDismissible,
|
|
required this.canPop,
|
|
required this.blockedDismissFeedback,
|
|
required this.barrierLabel,
|
|
required this.barrierColor,
|
|
required this.alignment,
|
|
required this.child,
|
|
});
|
|
|
|
/// 点击遮罩是否允许关闭当前弹框。
|
|
final bool barrierDismissible;
|
|
|
|
/// 系统返回是否允许关闭当前弹框。
|
|
final bool canPop;
|
|
|
|
/// 阻止关闭时是否启用视觉和触觉反馈。
|
|
final bool blockedDismissFeedback;
|
|
|
|
/// 遮罩关闭区域的无障碍名称。
|
|
final String barrierLabel;
|
|
|
|
/// 弹框背景遮罩颜色。
|
|
final Color barrierColor;
|
|
|
|
/// 弹框表面在安全区域内的初始对齐位置。
|
|
final Alignment alignment;
|
|
|
|
/// 调用方构建的实际弹框表面。
|
|
final Widget child;
|
|
|
|
/// 创建路由的抖动动画、反馈节流和关闭控制状态。
|
|
@override
|
|
State<_AppDialogRouteHost<T>> createState() => _AppDialogRouteHostState<T>();
|
|
}
|
|
|
|
/// [_AppDialogRouteHost] 的关闭反馈状态。
|
|
class _AppDialogRouteHostState<T> extends State<_AppDialogRouteHost<T>>
|
|
with SingleTickerProviderStateMixin {
|
|
/// 非减少动态效果模式下使用的短促抖动控制器。
|
|
late final AnimationController _shakeController;
|
|
|
|
/// 将抖动进度转换为水平方向位移的动画。
|
|
late final Animation<double> _shakeOffset;
|
|
|
|
/// 通知 [AppDialog] 在减少动态效果时显示边框高亮。
|
|
final ValueNotifier<int> _blockedFeedbackSignal = ValueNotifier<int>(0);
|
|
|
|
/// 上一次阻止关闭反馈时间,用于限制震动频率。
|
|
DateTime? _lastBlockedFeedbackAt;
|
|
|
|
/// 初始化 180ms 抖动序列,位移仅作用于合成层,不触发布局重排。
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_shakeController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 180),
|
|
);
|
|
_shakeOffset = TweenSequence<double>([
|
|
TweenSequenceItem(tween: Tween(begin: 0, end: -8), weight: 20),
|
|
TweenSequenceItem(tween: Tween(begin: -8, end: 8), weight: 30),
|
|
TweenSequenceItem(tween: Tween(begin: 8, end: -5), weight: 25),
|
|
TweenSequenceItem(tween: Tween(begin: -5, end: 0), weight: 25),
|
|
]).animate(CurvedAnimation(
|
|
parent: _shakeController,
|
|
curve: Curves.easeOutCubic,
|
|
));
|
|
}
|
|
|
|
/// 释放动画控制器和反馈信号,避免弹框退出后继续持有监听器。
|
|
@override
|
|
void dispose() {
|
|
_shakeController.dispose();
|
|
_blockedFeedbackSignal.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// 点击遮罩时按配置关闭,或向用户说明当前操作不可中断。
|
|
void _handleBarrierTap() {
|
|
if (widget.barrierDismissible) {
|
|
Navigator.of(context).pop<T>();
|
|
return;
|
|
}
|
|
_triggerBlockedFeedback();
|
|
}
|
|
|
|
/// 处理被 [PopScope] 阻止的系统返回操作。
|
|
void _handlePopInvoked(bool didPop, T? result) {
|
|
if (!didPop && !widget.canPop) _triggerBlockedFeedback();
|
|
}
|
|
|
|
/// 节流触发短抖动、减少动态高亮和设备触觉反馈。
|
|
void _triggerBlockedFeedback() {
|
|
if (!widget.blockedDismissFeedback) return;
|
|
final now = DateTime.now();
|
|
if (_lastBlockedFeedbackAt != null &&
|
|
now.difference(_lastBlockedFeedbackAt!) <
|
|
appDialogBlockedFeedbackThrottle) {
|
|
return;
|
|
}
|
|
_lastBlockedFeedbackAt = now;
|
|
_blockedFeedbackSignal.value++;
|
|
if (!MediaQuery.disableAnimationsOf(context)) {
|
|
_shakeController.forward(from: 0);
|
|
}
|
|
unawaited(AppRuntimeTool.vibrate(durationMs: 60));
|
|
}
|
|
|
|
/// 构建可命中遮罩、但不抢占弹框内部手势的安全区域布局。
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final safePadding = MediaQuery.viewPaddingOf(context);
|
|
final keyboardInset = MediaQuery.viewInsetsOf(context).bottom;
|
|
final bottomObstruction =
|
|
keyboardInset > safePadding.bottom ? keyboardInset : safePadding.bottom;
|
|
final topSpacing = widget.alignment.y <= Alignment.topCenter.y
|
|
? appDialogDefaultTopSpacing
|
|
: appDialogDefaultEdgeSpacing;
|
|
final dialogInsets = EdgeInsets.fromLTRB(
|
|
safePadding.left + appDialogDefaultEdgeSpacing,
|
|
safePadding.top + topSpacing,
|
|
safePadding.right + appDialogDefaultEdgeSpacing,
|
|
bottomObstruction + appDialogDefaultEdgeSpacing,
|
|
);
|
|
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
|
return PopScope<T>(
|
|
canPop: widget.canPop,
|
|
onPopInvokedWithResult: _handlePopInvoked,
|
|
child: ColoredBox(
|
|
color: widget.barrierColor,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
Semantics(
|
|
button: true,
|
|
enabled: widget.barrierDismissible,
|
|
label: widget.barrierLabel,
|
|
onTap: _handleBarrierTap,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: _handleBarrierTap,
|
|
child: const SizedBox.expand(),
|
|
),
|
|
),
|
|
AnimatedPadding(
|
|
// 不使用 SafeArea 改写后代 MediaQuery;键盘出现时直接缩小弹框路由的
|
|
// 可用区域,使输入框和底部操作区始终位于键盘上方。
|
|
padding: dialogInsets,
|
|
duration: reduceMotion
|
|
? Duration.zero
|
|
: appDialogDefaultTransitionDuration,
|
|
curve: Curves.easeOutCubic,
|
|
child: Align(
|
|
alignment: widget.alignment,
|
|
child: AnimatedBuilder(
|
|
animation: _shakeOffset,
|
|
builder: (_, child) => Transform.translate(
|
|
offset: Offset(_shakeOffset.value, 0),
|
|
child: child,
|
|
),
|
|
child: _AppDialogBlockedFeedbackScope(
|
|
signal: _blockedFeedbackSignal,
|
|
child: widget.child,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 向弹框表面传递禁止关闭反馈信号的内部作用域。
|
|
class _AppDialogBlockedFeedbackScope extends InheritedWidget {
|
|
/// 创建绑定到当前弹框路由的反馈作用域。
|
|
const _AppDialogBlockedFeedbackScope({
|
|
required this.signal,
|
|
required super.child,
|
|
});
|
|
|
|
/// 每次有效阻止关闭时递增的通知信号。
|
|
final ValueNotifier<int> signal;
|
|
|
|
/// 获取离当前弹框表面最近的反馈信号;非全局路由环境下返回空。
|
|
static ValueNotifier<int>? maybeOf(BuildContext context) {
|
|
return context
|
|
.dependOnInheritedWidgetOfExactType<_AppDialogBlockedFeedbackScope>()
|
|
?.signal;
|
|
}
|
|
|
|
/// 信号实例在路由生命周期内保持不变,不需要重建依赖子树。
|
|
@override
|
|
bool updateShouldNotify(_AppDialogBlockedFeedbackScope oldWidget) =>
|
|
!identical(signal, oldWidget.signal);
|
|
}
|