feat: UI清爽紫蓝风全面改造 + 对话卡片重构 + 配色体系升级
- 新增 AppColors 统一配色方案(清爽紫蓝,参考蚂蚁阿福风格) - 新增 common_widgets(GradientBorderButton / CardActionButton / IconBox) - AgentWelcomeCard 美化:渐变 header + AI标签 + 核心功能区 + 快捷操作区 - DataConfirmCard 统一:三合一(健康/药品/运动),可编辑字段列表 - 删除死代码:medicationConfirm/dietAnalysis/reportAnalysis/quickOptions 卡片 - 删除死路由:doctors/profileEdit/editProfile - AI 对话去除 Markdown 符号(_stripMd),统一行间距 1.5 - 逐字淡入动画修复:key 改用 stableId 避免重复触发 - 健康概览时间修复:前端发送 UTC 避免时区歧义 - PG 数据目录迁入项目 backend/pgdata/,加入 .gitignore - 新增欧姆龙血压计实施方案文档
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -8,6 +8,9 @@
|
||||
backend/**/bin/
|
||||
backend/**/obj/
|
||||
|
||||
# PostgreSQL local data
|
||||
backend/pgdata/
|
||||
|
||||
# Flutter build outputs
|
||||
health_app/build/
|
||||
health_app/.dart_tool/
|
||||
|
||||
170
docs/omron_bp_implementation_plan.md
Normal file
170
docs/omron_bp_implementation_plan.md
Normal file
@@ -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 后台断连 | 前台连接同步,完成后断开;不做后台持久连接 |
|
||||
@@ -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 {
|
||||
|
||||
161
health_app/lib/core/app_colors.dart
Normal file
161
health_app/lib/core/app_colors.dart
Normal file
@@ -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<BoxShadow> cardShadow = [
|
||||
BoxShadow(
|
||||
color: primaryPurple.withAlpha(15),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
];
|
||||
|
||||
/// 按钮阴影
|
||||
static List<BoxShadow> buttonShadow = [
|
||||
BoxShadow(
|
||||
color: primaryPurple.withAlpha(20),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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<String, Color> 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),
|
||||
);
|
||||
|
||||
@@ -137,7 +137,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
: _selected == 'spo2' ? 'SpO2'
|
||||
: 'Weight',
|
||||
'source': 'Manual',
|
||||
'recordedAt': DateTime.now().toIso8601String(),
|
||||
'recordedAt': DateTime.now().toUtc().toIso8601String(),
|
||||
'unit': _unit,
|
||||
};
|
||||
if (_isBP) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -33,6 +33,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
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<HomePage> {
|
||||
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)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<Map<String, dynamic>?>(
|
||||
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<Map<String, dynamic>> 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<String, dynamic> 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<String, dynamic> 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))))]));
|
||||
}
|
||||
}
|
||||
@@ -521,13 +521,6 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 编辑资料(已合并到健康档案,保留路由兼容)
|
||||
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});
|
||||
|
||||
@@ -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<ChatState> {
|
||||
StreamSubscription<Map<String, dynamic>>? _subscription;
|
||||
String _streamBuffer = '';
|
||||
Timer? _streamTimer;
|
||||
|
||||
void markNeedsRebuild() => state = state.copyWith();
|
||||
|
||||
@@ -174,6 +176,23 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
)]);
|
||||
}
|
||||
|
||||
/// 点击胶囊:先出用户标签 → 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<void> sendImage(String imagePath, String text) async {
|
||||
final file = File(imagePath);
|
||||
if (!await file.exists()) return;
|
||||
@@ -241,7 +260,8 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
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<ChatState> {
|
||||
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<String, dynamic>.from(j['metadata']);
|
||||
}
|
||||
// 逐字释放
|
||||
_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<ChatState> {
|
||||
|
||||
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<ChatState> {
|
||||
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);
|
||||
|
||||
136
health_app/lib/widgets/common_widgets.dart
Normal file
136
health_app/lib/widgets/common_widgets.dart
Normal file
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user