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:
MingNian
2026-06-11 22:11:02 +08:00
parent 66168e3261
commit d102205b18
11 changed files with 1083 additions and 248 deletions

View File

@@ -68,6 +68,17 @@ public static class HealthEndpoints
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); 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) => group.MapGet("/latest", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
{ {

View 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 天

View File

@@ -15,6 +15,7 @@ import '../pages/profile/profile_page.dart';
import '../pages/profile/service_package_detail_page.dart'; import '../pages/profile/service_package_detail_page.dart';
import '../pages/diet/diet_capture_page.dart'; import '../pages/diet/diet_capture_page.dart';
import '../pages/device/device_scan_page.dart'; import '../pages/device/device_scan_page.dart';
import '../pages/device/device_management_page.dart';
import '../pages/remaining_pages.dart'; import '../pages/remaining_pages.dart';
/// 根据路由信息返回对应页面 /// 根据路由信息返回对应页面

View File

@@ -26,7 +26,7 @@ class BpReading {
'type': 'BloodPressure', 'type': 'BloodPressure',
'systolic': systolic, 'systolic': systolic,
'diastolic': diastolic, 'diastolic': diastolic,
'source': 'Device', 'source': 'DeviceSync',
'unit': 'mmHg', 'unit': 'mmHg',
'recordedAt': timestamp.toUtc().toIso8601String(), 'recordedAt': timestamp.toUtc().toIso8601String(),
}; };

View File

@@ -1,6 +1,7 @@
import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/app_theme.dart'; import '../../core/app_theme.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../providers/data_providers.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>>() ?? []; final list = (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
for (final r in list) { for (final r in list) {
all.add({ all.add({
'id': r['id'],
'type': t, 'type': t,
'date': DateTime.tryParse(r['recordedAt']?.toString() ?? '') ?? DateTime.now(), 'date': DateTime.tryParse(r['recordedAt']?.toString() ?? '') ?? DateTime.now(),
'systolic': r['systolic'], 'systolic': r['systolic'],
@@ -346,34 +348,59 @@ class _TrendPageState extends ConsumerState<TrendPage> {
..._filtered.reversed.take(30).map((r) { ..._filtered.reversed.take(30).map((r) {
final date = r['date'] as DateTime; final date = r['date'] as DateTime;
final abnormal = r['isAbnormal'] == true; final abnormal = r['isAbnormal'] == true;
final id = r['id']?.toString() ?? '';
String display; String display;
if (_isBP) { if (_isBP) {
display = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}'; display = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}';
} else { } else {
display = '${r['value'] ?? '--'}'; display = '${r['value'] ?? '--'}';
} }
return Container( return Dismissible(
margin: const EdgeInsets.only(bottom: 6), key: Key(id),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), direction: DismissDirection.endToStart,
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), background: Container(
child: Row(children: [ margin: const EdgeInsets.only(bottom: 6),
Container(width: 40, height: 40, decoration: BoxDecoration( decoration: BoxDecoration(color: AppColors.error, borderRadius: BorderRadius.circular(12)),
color: _color.withAlpha(20), borderRadius: BorderRadius.circular(10)), alignment: Alignment.centerRight,
child: Center(child: Text(_getMetricIcon(_selected), style: const TextStyle(fontSize: 21))), padding: const EdgeInsets.only(right: 20),
), child: const Icon(Icons.delete_outline, color: Colors.white),
const SizedBox(width: 12), ),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ confirmDismiss: (_) async {
Text('${date.month}${date.day}${date.hour}:${date.minute.toString().padLeft(2, '0')}', try {
style: const TextStyle(fontSize: 15, color: Color(0xFF999999))), final api = ref.read(apiClientProvider);
const SizedBox(height: 2), await api.delete('/api/health-records/$id');
Text('$display $_unit', style: TextStyle(fontSize: 23, fontWeight: FontWeight.w700, setState(() {
color: abnormal ? const Color(0xFFEF4444) : const Color(0xFF1A1A1A))), _allRecords.removeWhere((x) => x['id']?.toString() == id);
])), _filtered.removeWhere((x) => x['id']?.toString() == id);
if (abnormal) });
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), return true;
decoration: BoxDecoration(color: const Color(0xFFFEE2E2), borderRadius: BorderRadius.circular(6)), } catch (_) {
child: const Text('异常', style: TextStyle(fontSize: 14, color: Color(0xFFEF4444), fontWeight: FontWeight.w500))), 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))),
]),
),
); );
}), }),
]); ]);

