diff --git a/.gitignore b/.gitignore index 7475bc2..81fdc8e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ backend/**/bin/ backend/**/obj/ +# PostgreSQL local data +backend/pgdata/ + # Flutter build outputs health_app/build/ health_app/.dart_tool/ diff --git a/docs/omron_bp_implementation_plan.md b/docs/omron_bp_implementation_plan.md new file mode 100644 index 0000000..cd80715 --- /dev/null +++ b/docs/omron_bp_implementation_plan.md @@ -0,0 +1,170 @@ +# 欧姆龙 J735 蓝牙血压计 — 实施计划 + +--- + +## 一、当前状态 + +- 设备已购:欧姆龙 J735 +- 前端占位:`DeviceManagementPage` 目前是空壳(显示"暂无绑定设备"),路由 `devices` 已注册,入口在 ProfilePage → 设备管理 +- 项目风格:淡紫清新风(`AppTheme`),Material 3 + shadcn_ui + Riverpod +- 本地存储:SQLite(`LocalDatabase`,kv_store 表) +- 后端:已有 `POST /api/health-records` 支持 BloodPressure 类型,Source 枚举需加 `Device` + +--- + +## 二、实施范围 + +### 2.1 新增依赖 + +```yaml +# pubspec.yaml +flutter_blue_plus: ^1.34.0 # BLE 核心 +permission_handler: ^11.3.0 # 蓝牙权限 +``` + +### 2.2 新增文件(6 个) + +``` +health_app/lib/ +├── services/ +│ └── omron_ble_service.dart # BLE 连接 + SFLOAT 协议解析 +├── models/ +│ └── bp_reading.dart # 血压数据模型 +├── providers/ +│ └── omron_device_provider.dart # 设备绑定状态 + 实时读数 +├── pages/ +│ └── device/ +│ ├── device_scan_page.dart # 扫描设备页 +│ └── device_bind_page.dart # 已绑定设备管理页(替换空壳) +``` + +### 2.3 修改文件(3 个) + +| 文件 | 改动内容 | +|------|---------| +| `pages/remaining_pages.dart` | 替换 `DeviceManagementPage` 空壳为 `DeviceBindPage` | +| `core/app_router.dart` | `devices` 路由指向新页面 | +| `providers/data_providers.dart` | 新增 `omronBleServiceProvider`、`bpReadingsProvider` | +| 后端 `health_enums.cs` | `HealthRecordSource` 加 `Device` | + +--- + +## 三、分步实施(3 天) + +### Day 1 — BLE 服务层 + +**目标**:手机能搜到 J735、连接上、收到蓝牙数据并正确解析 + +1. 添加 `flutter_blue_plus` + `permission_handler` 依赖 +2. 配置 Android 蓝牙权限(`AndroidManifest.xml`)+ iOS 权限(`Info.plist`) +3. 实现 `BpReading` 数据模型(systolic/diastolic/pulse/timestamp/status) +4. 实现 `OmronBleService`: + - `scan()` — 过滤蓝牙名含 `OMRON`/`HEM`/`BLEsmart` 且有 `0x1810` 服务的设备 + - `connect()` — 连接 → discoverServices → 订阅 `0x2A35` Indicate + - `_parseReading()` — SFLOAT 解码 + Flags 解析(kPa 转换、体动、心律等) + - `_syncTime()` — 写入 `0x2A08` 同步时间 + - `disconnect()` / `dispose()` +5. 用 nRF Connect 抓包验证原始 HEX → SFLOAT 解码结果与屏幕一致 + +**产出**:`OmronBleService` 可独立运行,单元测试验证 SFLOAT 解码 + +### Day 2 — UI 页面 + +**目标**:实现扫描绑定页 + 已绑定管理页,风格对齐项目淡紫主题 + +#### 扫描绑定页 (`DeviceScanPage`) +- AppBar 标题"添加血压计",返回按钮 +- 顶部:扫描状态指示(转圈 + "正在扫描..." / "扫描完成,发现 N 台设备") +- 空状态:未发现设备时显示使用说明卡片(装电池 → 长按蓝牙键 → 手机靠近 → 检查权限) +- 设备列表:每行显示 BLE 设备名 + 信号强度 + MAC + "连接"按钮 +- 连接中:按钮变 loading +- 连接成功:SnackBar "已连接 OMRON xxx",返回上一页并传递设备信息 +- 底部:重新扫描按钮 + +#### 已绑定管理页 (`DeviceBindPage`,替换空壳) +- **未绑定状态**:大蓝牙图标 + "未绑定设备" + 说明文字 + "添加设备"按钮,点击跳扫描页 +- **已绑定状态**:白底圆角卡片 + - 设备图标(紫色渐变圆)+ 设备名 + - MAC 地址(灰色小字) + - 上次同步时间 + - "解绑设备"按钮(红色文字) + +**风格要点**(保证不出现"全黑"): +- 背景色:`AppTheme.bg`(淡紫白 `#FAF9FF`) +- 卡片:白色 `Colors.white`,圆角 `AppTheme.rLg`(20),`AppTheme.shadowCard` 阴影 +- 按钮:紫色实心(`AppTheme.primary`)+ 圆角 `AppTheme.rPill` +- 空状态图标:`Colors.grey[300]`,文字 `AppTheme.textHint` +- 使用 shadcn_ui 图标(`LucideIcons`) +- 与 SettingsPage、ProfilePage 保持一致的 AppBar 风格 + +**产出**:两个 UI 页面,风格与项目统一,从 ProfilePage → 设备管理可进入 + +### Day 3 — 数据同步 + 联调 + +1. **Provider 层**:`omronDeviceProvider` 管理绑定状态(MAC/name/lastSync 存入 SQLite) +2. **自动同步**:收到 BLE 读数后自动 `POST /api/health-records` + ```dart + { + 'type': 'BloodPressure', + 'systolic': reading.systolic, + 'diastolic': reading.diastolic, + 'source': 'Device', + 'unit': 'mmHg', + 'recordedAt': reading.timestamp.toUtc().toIso8601String(), + } + ``` +3. **去重逻辑**:同一时间戳(精确到秒)+ 同一值 → 跳过 +4. **健康概览刷新**:同步后 `ref.invalidate(latestHealthProvider)` +5. **真机 + J735 联调**: + - 扫描 → 连接 → 测量 → 数据上传 → 健康概览显示新数据 + - 断连 → 重新打开 App → 再次连接 + - iOS 和 Android 双平台测试 + +--- + +## 四、交互流程 + +``` +用户操作 App 行为 +──────── ──────── +设置 → 蓝牙血压计 打开 DeviceBindPage(已绑定/未绑定) + │ + ├─ 未绑定 + │ └─ 点"添加设备" 打开 DeviceScanPage + │ └─ 扫到 J735 点击"连接" + │ └─ 连接成功 返回 DeviceBindPage(已绑定状态) + │ + └─ 已绑定 + └─ 血压计测量 血压计按开始键 → 测量完成 + │ BLE Indicate 推送数据 + │ App 收到 120/80 + │ SnackBar: "已同步: 120/80 mmHg" + │ 自动写入 HealthRecords + │ 健康概览刷新 + └─ 查看 点"健康概览"可看到新数据 +``` + +--- + +## 五、预期效果 + +- 从 ProfilePage → 设备管理,不再是全黑空壳,而是漂亮的设备管理页面 +- 点击"添加设备"打开扫描页,15 秒内搜索到 J735 +- 点击"连接" 2 秒内完成绑定 +- J735 测量完毕后,App 自动收到数据(无需手动操作) +- 数据自动写入健康记录,健康概览实时刷新 +- 同一数据不重复录入 +- 重新打开 App 后已绑定设备状态保留 +- iOS + Android 双平台均可使用 + +--- + +## 六、风险点 + +| 风险 | 应对 | +|------|------| +| J735 蓝牙名称与预期不符 | 不硬编码名称,用 `0x1810` 服务 UUID 过滤;首次连接后打印实际名称 | +| Android 12+ 蓝牙权限弹窗被拒 | `permission_handler` 检测权限状态,引导用户去设置开启 | +| 设备已被系统蓝牙占用 | 扫描页提示"如搜不到请先取消系统蓝牙配对" | +| SFLOAT 解码与屏幕值有偏差 | 用 nRF Connect 抓 HEX 对比,误差 > 1 则调整解码 | +| iOS BLE 后台断连 | 前台连接同步,完成后断开;不做后台持久连接 | diff --git a/health_app/lib/core/api_client.dart b/health_app/lib/core/api_client.dart index d991994..93b2b4b 100644 --- a/health_app/lib/core/api_client.dart +++ b/health_app/lib/core/api_client.dart @@ -3,7 +3,7 @@ import 'package:dio/dio.dart'; import 'local_database.dart'; /// API 基础地址 -const String baseUrl = 'http://192.168.1.45:5000'; +const String baseUrl = 'http://10.4.164.158:5000'; /// Dio HTTP 客户端封装——带 token 注入、401 自动刷新 class ApiClient { diff --git a/health_app/lib/core/app_colors.dart b/health_app/lib/core/app_colors.dart new file mode 100644 index 0000000..2a6e28c --- /dev/null +++ b/health_app/lib/core/app_colors.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; + +/// 统一配色方案 - 清爽紫蓝风格(参考蚂蚁阿福) +class AppColors { + AppColors._(); + + // ═══════════════════════════════════════════════════════════ + // 主色调 + // ═══════════════════════════════════════════════════════════ + + /// 纯白背景 + static const Color background = Color(0xFFFFFFFF); + + /// 次级背景色(非常淡的蓝紫色) + static const Color backgroundSecondary = Color(0xFFF8FAFF); + + /// 卡片背景 + static const Color cardBackground = Color(0xFFFFFFFF); + + // ═══════════════════════════════════════════════════════════ + // 紫色系(清爽浅紫) + // ═══════════════════════════════════════════════════════════ + + /// 主紫色(浅紫,蚂蚁阿福风格) + static const Color primaryPurple = Color(0xFFB8C4FF); + + /// 深一点的紫色(用于强调) + static const Color primaryPurpleDark = Color(0xFF8BA4E8); + + /// 紫色渐变起点 + static const Color purpleGradientStart = Color(0xFFD4DFFF); + + /// 紫色渐变终点 + static const Color purpleGradientEnd = Color(0xFFB8C4FF); + + // ═══════════════════════════════════════════════════════════ + // 蓝色系 + // ═══════════════════════════════════════════════════════════ + + /// 主蓝色(清爽蓝) + static const Color primaryBlue = Color(0xFF5B9FFF); + + /// 深一点的蓝色 + static const Color primaryBlueDark = Color(0xFF3A7FE8); + + /// 蓝色渐变起点 + static const Color blueGradientStart = Color(0xFFB8D4FF); + + /// 蓝色渐变终点 + static const Color blueGradientEnd = Color(0xFF7BB8FF); + + // ═══════════════════════════════════════════════════════════ + // 渐变组合 + // ═══════════════════════════════════════════════════════════ + + /// 紫蓝渐变(用于标题背景、图标背景) + static const LinearGradient purpleBlueGradient = LinearGradient( + colors: [purpleGradientStart, blueGradientEnd], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ); + + /// 轻量级渐变(用于卡片内小元素) + static const LinearGradient lightGradient = LinearGradient( + colors: [Color(0xFFEEF2FF), Color(0xFFF0F5FF)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ); + + // ═══════════════════════════════════════════════════════════ + // 文字颜色 + // ═══════════════════════════════════════════════════════════ + + /// 主要文字(深色) + static const Color textPrimary = Color(0xFF1A1A2E); + + /// 次要文字(中等灰色) + static const Color textSecondary = Color(0xFF666666); + + /// 辅助文字(浅灰色) + static const Color textHint = Color(0xFF999999); + + /// 文字颜色(用于渐变背景上的白色文字) + static const Color textOnGradient = Color(0xFFFFFFFF); + + // ═══════════════════════════════════════════════════════════ + // 图标和元素颜色 + // ═══════════════════════════════════════════════════════════ + + /// 图标背景色(非常淡的蓝紫色) + static const Color iconBackground = Color(0xFFEEF2FF); + + /// 图标主色(清爽蓝紫色) + static const Color iconColor = Color(0xFF5B8DEF); + + /// 次要图标色 + static const Color iconColorSecondary = Color(0xFF8BA4E8); + + // ═══════════════════════════════════════════════════════════ + // 边框和分割线 + // ═══════════════════════════════════════════════════════════ + + /// 默认边框色 + static const Color border = Color(0xFFE8EBF5); + + /// 浅边框色 + static const Color borderLight = Color(0xFFF0F2F7); + + // ═══════════════════════════════════════════════════════════ + // 功能色 + // ═══════════════════════════════════════════════════════════ + + /// 成功色(绿色) + static const Color success = Color(0xFF4CAF50); + + /// 错误色(柔和红) + static const Color error = Color(0xFFFF6B6B); + + /// 警告色(橙色) + static const Color warning = Color(0xFFFFB74D); + + // ═══════════════════════════════════════════════════════════ + // 按钮渐变(白色底+渐变边框) + // ═══════════════════════════════════════════════════════════ + + /// 主按钮渐变边框色 + static const LinearGradient buttonGradient = LinearGradient( + colors: [primaryPurple, primaryBlue], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ); + + /// 成功按钮渐变 + static const LinearGradient successButtonGradient = LinearGradient( + colors: [Color(0xFF81E6A0), success], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ); + + // ═══════════════════════════════════════════════════════════ + // 阴影 + // ═══════════════════════════════════════════════════════════ + + /// 卡片阴影 + static List cardShadow = [ + BoxShadow( + color: primaryPurple.withAlpha(15), + blurRadius: 20, + offset: const Offset(0, 6), + ), + ]; + + /// 按钮阴影 + static List buttonShadow = [ + BoxShadow( + color: primaryPurple.withAlpha(20), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ]; +} diff --git a/health_app/lib/core/app_router.dart b/health_app/lib/core/app_router.dart index 137358c..42bf99b 100644 --- a/health_app/lib/core/app_router.dart +++ b/health_app/lib/core/app_router.dart @@ -12,7 +12,6 @@ import '../pages/consultation/consultation_pages.dart'; import '../pages/settings/settings_pages.dart'; import '../pages/settings/notification_prefs_page.dart'; import '../pages/profile/profile_page.dart'; -import '../pages/profile/profile_detail_page.dart'; import '../pages/profile/service_package_detail_page.dart'; import '../pages/diet/diet_capture_page.dart'; import '../pages/remaining_pages.dart'; @@ -41,8 +40,6 @@ Widget buildPage(RouteInfo route) { return ReportDetailPage(id: params['id']!); case 'aiAnalysis': return AiAnalysisPage(id: params['id']!); - case 'doctors': - return const DoctorListPage(); case 'consultation': return DoctorChatPage(id: params['id']!); case 'exercisePlan': @@ -55,10 +52,6 @@ Widget buildPage(RouteInfo route) { return const DietCapturePage(); case 'profile': return const ProfilePage(); - case 'profileEdit': - return const ProfileDetailPage(); - case 'editProfile': - return const EditProfilePage(); case 'devices': return const DeviceManagementPage(); case 'healthArchive': diff --git a/health_app/lib/core/app_theme.dart b/health_app/lib/core/app_theme.dart index 2aaf7c2..5d47469 100644 --- a/health_app/lib/core/app_theme.dart +++ b/health_app/lib/core/app_theme.dart @@ -1,31 +1,32 @@ import 'package:flutter/material.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'app_colors.dart'; -/// 健康管家 — 淡紫清新风 +/// 健康管家 — 清爽紫蓝风 class AppTheme { AppTheme._(); - // ── 主色调:柔和紫 ── - static const Color primary = Color(0xFF7C6FF7); // 柔紫 - static const Color primaryLight = Color(0xFFEDEAFF); // 浅薰衣草 - static const Color primaryDark = Color(0xFF4A3FBE); // 深紫 - static const Color primaryGlow = Color(0xFFD9D4FF); // 发光紫 + // ── 主色调 ── + static const Color primary = AppColors.primaryPurple; + static const Color primaryLight = AppColors.iconBackground; + static const Color primaryDark = AppColors.iconColor; + static const Color primaryGlow = AppColors.purpleGradientStart; // ── 中性色 ── - static const Color bg = Color(0xFFFAF9FF); // 淡紫白 - static const Color surface = Color(0xFFFFFFFF); - static const Color text = Color(0xFF1E1B2E); // 紫黑 - static const Color textSub = Color(0xFF6E6B82); // 灰紫 - static const Color textHint = Color(0xFFB5B2C8); // 浅灰紫 - static const Color border = Color(0xFFE8E6F0); - static const Color divider = Color(0xFFF4F2FA); + static const Color bg = AppColors.backgroundSecondary; + static const Color surface = AppColors.cardBackground; + static const Color text = AppColors.textPrimary; + static const Color textSub = AppColors.textSecondary; + static const Color textHint = AppColors.textHint; + static const Color border = AppColors.border; + static const Color divider = AppColors.borderLight; // ── 语义色 ── - static const Color success = Color(0xFF52B788); // 翠绿 - static const Color error = Color(0xFFE85D75); // 柔红 - static const Color warning = Color(0xFFF5B041); // 暖琥珀 - static const Color accent = Color(0xFFA78BFA); // 中紫 - static const Color info = Color(0xFF7C6FF7); // 柔紫 + static const Color success = AppColors.success; + static const Color error = AppColors.error; + static const Color warning = AppColors.warning; + static const Color accent = AppColors.primaryBlue; + static const Color info = AppColors.primaryBlue; // ── 圆角 ── static const double rXs = 6; @@ -53,13 +54,13 @@ class AppTheme { // ── 智能体配色 ── static const Map agentColors = { - 'default': Color(0xFFEDEAFF), // 淡紫 - 'consultation': Color(0xFFE0F0FF), // 淡天蓝 - 'health': Color(0xFFE8FFE8), // 淡翠绿 - 'diet': Color(0xFFFFF0E0), // 淡杏 - 'medication': Color(0xFFFFE8EC), // 淡粉 - 'report': Color(0xFFE8F0FF), // 淡水蓝 - 'exercise': Color(0xFFF0E8FF), // 淡紫 + 'default': AppColors.iconBackground, + 'consultation': Color(0xFFE0F0FF), + 'health': Color(0xFFE8FFE8), + 'diet': Color(0xFFFFF0E0), + 'medication': Color(0xFFFFE8EC), + 'report': Color(0xFFE8F0FF), + 'exercise': Color(0xFFF0E8FF), }; static Color agentLight(String? name) => agentColors[name] ?? primaryLight; @@ -141,9 +142,9 @@ class AppTheme { popoverForeground: text, primary: primary, primaryForeground: Colors.white, - secondary: Color(0xFFF4F2FA), + secondary: divider, secondaryForeground: text, - muted: Color(0xFFF4F2FA), + muted: divider, mutedForeground: textSub, accent: accent, accentForeground: Colors.white, @@ -152,7 +153,7 @@ class AppTheme { border: border, input: border, ring: primary, - selection: Color(0xFFD9D4FF), + selection: primaryGlow, ), radius: BorderRadius.circular(rMd), ); diff --git a/health_app/lib/pages/chart/trend_page.dart b/health_app/lib/pages/chart/trend_page.dart index 020759d..950c084 100644 --- a/health_app/lib/pages/chart/trend_page.dart +++ b/health_app/lib/pages/chart/trend_page.dart @@ -137,7 +137,7 @@ class _TrendPageState extends ConsumerState { : _selected == 'spo2' ? 'SpO2' : 'Weight', 'source': 'Manual', - 'recordedAt': DateTime.now().toIso8601String(), + 'recordedAt': DateTime.now().toUtc().toIso8601String(), 'unit': _unit, }; if (_isBP) { diff --git a/health_app/lib/pages/consultation/consultation_pages.dart b/health_app/lib/pages/consultation/consultation_pages.dart index 997b0ff..134c922 100644 --- a/health_app/lib/pages/consultation/consultation_pages.dart +++ b/health_app/lib/pages/consultation/consultation_pages.dart @@ -7,108 +7,6 @@ import '../../core/navigation_provider.dart'; import '../../providers/data_providers.dart'; import '../../providers/consultation_provider.dart'; -/// 医生列表页 -class DoctorListPage extends ConsumerWidget { - const DoctorListPage({super.key}); - @override - Widget build(BuildContext context, WidgetRef ref) { - final doctors = ref.watch(doctorListProvider); - return Scaffold( - appBar: AppBar(title: const Text('选择医生')), - body: doctors.when( - data: (list) { - if (list.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.person_search, size: 64, color: Colors.grey[300]), - const SizedBox(height: 12), - Text('暂无可用医生', style: Theme.of(context).textTheme.bodyMedium), - ], - ), - ); - } - return SizedBox( - height: 330, - child: ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - itemCount: list.length, - itemBuilder: (ctx, i) { - final d = list[i]; - return Container( - width: 155, - margin: const EdgeInsets.symmetric(horizontal: 5), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow(color: AppTheme.primary.withAlpha(15), blurRadius: 12, offset: const Offset(0, 4)) - ], - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircleAvatar( - radius: 28, - backgroundColor: AppTheme.primaryLight, - child: Text( - (d['name'] as String?)?.isNotEmpty == true ? d['name']![0] : '?', - style: const TextStyle(fontSize: 25, fontWeight: FontWeight.w600, color: AppTheme.primaryLight), - ), - ), - const SizedBox(height: 10), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppTheme.primaryLight, - borderRadius: BorderRadius.circular(6), - ), - child: Text(d['title'] ?? '', style: const TextStyle(fontSize: 15, color: AppTheme.primaryLight, fontWeight: FontWeight.w500)), - ), - const SizedBox(height: 6), - Text(d['department'] ?? '', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: Color(0xFF333333))), - const SizedBox(height: 8), - Text(d['introduction'] ?? '', - style: const TextStyle(fontSize: 15, color: Color(0xFF888888), height: 1.4), - maxLines: 4, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center), - const SizedBox(height: 14), - SizedBox( - width: double.infinity, - height: 34, - child: ElevatedButton( - onPressed: () => pushRoute(ref, 'consultation', params: {'id': d['id']?.toString() ?? ''}), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primary, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - padding: EdgeInsets.zero, - ), - child: const Text('立即咨询', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - ), - ), - ], - ), - ), - ); - }, - ), - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (_, _) => Center( - child: Text('加载失败', style: Theme.of(context).textTheme.bodyMedium), - ), - ), - ); - } -} - /// 问诊对话页 — 完整的 AI 分身聊天界面 class DoctorChatPage extends ConsumerStatefulWidget { final String id; diff --git a/health_app/lib/pages/home/home_page.dart b/health_app/lib/pages/home/home_page.dart index 0f874cc..37b7965 100644 --- a/health_app/lib/pages/home/home_page.dart +++ b/health_app/lib/pages/home/home_page.dart @@ -33,6 +33,7 @@ class _HomePageState extends ConsumerState { final imagePath = _pickedImagePath; if (text.isEmpty && imagePath == null) return; _textCtrl.clear(); + _focusNode.unfocus(); // 收起键盘 setState(() => _pickedImagePath = null); if (imagePath != null) { ref.read(chatProvider.notifier).sendImage(imagePath, text); @@ -132,32 +133,22 @@ class _HomePageState extends ConsumerState { separatorBuilder: (_, i) => const SizedBox(width: 6), itemBuilder: (_, i) { final (agent, label, icon) = _agentDefs[i]; - final isActive = selected == agent; return GestureDetector( onTap: () { - final notifier = ref.read(selectedAgentProvider.notifier); - if (isActive) { - notifier.select(null); - } else { - notifier.select(agent); - ref.read(chatProvider.notifier).setAgent(agent); - if (_welcomedAgents.add(agent)) { - ref.read(chatProvider.notifier).insertAgentWelcome(agent); - } - } + // 用户标签 → 欢迎卡片,不发 AI 后端 + ref.read(chatProvider.notifier).triggerAgent(agent, label); }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), + child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( - color: isActive ? AppTheme.primary : theme.colorScheme.card, + color: theme.colorScheme.card, borderRadius: BorderRadius.circular(AppTheme.rPill), - boxShadow: isActive ? [AppTheme.shadowLight] : null, + border: Border.all(color: theme.colorScheme.border), ), child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 17, color: isActive ? Colors.white : theme.colorScheme.mutedForeground), + Icon(icon, size: 17, color: theme.colorScheme.mutedForeground), const SizedBox(width: 4), - Text(label, style: TextStyle(fontSize: 15, fontWeight: isActive ? FontWeight.w600 : FontWeight.w500, color: isActive ? Colors.white : theme.colorScheme.mutedForeground)), + Text(label, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: theme.colorScheme.mutedForeground)), ]), ), ); diff --git a/health_app/lib/pages/home/widgets/chat_messages_view.dart b/health_app/lib/pages/home/widgets/chat_messages_view.dart index 583d252..3472efd 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/app_colors.dart'; import '../../../core/app_theme.dart'; import '../../../core/navigation_provider.dart'; import '../../../providers/auth_provider.dart'; @@ -36,7 +36,7 @@ class ChatMessagesView extends ConsumerWidget { const SizedBox(height: 16), Text('开始和 AI 健康管家对话吧', style: Theme.of(context).textTheme.bodyMedium), const SizedBox(height: 8), - const Text('记录健康数据,获取专业建议', style: TextStyle(fontSize: 17, color: Color(0xFF9E9E9E))), + const Text('记录健康数据,获取专业建议', style: TextStyle(fontSize: 17, color: AppColors.textHint)), ], ), ); @@ -65,56 +65,68 @@ class ChatMessagesView extends ConsumerWidget { return _buildTaskCardInChat(context, ref); case MessageType.dataConfirm: return _buildDataConfirmCard(context, ref, msg); - case MessageType.medicationConfirm: - return _buildMedicationConfirmCard(context, ref, msg); - case MessageType.dietAnalysis: - return _buildDietAnalysisCard(context, msg); - case MessageType.reportAnalysis: - return _buildReportAnalysisCard(context, msg); - case MessageType.quickOptions: - return _buildQuickOptionsCard(context, msg); default: - // 只有当前正在流式回复的文本消息才显示"正在分析" - if (!msg.isUser && chatState.isStreaming && msg.content.isEmpty) { + if (!msg.isUser && chatState.isStreaming && msg.content.length < 5) { return _buildThinkingBubble(context, chatState.thinkingText); } - return _buildTextBubble(context, msg); + return _buildTextBubble(context, msg, chatState); } } // ═══════════════════════════════════════════════════════════ - // 1. AgentWelcomeCard — 智能体欢迎卡片 + // 逐字淡入文本(蚂蚁阿福风格) + // ═══════════════════════════════════════════════════════════ + Widget _buildFadeInText(String text, TextStyle style, String stableId, {bool enabled = true}) { + if (!enabled || text.length <= 2) return Text(text, style: style); + + return Wrap( + children: List.generate(text.length, (i) { + return TweenAnimationBuilder( + key: ValueKey('fade_${stableId}_$i'), + tween: Tween(begin: 0.0, end: 1.0), + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + builder: (_, value, child) => Opacity(opacity: value, child: child), + child: Text(text[i], style: style), + ); + }), + ); + } + + // ═══════════════════════════════════════════════════════════ + // 1. AgentWelcomeCard — 智能体欢迎卡片(蚂蚁阿福风格 + 美化版) // ═══════════════════════════════════════════════════════════ Widget _buildAgentWelcomeCard(BuildContext context, WidgetRef ref, ChatMessage msg, ActiveAgent agent) { final info = _agentInfo(agent); final actions = agent.actions; + final features = _getAgentFeatures(agent); final screenWidth = MediaQuery.of(context).size.width; - final colors = _agentColors(agent); return Align( alignment: Alignment.centerLeft, child: Container( - margin: const EdgeInsets.only(bottom: 12), - constraints: BoxConstraints(maxWidth: screenWidth * 0.92), + margin: const EdgeInsets.only(bottom: 16), + constraints: BoxConstraints(maxWidth: screenWidth * 0.95), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow(color: colors.gradient[0].withAlpha(30), blurRadius: 16, offset: const Offset(0, 4)), - ], + color: AppColors.cardBackground, + borderRadius: BorderRadius.circular(24), + boxShadow: AppColors.cardShadow, ), clipBehavior: Clip.antiAlias, child: Column( mainAxisSize: MainAxisSize.min, children: [ - // ── 渐变色头部 ── + // ── 标题区域(清爽蓝紫渐变背景) ── Container( width: double.infinity, - padding: const EdgeInsets.fromLTRB(20, 24, 16, 20), + padding: const EdgeInsets.fromLTRB(20, 22, 20, 20), decoration: BoxDecoration( gradient: LinearGradient( - colors: colors.gradient, + colors: [ + AppColors.purpleGradientStart.withAlpha(40), + AppColors.blueGradientEnd.withAlpha(30), + ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), @@ -122,30 +134,45 @@ class ChatMessagesView extends ConsumerWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // 大图标容器 Container( - width: 48, - height: 48, + width: 64, + height: 64, decoration: BoxDecoration( - color: Colors.white.withAlpha(30), - borderRadius: BorderRadius.circular(14), + gradient: AppColors.purpleBlueGradient, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: AppColors.primaryPurple.withAlpha(30), + blurRadius: 14, + offset: const Offset(0, 5), + ), + ], ), - child: Icon(info.$1, size: 26, color: Colors.white), + child: Icon(info.$1, size: 32, color: Colors.white), ), - const SizedBox(width: 14), + const SizedBox(width: 18), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(info.$2, style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w700, color: Colors.white)), - const SizedBox(height: 4), + // AI 助手标签 Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( - color: Colors.white.withAlpha(25), + color: Colors.white.withAlpha(160), borderRadius: BorderRadius.circular(10), ), - child: Text(info.$3, style: const TextStyle(fontSize: 15, color: Color(0xFFE8E6FF))), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.auto_awesome, size: 14, color: AppColors.iconColor), + const SizedBox(width: 4), + Text('AI 智能助手', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.iconColor)), + ]), ), + const SizedBox(height: 10), + Text(info.$2, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w800, color: AppColors.textPrimary, letterSpacing: -0.3)), + const SizedBox(height: 8), + Text(info.$3, style: const TextStyle(fontSize: 16, color: AppColors.textSecondary, height: 1.4)), ], ), ), @@ -153,31 +180,128 @@ class ChatMessagesView extends ConsumerWidget { ), ), - // ── 医生列表(一行三列)── - if (agent == ActiveAgent.consultation) ...[ + // ── 功能特性区 ── + if (features.isNotEmpty) ...[ Padding( - padding: const EdgeInsets.fromLTRB(18, 18, 18, 4), - child: _buildDoctorCards(ref), - ), - ] else ...[ - Padding( - padding: const EdgeInsets.fromLTRB(18, 18, 18, 4), - child: Wrap( - spacing: 10, - runSpacing: 10, - children: actions.map((a) => _agentActionBtn(a, screenWidth, context, ref, colors)).toList(), + padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), + child: Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: AppColors.backgroundSecondary, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Container( + width: 6, height: 18, + decoration: BoxDecoration( + gradient: AppColors.purpleBlueGradient, + borderRadius: BorderRadius.circular(3), + ), + ), + const SizedBox(width: 8), + Text('核心功能', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), + ]), + const SizedBox(height: 14), + Row( + children: features.map((feature) { + return Expanded( + child: Container( + margin: EdgeInsets.only( + left: features.indexOf(feature) == 0 ? 0 : 6, + right: features.indexOf(feature) == features.length - 1 ? 0 : 6, + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.borderLight), + ), + child: Column( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + gradient: AppColors.lightGradient, + borderRadius: BorderRadius.circular(14), + ), + child: Icon(feature.icon, size: 24, color: AppColors.iconColor), + ), + const SizedBox(height: 8), + Text(feature.title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: AppColors.textPrimary), textAlign: TextAlign.center), + const SizedBox(height: 2), + Text(feature.desc, style: const TextStyle(fontSize: 13, color: AppColors.textHint), textAlign: TextAlign.center), + ], + ), + ), + ); + }).toList(), + ), + ], + ), ), ), ], - const SizedBox(height: 12), + // ── 快捷操作按钮区 ── + if (actions.isNotEmpty) ...[ + const SizedBox(height: 14), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Container( + width: 6, height: 18, + decoration: BoxDecoration( + gradient: AppColors.purpleBlueGradient, + borderRadius: BorderRadius.circular(3), + ), + ), + const SizedBox(width: 8), + Text('快捷操作', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), + ]), + const SizedBox(height: 12), + Wrap( + spacing: 10, + runSpacing: 10, + children: actions.map((a) => _buildActionButton(a, context, ref)).toList(), + ), + ], + ), + ), + ], + + // ── 底部提示 ── + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: AppColors.iconBackground, + borderRadius: BorderRadius.circular(12), + ), + child: Row(children: [ + Icon(Icons.lightbulb_outline, size: 18, color: AppColors.iconColor), + const SizedBox(width: 8), + Expanded(child: Text('直接描述您的需求,AI 会自动为您记录和分析', style: TextStyle(fontSize: 14, color: AppColors.iconColor, fontWeight: FontWeight.w500))), + ]), + ), + ), ], ), ), ); } - Widget _agentActionBtn(_AgentAction a, double screenWidth, BuildContext context, WidgetRef ref, _AgentColors colors) { + Widget _buildActionButton(_AgentAction a, BuildContext context, WidgetRef ref) { + final screenWidth = MediaQuery.of(context).size.width; return InkWell( onTap: () { if (a.label == '服药打卡') { @@ -192,35 +316,70 @@ class ChatMessagesView extends ConsumerWidget { } } }, - borderRadius: BorderRadius.circular(14), + borderRadius: BorderRadius.circular(16), child: Container( - width: ((screenWidth - 72) / (a.isWide ? 2 : 3)) - 10, - padding: const EdgeInsets.symmetric(vertical: 13, horizontal: 8), + width: ((screenWidth - 40 - 10) / 2) - 5, + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12), decoration: BoxDecoration( - color: colors.bg, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: colors.border, width: 1), + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.borderLight, width: 1.5), + boxShadow: AppColors.cardShadow, ), - child: Column( - mainAxisSize: MainAxisSize.min, + child: Row( children: [ Container( - width: 38, - height: 38, + width: 40, height: 40, decoration: BoxDecoration( - color: colors.iconBg, - borderRadius: BorderRadius.circular(11), + gradient: AppColors.lightGradient, + borderRadius: BorderRadius.circular(12), ), - child: Icon(a.icon, size: 23, color: colors.accent), + child: Icon(a.icon, size: 22, color: AppColors.iconColor), ), - const SizedBox(height: 7), - Text(a.label, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: Color(0xFF333333))), + const SizedBox(width: 12), + Expanded(child: Text(a.label, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary))), + Icon(Icons.chevron_right, size: 18, color: AppColors.border), ], ), ), ); } + List<_AgentFeature> _getAgentFeatures(ActiveAgent agent) { + return switch (agent) { + ActiveAgent.health => [ + _AgentFeature(Icons.trending_up, '趋势分析', '查看历史数据'), + _AgentFeature(Icons.add, '快速录入', '血压心率血糖'), + ], + ActiveAgent.diet => [ + _AgentFeature(Icons.camera_alt, '拍照识别', '自动识别食物'), + _AgentFeature(Icons.bar_chart, '营养分析', '热量营养成分'), + ], + ActiveAgent.medication => [ + _AgentFeature(Icons.list, '用药清单', '管理所有药品'), + _AgentFeature(Icons.alarm, '智能提醒', '按时服药提醒'), + ], + ActiveAgent.consultation => [ + _AgentFeature(Icons.message, '在线问诊', '专业医生解答'), + _AgentFeature(Icons.local_hospital, '科室选择', '精准匹配'), + ], + ActiveAgent.report => [ + _AgentFeature(Icons.upload, '上传报告', '支持多种格式'), + _AgentFeature(Icons.search, 'AI解读', '智能分析报告'), + ], + ActiveAgent.exercise => [ + _AgentFeature(Icons.calendar_month, '运动计划', '科学规划'), + _AgentFeature(Icons.check_circle, '打卡记录', '坚持完成'), + ], + _ => [ + _AgentFeature(Icons.health_and_safety, '健康管理', '全方位呵护'), + _AgentFeature(Icons.chat, '智能咨询', '随时答疑'), + ], + }; + } + + + Widget _buildDoctorCards(WidgetRef ref) { const doctors = [ {'name': '张明', 'title': '主任医师', 'dept': '心脏康复科', 'desc': '冠心病、高血压术后管理', 'id': '468b82e2-d95a-4436-bff6-a50eecf99a66'}, @@ -255,8 +414,8 @@ class ChatMessagesView extends ConsumerWidget { child: Text(doc['name']![0], style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w600, color: AppTheme.primary)), ), const SizedBox(height: 6), - Text(doc['name']!, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF333333))), - Text(doc['title']!, style: const TextStyle(fontSize: 14, color: Color(0xFF999999))), + Text(doc['name']!, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + Text(doc['title']!, style: const TextStyle(fontSize: 14, color: AppColors.textHint)), const SizedBox(height: 2), Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), @@ -269,7 +428,7 @@ class ChatMessagesView extends ConsumerWidget { const SizedBox(height: 4), Text( doc['desc']!, - style: const TextStyle(fontSize: 13, color: Color(0xFF888888), height: 1.3), + style: const TextStyle(fontSize: 13, color: AppColors.textSecondary, height: 1.3), maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, @@ -280,149 +439,167 @@ class ChatMessagesView extends ConsumerWidget { } // ═══════════════════════════════════════════════════════════ - // 2. DataConfirmCard — 增强版数据确认卡片 + // 2. DataConfirmCard — 统一确认卡片(紫白蓝风格) // ═══════════════════════════════════════════════════════════ Widget _buildDataConfirmCard(BuildContext context, WidgetRef ref, ChatMessage msg) { - final meta = msg.metadata; - final metricType = meta?['type'] as String? ?? ''; - final value = meta?['value'] as String? ?? ''; - final abnormal = meta?['abnormal'] as bool? ?? false; - final recordTime = meta?['recordTime'] as String? ?? ''; - final unit = meta?['unit'] as String? ?? _getMetricUnit(metricType); + final meta = msg.metadata ?? {}; + final backendType = meta['type'] as String? ?? ''; + final screenWidth = MediaQuery.of(context).size.width; + + // 识别是哪种数据类型 + final isMedication = backendType == 'medication' || (meta['name'] != null && meta['name'].toString().isNotEmpty && backendType != 'exercise'); + final isExercise = backendType == 'exercise'; + final isHealth = !isMedication && !isExercise; + + // 根据类型获取显示字段 + String title = '数据确认'; + IconData titleIcon = Icons.assignment; + String mainLabel = ''; + String mainValue = ''; + String mainUnit = ''; + bool abnormal = meta['abnormal'] as bool? ?? false; + String recordTime = meta['recordTime'] as String? ?? ''; + + List<_ConfirmField> fields = []; + + if (isMedication) { + // 药品录入 + title = '药品录入确认'; + titleIcon = Icons.medication_liquid_outlined; + mainLabel = meta['name'] as String? ?? ''; + mainValue = meta['dosage'] as String? ?? ''; + mainUnit = ''; + + if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt); + final nameValue = meta['药品名称'] as String? ?? mainLabel; + fields.add(_ConfirmField(label: '药品名称', value: nameValue, icon: Icons.medication_outlined, editable: true)); + if (mainValue.isNotEmpty) { + final dosageValue = meta['剂量'] as String? ?? mainValue; + fields.add(_ConfirmField(label: '剂量', value: dosageValue, icon: Icons.local_pharmacy_outlined, editable: true)); + } + final time = meta['time'] as String? ?? ''; + if (time.isNotEmpty) { + final timeValue = meta['服药时间'] as String? ?? time; + fields.add(_ConfirmField(label: '服药时间', value: timeValue, icon: Icons.access_time, editable: true)); + } + final frequency = meta['frequency'] as String? ?? ''; + if (frequency.isNotEmpty) { + final freqValue = meta['频率'] as String? ?? _freqLabel(frequency); + fields.add(_ConfirmField(label: '频率', value: freqValue, icon: Icons.repeat_outlined, editable: true)); + } + final duration = meta['duration_days'] as int?; + if (duration != null && duration > 0) { + final durationValue = meta['服用天数'] as String? ?? '$duration 天'; + fields.add(_ConfirmField(label: '服用天数', value: durationValue, icon: Icons.event_available_outlined, editable: true)); + } + } else if (isExercise) { + // 运动计划 + title = '运动计划确认'; + titleIcon = Icons.directions_run_outlined; + mainLabel = meta['name'] as String? ?? meta['activity'] as String? ?? '运动计划'; + mainValue = meta['duration'] as String? ?? ''; + mainUnit = meta['duration_unit'] as String? ?? '分钟'; + + if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt); + final typeValue = meta['运动类型'] as String? ?? mainLabel; + fields.add(_ConfirmField(label: '运动类型', value: typeValue, icon: Icons.fitness_center_outlined, editable: true)); + if (mainValue.isNotEmpty) { + final durationValue = meta['运动时长'] as String? ?? '$mainValue $mainUnit'; + fields.add(_ConfirmField(label: '运动时长', value: durationValue, icon: Icons.timer_outlined, editable: true)); + } + final intensity = meta['intensity'] as String? ?? ''; + if (intensity.isNotEmpty) { + final intensityValue = meta['运动强度'] as String? ?? intensity; + fields.add(_ConfirmField(label: '运动强度', value: intensityValue, icon: Icons.local_fire_department_outlined, editable: true)); + } + final calories = meta['calories'] as String? ?? meta['calorie'] as String? ?? ''; + if (calories.isNotEmpty) { + final caloriesValue = meta['消耗热量'] as String? ?? '$calories kcal'; + fields.add(_ConfirmField(label: '消耗热量', value: caloriesValue, icon: Icons.local_fire_department, editable: true)); + } + final schedule = meta['schedule'] as String? ?? ''; + if (schedule.isNotEmpty) { + final scheduleValue = meta['计划时间'] as String? ?? schedule; + fields.add(_ConfirmField(label: '计划时间', value: scheduleValue, icon: Icons.calendar_month_outlined, editable: true)); + } + } else { + // 健康指标 + title = '健康数据确认'; + titleIcon = Icons.monitor_heart_outlined; + mainLabel = _getMetricName(backendType); + mainValue = meta['value'] as String? ?? ''; + mainUnit = meta['unit'] as String? ?? _getMetricUnit(backendType); + + if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt); + final metricValue = meta[mainLabel] as String? ?? '$mainValue $mainUnit'; + fields.add(_ConfirmField(label: mainLabel, value: metricValue, icon: Icons.favorite_outline, editable: true)); + final timeValue = meta['记录时间'] as String? ?? recordTime; + fields.add(_ConfirmField(label: '记录时间', value: timeValue, icon: Icons.access_time, editable: true)); + final note = meta['note'] as String? ?? ''; + if (note.isNotEmpty) { + final noteValue = meta['备注'] as String? ?? note; + fields.add(_ConfirmField(label: '备注', value: noteValue, icon: Icons.edit_note_outlined, editable: true)); + } + } return Align( alignment: Alignment.centerLeft, child: Container( - margin: const EdgeInsets.only(bottom: 12), - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.92), + margin: const EdgeInsets.only(bottom: 16), + constraints: BoxConstraints(maxWidth: screenWidth * 0.95), decoration: BoxDecoration( - color: const Color(0xFFFFFFFF), - borderRadius: BorderRadius.circular(20), - boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(15), blurRadius: 14, offset: const Offset(0, 4))], - ), - clipBehavior: Clip.antiAlias, - child: Column(mainAxisSize: MainAxisSize.min, children: [ - Container( - width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 10), - decoration: const BoxDecoration(gradient: LinearGradient(colors: [Color(0xFF4CAF50), AppTheme.success])), - child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.info_outline, size: 21, color: Colors.white), const SizedBox(width: 6), - Text(metricType == 'exercise' ? '请确认运动计划' : '请确认录入', - style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: Colors.white)), - ]), - ), - Padding(padding: const EdgeInsets.all(18), child: Column(children: [ - Align(alignment: Alignment.centerLeft, child: Text(recordTime.isNotEmpty ? recordTime : _formatTime(msg.createdAt), style: const TextStyle(fontSize: 15, color: Color(0xFF9E9E9E)))), - const SizedBox(height: 14), - Container( - padding: const EdgeInsets.all(18), - decoration: BoxDecoration(color: const Color(0xFFF9F8FF), borderRadius: BorderRadius.circular(16)), - child: Row(children: [ - Container(width: 52, height: 52, decoration: BoxDecoration(color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(14)), child: Center(child: Text(_getMetricIcon(metricType), style: const TextStyle(fontSize: 30)))), - const SizedBox(width: 14), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(_getMetricName(metricType), style: const TextStyle(fontSize: 16, color: Color(0xFF888888))), - const SizedBox(height: 4), - RichText(text: TextSpan(children: [ - TextSpan(text: value, style: TextStyle(fontSize: 32, fontWeight: FontWeight.w800, color: abnormal ? AppTheme.error : const Color(0xFF1A1A2E))), - TextSpan(text: ' $unit', style: TextStyle(fontSize: 17, color: abnormal ? const Color(0xFFE53970) : const Color(0xFF999999))), - ])), - ])), - ]), - ), - if (abnormal) ...[ - const SizedBox(height: 12), - Container(width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration(color: const Color(0xFFFFF3F0), borderRadius: BorderRadius.circular(10), border: Border.all(color: const Color(0xFFFFDAD4))), - child: const Row(children: [Icon(Icons.warning_amber_rounded, size: 21, color: AppTheme.error), SizedBox(width: 8), Expanded(child: Text('数值偏高,建议关注', style: TextStyle(fontSize: 16, color: AppTheme.error, fontWeight: FontWeight.w500)))])), - ], - const SizedBox(height: 18), - if (msg.confirmed) - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: null, - icon: const Icon(Icons.check_circle, size: 21), - label: const Text('录入成功'), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.success, - foregroundColor: Colors.white, - disabledBackgroundColor: AppTheme.success, - disabledForegroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), - padding: const EdgeInsets.symmetric(vertical: 12), - ), - ), - ) - else - Row(children: [ - Expanded(child: _cardFilledBtn('确认录入', Icons.check, onTap: () { - msg.confirmed = true; - ref.invalidate(latestHealthProvider); - final notifier = ref.read(chatProvider.notifier); - notifier.markNeedsRebuild(); - })), - ]), - ])), - ]), - ), - ); - } - - // ═══════════════════════════════════════════════════════════ - // 3. MedicationConfirmCard — 增强版用药确认卡片 - // ═══════════════════════════════════════════════════════════ - - Widget _buildMedicationConfirmCard(BuildContext context, WidgetRef ref, ChatMessage msg) { - final meta = msg.metadata; - final name = meta?['name'] as String? ?? ''; - final dosage = meta?['dosage'] as String? ?? ''; - final time = meta?['time'] as String? ?? ''; - final frequency = meta?['frequency'] as String? ?? ''; - final duration = meta?['duration_days'] as int?; - - return Align( - alignment: Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.only(bottom: 12), - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.92), - decoration: BoxDecoration( - color: const Color(0xFFFFFFFF), - borderRadius: BorderRadius.circular(20), - boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(15), blurRadius: 14, offset: const Offset(0, 4))], + color: AppColors.cardBackground, + borderRadius: BorderRadius.circular(24), + boxShadow: AppColors.cardShadow, ), clipBehavior: Clip.antiAlias, child: Column( mainAxisSize: MainAxisSize.min, children: [ - // ── 药品头部 ── + // ── 标题区域(清爽蓝紫渐变背景) ── Container( width: double.infinity, - padding: const EdgeInsets.fromLTRB(18, 20, 18, 16), - decoration: const BoxDecoration( - gradient: LinearGradient(colors: [Color(0xFFE8F0FE), AppTheme.primaryLight]), + padding: const EdgeInsets.fromLTRB(20, 20, 20, 18), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppColors.purpleGradientStart.withAlpha(45), + AppColors.blueGradientEnd.withAlpha(25), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), ), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( - width: 46, - height: 46, + width: 56, + height: 56, decoration: BoxDecoration( - color: const Color(0xFFFFF3E0), - borderRadius: BorderRadius.circular(13), + gradient: AppColors.purpleBlueGradient, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow(color: AppColors.primaryPurple.withAlpha(35), blurRadius: 10, offset: const Offset(0, 4)), + ], ), - child: const Center(child: Text('💊', style: TextStyle(fontSize: 28))), + child: Icon(titleIcon, size: 28, color: Colors.white), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(name, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: Color(0xFF1A1A2E))), - if (dosage.isNotEmpty) ...[ - const SizedBox(height: 3), - Text(dosage, style: const TextStyle(fontSize: 16, color: Color(0xFF777777))), - ], + Text(title, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), + const SizedBox(height: 6), + Row( + children: [ + Icon(Icons.access_time, size: 15, color: AppColors.iconColor), + const SizedBox(width: 4), + Text(recordTime, style: const TextStyle(fontSize: 15, color: AppColors.textSecondary)), + ], + ), ], ), ), @@ -430,397 +607,275 @@ class ChatMessagesView extends ConsumerWidget { ), ), + // ── 主要数据展示区(大卡片) ── Padding( - padding: const EdgeInsets.fromLTRB(18, 16, 18, 6), - child: Column( - children: [ - // 服药时间 & 频率 - _medInfoRow(Icons.schedule_outlined, time.isNotEmpty ? '服药时间:$time' : ''), - if (frequency.isNotEmpty) _medInfoRow(Icons.repeat, '频率:${_freqLabel(frequency)}'), - if (duration != null && duration > 0) - _medInfoRow(Icons.calendar_today_outlined, '服用天数:$duration 天'), - const SizedBox(height: 16), - if (msg.confirmed) - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: null, - icon: const Icon(Icons.check_circle, size: 21), - label: const Text('录入成功'), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.success, - foregroundColor: Colors.white, - disabledBackgroundColor: AppTheme.success, - disabledForegroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), - padding: const EdgeInsets.symmetric(vertical: 12), - ), + padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppColors.backgroundSecondary, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: AppColors.border), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 68, + height: 68, + decoration: BoxDecoration( + gradient: AppColors.lightGradient, + borderRadius: BorderRadius.circular(18), ), - ) - else - SizedBox(width: double.infinity, child: _cardFilledBtn('确认录入', Icons.check_circle_outlined, - onTap: () { - msg.confirmed = true; - ref.invalidate(medicationListProvider); - final notifier = ref.read(chatProvider.notifier); - notifier.markNeedsRebuild(); - })), - const SizedBox(height: 8), - ], + child: Center( + child: isHealth + ? Text(_getMetricIcon(backendType), style: const TextStyle(fontSize: 36)) + : Icon( + isMedication ? Icons.medication_liquid_outlined : Icons.directions_run_outlined, + size: 36, + color: AppColors.iconColor, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(mainLabel, style: const TextStyle(fontSize: 17, color: AppColors.textSecondary)), + const SizedBox(height: 8), + RichText( + text: TextSpan( + children: [ + TextSpan( + text: mainValue.isNotEmpty ? mainValue : '—', + style: TextStyle( + fontSize: 36, + fontWeight: FontWeight.w800, + color: abnormal ? AppColors.error : AppColors.textPrimary, + letterSpacing: -0.5, + ), + ), + if (mainUnit.isNotEmpty) + TextSpan( + text: ' $mainUnit', + style: TextStyle( + fontSize: 19, + color: abnormal ? AppColors.error : AppColors.iconColor, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), ), ), + + // ── 异常提示 ── + if (abnormal) ...[ + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppColors.error.withAlpha(10), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.error.withAlpha(30)), + ), + child: Row( + children: [ + Container( + width: 32, height: 32, + decoration: BoxDecoration(color: AppColors.error.withAlpha(15), borderRadius: BorderRadius.circular(10)), + child: Icon(Icons.warning_amber_rounded, size: 20, color: AppColors.error), + ), + const SizedBox(width: 10), + Expanded(child: Text('数值偏高,建议关注并咨询医生', style: TextStyle(fontSize: 16, color: AppColors.error, fontWeight: FontWeight.w500))), + ], + ), + ), + ), + ], + + // ── 详细字段列表(可点击修改) ── + if (fields.isNotEmpty) ...[ + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + ), + child: Column( + children: [ + for (int i = 0; i < fields.length; i++) ...[ + if (i > 0) Container(height: 1, color: AppColors.borderLight, margin: const EdgeInsets.symmetric(horizontal: 16)), + _buildFieldRow(fields[i], msg, ref), + ], + ], + ), + ), + ), + ], + + // ── 确认按钮(白色底+渐变边框) ── + const SizedBox(height: 18), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Container( + width: double.infinity, + height: 56, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.borderLight, width: 1.5), + boxShadow: AppColors.cardShadow, + ), + child: msg.confirmed + ? Container( + decoration: BoxDecoration( + gradient: AppColors.successButtonGradient, + borderRadius: BorderRadius.circular(16), + boxShadow: AppColors.buttonShadow, + ), + child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + const Icon(Icons.check_circle_outline, size: 22, color: Colors.white), + const SizedBox(width: 8), + const Text('录入成功', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: Colors.white)), + ]), + ) + : Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + msg.confirmed = true; + if (isMedication) ref.invalidate(medicationListProvider); + if (isHealth) ref.invalidate(latestHealthProvider); + ref.read(chatProvider.notifier).markNeedsRebuild(); + }, + borderRadius: BorderRadius.circular(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 32, height: 32, + decoration: BoxDecoration( + gradient: AppColors.purpleBlueGradient, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.check, size: 18, color: Colors.white), + ), + const SizedBox(width: 10), + Text('确认录入', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + ], + ), + ), + ), + ), + ), + const SizedBox(height: 20), ], ), ), ); } - Widget _medInfoRow(IconData icon, String text) { - return Padding( - padding: const EdgeInsets.only(bottom: 10), + Widget _buildFieldRow(_ConfirmField field, ChatMessage msg, WidgetRef ref) { + final isEditing = field.label == msg.metadata?['_editingField']; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Row( children: [ - Icon(icon, size: 20, color: const Color(0xFF888888)), - const SizedBox(width: 8), - Text(text, style: const TextStyle(fontSize: 16, color: Color(0xFF444444))), + Container( + width: 42, height: 42, + decoration: BoxDecoration(color: AppColors.iconBackground, borderRadius: BorderRadius.circular(12)), + child: Icon(field.icon, size: 22, color: AppColors.iconColor), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(field.label, style: const TextStyle(fontSize: 15, color: AppColors.textHint)), + const SizedBox(height: 4), + if (!field.editable) + Text(field.value.isNotEmpty ? field.value : '未设置', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary)) + else if (isEditing) + _buildEditableTextField(field, msg, ref) + else + InkWell( + onTap: () { + msg.metadata?['_editingField'] = field.label; + ref.read(chatProvider.notifier).markNeedsRebuild(); + }, + child: Row( + children: [ + Expanded(child: Text(field.value.isNotEmpty ? field.value : '未设置', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary))), + const SizedBox(width: 8), + Icon(Icons.edit_outlined, size: 18, color: AppColors.border), + ], + ), + ), + ], + ), + ), ], ), ); } - String _freqLabel(String freq) { - switch (freq.toLowerCase()) { - case 'daily': return '每天'; - case 'everyotherday': return '隔天'; - case 'weekly': return '每周'; - default: return freq; - } - } - - // ═══════════════════════════════════════════════════════════ - // 4. DietAnalysisCard — 增强版饮食分析卡片 - // ═══════════════════════════════════════════════════════════ - - Widget _buildDietAnalysisCard(BuildContext context, ChatMessage msg) { - final meta = msg.metadata; - final foods = meta?['foods'] as List? ?? []; - final totalCalories = meta?['totalCalories'] as int? ?? 0; - final advice = meta?['advice'] as String? ?? '饮食均衡,多吃蔬菜水果,减少高油高糖食物摄入。'; - - return Align( - alignment: Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.only(bottom: 12), - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.92), - decoration: BoxDecoration( - color: const Color(0xFFFFFFFF), - borderRadius: BorderRadius.circular(20), - boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(15), blurRadius: 14, offset: const Offset(0, 4))], - ), - clipBehavior: Clip.antiAlias, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // ── 头部 ── - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: const BoxDecoration(gradient: LinearGradient(colors: [Color(0xFFFFF8E1), Color(0xFFFFF3E0)])), - child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('🍽️ ', style: TextStyle(fontSize: 21)), - Text('饮食分析结果', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700, color: Color(0xFF1A1A2E))), - ]), - ), - Padding( - padding: const EdgeInsets.all(16), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - // ── 总热量(仅 >0 时显示) ── - if (totalCalories > 0) ...[ - Center(child: Column(children: [ - Text('$totalCalories', style: const TextStyle(fontSize: 40, fontWeight: FontWeight.w800, color: Color(0xFFFF8F00))), - const Text('千卡 (kcal)', style: TextStyle(fontSize: 15, color: Color(0xFFAAAAAA))), - ])), - const SizedBox(height: 16), - ], - - // ── 识别食物列表 ── - if (foods.isNotEmpty) ...[ - const Text('识别结果', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), - const SizedBox(height: 10), - ...foods.map((food) { - final f = food is Map ? food : {}; - final name = f['name'] as String? ?? ''; - final calories = f['calories'] as num? ?? 0; - final portion = f['portion'] as String?; - final nutrients = f['nutrients'] as String?; - - return Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(10), - decoration: BoxDecoration(color: const Color(0xFFFAFAFA), borderRadius: BorderRadius.circular(10), border: Border.all(color: const Color(0xFFF0F0F0))), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Expanded(child: Text(name, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w500, color: Color(0xFF1A1A1A)))), - if (calories > 0) Text('${calories is int ? calories : calories.toInt()} kcal', style: const TextStyle(fontSize: 16, color: Color(0xFF888888))), - ]), - if (portion != null && portion.isNotEmpty) Padding(padding: const EdgeInsets.only(top: 4), child: Text(portion, style: TextStyle(fontSize: 15, color: Colors.grey[500]))), - if (nutrients != null && nutrients.isNotEmpty) Padding(padding: const EdgeInsets.only(top: 2), child: Text(nutrients, style: TextStyle(fontSize: 14, color: Colors.grey[500]))), - ]), - ); - }), - const SizedBox(height: 6), - ], - - // ── AI 建议 ── - const SizedBox(height: 14), - const Text('AI 建议', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), - const SizedBox(height: 6), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration(color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(10)), - child: Text(advice, style: const TextStyle(fontSize: 16, height: 1.6, color: Color(0xFF555555))), - ), - ]), - ), - ], - ), - ), - ); - } - - // ═══════════════════════════════════════════════════════════ - // 5. ReportAnalysisCard — 增强版报告分析卡片 - // ═══════════════════════════════════════════════════════════ - - Widget _buildReportAnalysisCard(BuildContext context, ChatMessage msg) { - final meta = msg.metadata; - final reportType = meta?['type'] as String? ?? '体检报告'; - final reportDate = meta?['date'] as String? ?? ''; - final indicators = meta?['indicators'] as List? ?? []; - final summary = meta?['summary'] as String? ?? ''; - - return Align( - alignment: Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.only(bottom: 12), - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.92), - decoration: BoxDecoration( - color: const Color(0xFFFFFFFF), - borderRadius: BorderRadius.circular(20), - boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(15), blurRadius: 14, offset: const Offset(0, 4))], - ), - clipBehavior: Clip.antiAlias, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // ── 报告头部 ── - Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(18, 18, 18, 14), - decoration: const BoxDecoration( - gradient: LinearGradient(colors: [Color(0xFFE8EAF6), Color(0xFFEDE7F6)]), - ), - child: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: const Color(0xFFC5CAE9), - borderRadius: BorderRadius.circular(10), - ), - child: const Icon(Icons.description_outlined, size: 23, color: Color(0xFF3F51B5)), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(reportType, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w700, color: Color(0xFF1A1A2E))), - if (reportDate.isNotEmpty) - Text(reportDate, style: const TextStyle(fontSize: 15, color: Color(0xFF888888))), - ], - ), - ), - ], - ), - ), - - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 指标表格 - Container( - decoration: BoxDecoration( - color: const Color(0xFFFAFAFC), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFEEEEEE), width: 1), - ), - child: Column( - children: [ - // 表头 - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: const BoxDecoration( - color: Color(0xFFF5F4FA), - borderRadius: BorderRadius.vertical(top: Radius.circular(12)), - ), - child: const Row( - children: [ - Expanded(flex: 2, child: Text('指标名称', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF666666)))), - Expanded(flex: 1, child: Text('数值', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF666666)), textAlign: TextAlign.center)), - Expanded(flex: 1, child: Text('状态', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF666666)), textAlign: TextAlign.center)), - ], - ), - ), - // 数据行 - ...indicators.map((ind) { - final i = ind as Map? ?? {}; - final name = i['name'] as String? ?? ''; - final value = i['value'] as String? ?? ''; - final status = i['status'] as String? ?? 'normal'; - final refRange = i['refRange'] as String? ?? ''; - final isAbnormal = status != 'normal'; - Color sc; - switch (status) { - case 'high': sc = AppTheme.error; break; - case 'low': sc = const Color(0xFFF9A825); break; - default: sc = AppTheme.success; - } - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: isAbnormal ? const Color(0xFFFFF8F5) : Colors.transparent, - border: Border( - bottom: BorderSide(color: const Color(0xFFF0F0F0), width: 0.5), - ), - ), - child: Row( - children: [ - Expanded( - flex: 2, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(name, style: TextStyle(fontSize: 16, color: isAbnormal ? AppTheme.error : const Color(0xFF333333), fontWeight: isAbnormal ? FontWeight.w600 : FontWeight.normal)), - if (refRange.isNotEmpty) Text('参考:$refRange', style: const TextStyle(fontSize: 13, color: Color(0xFFAAAAAA))), - ], - ), - ), - Expanded(flex: 1, child: Text(value, textAlign: TextAlign.center, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: sc))), - Expanded( - flex: 1, - child: Center( - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration(shape: BoxShape.circle, color: sc), - ), - ), - ), - ], - ), - ); - }), - ], - ), - ), - - // AI 解读摘要 - if (summary.isNotEmpty) ...[ - const SizedBox(height: 14), - Container( - width: double.infinity, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: const Color(0xFFF3EFFF), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFDDD8FF), width: 0.8), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Row( - children: [ - Icon(Icons.auto_awesome, size: 19, color: AppTheme.primary), - SizedBox(width: 6), - Text('AI 解读摘要', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppTheme.primary)), - ], - ), - const SizedBox(height: 8), - Text(summary, style: const TextStyle(fontSize: 16, color: Color(0xFF555555), height: 1.5)), - ], - ), - ), - ], - - // 查看完整解读按钮 - const SizedBox(height: 14), - SizedBox( - width: double.infinity, - child: _cardFilledBtn('查看完整解读', Icons.article_outlined), - ), - const SizedBox(height: 4), - ], - ), - ), - ], - ), - ), - ); - } - - // 6. QuickOptionsCard — 优化样式 - // ═══════════════════════════════════════════════════════════ - - Widget _buildQuickOptionsCard(BuildContext context, ChatMessage msg) { - final meta = msg.metadata; - final options = meta?['options'] as List? ?? []; - - return Align( - alignment: Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.only(bottom: 12), - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.92), - decoration: BoxDecoration( - color: const Color(0xFFFFFFFF), - borderRadius: BorderRadius.circular(20), - boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(15), blurRadius: 14, offset: const Offset(0, 4))], - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(18, 16, 18, 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(msg.content, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500, color: Color(0xFF1A1A2E))), - const SizedBox(height: 14), - Wrap(spacing: 8, runSpacing: 8, children: options.map((opt) { - final o = opt as Map? ?? {}; - return ElevatedButton( - onPressed: () {}, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryLight, - foregroundColor: AppTheme.primary, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 11), - ), - child: Text(o['label'] as String? ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), - ); - }).toList()), - ], + Widget _buildEditableTextField(_ConfirmField field, ChatMessage msg, WidgetRef ref) { + return StatefulBuilder( + builder: (context, setState) { + final controller = TextEditingController(text: field.value); + return Container( + decoration: BoxDecoration( + border: Border.all(color: AppColors.primaryBlue.withAlpha(150), width: 1.5), + borderRadius: BorderRadius.circular(8), + color: AppColors.backgroundSecondary, ), - ), - ), + child: TextField( + controller: controller, + autofocus: true, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary), + decoration: const InputDecoration( + contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + border: InputBorder.none, + ), + onSubmitted: (value) { + msg.metadata?[field.label] = value; + msg.metadata?['_editingField'] = null; + ref.read(chatProvider.notifier).markNeedsRebuild(); + }, + onTapOutside: (_) { + msg.metadata?[field.label] = controller.text; + msg.metadata?['_editingField'] = null; + ref.read(chatProvider.notifier).markNeedsRebuild(); + }, + ), + ); + }, ); } - // ═══════════════════════════════════════════════════════════ - // 公共组件:思考气泡 & 文本气泡 - // ═══════════════════════════════════════════════════════════ + void _editField(_ConfirmField field, ChatMessage msg, WidgetRef ref) { + // 字段可点击修改的入口,后续可接入 showDialog 实现 + } + // ═══════════════════════════════════════════════════════════ + // 3. ThinkingBubble — 思考动画 + // ═══════════════════════════════════════════════════════════ Widget _buildThinkingBubble(BuildContext context, String? thinkingText) { return Align( alignment: Alignment.centerLeft, @@ -828,9 +883,9 @@ class ChatMessagesView extends ConsumerWidget { margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), decoration: BoxDecoration( - color: const Color(0xFFFEFEFF), + color: AppColors.cardBackground, borderRadius: const BorderRadius.only(topLeft: Radius.circular(4), topRight: Radius.circular(20), bottomLeft: Radius.circular(20), bottomRight: Radius.circular(20)), - border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5), + border: Border.all(color: AppColors.border, width: 1.5), boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(12), blurRadius: 10, offset: const Offset(0, 3))], ), child: Row( @@ -847,14 +902,14 @@ class ChatMessagesView extends ConsumerWidget { child: const CircularProgressIndicator(strokeWidth: 2.2, color: AppTheme.primary), ), const SizedBox(width: 10), - Text(thinkingText?.isNotEmpty == true ? thinkingText! : '正在分析...', style: const TextStyle(fontSize: 17, color: Color(0xFF999999))), + Text(thinkingText?.isNotEmpty == true ? thinkingText! : '正在分析...', style: const TextStyle(fontSize: 17, color: AppColors.textHint)), ], ), ), ); } - Widget _buildTextBubble(BuildContext context, ChatMessage msg) { + Widget _buildTextBubble(BuildContext context, ChatMessage msg, ChatState? chatState) { final isUser = msg.isUser; final imageUrl = msg.metadata?['imageUrl'] as String?; final localPath = msg.metadata?['localImagePath'] as String?; @@ -867,14 +922,14 @@ class ChatMessagesView extends ConsumerWidget { constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.82), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration( - color: isUser ? AppTheme.primary : const Color(0xFFFEFEFF), + color: isUser ? AppTheme.primary : AppColors.cardBackground, borderRadius: BorderRadius.only( topLeft: Radius.circular(isUser ? 20 : 4), topRight: Radius.circular(isUser ? 4 : 20), bottomLeft: const Radius.circular(20), bottomRight: const Radius.circular(20), ), - border: isUser ? null : Border.all(color: const Color(0xFFD8DCFD), width: 1.5), + border: isUser ? null : Border.all(color: AppColors.border, width: 1.5), boxShadow: isUser ? [] : [BoxShadow(color: AppTheme.primary.withAlpha(12), blurRadius: 10, offset: const Offset(0, 3))], ), child: Column( @@ -882,18 +937,12 @@ class ChatMessagesView extends ConsumerWidget { children: [ // 文字内容 if (isUser) - SelectableText(msg.content, style: const TextStyle(fontSize: 19, color: Colors.white, height: 1.4)) + SelectableText(msg.content, style: const TextStyle(fontSize: 19, color: Colors.white, height: 1.5)) + else if (chatState?.isStreaming ?? false) + // 流式输出中:逐字淡入 + _buildFadeInText(_stripMd(msg.content), const TextStyle(fontSize: 19, color: AppColors.textPrimary, height: 1.5), msg.id) else - MarkdownBody( - data: msg.content, - selectable: true, - styleSheet: MarkdownStyleSheet( - p: const TextStyle(fontSize: 19, color: Color(0xFF1A1A1A), height: 1.5), - h1: const TextStyle(fontSize: 21, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)), - h2: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)), - code: const TextStyle(fontSize: 17, backgroundColor: AppTheme.primaryLight), - ), - ), + SelectableText(_stripMd(msg.content), style: const TextStyle(fontSize: 19, color: AppColors.textPrimary, height: 1.5)), // 图片缩略图(在文字下方) if (hasImage) @@ -910,8 +959,8 @@ class ChatMessagesView extends ConsumerWidget { : imageUrl != null ? Image.network(imageUrl, fit: BoxFit.cover, errorBuilder: (_, e, s) => Container( width: 80, height: 60, - decoration: BoxDecoration(color: const Color(0xFFF0F0F0), borderRadius: BorderRadius.circular(8)), - child: const Icon(Icons.image, size: 28, color: Color(0xFFBBBBBB)), + decoration: BoxDecoration(color: AppColors.backgroundSecondary, borderRadius: BorderRadius.circular(8)), + child: const Icon(Icons.image, size: 28, color: AppColors.textHint), )) : const SizedBox.shrink(), ), @@ -925,9 +974,9 @@ class ChatMessagesView extends ConsumerWidget { child: Row(children: [ const CircleAvatar(radius: 10, backgroundColor: AppTheme.primaryLight, child: Icon(Icons.chat_bubble_outline, size: 17, color: AppTheme.primary)), const SizedBox(width: 6), - const Text('健康管家', style: TextStyle(fontSize: 15, color: Color(0xFF9E9E9E))), + const Text('健康管家', style: TextStyle(fontSize: 15, color: AppColors.textHint)), const SizedBox(width: 4), - const Text('仅供参考', style: TextStyle(fontSize: 14, color: Color(0xFFCCCCCC))), + const Text('仅供参考', style: TextStyle(fontSize: 14, color: AppColors.textHint)), ]), ), ], @@ -999,6 +1048,28 @@ class ChatMessagesView extends ConsumerWidget { ); } + /// 去掉 AI 返回的 Markdown 标记(**加粗**、*斜体*、`代码`、标题、列表符号等) + static String _stripMd(String text) { + return text + .replaceAll(RegExp(r'\*\*(.+?)\*\*'), r'$1') + .replaceAll(RegExp(r'\*(.+?)\*'), r'$1') + .replaceAll(RegExp(r'^#{1,6}\s+', multiLine: true), '') + .replaceAll(RegExp(r'^[\-\*]\s+', multiLine: true), '') + .replaceAll(RegExp(r'`(.+?)`'), r'$1') + .replaceAll(RegExp(r'\[([^\]]+)\]\([^)]+\)'), r'$1') + .replaceAll(RegExp(r'\n{3,}'), '\n\n') + .trim(); + } + + String _freqLabel(String freq) { + switch (freq.toLowerCase()) { + case 'daily': return '每天'; + case 'everyotherday': return '隔天'; + case 'weekly': return '每周'; + default: return freq; + } + } + String _formatTime(DateTime dt) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); @@ -1055,7 +1126,7 @@ class ChatMessagesView extends ConsumerWidget { if (reminders.isEmpty) { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('暂无待服药记录'), backgroundColor: Color(0xFFFF9800)), + const SnackBar(content: Text('暂无待服药记录'), backgroundColor: AppColors.warning), ); return; } @@ -1267,7 +1338,7 @@ class ChatMessagesView extends ConsumerWidget { Row(children: [ Icon(Icons.today, size: 21, color: AppTheme.primary), const SizedBox(width: 8), - const Text('今日任务', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + const Text('今日任务', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), ]), const SizedBox(height: 10), ...tasks, @@ -1276,7 +1347,7 @@ class ChatMessagesView extends ConsumerWidget { } Widget _taskRow(BuildContext context, IconData icon, String label, {String status = 'pending', String? trailing, VoidCallback? onTap}) { - final colors = {'done': AppTheme.success, 'warning': const Color(0xFFFF9800), 'pending': const Color(0xFF9E9E9E), 'overdue': AppTheme.error}; + final colors = {'done': AppTheme.success, 'warning': AppColors.warning, 'pending': AppColors.textHint, 'overdue': AppTheme.error}; final icons = {'done': Icons.check_circle, 'warning': Icons.warning, 'pending': Icons.circle_outlined, 'overdue': Icons.error}; final isOverdue = status == 'overdue'; return Padding( @@ -1293,7 +1364,7 @@ class ChatMessagesView extends ConsumerWidget { child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ Container(width: 30, height: 30, decoration: BoxDecoration(color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(8)), child: Icon(icon, size: 18, color: AppTheme.primary)), const SizedBox(width: 10), - Expanded(child: Text(trailing ?? label, style: const TextStyle(fontSize: 16, color: Color(0xFF333333)))), + Expanded(child: Text(trailing ?? label, style: const TextStyle(fontSize: 16, color: AppColors.textPrimary))), Icon(icons[status] ?? Icons.circle_outlined, size: 21, color: colors[status] ?? Colors.grey), ]), ), @@ -1333,6 +1404,27 @@ class _AgentAction { const _AgentAction({required this.label, required this.icon, this.isWide = false, this.route, this.action}); } +class _AgentFeature { + final IconData icon; + final String title; + final String desc; + + const _AgentFeature(this.icon, this.title, this.desc); +} + +class _ConfirmField { + final String label; + final String value; + final IconData icon; + final bool editable; + const _ConfirmField({ + required this.label, + required this.value, + this.icon = Icons.info_outline, + this.editable = false, + }); +} + final _agentActions = >{ ActiveAgent.health: [ _AgentAction(label: '查看趋势', icon: Icons.trending_up, isWide: true, route: 'trend'), @@ -1383,7 +1475,7 @@ class _ExpandableAdviceState extends State<_ExpandableAdvice> { width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration( - color: const Color(0xFFF7F5FF), + color: AppColors.backgroundSecondary, borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFFE8E4FF), width: 0.8), ), @@ -1396,7 +1488,7 @@ class _ExpandableAdviceState extends State<_ExpandableAdvice> { const SizedBox(width: 6), const Text('AI 建议', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppTheme.primary)), const Spacer(), - Icon(_expanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, size: 21, color: const Color(0xFFAAAAAA)), + Icon(_expanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, size: 21, color: AppColors.textHint), ], ), if (_expanded) ...[ diff --git a/health_app/lib/pages/profile/profile_detail_page.dart b/health_app/lib/pages/profile/profile_detail_page.dart deleted file mode 100644 index f320174..0000000 --- a/health_app/lib/pages/profile/profile_detail_page.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'package:flutter/material.dart'; -import '../../core/app_theme.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../core/navigation_provider.dart'; -import '../../providers/data_providers.dart'; -import '../../services/health_service.dart'; - -class ProfileDetailPage extends ConsumerWidget { - const ProfileDetailPage({super.key}); - - @override Widget build(BuildContext context, WidgetRef ref) { - final latestHealth = ref.watch(latestHealthProvider); - final userService = ref.watch(userServiceProvider); - - return Scaffold( - backgroundColor: AppTheme.bg, - appBar: AppBar( - backgroundColor: Colors.white, - elevation: 0, - leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => Navigator.pop(context)), - title: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.person_outline, size: 23, color: Colors.grey[600]), - const SizedBox(width: 6), - Text('健康档案', style: TextStyle(color: Colors.grey[800], fontWeight: FontWeight.w600)), - ]), - centerTitle: true, - ), - body: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [_buildUserCard(userService), const SizedBox(height: 16), _buildHealthOverview(latestHealth), const SizedBox(height: 16), _buildHistoryList(), const SizedBox(height: 16), SizedBox(width: double.infinity, height: 48, child: OutlinedButton(onPressed: () => pushRoute(ref, 'settings'), style: OutlinedButton.styleFrom(foregroundColor: AppTheme.primary, side: const BorderSide(color: AppTheme.primary), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24))), child: const Text('退出档案')))]))), - ); - } - - Widget _buildUserCard(UserService userService) { - return FutureBuilder?>( - future: userService.getProfile(), - builder: (ctx, snap) { - final p = snap.data; - final name = (p != null && p['name'] != null && (p['name'] as String).isNotEmpty) ? p['name'] : '用户'; - final gender = p?['gender'] ?? ''; - final birth = p?['birthDate'] ?? ''; - final ageStr = birth.toString().isNotEmpty ? _calcAge(birth.toString()) : ''; - final info = [if (ageStr.isNotEmpty) ageStr, if (gender.toString().isNotEmpty) gender].join(' · '); - return Container( - width: double.infinity, padding: const EdgeInsets.all(20), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), - boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]), - child: Row(children: [ - CircleAvatar(radius: 32, backgroundColor: AppTheme.primaryLight, - child: Text(name.toString().isNotEmpty ? name.toString()[0] : '?', style: const TextStyle(fontSize: 25, fontWeight: FontWeight.bold, color: AppTheme.primary))), - const SizedBox(width: 16), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(name.toString(), style: const TextStyle(fontSize: 25, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))), - if (info.isNotEmpty) const SizedBox(height: 4), - if (info.isNotEmpty) Text(info, style: TextStyle(fontSize: 17, color: Colors.grey[500])), - ])), - Icon(Icons.chevron_right, size: 28, color: Colors.grey[400]), - ]), - ); - }, - ); - } - - String _calcAge(String birthDate) { - final d = DateTime.tryParse(birthDate); - if (d == null) return ''; - final now = DateTime.now(); - var age = now.year - d.year; - if (now.month < d.month || (now.month == d.month && now.day < d.day)) age--; - return '$age岁'; - } - - Widget _buildHealthOverview(AsyncValue> healthData) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(20), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('健康概览', style: TextStyle(fontSize: 21, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), - const SizedBox(height: 4), - Text('(最近测量)', style: TextStyle(fontSize: 16, color: Colors.grey[500])), - const SizedBox(height: 16), - healthData.when(data: (data) => _buildMetricsList(data), loading: () => const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primary))), error: (_, e) => _buildMetricsEmpty()), - ]), - ); - } - - Widget _buildMetricsList(Map data) { - return Column(children: [_metricRow(Icons.favorite, '血压', _formatBP(data['BloodPressure'])), const Divider(), _metricRow(Icons.monitor_heart, '心率', _formatMetric(data['HeartRate'], '次/分')), const Divider(), _metricRow(Icons.bloodtype, '血糖', _formatMetric(data['Glucose'], 'mmol/L')), const Divider(), _metricRow(Icons.air, '血氧', _formatMetric(data['SpO2'], '%')), const Divider(), _metricRow(Icons.monitor_weight, '体重', _formatMetric(data['Weight'], 'kg'))]); - } - - Widget _buildMetricsEmpty() { - return Column(children: [_metricRow(Icons.favorite, '血压', '--/--'), const Divider(), _metricRow(Icons.monitor_heart, '心率', '-- 次/分'), const Divider(), _metricRow(Icons.bloodtype, '血糖', '-- mmol/L'), const Divider(), _metricRow(Icons.air, '血氧', '-- %'), const Divider(), _metricRow(Icons.monitor_weight, '体重', '-- kg')]); - } - - String _formatBP(dynamic bp) { if (bp is Map) { final s = bp['systolic']; final d = bp['diastolic']; if (s != null && d != null) return '$s/$d'; } return '--/--'; } - String _formatMetric(dynamic val, String unit) { if (val is Map) { final v = val['value']; if (v != null) return '$v$unit'; } return '-- $unit'; } - - Widget _metricRow(IconData icon, String label, String value) => InkWell(onTap: () {}, borderRadius: BorderRadius.circular(12), child: Padding(padding: const EdgeInsets.symmetric(vertical: 14), child: Row(children: [Container(width: 40, height: 40, decoration: BoxDecoration(color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 21, color: AppTheme.primary)), const SizedBox(width: 12), Expanded(child: Text(label, style: const TextStyle(fontSize: 18, color: Color(0xFF333333)))), Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const SizedBox(width: 8), Icon(Icons.chevron_right, size: 21, color: Colors.grey[400])]))); - - Widget _buildHistoryList() { - final items = [{'date': '05-31', 'label': '血压 · 餐前', 'value': '128/82', 'status': 'normal'}, {'date': '05-30', 'label': '血压 · 餐后', 'value': '135/85', 'status': 'warning'}, {'date': '05-29', 'label': '血压 · 餐前', 'value': '122/78', 'status': 'normal'}, {'date': '05-28', 'label': '血压 · 餐前', 'value': '118/76', 'status': 'normal'}, {'date': '05-27', 'label': '血糖 · 空腹', 'value': '5.6', 'status': 'normal'}, {'date': '05-26', 'label': '血压 · 餐前', 'value': '120/80', 'status': 'normal'}]; - return Container(decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]), child: Column(children: [Container(padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: Row(children: [const Text('历史记录', style: TextStyle(fontSize: 21, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const Spacer(), Text('查看更多', style: TextStyle(fontSize: 16, color: AppTheme.primary))])), ...items.map(_historyItem)])); - } - - Widget _historyItem(Map item) { - final isNormal = item['status'] == 'normal'; - return Container(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)), child: Row(children: [Text(item['date']?.toString() ?? '', style: const TextStyle(fontSize: 17, color: Color(0xFF9E9E9E))), const SizedBox(width: 8), Expanded(child: Text(item['label']?.toString() ?? '', style: const TextStyle(fontSize: 17, color: Color(0xFF333333)))), Text(item['value']?.toString() ?? '', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const SizedBox(width: 8), Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: isNormal ? AppTheme.success.withAlpha(20) : const Color(0xFFFF9800).withAlpha(20), borderRadius: BorderRadius.circular(10)), child: Text(isNormal ? '正常' : '偏高', style: TextStyle(fontSize: 14, color: isNormal ? AppTheme.success : const Color(0xFFFF9800))))])); - } -} diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index 3372736..619920b 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -521,13 +521,6 @@ class _HealthArchivePageState extends ConsumerState { ); } -/// 编辑资料(已合并到健康档案,保留路由兼容) -class EditProfilePage extends ConsumerWidget { - final String? id; - const EditProfilePage({super.key, this.id}); - @override Widget build(BuildContext context, WidgetRef ref) => const HealthArchivePage(); -} - /// 健康日历 class HealthCalendarPage extends ConsumerStatefulWidget { const HealthCalendarPage({super.key}); diff --git a/health_app/lib/providers/chat_provider.dart b/health_app/lib/providers/chat_provider.dart index 96da5d9..c570fe6 100644 --- a/health_app/lib/providers/chat_provider.dart +++ b/health_app/lib/providers/chat_provider.dart @@ -5,7 +5,7 @@ import 'auth_provider.dart'; import 'data_providers.dart'; import '../utils/sse_handler.dart'; -enum MessageType { text, dataConfirm, medicationConfirm, dietAnalysis, reportAnalysis, quickOptions, agentWelcome, taskCard } +enum MessageType { text, dataConfirm, agentWelcome, taskCard } class ChatMessage { final String id; @@ -77,6 +77,8 @@ ActiveAgent _parseAgent(String? type) { class ChatNotifier extends Notifier { StreamSubscription>? _subscription; + String _streamBuffer = ''; + Timer? _streamTimer; void markNeedsRebuild() => state = state.copyWith(); @@ -174,6 +176,23 @@ class ChatNotifier extends Notifier { )]); } + /// 点击胶囊:先出用户标签 → 0.5 秒后出欢迎卡片,不走 AI + void triggerAgent(ActiveAgent agent, String label) { + final userMsg = ChatMessage( + id: 'agent_trigger_${DateTime.now().millisecondsSinceEpoch}', + role: 'user', + content: label, + createdAt: DateTime.now(), + ); + // 先出用户消息 + state = state.copyWith(messages: [...state.messages, userMsg]); + + // 短暂延迟后弹出卡片 + Future.delayed(const Duration(milliseconds: 400), () { + insertAgentWelcome(agent); + }); + } + Future sendImage(String imagePath, String text) async { final file = File(imagePath); if (!await file.exists()) return; @@ -241,7 +260,8 @@ class ChatNotifier extends Notifier { createdAt: DateTime.now(), ); - state = state.copyWith(isStreaming: true); + // 立即加入空 AI 消息,让思考动画有载体 + state = state.copyWith(messages: [...state.messages, aiMsg], isStreaming: true); try { final token = await ref.read(apiClientProvider).accessToken; @@ -288,15 +308,21 @@ class ChatNotifier extends Notifier { case 'conversation_id': state = state.copyWith(conversationId: j['data']?.toString()); case 'answer': - aiMsg.content += (j['data'] as String?) ?? ''; + _streamBuffer += (j['data'] as String?) ?? ''; final messageType = j['type'] as String? ?? 'text'; aiMsg.type = _parseMessageType(messageType); - // 从 SSE 中获取元数据(用药/健康数据的确认信息) if (j['metadata'] is Map) { aiMsg.metadata = Map.from(j['metadata']); } - state = state.copyWith(thinkingText: null); - _update(aiMsg); + // 逐字释放 + _streamTimer?.cancel(); + _streamTimer = Timer.periodic(const Duration(milliseconds: 40), (t) { + if (_streamBuffer.isEmpty) { t.cancel(); state = state.copyWith(thinkingText: null); return; } + aiMsg.content += _streamBuffer[0]; + _streamBuffer = _streamBuffer.substring(1); + state = state.copyWith(thinkingText: null); + _update(aiMsg); + }); case 'notice': state = state.copyWith(thinkingText: j['message'] as String?); case 'tool_result': @@ -315,13 +341,13 @@ class ChatNotifier extends Notifier { MessageType _parseMessageType(String type) { switch (type) { - case 'data_confirm': return MessageType.dataConfirm; - case 'medication_confirm': return MessageType.medicationConfirm; - case 'diet_analysis': return MessageType.dietAnalysis; - case 'report_analysis': return MessageType.reportAnalysis; - case 'quick_options': return MessageType.quickOptions; - case 'agent_welcome': return MessageType.agentWelcome; - default: return MessageType.text; + case 'data_confirm': + case 'medication_confirm': + return MessageType.dataConfirm; + case 'agent_welcome': + return MessageType.agentWelcome; + default: + return MessageType.text; } } @@ -330,13 +356,19 @@ class ChatNotifier extends Notifier { final i = u.indexWhere((x) => x.id == m.id); if (i >= 0) { u[i] = m; - } else if (m.content.isNotEmpty) { - u.add(m); + } else { + u.add(m); // 空内容也加入,让思考气泡有载体 } state = state.copyWith(messages: u); } void _done(ChatMessage m) { + _streamTimer?.cancel(); + // 释放剩余 buffer + while (_streamBuffer.isNotEmpty) { + m.content += _streamBuffer[0]; + _streamBuffer = _streamBuffer.substring(1); + } final u = state.messages.toList(); if (!u.any((x) => x.id == m.id) && m.content.isNotEmpty) u.add(m); state = state.copyWith(messages: u, isStreaming: false, thinkingText: null); diff --git a/health_app/lib/widgets/common_widgets.dart b/health_app/lib/widgets/common_widgets.dart new file mode 100644 index 0000000..5c1890d --- /dev/null +++ b/health_app/lib/widgets/common_widgets.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import '../core/app_colors.dart'; + +/// 渐变边框按钮(白色底+彩色渐变边框) +class GradientBorderButton extends StatelessWidget { + final String text; + final IconData? icon; + final VoidCallback? onTap; + final double height; + final bool isSuccess; + + const GradientBorderButton({ + super.key, + required this.text, + this.icon, + this.onTap, + this.height = 52, + this.isSuccess = false, + }); + + @override + Widget build(BuildContext context) { + return Container( + height: height, + decoration: BoxDecoration( + gradient: isSuccess ? AppColors.successButtonGradient : AppColors.buttonGradient, + borderRadius: BorderRadius.circular(14), + boxShadow: AppColors.buttonShadow, + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + Icon(icon, size: 22, color: AppColors.textOnGradient), + const SizedBox(width: 8), + ], + Text( + text, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: AppColors.textOnGradient, + ), + ), + ], + ), + ), + ), + ); + } +} + +/// 卡片内的小操作按钮 +class CardActionButton extends StatelessWidget { + final String label; + final IconData icon; + final VoidCallback? onTap; + final Color? iconColor; + + const CardActionButton({ + super.key, + required this.label, + required this.icon, + this.onTap, + this.iconColor, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: AppColors.backgroundSecondary, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.borderLight), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 18, color: iconColor ?? AppColors.iconColor), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), + ); + } +} + +/// 功能区小图标容器 +class IconBox extends StatelessWidget { + final IconData icon; + final double size; + final Color? backgroundColor; + final Color? iconColor; + + const IconBox({ + super.key, + required this.icon, + this.size = 44, + this.backgroundColor, + this.iconColor, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: backgroundColor ?? AppColors.iconBackground, + borderRadius: BorderRadius.circular(size / 3), + ), + child: Icon( + icon, + size: size * 0.55, + color: iconColor ?? AppColors.iconColor, + ), + ); + } +}