b34697660d
- 新增 AuthSessionInvalidated 事件处理 token 失效场景 - ApiInterceptor 新增 authFailureCallback 单飞机制 - AuthBloc 区分 manual logout 与 auto expiry 语义 - 新增 startup recovery fallback 防止启动卡死 feat: 重构 Calendar DayWeek 视图事件布局引擎 - 新增 DayEventLayoutEngine 解耦事件计算与渲染 - 新增 DayTimelineMetrics 统一时间轴常量 - 新增 DayViewScale 支持捏合缩放 feat: 新增 Settings 页面共享 UI 组件 - 新增 BackTitlePageHeader 统一页面 header - 新增 DetailHeaderActionMenu 统一操作菜单 - 新增 DestructiveActionSheet 统一删除确认 - 新增 AppToggleSwitch 统一开关组件 feat: Chat UI Schema 支持导航操作 - 支持 navigation 类型 action 触发内部路由跳转 - 新增路径验证与参数处理 chore: 更新相关测试覆盖 auth 失效路径
220 lines
5.7 KiB
Dart
220 lines
5.7 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../../core/theme/design_tokens.dart';
|
|
|
|
class DetailHeaderActionItem<T> {
|
|
const DetailHeaderActionItem({
|
|
required this.value,
|
|
required this.label,
|
|
required this.icon,
|
|
this.isDestructive = false,
|
|
});
|
|
|
|
final T value;
|
|
final String label;
|
|
final IconData icon;
|
|
final bool isDestructive;
|
|
}
|
|
|
|
class DetailHeaderActionMenu<T> extends StatefulWidget {
|
|
const DetailHeaderActionMenu({
|
|
super.key,
|
|
required this.items,
|
|
required this.onSelected,
|
|
});
|
|
|
|
final List<DetailHeaderActionItem<T>> items;
|
|
final ValueChanged<T> onSelected;
|
|
|
|
@override
|
|
State<DetailHeaderActionMenu<T>> createState() =>
|
|
_DetailHeaderActionMenuState<T>();
|
|
}
|
|
|
|
class _DetailHeaderActionMenuState<T> extends State<DetailHeaderActionMenu<T>> {
|
|
static const double _buttonSize = AppSpacing.xl * 2;
|
|
static const double _menuWidth = AppSpacing.xxl * 8;
|
|
|
|
final LayerLink _layerLink = LayerLink();
|
|
OverlayEntry? _menuEntry;
|
|
|
|
bool get _isMenuOpen => _menuEntry != null;
|
|
|
|
@override
|
|
void dispose() {
|
|
_hideMenu();
|
|
super.dispose();
|
|
}
|
|
|
|
void _toggleMenu() {
|
|
if (_isMenuOpen) {
|
|
_hideMenu();
|
|
return;
|
|
}
|
|
_showMenu();
|
|
}
|
|
|
|
void _showMenu() {
|
|
final overlay = Overlay.of(context);
|
|
_menuEntry = OverlayEntry(
|
|
builder: (context) {
|
|
return Stack(
|
|
children: [
|
|
Positioned.fill(
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: _hideMenu,
|
|
child: const SizedBox.expand(),
|
|
),
|
|
),
|
|
CompositedTransformFollower(
|
|
link: _layerLink,
|
|
showWhenUnlinked: false,
|
|
offset: const Offset(
|
|
_buttonSize - _menuWidth,
|
|
_buttonSize + AppSpacing.sm,
|
|
),
|
|
child: _buildMenuCard(),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
|
|
overlay.insert(_menuEntry!);
|
|
setState(() {});
|
|
}
|
|
|
|
void _hideMenu() {
|
|
_menuEntry?.remove();
|
|
_menuEntry = null;
|
|
if (mounted) {
|
|
setState(() {});
|
|
}
|
|
}
|
|
|
|
void _handleSelect(T value) {
|
|
_hideMenu();
|
|
widget.onSelected(value);
|
|
}
|
|
|
|
Widget _buildMenuCard() {
|
|
return Material(
|
|
color: Colors.transparent,
|
|
child: Container(
|
|
width: _menuWidth,
|
|
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.white,
|
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
|
border: Border.all(color: AppColors.borderSecondary),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: AppColors.slate300.withValues(alpha: 0.42),
|
|
blurRadius: AppSpacing.xl,
|
|
offset: const Offset(0, AppSpacing.sm),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
for (int i = 0; i < widget.items.length; i++) ...[
|
|
_buildMenuItem(widget.items[i]),
|
|
if (i < widget.items.length - 1)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
|
child: Divider(
|
|
height: 1,
|
|
thickness: 1,
|
|
color: AppColors.slate100,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMenuItem(DetailHeaderActionItem<T> item) {
|
|
final textColor = item.isDestructive
|
|
? AppColors.red500
|
|
: AppColors.slate700;
|
|
final pressedColor = item.isDestructive
|
|
? AppColors.feedbackErrorSurface
|
|
: AppColors.surfaceInfoLight;
|
|
|
|
return SizedBox(
|
|
height: AppSpacing.xxl * 2,
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
|
splashColor: pressedColor,
|
|
highlightColor: pressedColor,
|
|
onTap: () => _handleSelect(item.value),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
item.icon,
|
|
size: AppSpacing.lg + AppSpacing.xs,
|
|
color: textColor,
|
|
),
|
|
const SizedBox(width: AppSpacing.md),
|
|
Text(
|
|
item.label,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: textColor,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (widget.items.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
return CompositedTransformTarget(
|
|
link: _layerLink,
|
|
child: GestureDetector(
|
|
onTap: _toggleMenu,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 140),
|
|
width: _buttonSize,
|
|
height: _buttonSize,
|
|
decoration: BoxDecoration(
|
|
color: _isMenuOpen
|
|
? AppColors.surfaceInfo
|
|
: AppColors.surfaceTertiary,
|
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
|
border: Border.all(
|
|
color: _isMenuOpen
|
|
? AppColors.borderQuaternary
|
|
: AppColors.borderTertiary,
|
|
),
|
|
),
|
|
child: const Icon(
|
|
Icons.more_horiz,
|
|
size: AppSpacing.lg + AppSpacing.xs,
|
|
color: AppColors.slate600,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|