View 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))),
]),
);
}

View File

@@ -1,10 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import '../../core/app_colors.dart'; import '../../core/app_colors.dart';
import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../providers/omron_device_provider.dart'; import '../../providers/omron_device_provider.dart';
import '../../services/omron_ble_service.dart'; import '../../services/omron_ble_service.dart';
@@ -14,36 +16,67 @@ class DeviceScanPage extends ConsumerStatefulWidget {
@override ConsumerState<DeviceScanPage> createState() => _DeviceScanPageState(); @override ConsumerState<DeviceScanPage> createState() => _DeviceScanPageState();
} }
class _DeviceScanPageState extends ConsumerState<DeviceScanPage> { class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
with SingleTickerProviderStateMixin {
final _results = <ScanResult>[]; final _results = <ScanResult>[];
bool _scanning = false; bool _scanning = false;
String? _connectingId; String? _connectingId;
StreamSubscription? _scanSub; 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() { @override void initState() {
super.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) { _scanSub = FlutterBluePlus.scanResults.listen((list) {
if (!mounted) return; 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(); _start();
} }
@override void dispose() { @override void dispose() {
_pulseCtrl.dispose();
_scanSub?.cancel(); _scanSub?.cancel();
_readingSub?.cancel();
_bleConnSub?.cancel();
FlutterBluePlus.stopScan(); FlutterBluePlus.stopScan();
super.dispose(); super.dispose();
} }
Future<void> _start() async { Future<void> _start() async {
setState(() => _scanning = true); setState(() { _scanning = true; _connected = false; });
_results.clear();
await [Permission.bluetoothScan, Permission.bluetoothConnect].request(); 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( await FlutterBluePlus.startScan(
timeout: const Duration(seconds: 30), timeout: const Duration(seconds: 30),
androidScanMode: AndroidScanMode.lowLatency, androidScanMode: AndroidScanMode.lowLatency,
@@ -58,6 +91,7 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage> {
Future<void> _connect(ScanResult r) async { Future<void> _connect(ScanResult r) async {
setState(() => _connectingId = r.device.remoteId.toString()); setState(() => _connectingId = r.device.remoteId.toString());
FlutterBluePlus.stopScan(); FlutterBluePlus.stopScan();
_scanSub?.cancel();
try { try {
final ble = ref.read(omronBleServiceProvider); 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); await ref.read(omronDeviceProvider.notifier).bind(r.device.remoteId.toString(), name);
ble.readings.listen((reading) async { _bleConnSub = r.device.connectionState.listen((s) {
try { if (s == BluetoothConnectionState.disconnected && mounted) {
final api = ref.read(apiClientProvider); _readingSub?.cancel();
await api.post('/api/health-records', data: reading.toHealthRecord()); ref.read(omronBleServiceProvider).disconnect();
await ref.read(omronDeviceProvider.notifier).onReading(reading); if (_readingReceived) {
} catch (e) { debugPrint('[BLE Scan] 操作失败: $e'); } 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) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( setState(() {
SnackBar(content: Text('已连接 $name'), backgroundColor: AppColors.success), _connected = true;
); _deviceName = name;
Navigator.pop(context); _connectingId = null;
_readingReceived = false;
});
} }
} catch (_) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('连接失败,请重试'), backgroundColor: AppColors.error), SnackBar(content: Text('连接失败: $e'), backgroundColor: AppColors.error),
); );
_start(); _start();
} }
} finally {
if (mounted) setState(() => _connectingId = null);
} }
} }
// ═══════════════════ UI ═══════════════════
@override Widget build(BuildContext context) { @override Widget build(BuildContext context) {
return GradientScaffold( return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar( appBar: AppBar(
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).pop()), backgroundColor: Colors.white,
backgroundColor: AppColors.cardBackground, elevation: 0,
title: const Text('添加血压计'), 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: [ body: _connected ? _buildConnected() : _buildScan(),
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),
),
)),
),
]),
); );
} }
// ── 扫描视图 ──
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) { Widget _buildTile(ScanResult r) {
final isBP = OmronBleService.isBpDevice(r); final isConnecting = _connectingId == r.device.remoteId.toString();
final name = r.advertisementData.localName.isNotEmpty final name = r.advertisementData.localName.isNotEmpty
? r.advertisementData.localName ? r.advertisementData.localName
: (r.device.platformName.isNotEmpty ? r.device.platformName : '未知设备'); : (r.device.platformName.isNotEmpty ? r.device.platformName : '未知设备');
final isConnecting = _connectingId == r.device.remoteId.toString();
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.cardBackground, borderRadius: BorderRadius.circular(16), color: Colors.white,
boxShadow: AppColors.cardShadow, borderRadius: BorderRadius.circular(16),
border: isBP ? Border.all(color: AppColors.primary, width: 1.5) : null, border: Border.all(color: const Color(0xFFEEEEEE)),
), ),
child: Row(children: [ child: Row(children: [
Container( Container(
width: 44, height: 44, width: 44, height: 44,
decoration: BoxDecoration(color: isBP ? AppColors.iconBg : AppColors.cardInner, borderRadius: BorderRadius.circular(12)), decoration: BoxDecoration(color: const Color(0xFFF0F0FF), borderRadius: BorderRadius.circular(12)),
child: Icon(Icons.bluetooth, size: 24, color: isBP ? AppColors.primary : AppColors.textHint), child: const Icon(Icons.bluetooth, size: 22, color: AppColors.primary),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(name, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
Row(children: [ const SizedBox(height: 2),
Flexible(child: Text(name, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: AppColors.textPrimary))), Text('${r.device.remoteId} · 信号: ${_rssiLabel(r.rssi)}', style: const TextStyle(fontSize: 12, color: AppColors.textHint)),
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)),
]),
),
if (isConnecting) 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 else
GestureDetector( GestureDetector(
onTap: () => _connect(r), 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( String _rssiLabel(int r) {
padding: const EdgeInsets.all(32), if (r > -55) return '';
child: Column(children: [ if (r > -70) return '';
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 '';
return ''; return '';
} }
} }

View File

@@ -864,9 +864,9 @@ class StaticTextPage extends ConsumerWidget {
} }
} }
/// 血压计设备管理(已绑定/未绑定) /// 旧版设备管理页,已移至 device/device_management_page.dart
class DeviceManagementPage extends ConsumerWidget { class _DeviceManagementPageLegacy extends ConsumerWidget {
const DeviceManagementPage({super.key}); const _DeviceManagementPageLegacy({super.key});
@override Widget build(BuildContext context, WidgetRef ref) { @override Widget build(BuildContext context, WidgetRef ref) {
final device = ref.watch(omronDeviceProvider); final device = ref.watch(omronDeviceProvider);
@@ -930,12 +930,28 @@ class DeviceManagementPage extends ConsumerWidget {
Container( Container(
width: 72, height: 72, width: 72, height: 72,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: AppColors.primaryGradient, gradient: device.isConnected ? AppColors.primaryGradient : const LinearGradient(colors: [Color(0xFF9CA3AF), Color(0xFF6B7280)]),
borderRadius: BorderRadius.circular(AppTheme.rMd), 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)), Text(device.name ?? '血压计', style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
const SizedBox(height: 6), const SizedBox(height: 6),
Text('MAC: ${device.mac ?? ''}', style: const TextStyle(fontSize: 14, color: AppColors.textHint)), 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)), Text('上次同步: ${device.lastSync}', style: const TextStyle(fontSize: 13, color: AppColors.textHint)),
], ],
const SizedBox(height: 24), const SizedBox(height: 24),
Row(children: [ if (device.isConnected) ...[
Expanded( // 已连接:显示断开和解绑
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( child: OutlinedButton.icon(
onPressed: () => _unbind(context, ref), onPressed: () => _unbind(context, ref),
icon: const Icon(Icons.delete_outline, size: 18, color: AppColors.error), 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( style: OutlinedButton.styleFrom(
side: const BorderSide(color: AppColors.error), side: const BorderSide(color: AppColors.error),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)), 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),
),
),
),
]),
]), ]),
), ),

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_provider.dart'; import 'auth_provider.dart';
import 'data_providers.dart'; import 'data_providers.dart';
@@ -45,12 +46,27 @@ class DeviceBindState {
} }
class DeviceBindNotifier extends Notifier<DeviceBindState> { class DeviceBindNotifier extends Notifier<DeviceBindState> {
StreamSubscription? _connSub;
@override @override
DeviceBindState build() { DeviceBindState build() {
_loadBinding(); _loadBinding();
_listenConnection();
return const DeviceBindState(); 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 { Future<void> _loadBinding() async {
final db = ref.read(localDbProvider); final db = ref.read(localDbProvider);
final mac = await db.read('bp_device_mac'); final mac = await db.read('bp_device_mac');

View File

@@ -27,6 +27,11 @@ class HealthService {
final list = res.data['data'] as List? ?? []; final list = res.data['data'] as List? ?? [];
return list.cast<Map<String, dynamic>>(); return list.cast<Map<String, dynamic>>();
} }
/// 删除记录
Future<void> deleteRecord(String id) async {
await _api.delete('/api/health-records/$id');
}
} }
/// 用户服务 /// 用户服务

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../models/bp_reading.dart'; import '../models/bp_reading.dart';
@@ -9,25 +10,43 @@ class OmronBleService {
static const bpMeasurementUuid = '00002A35-0000-1000-8000-00805F9B34FB'; static const bpMeasurementUuid = '00002A35-0000-1000-8000-00805F9B34FB';
static const bpFeatureUuid = '00002A49-0000-1000-8000-00805F9B34FB'; static const bpFeatureUuid = '00002A49-0000-1000-8000-00805F9B34FB';
static const dateTimeUuid = '00002A08-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; BluetoothDevice? _device;
StreamSubscription<List<int>>? _subscription; StreamSubscription<List<int>>? _measurementSub;
StreamSubscription<BluetoothConnectionState>? _connStateSub;
final _readingsController = StreamController<BpReading>.broadcast(); final _readingsController = StreamController<BpReading>.broadcast();
final _connStateController = StreamController<bool>.broadcast();
Stream<BpReading> get readings => _readingsController.stream; Stream<BpReading> get readings => _readingsController.stream;
Stream<bool> get connectionState => _connStateController.stream;
bool get isConnected => _device?.isConnected ?? false; bool get isConnected => _device?.isConnected ?? false;
/// 扫描血压计设备 /// 直接重连已知设备通过MAC地址
Stream<ScanResult> scan({Duration timeout = const Duration(seconds: 15)}) async* { Future<bool> reconnectByMac(String mac) async {
await FlutterBluePlus.startScan(timeout: timeout); debugPrint('[BLE] 直连设备: $mac');
await for (final list in FlutterBluePlus.scanResults) { final bonded = await FlutterBluePlus.bondedDevices;
for (final r in list) { BluetoothDevice? target;
yield r; 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) { static bool isBpDevice(ScanResult r) {
final name = [ final name = [
r.advertisementData.localName, r.advertisementData.localName,
@@ -42,124 +61,184 @@ class OmronBleService {
} }
/// 连接设备并订阅测量数据 /// 连接设备并订阅测量数据
/// 设备连接后会自动发送存储的最后一次测量数据
Future<void> connect(BluetoothDevice device) async { Future<void> connect(BluetoothDevice device) async {
_device = device; _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.connect(autoConnect: false);
await device.discoverServices(); debugPrint('[BLE] ① 已连接, MTU=${device.mtuNow}');
try { await device.requestMtu(512); } catch (_) {}
// 2. 发现服务
final services = await device.discoverServices(); final services = await device.discoverServices();
final bpService = services.firstWhere( debugPrint('[BLE] ② 发现${services.length}个服务');
(s) => s.uuid.str128.toUpperCase() == bpServiceUuid.toUpperCase(),
// 遍历所有服务/特征
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( // 4. 配置CCCD
(c) => c.uuid.str128.toUpperCase() == bpMeasurementUuid.toUpperCase(), 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); // 5. 主动读取缓存数据(设备存储的最后一次测量)
_subscription = measurementChar.lastValueStream.listen(_parseReading); debugPrint('[BLE] ⑥ 主动读取0x2A35...');
await _syncTime(bpService); 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 { Future<void> disconnect() async {
_subscription?.cancel(); _connStateSub?.cancel();
_subscription = null; _connStateSub = null;
_measurementSub?.cancel();
_measurementSub = null;
await _device?.disconnect(); await _device?.disconnect();
_device = null; _device = null;
debugPrint('[BLE] 已断开');
} }
void dispose() { void dispose() {
disconnect(); disconnect();
_readingsController.close(); _readingsController.close();
_connStateController.close();
} }
// ── 解析 BLE 测量数据Bluetooth SIG BP Service 1.1 ── // ── 解析 BLE 测量数据 ──
void _parseReading(List<int> raw) { 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 flags = raw[0];
final isKpa = (flags & 0x01) != 0; final isKpa = (flags & 0x01) != 0;
final hasTimestamp = (flags & 0x02) != 0; final hasTs = (flags & 0x02) != 0;
final hasPulse = (flags & 0x04) != 0; final hasPulse = (flags & 0x04) != 0;
final hasUserId = (flags & 0x08) != 0; final hasUid = (flags & 0x08) != 0;
final hasStatus = (flags & 0x10) != 0; final hasStatus = (flags & 0x10) != 0;
int systolic = _decodeSFloat(raw[1] | (raw[2] << 8)).round(); int sys = _decodeSFloat(raw[1] | (raw[2] << 8)).round();
int diastolic = _decodeSFloat(raw[3] | (raw[4] << 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) { int off = 7;
systolic = (systolic * 7.50062).round(); DateTime ts = DateTime.now();
diastolic = (diastolic * 7.50062).round(); 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]);
int offset = 7; off += 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? pulse; int? pulse;
if (hasPulse && raw.length >= offset + 2) { if (hasPulse && raw.length >= off + 2) {
pulse = _decodeSFloat(raw[offset] | (raw[offset + 1] << 8)).round(); pulse = _decodeSFloat(raw[off] | (raw[off + 1] << 8)).round();
offset += 2; off += 2;
} }
String? userId; String? uid;
if (hasUserId && raw.length > offset) { if (hasUid && raw.length > off) { uid = raw[off].toString(); off++; }
userId = raw[offset].toString();
offset += 1; 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; final r = BpReading(systolic: sys, diastolic: dia, pulse: pulse, timestamp: ts,
if (hasStatus && raw.length >= offset + 2) { userId: uid, isKpa: isKpa, hasBodyMovement: bm, hasIrregularPulse: ir);
int status = raw[offset] | (raw[offset + 1] << 8); debugPrint('[BLE] ★ $sys/$dia mmHg, pulse=$pulse');
bodyMovement = (status & 0x0001) != 0; _readingsController.add(r);
irregularPulse = (status & 0x0800) != 0;
}
_readingsController.add(BpReading(
systolic: systolic,
diastolic: diastolic,
pulse: pulse,
timestamp: timestamp,
userId: userId,
isKpa: isKpa,
hasBodyMovement: bodyMovement,
hasIrregularPulse: irregularPulse,
));
} }
/// SFLOAT IEEE-11073 16-bit 解码
static double _decodeSFloat(int raw) { static double _decodeSFloat(int raw) {
int mantissa = raw & 0x0FFF; int m = raw & 0x0FFF;
if (mantissa & 0x0800 != 0) mantissa |= 0xFFFFF000; if (m & 0x0800 != 0) m |= 0xFFFFF000;
int exponent = (raw >> 12) & 0x0F; int e = (raw >> 12) & 0x0F;
if (exponent & 0x08 != 0) exponent |= 0xFFFFFFF0; if (e & 0x08 != 0) e |= 0xFFFFFFF0;
return (mantissa * pow(10, exponent)).toDouble(); return (m * pow(10, e)).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'); }
} }
} }