feat: 蓝牙血压计BLE数据同步 + 设备管理页重构 + 健康记录滑动删除 + 医生端合并设计文档
- 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完整设计文档(已确认版)
This commit is contained in:
@@ -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) =>
|
||||
{
|
||||
|
||||
313
docs/doctor_app_merge_design.md
Normal file
313
docs/doctor_app_merge_design.md
Normal file
@@ -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 天
|
||||
@@ -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';
|
||||
|
||||
/// 根据路由信息返回对应页面
|
||||
|
||||
@@ -26,7 +26,7 @@ class BpReading {
|
||||
'type': 'BloodPressure',
|
||||
'systolic': systolic,
|
||||
'diastolic': diastolic,
|
||||
'source': 'Device',
|
||||
'source': 'DeviceSync',
|
||||
'unit': 'mmHg',
|
||||
'recordedAt': timestamp.toUtc().toIso8601String(),
|
||||
};
|
||||
|
||||
@@ -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<TrendPage> {
|
||||
final list = (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
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<TrendPage> {
|
||||
..._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))),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}),
|
||||
]);
|
||||
|
||||
223
health_app/lib/pages/device/device_management_page.dart
Normal file
223
health_app/lib/pages/device/device_management_page.dart
Normal file
@@ -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<DeviceManagementPage> createState() => _DeviceManagementPageState();
|
||||
}
|
||||
|
||||
class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage> {
|
||||
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<double>(
|
||||
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<void> _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<bool>(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))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -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<DeviceScanPage> createState() => _DeviceScanPageState();
|
||||
}
|
||||
|
||||
class _DeviceScanPageState extends ConsumerState<DeviceScanPage> {
|
||||
class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final _results = <ScanResult>[];
|
||||
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<double> _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<void> _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<DeviceScanPage> {
|
||||
Future<void> _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<DeviceScanPage> {
|
||||
|
||||
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 '弱';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
]),
|
||||
),
|
||||
|
||||
|
||||
@@ -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<DeviceBindState> {
|
||||
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<void> _loadBinding() async {
|
||||
final db = ref.read(localDbProvider);
|
||||
final mac = await db.read('bp_device_mac');
|
||||
|
||||
@@ -27,6 +27,11 @@ class HealthService {
|
||||
final list = res.data['data'] as List? ?? [];
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
/// 删除记录
|
||||
Future<void> deleteRecord(String id) async {
|
||||
await _api.delete('/api/health-records/$id');
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户服务
|
||||
|
||||
@@ -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<List<int>>? _subscription;
|
||||
StreamSubscription<List<int>>? _measurementSub;
|
||||
StreamSubscription<BluetoothConnectionState>? _connStateSub;
|
||||
final _readingsController = StreamController<BpReading>.broadcast();
|
||||
final _connStateController = StreamController<bool>.broadcast();
|
||||
Stream<BpReading> get readings => _readingsController.stream;
|
||||
Stream<bool> get connectionState => _connStateController.stream;
|
||||
|
||||
bool get isConnected => _device?.isConnected ?? false;
|
||||
|
||||
/// 扫描血压计设备
|
||||
Stream<ScanResult> 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<bool> 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<void> 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 = <String>[];
|
||||
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<void> 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<int> 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<void> _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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user