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 失效路径
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user