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:
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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!,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user