feat: 二级页面色彩刷新 + 用药/通知/设备重构 + 后端健康档案/通知管线增强 + 大量测试

## 后端
- 健康档案: 新增手术状态字段 + EF 迁移; HealthArchiveService 新增查询方法
- 健康记录: HealthRecordService 新增批量/统计方法; 契约扩展
- 用药: 新增 MedicationScheduleStatus 枚举; MedicationService 排班逻辑调整
- 通知: EfUserNotificationPipeline 重构; 新增 EfReminderCatchUpService; 通知管线支持更多场景
- 用户: UserService 账号删除逻辑; 新增 local_account_file_cleanup; EfUserRepository 扩展
- AI: medication_agent_handler 微调; prompt_manager 优化; AiConversationService 上下文处理
- Endpoint: doctor/medication/exercise/health/notification/user 等多接口调整
- BackgroundService: health_record_reminder_service 重构, 提醒补漏逻辑
- 测试: 新增 account_deletion/doctor_endpoint/medication_schedule/medication_update/prompt_manager 测试

## 前端
- UI 系统: app_theme 大幅重构; app_colors/app_design_tokens/app_module_visuals 调整; 二级页面色彩刷新
- 主页: home_page 背景渐变 + 消息列表提取 _HomeMessages + 通知检查逻辑; chat_messages_view 全面重构
- 用药: medication_list/edit/checkin 三页重构, 新增 medication_ui_logic 抽取
- 通知: notification_prefs_page 重构, 新增 notification_prefs_logic; notification_center 优化
- 设备: device_management 重构, 新增 device_sync_ui_logic; device_scan 优化
- 趋势图: trend_page 大幅重构
- 登录: login_page 重构
- 个人资料: 新增 profile_edit_page; profile_page 优化
- 运动: 新增 exercise/ 目录 + care_plan_ui_logic
- 其他: remaining_pages/report_pages/health_drawer/admin/doctor 等多页面调整
- 组件: common_widgets/app_empty_state/app_error_state/app_future_view/app_toast/ai_content 优化
- Provider: chat_provider/consultation_provider/data_providers/auth_provider 调整
- AndroidManifest: 移除多余权限
- 测试: 新增 ai_content/care_plan/home_message/login_flow/medication_checkin/medication_ui/notification_prefs/profile_device/secondary_page/swipe_delete 等大量测试

## 文档
- 新增 ui-design-system.md 设计系统文档
- 新增 secondary-page-color-refresh 计划 + specs 目录
This commit is contained in:
MingNian
2026-07-15 23:22:52 +08:00
parent e654c1e0cc
commit fade61ac21
138 changed files with 12636 additions and 5013 deletions

View File

@@ -62,7 +62,9 @@ class AiMarkdownView extends StatelessWidget {
fontWeight: FontWeight.w900,
),
listBulletPadding: const EdgeInsets.only(right: 8),
blockquote: AppTextStyles.chatBody.copyWith(color: AppColors.textSecondary),
blockquote: AppTextStyles.chatBody.copyWith(
color: AppColors.textSecondary,
),
),
);
}
@@ -74,14 +76,6 @@ class AiGeneratedNote extends StatelessWidget {
@override
Widget build(BuildContext context) => const Padding(
padding: EdgeInsets.only(top: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.auto_awesome_rounded, size: 15, color: AppColors.textHint),
SizedBox(width: 5),
Text('内容由 AI 生成', style: AppTextStyles.aiNote),
],
),
child: Text('内容由 AI 生成', style: AppTextStyles.aiNote),
);
}

View File

