Files
biz_xfj_cloud/lib/common/widgets/app/app_dialog.dart
T
2026-08-09 14:45:18 +08:00

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);
}