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,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/design_tokens.dart';
|
||||
|
||||
class AppToggleSwitch extends StatelessWidget {
|
||||
const AppToggleSwitch({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.activeBackgroundColor,
|
||||
this.inactiveBackgroundColor,
|
||||
this.activeBorderColor,
|
||||
this.inactiveBorderColor,
|
||||
});
|
||||
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
final Color? activeBackgroundColor;
|
||||
final Color? inactiveBackgroundColor;
|
||||
final Color? activeBorderColor;
|
||||
final Color? inactiveBorderColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => onChanged(!value),
|
||||
child: Container(
|
||||
width: AppSpacing.xxl + AppSpacing.xl,
|
||||
height: AppSpacing.xl + AppSpacing.xs,
|
||||
padding: const EdgeInsets.all(AppSpacing.xs / 2),
|
||||
decoration: BoxDecoration(
|
||||
color: value
|
||||
? (activeBackgroundColor ?? AppColors.blue100)
|
||||
: (inactiveBackgroundColor ?? AppColors.surfaceTertiary),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
border: Border.all(
|
||||
color: value
|
||||
? (activeBorderColor ?? AppColors.blue300)
|
||||
: (inactiveBorderColor ?? AppColors.borderSecondary),
|
||||
),
|
||||
),
|
||||
child: AnimatedAlign(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
alignment: value ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
width: AppSpacing.lg + AppSpacing.xs,
|
||||
height: AppSpacing.lg + AppSpacing.xs,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
border: Border.all(color: AppColors.borderSecondary),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/design_tokens.dart';
|
||||
import 'page_header.dart' as widgets;
|
||||
|
||||
class BackTitlePageHeader extends StatelessWidget {
|
||||
const BackTitlePageHeader({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.onBack,
|
||||
this.showBackButton = true,
|
||||
this.trailing,
|
||||
this.height = 64,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final VoidCallback? onBack;
|
||||
final bool showBackButton;
|
||||
final Widget? trailing;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
widgets.PageHeader(
|
||||
leading: showBackButton
|
||||
? widgets.BackButton(onPressed: onBack)
|
||||
: const SizedBox(
|
||||
width: AppSpacing.xl * 2,
|
||||
height: AppSpacing.xl * 2,
|
||||
),
|
||||
trailing:
|
||||
trailing ??
|
||||
const SizedBox(
|
||||
width: AppSpacing.xl * 2,
|
||||
height: AppSpacing.xl * 2,
|
||||
),
|
||||
height: height,
|
||||
),
|
||||
IgnorePointer(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xxl * 2,
|
||||
),
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/design_tokens.dart';
|
||||
import 'app_button.dart';
|
||||
|
||||
Future<bool> showDestructiveActionSheet(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String message,
|
||||
required String confirmText,
|
||||
}) async {
|
||||
final result = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (sheetContext) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.md,
|
||||
AppSpacing.none,
|
||||
AppSpacing.md,
|
||||
AppSpacing.md,
|
||||
),
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
border: Border.all(color: AppColors.borderSecondary),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 14, color: AppColors.slate500),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
SizedBox(
|
||||
height: 52,
|
||||
child: GestureDetector(
|
||||
onTap: () => Navigator.of(sheetContext).pop(true),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.feedbackErrorIcon,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: Text(
|
||||
confirmText,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
SizedBox(
|
||||
height: 52,
|
||||
child: AppButton(
|
||||
text: '取消',
|
||||
isOutlined: true,
|
||||
onPressed: () => Navigator.of(sheetContext).pop(false),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
return result == true;
|
||||
}
|
||||
@@ -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