Files
social-app/apps/lib/shared/widgets/detail_header_action_menu.dart
T
qzl b34697660d feat: 实现 Auth 全局状态机与 401 统一处理机制
- 新增 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 失效路径
2026-03-18 13:35:25 +08:00

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