@@ -1,12 +1,14 @@
import 'package:flutter/material.dart';
import '../core/app_colors.dart';
import 'app_gradient_widgets.dart';
class AppEmptyState extends StatelessWidget {
final IconData icon;
final String title;
final String? subtitle;
final Widget? action;
final Color iconColor;
final Color? iconBackground;
final EdgeInsetsGeometry padding;
const AppEmptyState({
super.key,
@@ -14,32 +16,34 @@ class AppEmptyState extends StatelessWidget {
required this.title,
this.subtitle,
this.action,
this.iconColor = AppColors.primaryDark,
this.iconBackground,
this.padding = const EdgeInsets.all(48),
});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(48),
padding: padding,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 80,
height: 80,
key: const ValueKey('empty-state-icon-surface'),
width: 72,
height: 72,
decoration: BoxDecoration(
gradient: AppColors.softGlassGradient,
borderRadius: BorderRadius.circular(40),
border: Border.all(color: AppColors.borderLight),
boxShadow: AppColors.cardShadowLight,
color: iconBackground ?? iconColor.withValues(alpha: 0.09),
shape: BoxShape.circle,
),
child: AppGradientIcon(icon: icon, size: 34),
child: Icon(icon, size: 32, color: iconColor),
),
const SizedBox(height: 18),
const SizedBox(height: 16),
Text(
title,
style: const TextStyle(
fontSize: 18,
fontSize: 17,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import '../core/app_colors.dart';
import 'app_gradient_widgets.dart';
import 'common_widgets.dart';
/// 统一的"加载失败"状态——与 AppEmptyState 风格一致,区别在于明确表达"出错了,可重试"
/// 避免把网络/服务失败误显示成"暂无数据"。
@@ -28,14 +28,13 @@ class AppErrorState extends StatelessWidget {
width: 80,
height: 80,
decoration: BoxDecoration(
gradient: AppColors.softGlassGradient,
borderRadius: BorderRadius.circular(40),
border: Border.all(color: AppColors.borderLight),
boxShadow: AppColors.cardShadowLight,
color: AppColors.errorLight,
shape: BoxShape.circle,
),
child: const AppGradientIcon(
icon: Icons.cloud_off_outlined,
size: 34,
child: const Icon(
Icons.cloud_off_outlined,
size: 32,
color: AppColors.errorText,
),
),
const SizedBox(height: 18),
@@ -63,7 +62,10 @@ class AppErrorState extends StatelessWidget {
const SizedBox(height: 20),
SizedBox(
width: 116,
child: AppGradientOutlineButton(label: '重试', onTap: onRetry!),
child: AppGradientOutlineButton(
label: '重试',
onPressed: onRetry!,
),
),
],
],

View File

@@ -29,10 +29,7 @@ class AppFutureView<T> extends StatelessWidget {
builder: (ctx, snap) {
// 失败优先于其它判断,避免把错误显示成"暂无数据"
if (snap.hasError) {
return AppErrorState(
title: errorTitle ?? '加载失败',
onRetry: onRetry,
);
return AppErrorState(title: errorTitle ?? '加载失败', onRetry: onRetry);
}
// 首次加载、尚无数据 → 转圈
if (snap.connectionState == ConnectionState.waiting && !snap.hasData) {

View File

@@ -34,11 +34,16 @@ class AppToast {
minHeight: 42,
maxWidth: MediaQuery.sizeOf(ctx).width * 0.82,
),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.lgBorder,
border: Border.all(color: visual.color.withValues(alpha: 0.20)),
border: Border.all(
color: visual.color.withValues(alpha: 0.20),
),
boxShadow: AppShadows.floating,
),
child: Row(
@@ -76,11 +81,19 @@ class AppToast {
static ({IconData icon, Color color}) _visual(AppToastType type) {
return switch (type) {
AppToastType.success => (icon: LucideIcons.circleCheck, color: AppColors.success),
AppToastType.error => (icon: LucideIcons.circleX, color: AppColors.error),
AppToastType.warning => (icon: LucideIcons.triangleAlert, color: AppColors.warning),
AppToastType.success => (
icon: LucideIcons.circleCheck,
color: AppColors.successText,
),
AppToastType.error => (
icon: LucideIcons.circleX,
color: AppColors.errorText,
),
AppToastType.warning => (
icon: LucideIcons.triangleAlert,
color: AppColors.warningText,
),
AppToastType.info => (icon: LucideIcons.info, color: AppColors.health),
};
}
}

View File

@@ -1,12 +1,118 @@
import 'package:flutter/material.dart';
import '../core/app_colors.dart';
import '../core/app_design_tokens.dart';
class AppGradientOutlineButton extends StatelessWidget {
final String label;
final VoidCallback? onPressed;
final bool loading;
const AppGradientOutlineButton({
super.key,
required this.label,
required this.onPressed,
this.loading = false,
});
@override
Widget build(BuildContext context) {
final enabled = onPressed != null && !loading;
return DecoratedBox(
decoration: BoxDecoration(
gradient: enabled ? AppColors.actionOutlineGradient : null,
color: enabled ? null : AppColors.borderLight,
borderRadius: AppRadius.mdBorder,
),
child: Padding(
padding: const EdgeInsets.all(1.4),
child: Material(
color: Colors.white,
borderRadius: BorderRadius.circular(AppRadius.md - 1.4),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: enabled ? onPressed : null,
child: SizedBox(
height: 50,
child: Center(
child: loading
? const SizedBox.square(
dimension: 19,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(
label,
style: AppTextStyles.button.copyWith(
color: enabled
? AppColors.primaryDark
: AppColors.textHint,
),
),
),
),
),
),
),
);
}
}
class AppCreateFab extends StatelessWidget {
final VoidCallback onPressed;
final String tooltip;
final IconData icon;
const AppCreateFab({
super.key,
required this.onPressed,
required this.tooltip,
this.icon = Icons.add_rounded,
});
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: DecoratedBox(
key: const ValueKey('app-create-fab-border'),
decoration: BoxDecoration(
gradient: AppColors.actionOutlineGradient,
borderRadius: AppRadius.pillBorder,
boxShadow: [
BoxShadow(
color: const Color(0xFF101828).withValues(alpha: 0.08),
blurRadius: 12,
offset: const Offset(0, 5),
),
],
),
child: Padding(
padding: const EdgeInsets.all(1.5),
child: Material(
color: Colors.white,
borderRadius: AppRadius.pillBorder,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onPressed,
child: SizedBox.square(
dimension: 53,
child: Icon(icon, color: AppColors.primaryDark, size: 27),
),
),
),
),
),
);
}
}
class SwipeDeleteTile extends StatefulWidget {
final Widget child;
final VoidCallback onDelete;
final VoidCallback? onTap;
final EdgeInsetsGeometry margin;
final BorderRadiusGeometry borderRadius;
final bool enabled;
const SwipeDeleteTile({
super.key,
@@ -14,6 +120,8 @@ class SwipeDeleteTile extends StatefulWidget {
required this.onDelete,
this.onTap,
this.margin = const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
this.borderRadius = const BorderRadius.all(Radius.circular(16)),
this.enabled = true,
});
@override
@@ -27,59 +135,89 @@ class _SwipeDeleteTileState extends State<SwipeDeleteTile>
static const _threshold = 24.0;
void _onDragUpdate(DragUpdateDetails d) {
if (!widget.enabled) return;
setState(() => _dx = (_dx + d.delta.dx).clamp(-_maxSlide, 0.0));
}
void _onDragEnd(DragEndDetails d) {
if (!widget.enabled) return;
setState(() => _dx = _dx < -_threshold ? -_maxSlide : 0);
}
void _close() {
if (_dx == 0) return;
setState(() => _dx = 0);
}
void _delete() {
_close();
widget.onDelete();
}
@override
Widget build(BuildContext context) {
final isSwiped = _dx < 0;
return Padding(
padding: widget.margin,
child: Stack(
children: [
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color: AppColors.error,
borderRadius: BorderRadius.circular(16),
),
child: const Align(
alignment: Alignment.centerRight,
child: Padding(
padding: EdgeInsets.only(right: 20),
child: Icon(
Icons.delete_outline,
color: AppColors.textOnGradient,
size: 28,
),
child: ClipRRect(
borderRadius: widget.borderRadius,
child: Stack(
children: [
Positioned.fill(child: ColoredBox(color: AppColors.error)),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: isSwiped ? _close : widget.onTap,
onHorizontalDragUpdate: widget.enabled ? _onDragUpdate : null,
onHorizontalDragEnd: widget.enabled ? _onDragEnd : null,
child: Transform.translate(
offset: Offset(_dx, 0),
child: ColoredBox(
key: const ValueKey('swipe-delete-content-surface'),
color: Colors.white,
child: isSwiped
? AbsorbPointer(child: widget.child)
: widget.child,
),
),
),
),
GestureDetector(
behavior: isSwiped
? HitTestBehavior.opaque
: HitTestBehavior.deferToChild,
onTap: isSwiped
? () {
widget.onDelete();
setState(() => _dx = 0);
}
: widget.onTap,
onHorizontalDragUpdate: _onDragUpdate,
onHorizontalDragEnd: _onDragEnd,
child: Transform.translate(
offset: Offset(_dx, 0),
child: isSwiped
? AbsorbPointer(absorbing: true, child: widget.child)
: widget.child,
),
),
],
if (isSwiped)
Positioned.fill(
child: Align(
alignment: Alignment.centerRight,
child: Material(
color: Colors.transparent,
child: InkWell(
key: const ValueKey('swipe-delete-action'),
onTap: widget.enabled ? _delete : null,
child: const SizedBox(
width: _maxSlide,
height: double.infinity,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.delete_outline,
color: AppColors.textOnGradient,
size: 24,
),
SizedBox(height: 2),
Text(
'删除',
style: TextStyle(
color: AppColors.textOnGradient,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
),
),
],
),
),
);
}

View File

@@ -8,7 +8,7 @@ class DrawerShell extends StatelessWidget {
const DrawerShell({
super.key,
required this.child,
this.widthFactor = 0.84,
this.widthFactor = 0.78,
this.showBackground = true,
});

View File

@@ -39,9 +39,7 @@ class EnterpriseHeader extends StatelessWidget {
padding: AppSpacing.panel,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.xlBorder,
border: Border.all(color: AppColors.border, width: 1.1),
boxShadow: AppShadows.soft,
borderRadius: AppRadius.lgBorder,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -50,17 +48,13 @@ class EnterpriseHeader extends StatelessWidget {
children: [
if (showIcon) ...[
Container(
width: 46,
height: 46,
width: 42,
height: 42,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [color, accent],
),
borderRadius: BorderRadius.circular(14),
color: color.withValues(alpha: 0.10),
borderRadius: AppRadius.smBorder,
),
child: Icon(icon, color: Colors.white, size: 25),
child: Icon(icon, color: color, size: 22),
),
const SizedBox(width: 12),
],
@@ -96,7 +90,6 @@ class EnterpriseHeader extends StatelessWidget {
child: _HeaderStat(
stat: stats[i],
color: i.isEven ? color : accent,
accent: i.isEven ? accent : color,
),
),
if (i != stats.length - 1) const SizedBox(width: 8),
@@ -113,13 +106,8 @@ class EnterpriseHeader extends StatelessWidget {
class _HeaderStat extends StatelessWidget {
final EnterpriseStat stat;
final Color color;
final Color accent;
const _HeaderStat({
required this.stat,
required this.color,
required this.accent,
});
const _HeaderStat({required this.stat, required this.color});
@override
Widget build(BuildContext context) {
@@ -127,16 +115,8 @@ class _HeaderStat extends StatelessWidget {
constraints: const BoxConstraints(minHeight: 66),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
color.withValues(alpha: 0.10),
accent.withValues(alpha: 0.06),
],
),
color: const Color(0xFFF7F8FA),
borderRadius: AppRadius.smBorder,
border: Border.all(color: AppColors.borderLight, width: 1.1),
),
child: Row(
children: [

View File

@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../core/app_colors.dart';
import '../core/app_design_tokens.dart';
import '../core/app_module_visuals.dart';
import '../core/navigation_provider.dart';
import '../providers/auth_provider.dart';
import '../providers/chat_provider.dart';
@@ -16,22 +17,13 @@ class HealthDrawer extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authProvider);
final latestHealth = ref.watch(latestHealthProvider);
return DrawerShell(
widthFactor: 0.9,
showBackground: false,
child: CustomScrollView(
physics: const BouncingScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: _DrawerHero(
user: auth.user,
latestHealth: latestHealth,
ref: ref,
),
),
const SliverToBoxAdapter(child: _DrawerHeroConsumer()),
SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 18),
@@ -43,13 +35,7 @@ class HealthDrawer extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_NavigationSection(ref: ref),
const SizedBox(height: 14),
const Divider(
height: 1,
thickness: 0.7,
color: Color(0xFFE8ECF2),
),
const SizedBox(height: 14),
const SizedBox(height: 22),
_HistorySection(ref: ref),
],
),
@@ -61,6 +47,17 @@ class HealthDrawer extends ConsumerWidget {
}
}
class _DrawerHeroConsumer extends ConsumerWidget {
const _DrawerHeroConsumer();
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(authProvider.select((state) => state.user));
final latestHealth = ref.watch(latestHealthProvider);
return _DrawerHero(user: user, latestHealth: latestHealth, ref: ref);
}
}
class _DrawerHero extends StatelessWidget {
final dynamic user;
final AsyncValue<Map<String, dynamic>> latestHealth;
@@ -413,7 +410,7 @@ class _NavigationSection extends StatelessWidget {
colors: const [Color(0xFFFBCFE8), Color(0xFFF9A8D4)],
),
_NavItem(
icon: LucideIcons.footprints,
icon: AppModuleVisuals.exercise.icon,
title: '运动',
route: 'exercisePlan',
colors: AppColors.exerciseGradient.colors,
@@ -429,7 +426,7 @@ class _NavigationSection extends StatelessWidget {
return _LightSection(
title: '常用功能',
child: GridView.builder(
padding: EdgeInsets.zero,
padding: const EdgeInsets.only(top: 3),
itemCount: items.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
@@ -530,8 +527,13 @@ class _NavTile extends StatelessWidget {
class _LightSection extends StatelessWidget {
final String title;
final Widget child;
final Widget? trailing;
const _LightSection({required this.title, required this.child});
const _LightSection({
required this.title,
required this.child,
this.trailing,
});
@override
Widget build(BuildContext context) => Column(
@@ -551,10 +553,11 @@ class _LightSection extends StatelessWidget {
),
),
),
?trailing,
],
),
),
const SizedBox(height: 2),
const SizedBox(height: 8),
child,
],
);
@@ -711,7 +714,7 @@ class _HistorySection extends ConsumerStatefulWidget {
final WidgetRef ref;
const _HistorySection({required this.ref});
static const int _previewCount = 5;
static const int _previewCount = 7;
@override
ConsumerState<_HistorySection> createState() => _HistorySectionState();
@@ -721,6 +724,9 @@ class _HistorySectionState extends ConsumerState<_HistorySection> {
String? _selectedId;
bool _selecting = false;
bool _deleting = false;
bool _expanded = false;
bool _clearing = false;
String? _loadingConversationId;
void _enterSelect(String id) {
setState(() {
@@ -750,9 +756,13 @@ class _HistorySectionState extends ConsumerState<_HistorySection> {
@override
Widget build(BuildContext context) {
final async = ref.watch(conversationHistoryProvider);
final currentConversationId = ref.watch(
chatProvider.select((state) => state.conversationId),
);
return _LightSection(
title: '对话记录',
trailing: _buildHeaderAction(context, async),
child: async.when(
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
@@ -771,7 +781,10 @@ class _HistorySectionState extends ConsumerState<_HistorySection> {
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
),
),
data: (list) {
data: (allItems) {
final list = allItems
.where((item) => item.id != currentConversationId)
.toList();
if (list.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
@@ -781,89 +794,140 @@ class _HistorySectionState extends ConsumerState<_HistorySection> {
),
);
}
final preview = list.take(_HistorySection._previewCount).toList();
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: 48),
child: Stack(
clipBehavior: Clip.none,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < preview.length; i++)
_DrawerHistoryTile(
item: preview[i],
isLast: i == preview.length - 1,
selecting: _selecting,
selected: _selectedId == preview[i].id,
onTap: () async {
if (_selecting) {
_toggleSelect(preview[i].id);
return;
}
final error = await ref
.read(chatProvider.notifier)
.loadConversation(preview[i].id);
if (!context.mounted) return;
if (error != null) {
AppToast.show(
context,
error,
type: AppToastType.error,
);
return;
}
Navigator.of(context).maybePop();
},
onLongPress: () => _enterSelect(preview[i].id),
final visible = _expanded
? list
: list.take(_HistorySection._previewCount).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < visible.length; i++)
_DrawerHistoryTile(
item: visible[i],
selecting: _selecting,
selected: _selectedId == visible[i].id,
loading: _loadingConversationId == visible[i].id,
onTap: () => _openConversation(context, visible[i].id),
onLongPress: _loadingConversationId == null && !_deleting
? () => _enterSelect(visible[i].id)
: null,
),
if (list.length > _HistorySection._previewCount) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: InkWell(
onTap: _selecting
? null
: () => setState(() => _expanded = !_expanded),
borderRadius: BorderRadius.circular(999),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 6,
),
if (list.length > _HistorySection._previewCount) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: InkWell(
onTap: () {
Navigator.of(context).maybePop();
pushRoute(ref, 'conversationHistory');
},
borderRadius: BorderRadius.circular(999),
child: const Padding(
padding: EdgeInsets.symmetric(
horizontal: 8,
vertical: 6,
),
child: Text(
'查看全部',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
color: AppColors.textSecondary,
),
),
),
child: Text(
_expanded ? '收起' : '显示全部',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
color: AppColors.textSecondary,
),
),
],
],
),
if (_selecting && _selectedId != null)
Positioned(
top: -46,
right: 2,
child: _HistorySelectionBar(
deleting: _deleting,
onCancel: _exitSelect,
onDelete: () => _deleteSelected(context),
),
),
),
],
),
],
);
},
),
);
}
Widget? _buildHeaderAction(
BuildContext context,
AsyncValue<List<ConversationListItem>> async,
) {
if (_selecting && _selectedId != null) {
return _HistorySelectionBar(
deleting: _deleting,
onCancel: _exitSelect,
onDelete: () => _deleteSelected(context),
);
}
final hasHistory = async.asData?.value.isNotEmpty == true;
if (!_expanded || !hasHistory) return null;
return TextButton(
onPressed: _clearing ? null : () => _confirmClearAll(context),
style: TextButton.styleFrom(
foregroundColor: AppColors.error,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
_clearing ? '清空中' : '清空全部',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w800),
),
);
}
Future<void> _openConversation(BuildContext context, String id) async {
if (_loadingConversationId != null || _deleting || _clearing) return;
if (_selecting) {
_toggleSelect(id);
return;
}
setState(() => _loadingConversationId = id);
final error = await ref.read(chatProvider.notifier).loadConversation(id);
if (!mounted) return;
setState(() => _loadingConversationId = null);
if (error != null) {
if (context.mounted) {
AppToast.show(context, error, type: AppToastType.error);
}
return;
}
if (context.mounted) Navigator.of(context).maybePop();
}
Future<void> _confirmClearAll(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('清空全部对话'),
content: const Text('清空后无法恢复,确定继续吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
style: TextButton.styleFrom(foregroundColor: AppColors.error),
child: const Text('清空'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _clearing = true);
try {
final count = await ref
.read(conversationHistoryProvider.notifier)
.clearAll();
if (!context.mounted) return;
setState(() => _expanded = false);
AppToast.show(context, '已清空 $count 条对话', type: AppToastType.success);
} catch (_) {
if (context.mounted) {
AppToast.show(context, '清空失败,请稍后重试', type: AppToastType.error);
}
} finally {
if (mounted) setState(() => _clearing = false);
}
}
Future<void> _deleteSelected(BuildContext context) async {
final id = _selectedId;
if (id == null || _deleting) return;
@@ -975,16 +1039,16 @@ class _HistoryActionButton extends StatelessWidget {
class _DrawerHistoryTile extends StatelessWidget {
final ConversationListItem item;
final bool isLast;
final bool selecting;
final bool selected;
final bool loading;
final VoidCallback onTap;
final VoidCallback onLongPress;
final VoidCallback? onLongPress;
const _DrawerHistoryTile({
required this.item,
required this.isLast,
required this.selecting,
required this.selected,
required this.loading,
required this.onTap,
required this.onLongPress,
});
@@ -1025,23 +1089,24 @@ class _DrawerHistoryTile extends StatelessWidget {
),
),
const SizedBox(width: 10),
Text(
_shortDate(item.updatedAt),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textHint,
if (loading)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
Text(
_shortDate(item.updatedAt),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textHint,
),
),
),
],
),
),
if (!isLast)
const Divider(
height: 1,
thickness: 0.7,
color: Color(0xFFE8ECF2),
),
],
),
),