From d102205b18a6dcc261be66b757c58389eaa3b39d Mon Sep 17 00:00:00 2001 From: MingNian <1281442923@qq.com> Date: Thu, 11 Jun 2026 22:11:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=93=9D=E7=89=99=E8=A1=80=E5=8E=8B?= =?UTF-8?q?=E8=AE=A1BLE=E6=95=B0=E6=8D=AE=E5=90=8C=E6=AD=A5=20+=20?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E7=AE=A1=E7=90=86=E9=A1=B5=E9=87=8D=E6=9E=84?= =?UTF-8?q?=20+=20=E5=81=A5=E5=BA=B7=E8=AE=B0=E5=BD=95=E6=BB=91=E5=8A=A8?= =?UTF-8?q?=E5=88=A0=E9=99=A4=20+=20=E5=8C=BB=E7=94=9F=E7=AB=AF=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BLE: 修复Omron设备Indication模式连接(先订阅后配CCCD + forceIndications) + 直连重连 - BLE: 设备断连实时检测(connectionState流→Provider→UI) - 修复: 血压数据上报后端枚举不匹配(Device→DeviceSync)导致500 - 新增: DELETE /api/health-records/{id} 后端接口 - 设备管理页: 白色主题重设计 + 直连重连 + 实时状态 + Toast通知 - 设备扫描页: 白色主题 + 扫描动画居中 + 已连接等待页面 - 健康记录: 左滑删除(Dismissible) + 同时清理_allRecords和_filtered - 导航: 修复自定义路由栈下Navigator.pop黑屏bug - 文档: 医生端合并App完整设计文档(已确认版) --- .../Endpoints/health_endpoints.cs | 11 + docs/doctor_app_merge_design.md | 313 ++++++++++++++++ health_app/lib/core/app_router.dart | 1 + health_app/lib/models/bp_reading.dart | 2 +- health_app/lib/pages/chart/trend_page.dart | 71 ++-- .../pages/device/device_management_page.dart | 223 +++++++++++ .../lib/pages/device/device_scan_page.dart | 351 ++++++++++++------ health_app/lib/pages/remaining_pages.dart | 83 +++-- .../lib/providers/omron_device_provider.dart | 16 + health_app/lib/services/health_service.dart | 5 + .../lib/services/omron_ble_service.dart | 255 ++++++++----- 11 files changed, 1083 insertions(+), 248 deletions(-) create mode 100644 docs/doctor_app_merge_design.md create mode 100644 health_app/lib/pages/device/device_management_page.dart diff --git a/backend/src/Health.WebApi/Endpoints/health_endpoints.cs b/backend/src/Health.WebApi/Endpoints/health_endpoints.cs index f1683fe..57dfd91 100644 --- a/backend/src/Health.WebApi/Endpoints/health_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/health_endpoints.cs @@ -68,6 +68,17 @@ public static class HealthEndpoints return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); }); + // 删除健康记录 + group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => + { + var userId = GetUserId(http); + var record = await db.HealthRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct); + if (record == null) return Results.Ok(new { code = 40004, data = (object?)null, message = "记录不存在" }); + db.HealthRecords.Remove(record); + await db.SaveChangesAsync(ct); + return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); + }); + // 获取各指标最新值 group.MapGet("/latest", async (HttpContext http, AppDbContext db, CancellationToken ct) => { diff --git a/docs/doctor_app_merge_design.md b/docs/doctor_app_merge_design.md new file mode 100644 index 0000000..00df4a1 --- /dev/null +++ b/docs/doctor_app_merge_design.md @@ -0,0 +1,313 @@ +# 医生端合并至App — 详细设计文档(已确认版) + +> 目标:将 `doctor_web` (React SPA) 的功能完整迁移到 `health_app` (Flutter),通过注册时选择"用户/医生"身份实现双端合一。 +> +> 本文档所有决策均已经过逐一讨论和确认。 + +--- + +## 一、已确认设计决策总表 + +| # | 决策项 | 结论 | +|---|--------|------| +| 1 | 角色体系 | **方案A** — User表加Role字段 + 新建DoctorProfile子表,废弃旧Doctor表 | +| 2 | 手机号角色 | **一个手机号只能一个角色** | +| 3 | 种子医生 | **全部删除**(王建国/李芳/张明),重新注册测试 | +| 4 | 审核码 | 硬编码 `6666`,注册医生时校验 | +| 5 | 代码位置 | 放在 `health_app` 同一项目,同一安装包 | +| 6 | 开发节奏 | **一次性全量做完**,不分期 | +| 7 | 患者端 | 开发期间保持可用,患者问诊聊天保留 | +| 8 | 医生主色调 | **医疗蓝 `#0891B2`** + 白色 | +| 9 | 深色模式 | 不做,仅浅色 | +| 10 | 导航结构 | **侧边抽屉**,与患者端一致 | +| 11 | 报告文件预览 | 图片直接内嵌查看,PDF调用系统查看器 | +| 12 | 医生头像 | 支持从相册上传 | +| 13 | 所有医生端点 | 加JWT鉴权 | + +--- + +## 二、注册流程 + +``` +注册页 + ├─ ① 选择身份:[用户] [医生] ← 第一步,先选 + ├─ ② 输入手机号 → 获取短信验证码 ← 通用 + ├─ ③ 输入验证码 ← 通用 + ├─ ④ (仅医生) 输入审核码 [6666] ← 选医生才弹出 + ├─ ⑤ 勾选同意《隐私政策》《用户协议》 ← 可点击查看详情页 + └─ ⑥ 点击注册 +``` + +- 文案统一用"用户"而非"患者" +- 医生注册时不填姓名/职称/科室/医院,登录后在设置里补全 +- 登录时不需要选身份,自动进入注册时选择的身份端 + +--- + +## 三、多端登录体系(新增) + +### 3.1 账号核心原则 + +- **手机号 = 主标识**,微信/Apple 都是绑定到手机号上 +- **注册时定身份,永久不可改**(除非删除账号重来) +- 一个手机号只能绑定一个微信 + 一个 Apple ID +- 登录方式可选(手机号/微信/Apple),但身份由注册时的选择决定 + +### 3.2 注册流程(手机号注册 + 立刻绑定) + +``` +注册页 + ├─ ① 选择身份:[用户] [医生] + ├─ ② 输入手机号 → 短信验证码 + ├─ ③ (仅医生) 审核码 6666 + ├─ ④ 勾选同意《隐私政策》《用户协议》 + ├─ ⑤ 点击注册 + └─ ⑥ 注册成功后弹绑定页: + ├─ [绑定微信] + ├─ [绑定Apple] + └─ [跳过,以后再绑] +``` + +### 3.3 后续登录方式 + +注册后,登录页直接显示三个按钮: + +``` +登录页 + ├─ [微信登录] → 查手机号 → 自动进入对应身份端 + ├─ [Apple登录] → 查手机号 → 自动进入对应身份端 + └─ [手机号登录] → 短信验证 → 自动进入对应身份端 +``` + +### 3.4 删除账号(≠ 退出登录) + +- 在设置页独立入口,不与退出登录混淆 +- 确认后彻底删除:用户数据、健康记录、问诊记录、绑定关系 +- Apple 用户额外调用 Apple revocation API +- 删除后手机号释放,可重新注册(可换身份) + +### 3.5 微信/Apple 平台准备 + +| 微信开放平台 | 需要新的 AppID,绑定 `com.datalumina.YYA` | +|-------------|------------------------------------------| +| Apple Sign In | Apple Developer 账号开启 Capability | +| Flutter包 | 微信用 `fluwx`,Apple 用 `sign_in_with_apple` | + +> AppID 暂时空着,等申请下来再填。 + +--- + +## 四、登录后路由(不变) + +``` +登录成功 + ├─ Role = 'User' → 现有患者端(AI对话首页 + 侧边抽屉) + └─ Role = 'Doctor' → 医生端(工作台Dashboard + 侧边抽屉) +``` + +--- + +## 五、医生职责与功能 + +### 4.1 患者范围(初期) + +所有注册用户都是所有医生的患者。后期VIP体系再做患者分配。 + +### 4.2 工作台 Dashboard + +- 统计卡片:患者总数 / 进行中问诊 / 待审核报告 / 今日随访 +- 待办事项列表(全部可点击快捷进入): + - 未回复问诊消息数 + - 待审核报告数 + - 今日随访对象 +- 每个待办项都是快捷入口,点击直接进入对应工作页 + +### 4.3 患者管理 + +- 搜索框(姓名/手机号)+ 分页加载(上拉更多) +- 点击患者进入详情页 +- **详情页结构**: + - 默认展示:基本信息(姓名/性别/年龄/电话)、健康档案(病史/手术史/过敏/慢病/家族史)、当前用药 + - 按钮"更多信息" → 展开:趋势图/饮食记录/运动计划/报告列表/随访记录 + +### 4.4 问诊聊天 + +- 保持现有流程:患者发起 → AI先接待 → 医生后续介入 +- AI行为暂不改动(后续优化) +- 电话功能:留UI入口,功能暂不做 +- 问诊列表显示:患者头像+姓名、最后消息预览、状态、时间 +- 聊天页面复用现有 `DoctorChatPage`,但需要新建医生端Provider(使用 `/api/doctor/consultations/*` 端点,senderType='Doctor') +- SignalR 实时通信保留(已有实现) + +### 4.5 报告审核 + +- 全功能迁移,包含: + 1. AI预分析摘要 + 2. 指标表格(正常/异常颜色标记) + 3. 严重程度4级评定(正常/异常/严重/危急) + 4. 建议模板多选(药量调整/复诊建议/生活建议/其他) + 5. 自定义评语(可选) + 6. 原始报告查看(图片内嵌/PDF系统查看器) + 7. 提交审核 +- 所有医生都能看到所有患者的报告 + +### 4.6 复查随访 + +- 医生创建随访计划(标题+时间+备注) +- 到期只提醒医生 → 医生手动联系患者 +- 随访列表 + 新建/编辑/标记完成/删除 + +--- + +## 六、导航与页面结构 + +### 5.1 医生端侧边抽屉 + +``` +┌─────────────────────┐ +│ [头像] 在线/离线 🔵 │ ← 点击头像→个人信息编辑页 +│ 姓名 职称 科室 │ ← 在线状态可切换 +│ │ +│ ━━━━━━━━━━━━━━━━━━━ │ +│ 📊 工作台 │ ← 默认首页 +│ 👥 患者管理 │ +│ 💬 问诊列表 │ +│ 📋 报告审核 │ +│ 📅 复查随访 │ +│ ⚙️ 设置 │ +│ ━━━━━━━━━━━━━━━━━━━ │ +│ 🚪 退出登录 │ +└─────────────────────┘ +``` + +- 点击抽屉项 → 主页面切换到对应页面 +- 在线/离线状态在抽屉顶部头像区快速切换 + +### 5.2 "设置"页面内容 + +- 推送通知开关 +- 隐私政策查看 +- 用户协议查看 +- 退出登录 + +### 5.3 "个人信息"页面(点击头像进入) + +- 姓名 / 职称 / 科室 / 医院 +- 头像上传(相册选图) + +### 5.4 医生首次登录 + +直接进工作台,但顶部显示提示条"请完善个人信息",点击跳转到个人信息编辑页。 + +--- + +## 七、路由表 + +| 路由名 | 页面 | 用户端 | 医生端 | +|--------|------|--------|--------| +| `login` | 登录/注册 | ✅ | ✅ | +| `home` | 首页(AI对话/工作台) | ✅ | ✅ (根据Role) | +| `doctorPatients` | 患者列表 | — | ✅ | +| `doctorPatientDetail` | 患者详情 | — | ✅ | +| `doctorChat` | 问诊聊天 | ✅(复用) | ✅(新Provider) | +| `doctorConsultations` | 问诊列表 | — | ✅ | +| `doctorReports` | 报告列表 | — | ✅ | +| `doctorReportDetail` | 报告审核 | — | ✅ | +| `doctorFollowUps` | 随访列表 | — | ✅ | +| `doctorFollowUpEdit` | 随访编辑 | — | ✅ | +| `doctorSettings` | 医生设置 | — | ✅ | +| `doctorProfile` | 个人信息编辑 | — | ✅ | + +--- + +## 八、数据库变更 + +### 7.1 Users 表加字段 + +```sql +ALTER TABLE "Users" ADD COLUMN "Role" TEXT NOT NULL DEFAULT 'User'; -- 'User' | 'Doctor' +``` + +### 7.2 新建 DoctorProfiles 表 + +```sql +CREATE TABLE "DoctorProfiles" ( + "Id" UUID PRIMARY KEY, + "UserId" UUID NOT NULL REFERENCES "Users"("Id"), + "Name" TEXT, + "Title" TEXT, + "Department" TEXT, + "Hospital" TEXT, + "AvatarUrl" TEXT, + "IsOnline" BOOLEAN DEFAULT false, + "IsActive" BOOLEAN DEFAULT true, + "CreatedAt" TIMESTAMP DEFAULT now(), + "UpdatedAt" TIMESTAMP DEFAULT now() +); +``` + +### 7.3 清理 + +- 删除旧 `Doctors` 表种子数据 +- 迁移 Consultation 表的 DoctorId 外键关联 + +--- + +## 九、后端变更 + +### 8.1 JWT 增加 Role Claim + +```csharp +new Claim("Role", user.Role) +``` + +### 8.2 所有 `/api/doctor/*` 端点 + +- 添加 `.RequireAuthorization()` +- 添加 Role 校验(Role = "Doctor") +- 从 JWT 获取医生信息替代硬编码"王建国" + +### 8.3 注册接口改造 + +- 支持 Role 参数 +- 医生注册时校验审核码 6666 +- 医生注册创建 DoctorProfile 空记录 + +### 8.4 审核码配置 + +```csharp +// appsettings.json 或环境变量 +const string DoctorInviteCode = "6666"; +``` + +--- + +## 十、UI设计规范 + +### 9.1 颜色 + +| 用途 | 颜色 | +|------|------| +| 主色 | `#0891B2` (医疗蓝) | +| 背景 | `#FFFFFF` / `#F5F5F5` | +| 卡片 | 白色 + 浅阴影 | +| 成功/已连接 | `#10B981` | +| 状态标签 | 待处理(橙) / 已处理(绿) / 异常(红) | +| 文字 | `#1A1A1A` / `#999999` | + +### 9.2 与患者端的视觉区分 + +- 患者端:紫色渐变 `#5B8DEF` +- 医生端:医疗蓝 `#0891B2` +- 抽屉结构和布局保持一致 + +--- + +## 十一、实施参考 + +- 新增约 12 条路由 +- 新增约 10 个页面组件 +- 新增约 4 个 Provider +- 修改约 15 个后端端点 +- 修改注册/登录流程 +- 总计预估 6-8 天 diff --git a/health_app/lib/core/app_router.dart b/health_app/lib/core/app_router.dart index 4a915d2..081ef05 100644 --- a/health_app/lib/core/app_router.dart +++ b/health_app/lib/core/app_router.dart @@ -15,6 +15,7 @@ import '../pages/profile/profile_page.dart'; import '../pages/profile/service_package_detail_page.dart'; import '../pages/diet/diet_capture_page.dart'; import '../pages/device/device_scan_page.dart'; +import '../pages/device/device_management_page.dart'; import '../pages/remaining_pages.dart'; /// 根据路由信息返回对应页面 diff --git a/health_app/lib/models/bp_reading.dart b/health_app/lib/models/bp_reading.dart index 5cb56d0..855c369 100644 --- a/health_app/lib/models/bp_reading.dart +++ b/health_app/lib/models/bp_reading.dart @@ -26,7 +26,7 @@ class BpReading { 'type': 'BloodPressure', 'systolic': systolic, 'diastolic': diastolic, - 'source': 'Device', + 'source': 'DeviceSync', 'unit': 'mmHg', 'recordedAt': timestamp.toUtc().toIso8601String(), }; diff --git a/health_app/lib/pages/chart/trend_page.dart b/health_app/lib/pages/chart/trend_page.dart index 68eb698..2a1c2c9 100644 --- a/health_app/lib/pages/chart/trend_page.dart +++ b/health_app/lib/pages/chart/trend_page.dart @@ -1,6 +1,7 @@ import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/app_colors.dart'; import '../../core/app_theme.dart'; import '../../providers/auth_provider.dart'; import '../../providers/data_providers.dart'; @@ -61,6 +62,7 @@ class _TrendPageState extends ConsumerState { final list = (res.data['data'] as List?)?.cast>() ?? []; for (final r in list) { all.add({ + 'id': r['id'], 'type': t, 'date': DateTime.tryParse(r['recordedAt']?.toString() ?? '') ?? DateTime.now(), 'systolic': r['systolic'], @@ -346,34 +348,59 @@ class _TrendPageState extends ConsumerState { ..._filtered.reversed.take(30).map((r) { final date = r['date'] as DateTime; final abnormal = r['isAbnormal'] == true; + final id = r['id']?.toString() ?? ''; String display; if (_isBP) { display = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}'; } else { display = '${r['value'] ?? '--'}'; } - return Container( - margin: const EdgeInsets.only(bottom: 6), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), - child: Row(children: [ - Container(width: 40, height: 40, decoration: BoxDecoration( - color: _color.withAlpha(20), borderRadius: BorderRadius.circular(10)), - child: Center(child: Text(_getMetricIcon(_selected), style: const TextStyle(fontSize: 21))), - ), - const SizedBox(width: 12), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('${date.month}月${date.day}日 ${date.hour}:${date.minute.toString().padLeft(2, '0')}', - style: const TextStyle(fontSize: 15, color: Color(0xFF999999))), - const SizedBox(height: 2), - Text('$display $_unit', style: TextStyle(fontSize: 23, fontWeight: FontWeight.w700, - color: abnormal ? const Color(0xFFEF4444) : const Color(0xFF1A1A1A))), - ])), - if (abnormal) - Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration(color: const Color(0xFFFEE2E2), borderRadius: BorderRadius.circular(6)), - child: const Text('异常', style: TextStyle(fontSize: 14, color: Color(0xFFEF4444), fontWeight: FontWeight.w500))), - ]), + return Dismissible( + key: Key(id), + direction: DismissDirection.endToStart, + background: Container( + margin: const EdgeInsets.only(bottom: 6), + decoration: BoxDecoration(color: AppColors.error, borderRadius: BorderRadius.circular(12)), + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: const Icon(Icons.delete_outline, color: Colors.white), + ), + confirmDismiss: (_) async { + try { + final api = ref.read(apiClientProvider); + await api.delete('/api/health-records/$id'); + setState(() { + _allRecords.removeWhere((x) => x['id']?.toString() == id); + _filtered.removeWhere((x) => x['id']?.toString() == id); + }); + return true; + } catch (_) { + return false; + } + }, + child: Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), + child: Row(children: [ + Container(width: 40, height: 40, decoration: BoxDecoration( + color: _color.withAlpha(20), borderRadius: BorderRadius.circular(10)), + child: Center(child: Text(_getMetricIcon(_selected), style: const TextStyle(fontSize: 21))), + ), + const SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('${date.month}月${date.day}日 ${date.hour}:${date.minute.toString().padLeft(2, '0')}', + style: const TextStyle(fontSize: 15, color: Color(0xFF999999))), + const SizedBox(height: 2), + Text('$display $_unit', style: TextStyle(fontSize: 23, fontWeight: FontWeight.w700, + color: abnormal ? const Color(0xFFEF4444) : const Color(0xFF1A1A1A))), + ])), + if (abnormal) + Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: const Color(0xFFFEE2E2), borderRadius: BorderRadius.circular(6)), + child: const Text('异常', style: TextStyle(fontSize: 14, color: Color(0xFFEF4444), fontWeight: FontWeight.w500))), + ]), + ), ); }), ]); diff --git a/health_app/lib/pages/device/device_management_page.dart b/health_app/lib/pages/device/device_management_page.dart new file mode 100644 index 0000000..6c9dece --- /dev/null +++ b/health_app/lib/pages/device/device_management_page.dart @@ -0,0 +1,223 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/app_colors.dart'; +import '../../core/navigation_provider.dart'; +import '../../providers/omron_device_provider.dart'; +import '../../services/omron_ble_service.dart'; + +class DeviceManagementPage extends ConsumerStatefulWidget { + const DeviceManagementPage({super.key}); + @override ConsumerState createState() => _DeviceManagementPageState(); +} + +class _DeviceManagementPageState extends ConsumerState { + bool _reconnecting = false; + OverlayEntry? _toast; + + @override void dispose() { _hideToast(); super.dispose(); } + + void _showToast(String msg, {bool success = false}) { + _hideToast(); + _toast = OverlayEntry( + builder: (_) => Positioned( + top: MediaQuery.of(context).padding.top + 8, left: 16, right: 16, + child: TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), duration: const Duration(milliseconds: 250), + builder: (_, v, __) => Opacity( + opacity: v, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: success ? const Color(0xFF059669) : const Color(0xFFDC2626), + borderRadius: BorderRadius.circular(24), + boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 8, offset: Offset(0, 2))], + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(success ? Icons.check_circle : Icons.info_outline, color: Colors.white, size: 18), + const SizedBox(width: 8), + Expanded(child: Text(msg, style: const TextStyle(color: Colors.white, fontSize: 14))), + ]), + ), + ), + ), + ), + ); + Overlay.of(context).insert(_toast!); + Future.delayed(const Duration(seconds: 2), _hideToast); + } + + void _hideToast() { _toast?.remove(); _toast = null; } + + Future _reconnect(String mac) async { + if (_reconnecting) return; + setState(() => _reconnecting = true); + final ok = await ref.read(omronBleServiceProvider).reconnectByMac(mac); + if (!mounted) return; + setState(() => _reconnecting = false); + if (ok) { + _showToast('设备已连接', success: true); + } else { + _showToast('设备未在线,请确认血压计已开机并处于通信模式'); + } + } + + @override Widget build(BuildContext context) { + final device = ref.watch(omronDeviceProvider); + + return Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + title: const Text('蓝牙设备', style: TextStyle(color: AppColors.textPrimary)), + actions: [ + IconButton( + icon: const Icon(Icons.add, color: AppColors.primary), + onPressed: () => pushRoute(ref, 'deviceScan'), + tooltip: '添加设备', + ), + ], + ), + body: device.isBound ? _buildDeviceList(device) : _buildEmpty(), + ); + } + + Widget _buildEmpty() => Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container( + width: 80, height: 80, + decoration: BoxDecoration(color: const Color(0xFFF0F0F0), borderRadius: BorderRadius.circular(24)), + child: const Icon(Icons.bluetooth_disabled, size: 40, color: Color(0xFFBBBBBB)), + ), + const SizedBox(height: 16), + const Text('暂无设备', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + const SizedBox(height: 6), + const Text('点击右上角 + 添加血压计', style: TextStyle(fontSize: 14, color: AppColors.textHint)), + ]), + ), + ); + + Widget _buildDeviceList(DeviceBindState d) => ListView( + padding: const EdgeInsets.all(16), + children: [ + // 设备卡片 + GestureDetector( + onTap: d.isConnected ? null : () => _reconnect(d.mac ?? ''), + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: d.isConnected ? const Color(0xFF10B981) : const Color(0xFFEEEEEE), + width: d.isConnected ? 1.5 : 1, + ), + ), + child: Row(children: [ + Container( + width: 48, height: 48, + decoration: BoxDecoration( + color: d.isConnected ? const Color(0xFFD1FAE5) : const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(14), + ), + child: Icon(Icons.bluetooth, size: 24, + color: d.isConnected ? const Color(0xFF10B981) : const Color(0xFFBBBBBB)), + ), + const SizedBox(width: 14), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(d.name ?? '血压计', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + const SizedBox(height: 2), + Text(d.mac ?? '', style: const TextStyle(fontSize: 12, color: AppColors.textHint)), + if (d.lastSync != null) + Text('上次同步: ${d.lastSync}', style: const TextStyle(fontSize: 11, color: AppColors.textHint)), + ])), + if (_reconnecting) + const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) + else ...[ + Container( + width: 8, height: 8, + decoration: BoxDecoration( + color: d.isConnected ? const Color(0xFF10B981) : const Color(0xFFBBBBBB), + shape: BoxShape.circle, + boxShadow: d.isConnected ? [BoxShadow(color: const Color(0xFF10B981).withOpacity(0.4), blurRadius: 4)] : null, + ), + ), + const SizedBox(width: 8), + Text( + d.isConnected ? '已连接' : '未连接', + style: TextStyle(fontSize: 13, color: d.isConnected ? const Color(0xFF10B981) : AppColors.textHint), + ), + ], + ]), + ), + ), + + // 最近读数 + if (d.lastReading != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)), + child: Row(children: [ + const Text('最近测量', style: TextStyle(fontSize: 14, color: AppColors.textHint)), + const Spacer(), + Text(d.lastReading!.display, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), + const SizedBox(width: 4), + const Text('mmHg', style: TextStyle(fontSize: 12, color: AppColors.textHint)), + if (d.lastReading!.pulse != null) ...[ + const SizedBox(width: 16), + Text('${d.lastReading!.pulse} bpm', style: const TextStyle(fontSize: 14, color: AppColors.primary)), + ], + ]), + ), + ], + + const SizedBox(height: 16), + // 解绑 + SizedBox(width: double.infinity, child: OutlinedButton( + onPressed: () async { + final ok = await showDialog(context: context, builder: (ctx) => AlertDialog( + title: const Text('解绑设备'), + content: const Text('解绑后需重新扫描连接,确定吗?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定'), style: TextButton.styleFrom(foregroundColor: AppColors.error)), + ], + )); + if (ok == true) await ref.read(omronDeviceProvider.notifier).unbind(); + }, + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.error, side: const BorderSide(color: Color(0xFFFECACA)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text('解绑设备'), + )), + + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('使用说明', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + _tip('1', '血压计装好电池,绑好袖带'), + _tip('2', '按开始键测量,结束后设备自动进入通信模式'), + _tip('3', '点击设备栏即可自动连接并同步数据'), + ]), + ), + ], + ); + + Widget _tip(String n, String t) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('$n.', style: const TextStyle(fontSize: 13, color: AppColors.primary, fontWeight: FontWeight.w600)), + const SizedBox(width: 4), + Expanded(child: Text(t, style: const TextStyle(fontSize: 13, color: AppColors.textSecondary))), + ]), + ); +} diff --git a/health_app/lib/pages/device/device_scan_page.dart b/health_app/lib/pages/device/device_scan_page.dart index 3d8861d..36723ce 100644 --- a/health_app/lib/pages/device/device_scan_page.dart +++ b/health_app/lib/pages/device/device_scan_page.dart @@ -1,10 +1,12 @@ import 'dart:async'; +import 'dart:io' show Platform; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:permission_handler/permission_handler.dart'; import '../../core/app_colors.dart'; -import '../../core/app_theme.dart'; +import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../providers/omron_device_provider.dart'; import '../../services/omron_ble_service.dart'; @@ -14,36 +16,67 @@ class DeviceScanPage extends ConsumerStatefulWidget { @override ConsumerState createState() => _DeviceScanPageState(); } -class _DeviceScanPageState extends ConsumerState { +class _DeviceScanPageState extends ConsumerState + with SingleTickerProviderStateMixin { final _results = []; bool _scanning = false; String? _connectingId; StreamSubscription? _scanSub; + bool _connected = false; + String _deviceName = ''; + bool _readingReceived = false; + int? _systolic, _diastolic, _pulse; + StreamSubscription? _readingSub; + StreamSubscription? _bleConnSub; + + late AnimationController _pulseCtrl; + late Animation _pulseAnim; + @override void initState() { super.initState(); - // 一次性订阅,持续整个页面生命周期 + _pulseCtrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 1200)); + _pulseAnim = Tween(begin: 0.8, end: 1.4).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut)); + _pulseCtrl.repeat(reverse: true); + _scanSub = FlutterBluePlus.scanResults.listen((list) { if (!mounted) return; - setState(() { _results.clear(); _results.addAll(list); }); + final bpList = list.where((r) => OmronBleService.isBpDevice(r)).toList(); + setState(() { _results.clear(); _results.addAll(bpList); }); }); _start(); } @override void dispose() { + _pulseCtrl.dispose(); _scanSub?.cancel(); + _readingSub?.cancel(); + _bleConnSub?.cancel(); FlutterBluePlus.stopScan(); super.dispose(); } Future _start() async { - setState(() => _scanning = true); + setState(() { _scanning = true; _connected = false; }); + _results.clear(); await [Permission.bluetoothScan, Permission.bluetoothConnect].request(); - try { await FlutterBluePlus.turnOn(); } catch (e) { debugPrint('[BLE Scan] 操作失败: $e'); } + if (Platform.isAndroid) { + final locStatus = await Permission.locationWhenInUse.serviceStatus; + if (locStatus != ServiceStatus.enabled && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('请先打开GPS定位,否则无法扫描蓝牙'), backgroundColor: AppColors.warning), + ); + } + } - try { await FlutterBluePlus.stopScan(); } catch (e) { debugPrint('[BLE Scan] 操作失败: $e'); } + final adapterState = await FlutterBluePlus.adapterState.first; + if (adapterState != BluetoothAdapterState.on) { + try { await FlutterBluePlus.turnOn(); } catch (_) {} + } + + try { await FlutterBluePlus.stopScan(); } catch (_) {} await FlutterBluePlus.startScan( timeout: const Duration(seconds: 30), androidScanMode: AndroidScanMode.lowLatency, @@ -58,6 +91,7 @@ class _DeviceScanPageState extends ConsumerState { Future _connect(ScanResult r) async { setState(() => _connectingId = r.device.remoteId.toString()); FlutterBluePlus.stopScan(); + _scanSub?.cancel(); try { final ble = ref.read(omronBleServiceProvider); @@ -68,153 +102,244 @@ class _DeviceScanPageState extends ConsumerState { await ref.read(omronDeviceProvider.notifier).bind(r.device.remoteId.toString(), name); - ble.readings.listen((reading) async { - try { - final api = ref.read(apiClientProvider); - await api.post('/api/health-records', data: reading.toHealthRecord()); - await ref.read(omronDeviceProvider.notifier).onReading(reading); - } catch (e) { debugPrint('[BLE Scan] 操作失败: $e'); } + _bleConnSub = r.device.connectionState.listen((s) { + if (s == BluetoothConnectionState.disconnected && mounted) { + _readingSub?.cancel(); + ref.read(omronBleServiceProvider).disconnect(); + if (_readingReceived) { + popRoute(ref); + } else { + setState(() { _connected = false; }); + _start(); + } + } + }); + + _readingSub = ble.readings.listen((reading) async { + debugPrint('[PAGE] 收到: ${reading.systolic}/${reading.diastolic}'); + if (mounted) { + setState(() { + _readingReceived = true; + _systolic = reading.systolic; + _diastolic = reading.diastolic; + _pulse = reading.pulse; + }); + try { + await ref.read(apiClientProvider).post('/api/health-records', data: reading.toHealthRecord()); + await ref.read(omronDeviceProvider.notifier).onReading(reading); + } catch (e) { debugPrint('[PAGE] 上报失败: $e'); } + Future.delayed(const Duration(milliseconds: 1500), () { + if (mounted) popRoute(ref); + }); + } }); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('已连接 $name'), backgroundColor: AppColors.success), - ); - Navigator.pop(context); + setState(() { + _connected = true; + _deviceName = name; + _connectingId = null; + _readingReceived = false; + }); } - } catch (_) { + } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('连接失败,请重试'), backgroundColor: AppColors.error), + SnackBar(content: Text('连接失败: $e'), backgroundColor: AppColors.error), ); _start(); } - } finally { - if (mounted) setState(() => _connectingId = null); } } - // ═══════════════════ UI ═══════════════════ - @override Widget build(BuildContext context) { - return GradientScaffold( + return Scaffold( + backgroundColor: Colors.white, appBar: AppBar( - leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).pop()), - backgroundColor: AppColors.cardBackground, - title: const Text('添加血压计'), + backgroundColor: Colors.white, + elevation: 0, + title: Text(_connected ? _deviceName : '添加设备', style: const TextStyle(color: AppColors.textPrimary)), + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), + onPressed: () { + if (_connected) ref.read(omronBleServiceProvider).disconnect(); + popRoute(ref); + }, + ), ), - body: Column(children: [ - Container( - width: double.infinity, padding: const EdgeInsets.all(16), - color: AppColors.iconBg, - child: Row(children: [ - if (_scanning) ...[ - const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF5B8DEF))), - const SizedBox(width: 12), - ], - Text(_scanning ? '正在扫描...' : '发现 ${_results.length} 台设备', - style: const TextStyle(fontSize: 15, color: Color(0xFF666666))), - ]), - ), - if (!_scanning && _results.isEmpty) _buildEmpty(), - Expanded( - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _results.length, - itemBuilder: (_, i) => _buildTile(_results[i]), - ), - ), - if (!_scanning) - Padding( - padding: const EdgeInsets.all(16), - child: SizedBox(width: double.infinity, child: OutlinedButton.icon( - onPressed: _start, - icon: const Icon(Icons.refresh), - label: const Text('重新扫描'), - style: OutlinedButton.styleFrom( - foregroundColor: AppColors.primary, - side: const BorderSide(color: Color(0xFF5B8DEF)), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), - padding: const EdgeInsets.symmetric(vertical: 14), - ), - )), - ), - ]), + body: _connected ? _buildConnected() : _buildScan(), ); } + // ── 扫描视图 ── + Widget _buildScan() => Column(children: [ + if (_scanning && _results.isEmpty) Expanded(child: _buildScanningCenter()), + if (!_scanning && _results.isEmpty) Expanded(child: _buildScanningCenter()), + if (_results.isNotEmpty) + Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _results.length, + itemBuilder: (_, i) => _buildTile(_results[i]), + ), + ), + if (!_scanning) + Padding( + padding: const EdgeInsets.all(16), + child: SizedBox(width: double.infinity, child: OutlinedButton.icon( + onPressed: _start, + icon: const Icon(Icons.refresh), + label: const Text('重新扫描'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + side: const BorderSide(color: Color(0xFFE5E7EB)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + )), + ), + ]); + + Widget _buildScanningCenter() => Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Stack(alignment: Alignment.center, children: [ + AnimatedBuilder( + animation: _pulseAnim, + builder: (_, child) => Container( + width: 80 * _pulseAnim.value, height: 80 * _pulseAnim.value, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.primary.withOpacity(0.08), + ), + ), + ), + Container( + width: 64, height: 64, + decoration: BoxDecoration( + color: const Color(0xFFF0F0FF), borderRadius: BorderRadius.circular(20), + ), + child: const Icon(Icons.bluetooth_searching, size: 32, color: AppColors.primary), + ), + ]), + const SizedBox(height: 20), + Text(_scanning ? '正在搜索蓝牙设备...' : '未发现设备', style: const TextStyle(fontSize: 16, color: AppColors.textHint)), + const SizedBox(height: 8), + Text(_scanning ? '请确保血压计处于通信模式' : '点击下方按钮重新搜索', style: const TextStyle(fontSize: 13, color: Color(0xFFCCCCCC))), + ]), + ); + + // ── 已连接视图 ── + Widget _buildConnected() => Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 500), + width: 100, height: 100, + decoration: BoxDecoration( + color: _readingReceived ? const Color(0xFFD1FAE5) : const Color(0xFFF0F0FF), + borderRadius: BorderRadius.circular(32), + ), + child: Icon( + _readingReceived ? Icons.check_rounded : Icons.bluetooth_connected, + size: 48, + color: _readingReceived ? const Color(0xFF10B981) : AppColors.primary, + ), + ), + const SizedBox(height: 24), + Text(_readingReceived ? '测量完成' : '设备已连接', style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), + const SizedBox(height: 8), + + if (_readingReceived) ...[ + Row(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ + Text('$_systolic', style: const TextStyle(fontSize: 56, fontWeight: FontWeight.w800, color: AppColors.textPrimary, height: 1)), + Padding(padding: const EdgeInsets.only(bottom: 10), child: Text('/', style: TextStyle(fontSize: 24, color: AppColors.textHint.withOpacity(0.5)))), + Text('$_diastolic', style: const TextStyle(fontSize: 56, fontWeight: FontWeight.w800, color: AppColors.textPrimary, height: 1)), + const SizedBox(width: 8), + Padding(padding: const EdgeInsets.only(bottom: 12), child: Text('mmHg', style: TextStyle(fontSize: 15, color: AppColors.textHint))), + ]), + if (_pulse != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration(color: const Color(0xFFEFF6FF), borderRadius: BorderRadius.circular(20)), + child: Text('脉搏 $_pulse bpm', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: AppColors.primary)), + ), + ), + const SizedBox(height: 16), + const Text('数据已自动同步', style: TextStyle(fontSize: 14, color: AppColors.textHint)), + const SizedBox(height: 8), + const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.primary)), + ] else ...[ + const Text('请按下血压计的开始键进行测量', style: TextStyle(fontSize: 15, color: AppColors.textHint)), + const SizedBox(height: 32), + AnimatedBuilder( + animation: _pulseAnim, + builder: (_, __) => Container( + width: 48, height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: AppColors.primary.withOpacity(0.3), width: 2), + ), + child: Center( + child: Transform.scale( + scale: _pulseAnim.value, + child: Container(width: 12, height: 12, decoration: const BoxDecoration(shape: BoxShape.circle, color: AppColors.primary)), + ), + ), + ), + ), + ], + ]), + ), + ); + + // ── 设备列表项 ── Widget _buildTile(ScanResult r) { - final isBP = OmronBleService.isBpDevice(r); + final isConnecting = _connectingId == r.device.remoteId.toString(); final name = r.advertisementData.localName.isNotEmpty ? r.advertisementData.localName : (r.device.platformName.isNotEmpty ? r.device.platformName : '未知设备'); - final isConnecting = _connectingId == r.device.remoteId.toString(); return Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(14), decoration: BoxDecoration( - color: AppColors.cardBackground, borderRadius: BorderRadius.circular(16), - boxShadow: AppColors.cardShadow, - border: isBP ? Border.all(color: AppColors.primary, width: 1.5) : null, + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFEEEEEE)), ), child: Row(children: [ Container( width: 44, height: 44, - decoration: BoxDecoration(color: isBP ? AppColors.iconBg : AppColors.cardInner, borderRadius: BorderRadius.circular(12)), - child: Icon(Icons.bluetooth, size: 24, color: isBP ? AppColors.primary : AppColors.textHint), + decoration: BoxDecoration(color: const Color(0xFFF0F0FF), borderRadius: BorderRadius.circular(12)), + child: const Icon(Icons.bluetooth, size: 22, color: AppColors.primary), ), const SizedBox(width: 12), - Expanded( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Flexible(child: Text(name, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: AppColors.textPrimary))), - if (isBP) ...[ - const SizedBox(width: 6), - Container(padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration(color: AppColors.primary, borderRadius: BorderRadius.circular(4)), child: const Text('血压计', style: TextStyle(fontSize: 11, color: Colors.white))), - ], - ]), - const SizedBox(height: 2), - Text('信号: ${_rssiLabel(r.rssi)} · ${r.device.remoteId}', style: const TextStyle(fontSize: 13, color: AppColors.textHint)), - ]), - ), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(name, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + const SizedBox(height: 2), + Text('${r.device.remoteId} · 信号: ${_rssiLabel(r.rssi)}', style: const TextStyle(fontSize: 12, color: AppColors.textHint)), + ])), if (isConnecting) - const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF5B8DEF))) + const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) else GestureDetector( onTap: () => _connect(r), - child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration(gradient: AppColors.primaryGradient, borderRadius: BorderRadius.circular(12)), child: const Text('连接', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Colors.white))), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + decoration: BoxDecoration(color: AppColors.primary, borderRadius: BorderRadius.circular(12)), + child: const Text('连接', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.white)), + ), ), ]), ); } - Widget _buildEmpty() => Padding( - padding: const EdgeInsets.all(32), - child: Column(children: [ - Icon(Icons.bluetooth_disabled, size: 64, color: AppColors.textHint), - const SizedBox(height: 16), - const Text('未发现设备', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500, color: AppColors.textPrimary)), - const SizedBox(height: 24), - _tip('1', '确保血压计已装电池'), - _tip('2', '长按血压计蓝牙键 3 秒直到图标闪烁'), - _tip('3', '手机靠近血压计(1米内)'), - _tip('4', '打开手机蓝牙'), - ]), - ); - - Widget _tip(String num, String text) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container(width: 20, height: 20, alignment: Alignment.center, decoration: BoxDecoration(color: AppColors.primary, borderRadius: BorderRadius.circular(10)), child: Text(num, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600))), - const SizedBox(width: 10), - Expanded(child: Text(text, style: TextStyle(fontSize: 14, color: AppColors.textSecondary))), - ]), - ); - - String _rssiLabel(int rssi) { - if (rssi > -55) return '强'; - if (rssi > -70) return '中'; + String _rssiLabel(int r) { + if (r > -55) return '强'; + if (r > -70) return '中'; return '弱'; } } diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index 4e21725..9d1d7bc 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -864,9 +864,9 @@ class StaticTextPage extends ConsumerWidget { } } -/// 血压计设备管理(已绑定/未绑定) -class DeviceManagementPage extends ConsumerWidget { - const DeviceManagementPage({super.key}); +/// 旧版设备管理页,已移至 device/device_management_page.dart +class _DeviceManagementPageLegacy extends ConsumerWidget { + const _DeviceManagementPageLegacy({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final device = ref.watch(omronDeviceProvider); @@ -930,12 +930,28 @@ class DeviceManagementPage extends ConsumerWidget { Container( width: 72, height: 72, decoration: BoxDecoration( - gradient: AppColors.primaryGradient, + gradient: device.isConnected ? AppColors.primaryGradient : const LinearGradient(colors: [Color(0xFF9CA3AF), Color(0xFF6B7280)]), borderRadius: BorderRadius.circular(AppTheme.rMd), ), - child: const Icon(Icons.bluetooth_connected, size: 36, color: Colors.white), + child: Icon( + device.isConnected ? Icons.bluetooth_connected : Icons.bluetooth_disabled, + size: 36, color: Colors.white, + ), ), - const SizedBox(height: 16), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: device.isConnected ? const Color(0xFFD1FAE5) : const Color(0xFFFEE2E2), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + device.isConnected ? '已连接' : '未连接', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, + color: device.isConnected ? const Color(0xFF059669) : const Color(0xFFDC2626)), + ), + ), + const SizedBox(height: 12), Text(device.name ?? '血压计', style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), const SizedBox(height: 6), Text('MAC: ${device.mac ?? '—'}', style: const TextStyle(fontSize: 14, color: AppColors.textHint)), @@ -944,12 +960,45 @@ class DeviceManagementPage extends ConsumerWidget { Text('上次同步: ${device.lastSync}', style: const TextStyle(fontSize: 13, color: AppColors.textHint)), ], const SizedBox(height: 24), - Row(children: [ - Expanded( + if (device.isConnected) ...[ + // 已连接:显示断开和解绑 + Row(children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => _unbind(context, ref), + icon: const Icon(Icons.delete_outline, size: 18, color: AppColors.error), + label: const Text('解绑', style: TextStyle(color: AppColors.error)), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: AppColors.error), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + ), + ]), + ] else ...[ + // 未连接:显示重新连接和解绑 + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton.icon( + onPressed: () => pushRoute(ref, 'deviceScan'), + icon: const Icon(Icons.bluetooth_searching, size: 20), + label: const Text('重新连接设备', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)), + ), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, child: OutlinedButton.icon( onPressed: () => _unbind(context, ref), icon: const Icon(Icons.delete_outline, size: 18, color: AppColors.error), - label: const Text('解绑', style: TextStyle(color: AppColors.error)), + label: const Text('解绑设备', style: TextStyle(color: AppColors.error)), style: OutlinedButton.styleFrom( side: const BorderSide(color: AppColors.error), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)), @@ -957,21 +1006,7 @@ class DeviceManagementPage extends ConsumerWidget { ), ), ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton.icon( - onPressed: () => pushRoute(ref, 'deviceScan'), - icon: const Icon(Icons.swap_horiz, size: 18), - label: const Text('更换设备'), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)), - padding: const EdgeInsets.symmetric(vertical: 14), - ), - ), - ), - ]), + ], ]), ), diff --git a/health_app/lib/providers/omron_device_provider.dart b/health_app/lib/providers/omron_device_provider.dart index 7e1d6cf..bec47ca 100644 --- a/health_app/lib/providers/omron_device_provider.dart +++ b/health_app/lib/providers/omron_device_provider.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'auth_provider.dart'; import 'data_providers.dart'; @@ -45,12 +46,27 @@ class DeviceBindState { } class DeviceBindNotifier extends Notifier { + StreamSubscription? _connSub; + @override DeviceBindState build() { _loadBinding(); + _listenConnection(); return const DeviceBindState(); } + void _listenConnection() { + _connSub?.cancel(); + _connSub = ref.read(omronBleServiceProvider).connectionState.listen((connected) { + if (state.isConnected != connected) { + state = state.copyWith(isConnected: connected); + } + if (!connected && state.isBound) { + // 设备断开,但保持绑定信息(下次可以重连) + } + }); + } + Future _loadBinding() async { final db = ref.read(localDbProvider); final mac = await db.read('bp_device_mac'); diff --git a/health_app/lib/services/health_service.dart b/health_app/lib/services/health_service.dart index ecd304e..76c5be6 100644 --- a/health_app/lib/services/health_service.dart +++ b/health_app/lib/services/health_service.dart @@ -27,6 +27,11 @@ class HealthService { final list = res.data['data'] as List? ?? []; return list.cast>(); } + + /// 删除记录 + Future deleteRecord(String id) async { + await _api.delete('/api/health-records/$id'); + } } /// 用户服务 diff --git a/health_app/lib/services/omron_ble_service.dart b/health_app/lib/services/omron_ble_service.dart index 1f17615..9c1f534 100644 --- a/health_app/lib/services/omron_ble_service.dart +++ b/health_app/lib/services/omron_ble_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:math'; +import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import '../models/bp_reading.dart'; @@ -9,25 +10,43 @@ class OmronBleService { static const bpMeasurementUuid = '00002A35-0000-1000-8000-00805F9B34FB'; static const bpFeatureUuid = '00002A49-0000-1000-8000-00805F9B34FB'; static const dateTimeUuid = '00002A08-0000-1000-8000-00805F9B34FB'; + static const intermediateCuffUuid = '00002A36-0000-1000-8000-00805F9B34FB'; + static const racpUuid = '00002A52-0000-1000-8000-00805F9B34FB'; BluetoothDevice? _device; - StreamSubscription>? _subscription; + StreamSubscription>? _measurementSub; + StreamSubscription? _connStateSub; final _readingsController = StreamController.broadcast(); + final _connStateController = StreamController.broadcast(); Stream get readings => _readingsController.stream; + Stream get connectionState => _connStateController.stream; bool get isConnected => _device?.isConnected ?? false; - /// 扫描血压计设备 - Stream scan({Duration timeout = const Duration(seconds: 15)}) async* { - await FlutterBluePlus.startScan(timeout: timeout); - await for (final list in FlutterBluePlus.scanResults) { - for (final r in list) { - yield r; + /// 直接重连已知设备(通过MAC地址) + Future reconnectByMac(String mac) async { + debugPrint('[BLE] 直连设备: $mac'); + final bonded = await FlutterBluePlus.bondedDevices; + BluetoothDevice? target; + for (final d in bonded) { + if (d.remoteId.toString() == mac) { + target = d; + break; } } + if (target == null) { + debugPrint('[BLE] 未在已配对设备中找到 $mac'); + return false; + } + try { + await connect(target); + return true; + } catch (e) { + debugPrint('[BLE] 直连失败: $e'); + return false; + } } - /// 判断是否为血压计设备 static bool isBpDevice(ScanResult r) { final name = [ r.advertisementData.localName, @@ -42,124 +61,184 @@ class OmronBleService { } /// 连接设备并订阅测量数据 + /// 设备连接后会自动发送存储的最后一次测量数据 Future connect(BluetoothDevice device) async { _device = device; + debugPrint('[BLE] ═══ 连接: "${device.platformName}" ═══'); + + _connStateSub = device.connectionState.listen((s) { + debugPrint('[BLE] 连接状态: $s'); + _connStateController.add(s == BluetoothConnectionState.connected); + }); + + // 1. GATT连接 await device.connect(autoConnect: false); - await device.discoverServices(); + debugPrint('[BLE] ① 已连接, MTU=${device.mtuNow}'); + try { await device.requestMtu(512); } catch (_) {} + // 2. 发现服务 final services = await device.discoverServices(); - final bpService = services.firstWhere( - (s) => s.uuid.str128.toUpperCase() == bpServiceUuid.toUpperCase(), + debugPrint('[BLE] ② 发现${services.length}个服务'); + + // 遍历所有服务/特征 + BluetoothService? bpSvc; + BluetoothCharacteristic? measChar, racpChar; + for (final s in services) { + debugPrint('[BLE] 服务: ${s.uuid.str128}'); + for (final c in s.characteristics) { + final p = []; + if (c.properties.read) p.add('R'); + if (c.properties.write) p.add('W'); + if (c.properties.notify) p.add('N'); + if (c.properties.indicate) p.add('I'); + debugPrint('[BLE] ${c.uuid.str128} [$p]'); + for (final d in c.descriptors) { + debugPrint('[BLE] desc: ${d.uuid.str128}'); + } + } + if (s.uuid.str128.toUpperCase() == bpServiceUuid.toUpperCase()) bpSvc = s; + } + + if (bpSvc == null) throw Exception('未找到血压服务0x1810'); + for (final c in bpSvc.characteristics) { + final u = c.uuid.str128.toUpperCase(); + if (u == bpMeasurementUuid.toUpperCase()) measChar = c; + if (u == racpUuid.toUpperCase()) racpChar = c; + } + if (measChar == null) throw Exception('未找到0x2A35'); + debugPrint('[BLE] ③ 0x2A35: indicate=${measChar.properties.indicate} read=${measChar.properties.read}'); + debugPrint('[BLE] RACP: ${racpChar != null ? "有" : "无"}'); + + // ★ 3. 先订阅数据流(在配置CCCD之前!) + debugPrint('[BLE] ④ 订阅lastValueStream...'); + _measurementSub = measChar.lastValueStream.listen( + (raw) { + debugPrint('[BLE] ★★★ 收到数据! ${raw.length}字节 ★★★'); + debugPrint('[BLE] HEX: ${raw.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); + _parseReading(raw); + }, + onError: (e) => debugPrint('[BLE] 流错误: $e'), + cancelOnError: false, ); - final measurementChar = bpService.characteristics.firstWhere( - (c) => c.uuid.str128.toUpperCase() == bpMeasurementUuid.toUpperCase(), - ); + // 4. 配置CCCD + final isIndicate = measChar.properties.indicate; + final cccdVal = isIndicate ? [0x02, 0x00] : [0x01, 0x00]; + debugPrint('[BLE] ⑤ 配置CCCD → ${isIndicate ? "Indicate" : "Notify"}...'); + var ok = false; + for (final d in measChar.descriptors) { + if (d.uuid.str128 == '00002902-0000-1000-8000-00805F9B34FB') { + final before = await d.read(); + await d.write(cccdVal); + final after = await d.read(); + ok = after.length >= 2 && after[0] == cccdVal[0] && after[1] == cccdVal[1]; + debugPrint('[BLE] ⑤ CCCD: $before → $after ${ok ? "OK" : "FAIL"}'); + break; + } + } + if (!ok) { + await measChar.setNotifyValue(true, forceIndications: isIndicate); + debugPrint('[BLE] ⑤ 降级setNotifyValue, isNotifying=${measChar.isNotifying}'); + } - await measurementChar.setNotifyValue(true); - _subscription = measurementChar.lastValueStream.listen(_parseReading); - await _syncTime(bpService); + // 5. 主动读取缓存数据(设备存储的最后一次测量) + debugPrint('[BLE] ⑥ 主动读取0x2A35...'); + if (measChar.properties.read) { + try { + final data = await measChar.read(); + if (data.isNotEmpty) { + debugPrint('[BLE] ⑥ 读到${data.length}字节 HEX=${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); + _parseReading(data); + } else { + debugPrint('[BLE] ⑥ 空数据'); + } + } catch (e) { + debugPrint('[BLE] ⑥ 读取失败: $e'); + } + } + + // 6. RACP请求存储记录 + if (racpChar != null) { + debugPrint('[BLE] ⑦ RACP请求存储记录...'); + try { + await racpChar.write([0x01, 0x01]); // ReportStoredRecords, All + debugPrint('[BLE] ⑦ RACP已发送'); + } catch (e) { + debugPrint('[BLE] ⑦ RACP失败: $e'); + } + } + + debugPrint('[BLE] ═══ 连接完成, 等待数据 ═══'); } - /// 断开连接 Future disconnect() async { - _subscription?.cancel(); - _subscription = null; + _connStateSub?.cancel(); + _connStateSub = null; + _measurementSub?.cancel(); + _measurementSub = null; await _device?.disconnect(); _device = null; + debugPrint('[BLE] 已断开'); } void dispose() { disconnect(); _readingsController.close(); + _connStateController.close(); } - // ── 解析 BLE 测量数据(Bluetooth SIG BP Service 1.1) ── + // ── 解析 BLE 测量数据 ── void _parseReading(List raw) { - if (raw.length < 7) return; + debugPrint('[BLE] 解析: flags=0x${raw[0].toRadixString(16)}, len=${raw.length}'); + if (raw.length < 7) { debugPrint('[BLE] 太短,跳过'); return; } final flags = raw[0]; final isKpa = (flags & 0x01) != 0; - final hasTimestamp = (flags & 0x02) != 0; + final hasTs = (flags & 0x02) != 0; final hasPulse = (flags & 0x04) != 0; - final hasUserId = (flags & 0x08) != 0; + final hasUid = (flags & 0x08) != 0; final hasStatus = (flags & 0x10) != 0; - int systolic = _decodeSFloat(raw[1] | (raw[2] << 8)).round(); - int diastolic = _decodeSFloat(raw[3] | (raw[4] << 8)).round(); + int sys = _decodeSFloat(raw[1] | (raw[2] << 8)).round(); + int dia = _decodeSFloat(raw[3] | (raw[4] << 8)).round(); + if (isKpa) { sys = (sys * 7.50062).round(); dia = (dia * 7.50062).round(); } - if (isKpa) { - systolic = (systolic * 7.50062).round(); - diastolic = (diastolic * 7.50062).round(); - } - - int offset = 7; - DateTime timestamp = DateTime.now(); - - if (hasTimestamp && raw.length >= offset + 7) { - int year = raw[offset] | (raw[offset + 1] << 8); - int month = raw[offset + 2]; - int day = raw[offset + 3]; - int hour = raw[offset + 4]; - int minute = raw[offset + 5]; - int second = raw[offset + 6]; - timestamp = DateTime(year, month, day, hour, minute, second); - offset += 7; + int off = 7; + DateTime ts = DateTime.now(); + if (hasTs && raw.length >= off + 7) { + ts = DateTime(raw[off] | (raw[off + 1] << 8), raw[off + 2], raw[off + 3], + raw[off + 4], raw[off + 5], raw[off + 6]); + off += 7; } int? pulse; - if (hasPulse && raw.length >= offset + 2) { - pulse = _decodeSFloat(raw[offset] | (raw[offset + 1] << 8)).round(); - offset += 2; + if (hasPulse && raw.length >= off + 2) { + pulse = _decodeSFloat(raw[off] | (raw[off + 1] << 8)).round(); + off += 2; } - String? userId; - if (hasUserId && raw.length > offset) { - userId = raw[offset].toString(); - offset += 1; + String? uid; + if (hasUid && raw.length > off) { uid = raw[off].toString(); off++; } + + bool bm = false, ir = false; + if (hasStatus && raw.length >= off + 2) { + int s = raw[off] | (raw[off + 1] << 8); + bm = (s & 0x0001) != 0; + ir = (s & 0x0800) != 0; } - bool bodyMovement = false, irregularPulse = false; - if (hasStatus && raw.length >= offset + 2) { - int status = raw[offset] | (raw[offset + 1] << 8); - bodyMovement = (status & 0x0001) != 0; - irregularPulse = (status & 0x0800) != 0; - } - - _readingsController.add(BpReading( - systolic: systolic, - diastolic: diastolic, - pulse: pulse, - timestamp: timestamp, - userId: userId, - isKpa: isKpa, - hasBodyMovement: bodyMovement, - hasIrregularPulse: irregularPulse, - )); + final r = BpReading(systolic: sys, diastolic: dia, pulse: pulse, timestamp: ts, + userId: uid, isKpa: isKpa, hasBodyMovement: bm, hasIrregularPulse: ir); + debugPrint('[BLE] ★ $sys/$dia mmHg, pulse=$pulse ★'); + _readingsController.add(r); } - /// SFLOAT IEEE-11073 16-bit 解码 static double _decodeSFloat(int raw) { - int mantissa = raw & 0x0FFF; - if (mantissa & 0x0800 != 0) mantissa |= 0xFFFFF000; - int exponent = (raw >> 12) & 0x0F; - if (exponent & 0x08 != 0) exponent |= 0xFFFFFFF0; - return (mantissa * pow(10, exponent)).toDouble(); - } - - /// 同步当前时间到设备 - Future _syncTime(BluetoothService service) async { - try { - final dtChar = service.characteristics.firstWhere( - (c) => c.uuid.str128.toUpperCase() == dateTimeUuid.toUpperCase(), - ); - final now = DateTime.now(); - await dtChar.write([ - now.year & 0xFF, now.year >> 8, - now.month, now.day, - now.hour, now.minute, now.second, - ]); - } catch (e) { print('[BLE]读取数据失败: $e'); } + int m = raw & 0x0FFF; + if (m & 0x0800 != 0) m |= 0xFFFFF000; + int e = (raw >> 12) & 0x0F; + if (e & 0x08 != 0) e |= 0xFFFFFFF0; + return (m * pow(10, e)).toDouble(); } }