From 3964cf2bcbbd9a7faefa773ce597905ad097195a Mon Sep 17 00:00:00 2001 From: MingNian <1281442923@qq.com> Date: Tue, 9 Jun 2026 18:48:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=93=9D=E7=89=99=E8=A1=80=E5=8E=8B?= =?UTF-8?q?=E8=AE=A1=E5=9F=BA=E7=A1=80=E6=9E=B6=E6=9E=84=20+=20UI=E9=85=8D?= =?UTF-8?q?=E8=89=B2=E4=BD=93=E7=B3=BB=E5=8D=87=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 AppColors 配色体系(紫蓝渐变 + 超大圆角 + 漂浮卡片) - 新增 OmronBleService(BLE 扫描/连接/SFLOAT 协议解析) - 新增 BpReading 数据模型 + 血压数据自动同步后端 - 新增 DeviceScanPage(血压计扫描绑定页)+ DeviceBindPage(已绑定管理页) - 侧边栏新增"蓝牙设备"入口,ProfilePage 移除设备管理 - Android/iOS BLE 权限配置(BLUETOOTH_SCAN/CONNECT) - AppTheme 升级:新阴影系统、新语义色、圆角放大 --- .../android/app/src/main/AndroidManifest.xml | 8 + health_app/ios/Runner/Info.plist | 4 + health_app/lib/core/app_colors.dart | 224 ++++++++++-------- health_app/lib/core/app_router.dart | 3 + health_app/lib/core/app_theme.dart | 147 +++++++----- health_app/lib/models/bp_reading.dart | 33 +++ .../lib/pages/device/device_scan_page.dart | 216 +++++++++++++++++ .../lib/pages/profile/profile_page.dart | 1 - health_app/lib/pages/remaining_pages.dart | 210 +++++++++++++++- .../lib/providers/omron_device_provider.dart | 92 +++++++ .../lib/services/omron_ble_service.dart | 165 +++++++++++++ health_app/lib/widgets/health_drawer.dart | 6 + health_app/pubspec.lock | 112 +++++++++ health_app/pubspec.yaml | 4 + 14 files changed, 1068 insertions(+), 157 deletions(-) create mode 100644 health_app/lib/models/bp_reading.dart create mode 100644 health_app/lib/pages/device/device_scan_page.dart create mode 100644 health_app/lib/providers/omron_device_provider.dart create mode 100644 health_app/lib/services/omron_ble_service.dart diff --git a/health_app/android/app/src/main/AndroidManifest.xml b/health_app/android/app/src/main/AndroidManifest.xml index e678447..c0f4685 100644 --- a/health_app/android/app/src/main/AndroidManifest.xml +++ b/health_app/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,14 @@ + + + + + + + + UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + NSBluetoothAlwaysUsageDescription + 需要使用蓝牙连接欧姆龙血压计以同步测量数据 + NSBluetoothPeripheralUsageDescription + 需要使用蓝牙连接血压计 CADisableMinimumFrameDurationOnPhone UIApplicationSupportsIndirectInputEvents diff --git a/health_app/lib/core/app_colors.dart b/health_app/lib/core/app_colors.dart index 2a6e28c..fb3267f 100644 --- a/health_app/lib/core/app_colors.dart +++ b/health_app/lib/core/app_colors.dart @@ -1,161 +1,197 @@ import 'package:flutter/material.dart'; -/// 统一配色方案 - 清爽紫蓝风格(参考蚂蚁阿福) +/// 统一配色方案 - 蚂蚁阿福风格:紫蓝渐变 + 淡紫背景 + 圆角卡片 class AppColors { AppColors._(); // ═══════════════════════════════════════════════════════════ - // 主色调 + // 主色调 - 紫蓝渐变系 // ═══════════════════════════════════════════════════════════ - /// 纯白背景 - static const Color background = Color(0xFFFFFFFF); + /// 主紫色(鲜艳紫蓝,阿福风主按钮色) + static const Color primary = Color(0xFF6366F1); - /// 次级背景色(非常淡的蓝紫色) - static const Color backgroundSecondary = Color(0xFFF8FAFF); + /// 主紫色深色 + static const Color primaryDark = Color(0xFF4F46E5); - /// 卡片背景 + /// 浅紫(用于背景、图标底) + static const Color primaryLight = Color(0xFFEEF2FF); + + /// 中紫(渐变过渡) + static const Color primaryMid = Color(0xFFA78BFA); + + /// 青色点缀(渐变右侧,标签色) + static const Color accentCyan = Color(0xFF22D3EE); + + /// 青色深色 + static const Color accentCyanDark = Color(0xFF06B6D4); + + // ═══════════════════════════════════════════════════════════ + // 背景色 + // ═══════════════════════════════════════════════════════════ + + /// 页面背景(极淡紫蓝,营造漂浮感) + static const Color background = Color(0xFFF8FAFF); + + /// 更淡的紫背景(最底层,带渐变感) + static const Color backgroundSoft = Color(0xFFF5F3FF); + + /// 卡片背景 - 纯白 static const Color cardBackground = Color(0xFFFFFFFF); - // ═══════════════════════════════════════════════════════════ - // 紫色系(清爽浅紫) - // ═══════════════════════════════════════════════════════════ - - /// 主紫色(浅紫,蚂蚁阿福风格) - static const Color primaryPurple = Color(0xFFB8C4FF); - - /// 深一点的紫色(用于强调) - static const Color primaryPurpleDark = Color(0xFF8BA4E8); - - /// 紫色渐变起点 - static const Color purpleGradientStart = Color(0xFFD4DFFF); - - /// 紫色渐变终点 - static const Color purpleGradientEnd = Color(0xFFB8C4FF); + /// 卡片内小卡片背景(极淡紫灰) + static const Color cardInner = Color(0xFFF8FAFF); // ═══════════════════════════════════════════════════════════ - // 蓝色系 + // 图标渐变配色 // ═══════════════════════════════════════════════════════════ - /// 主蓝色(清爽蓝) - static const Color primaryBlue = Color(0xFF5B9FFF); + /// 报告/数据图标:蓝紫渐变 + static const Color iconBlueStart = Color(0xFF818CF8); + static const Color iconBlueEnd = Color(0xFF6366F1); - /// 深一点的蓝色 - static const Color primaryBlueDark = Color(0xFF3A7FE8); + /// 药管家图标:青蓝渐变 + static const Color iconCyanStart = Color(0xFF67E8F9); + static const Color iconCyanEnd = Color(0xFF22D3EE); - /// 蓝色渐变起点 - static const Color blueGradientStart = Color(0xFFB8D4FF); + /// 拍皮肤图标:紫蓝渐变 + static const Color iconPurpleStart = Color(0xFFA78BFA); + static const Color iconPurpleEnd = Color(0xFF7C3AED); - /// 蓝色渐变终点 - static const Color blueGradientEnd = Color(0xFF7BB8FF); - - // ═══════════════════════════════════════════════════════════ - // 渐变组合 - // ═══════════════════════════════════════════════════════════ - - /// 紫蓝渐变(用于标题背景、图标背景) - static const LinearGradient purpleBlueGradient = LinearGradient( - colors: [purpleGradientStart, blueGradientEnd], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ); - - /// 轻量级渐变(用于卡片内小元素) - static const LinearGradient lightGradient = LinearGradient( - colors: [Color(0xFFEEF2FF), Color(0xFFF0F5FF)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ); + /// 通用图标底色 + static const Color iconBg = Color(0xFFEEF2FF); // ═══════════════════════════════════════════════════════════ // 文字颜色 // ═══════════════════════════════════════════════════════════ - /// 主要文字(深色) - static const Color textPrimary = Color(0xFF1A1A2E); + /// 主要文字(深紫黑) + static const Color textPrimary = Color(0xFF1E1B4B); - /// 次要文字(中等灰色) - static const Color textSecondary = Color(0xFF666666); + /// 次要文字(深灰) + static const Color textSecondary = Color(0xFF64748B); - /// 辅助文字(浅灰色) - static const Color textHint = Color(0xFF999999); + /// 辅助文字(浅灰) + static const Color textHint = Color(0xFF94A3B8); - /// 文字颜色(用于渐变背景上的白色文字) + /// 渐变上的白色文字 static const Color textOnGradient = Color(0xFFFFFFFF); - // ═══════════════════════════════════════════════════════════ - // 图标和元素颜色 - // ═══════════════════════════════════════════════════════════ - - /// 图标背景色(非常淡的蓝紫色) - static const Color iconBackground = Color(0xFFEEF2FF); - - /// 图标主色(清爽蓝紫色) - static const Color iconColor = Color(0xFF5B8DEF); - - /// 次要图标色 - static const Color iconColorSecondary = Color(0xFF8BA4E8); - // ═══════════════════════════════════════════════════════════ // 边框和分割线 // ═══════════════════════════════════════════════════════════ - /// 默认边框色 - static const Color border = Color(0xFFE8EBF5); + /// 默认边框色(极淡) + static const Color border = Color(0xFFE2E8F0); - /// 浅边框色 - static const Color borderLight = Color(0xFFF0F2F7); + /// 更浅边框色 + static const Color borderLight = Color(0xFFF1F5F9); // ═══════════════════════════════════════════════════════════ // 功能色 // ═══════════════════════════════════════════════════════════ - /// 成功色(绿色) - static const Color success = Color(0xFF4CAF50); + /// 成功色(柔和绿) + static const Color success = Color(0xFF22C55E); + + /// 成功浅色 + static const Color successLight = Color(0xFFDCFCE7); /// 错误色(柔和红) - static const Color error = Color(0xFFFF6B6B); + static const Color error = Color(0xFFEF4444); - /// 警告色(橙色) - static const Color warning = Color(0xFFFFB74D); + /// 错误浅色 + static const Color errorLight = Color(0xFFFEE2E2); + + /// 警告色(橙) + static const Color warning = Color(0xFFF59E0B); + + /// 警告浅色 + static const Color warningLight = Color(0xFFFEF3C7); // ═══════════════════════════════════════════════════════════ - // 按钮渐变(白色底+渐变边框) + // 渐变组 // ═══════════════════════════════════════════════════════════ - /// 主按钮渐变边框色 - static const LinearGradient buttonGradient = LinearGradient( - colors: [primaryPurple, primaryBlue], + /// 主渐变:紫 → 蓝紫(主按钮填充) + static const LinearGradient primaryGradient = LinearGradient( + colors: [Color(0xFF818CF8), Color(0xFF6366F1)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ); + + /// 边框渐变:蓝紫 → 紫 → 青(按钮/卡片描边) + static const LinearGradient borderGradient = LinearGradient( + colors: [Color(0xFF818CF8), Color(0xFFA78BFA), Color(0xFF22D3EE)], begin: Alignment.centerLeft, end: Alignment.centerRight, ); - /// 成功按钮渐变 - static const LinearGradient successButtonGradient = LinearGradient( - colors: [Color(0xFF81E6A0), success], - begin: Alignment.centerLeft, - end: Alignment.centerRight, + /// 青色渐变(药管家等) + static const LinearGradient cyanGradient = LinearGradient( + colors: [Color(0xFF67E8F9), Color(0xFF22D3EE)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ); + + /// 紫色渐变(拍皮肤等) + static const LinearGradient purpleGradient = LinearGradient( + colors: [Color(0xFFC4B5FD), Color(0xFF8B5CF6)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ); + + /// 页面背景渐变(淡紫 → 淡蓝,营造漂浮感) + static const LinearGradient pageBgGradient = LinearGradient( + colors: [Color(0xFFF5F3FF), Color(0xFFEFF6FF)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ); + + /// 卡片内轻渐变 + static const LinearGradient cardLightGradient = LinearGradient( + colors: [Color(0xFFEEF2FF), Color(0xFFF0F9FF)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, ); // ═══════════════════════════════════════════════════════════ // 阴影 // ═══════════════════════════════════════════════════════════ - /// 卡片阴影 + /// 卡片阴影(淡紫色柔和阴影) static List cardShadow = [ BoxShadow( - color: primaryPurple.withAlpha(15), - blurRadius: 20, - offset: const Offset(0, 6), + color: Color(0xFF6366F1).withOpacity(0.08), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ]; + + /// 轻卡片阴影 + static List cardShadowLight = [ + BoxShadow( + color: Color(0xFF6366F1).withOpacity(0.05), + blurRadius: 16, + offset: const Offset(0, 4), ), ]; /// 按钮阴影 static List buttonShadow = [ BoxShadow( - color: primaryPurple.withAlpha(20), - blurRadius: 12, - offset: const Offset(0, 4), + color: Color(0xFF6366F1).withOpacity(0.25), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ]; + + /// 悬浮操作按钮阴影(右下角大号按钮) + static List fabShadow = [ + BoxShadow( + color: Color(0xFF6366F1).withOpacity(0.35), + blurRadius: 20, + offset: const Offset(0, 8), ), ]; } diff --git a/health_app/lib/core/app_router.dart b/health_app/lib/core/app_router.dart index 42bf99b..d585f40 100644 --- a/health_app/lib/core/app_router.dart +++ b/health_app/lib/core/app_router.dart @@ -14,6 +14,7 @@ import '../pages/settings/notification_prefs_page.dart'; 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/remaining_pages.dart'; /// 根据路由信息返回对应页面 @@ -54,6 +55,8 @@ Widget buildPage(RouteInfo route) { return const ProfilePage(); case 'devices': return const DeviceManagementPage(); + case 'deviceScan': + return const DeviceScanPage(); case 'healthArchive': return const HealthArchivePage(); case 'followups': diff --git a/health_app/lib/core/app_theme.dart b/health_app/lib/core/app_theme.dart index 5d47469..4b50954 100644 --- a/health_app/lib/core/app_theme.dart +++ b/health_app/lib/core/app_theme.dart @@ -2,65 +2,84 @@ import 'package:flutter/material.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'app_colors.dart'; -/// 健康管家 — 清爽紫蓝风 +/// 健康管家 —— 蚂蚁阿福风格:紫蓝渐变 + 超大圆角 + 漂浮卡片 class AppTheme { AppTheme._(); // ── 主色调 ── - static const Color primary = AppColors.primaryPurple; - static const Color primaryLight = AppColors.iconBackground; - static const Color primaryDark = AppColors.iconColor; - static const Color primaryGlow = AppColors.purpleGradientStart; + static const Color primary = AppColors.primary; + static const Color primaryLight = AppColors.primaryLight; + static const Color primaryDark = AppColors.primaryDark; + static const Color primaryMid = AppColors.primaryMid; + static const Color accent = AppColors.accentCyan; + static const Color info = AppColors.accentCyan; // ── 中性色 ── - static const Color bg = AppColors.backgroundSecondary; - static const Color surface = AppColors.cardBackground; - static const Color text = AppColors.textPrimary; - static const Color textSub = AppColors.textSecondary; + static const Color bg = AppColors.background; + static const Color bgSoft = AppColors.backgroundSoft; + static const Color surface = AppColors.cardBackground; + static const Color surfaceInner = AppColors.cardInner; + static const Color text = AppColors.textPrimary; + static const Color textSub = AppColors.textSecondary; static const Color textHint = AppColors.textHint; - static const Color border = AppColors.border; - static const Color divider = AppColors.borderLight; + static const Color border = AppColors.border; + static const Color divider = AppColors.borderLight; // ── 语义色 ── static const Color success = AppColors.success; - static const Color error = AppColors.error; + static const Color successLight = AppColors.successLight; + static const Color error = AppColors.error; + static const Color errorLight = AppColors.errorLight; static const Color warning = AppColors.warning; - static const Color accent = AppColors.primaryBlue; - static const Color info = AppColors.primaryBlue; + static const Color warningLight = AppColors.warningLight; - // ── 圆角 ── - static const double rXs = 6; - static const double rSm = 10; - static const double rMd = 14; - static const double rLg = 20; - static const double rXl = 28; + // ── 圆角(阿福风格:超大圆角)── + static const double rXs = 8; + static const double rSm = 12; + static const double rMd = 18; + static const double rLg = 24; + static const double rXl = 32; static const double rPill = 999; // ── 间距 ── - static const double sXs = 4; - static const double sSm = 8; - static const double sMd = 12; - static const double sLg = 16; - static const double sXl = 20; + static const double sXs = 4; + static const double sSm = 8; + static const double sMd = 12; + static const double sLg = 16; + static const double sXl = 20; static const double sXxl = 28; - // ── 阴影 ── - static BoxShadow get shadowCard => - BoxShadow(color: primary.withAlpha(14), blurRadius: 20, offset: const Offset(0, 4)); - static BoxShadow get shadowLight => - BoxShadow(color: primary.withAlpha(8), blurRadius: 12, offset: const Offset(0, 2)); - static BoxShadow get shadowElevated => - BoxShadow(color: primary.withAlpha(20), blurRadius: 30, offset: const Offset(0, 8)); + // ── 阴影(淡紫柔和阴影)── + static BoxShadow get shadowCard => BoxShadow( + color: AppColors.primary.withOpacity(0.08), + blurRadius: 24, + offset: const Offset(0, 8), + ); + static BoxShadow get shadowLight => BoxShadow( + color: AppColors.primary.withOpacity(0.05), + blurRadius: 16, + offset: const Offset(0, 4), + ); + static BoxShadow get shadowElevated => BoxShadow( + color: AppColors.primary.withOpacity(0.15), + blurRadius: 32, + offset: const Offset(0, 10), + ); + static BoxShadow get shadowButton => BoxShadow( + color: AppColors.primary.withOpacity(0.25), + blurRadius: 16, + offset: const Offset(0, 6), + ); // ── 智能体配色 ── static const Map agentColors = { - 'default': AppColors.iconBackground, - 'consultation': Color(0xFFE0F0FF), - 'health': Color(0xFFE8FFE8), - 'diet': Color(0xFFFFF0E0), - 'medication': Color(0xFFFFE8EC), - 'report': Color(0xFFE8F0FF), - 'exercise': Color(0xFFF0E8FF), + 'default': AppColors.primaryLight, + 'consultation': Color(0xFFE0E7FF), + 'health': Color(0xFFDCFCE7), + 'diet': Color(0xFFFEF3C7), + 'medication': Color(0xFFFCE7F3), + 'report': Color(0xFFDBEAFE), + 'exercise': Color(0xFFEDE9FE), }; static Color agentLight(String? name) => agentColors[name] ?? primaryLight; @@ -71,19 +90,26 @@ class AppTheme { colorScheme: ColorScheme.fromSeed( seedColor: primary, primary: primary, - surface: bg, + primaryContainer: primaryLight, + onPrimary: Colors.white, + secondary: accent, + surface: surface, + background: bg, brightness: Brightness.light, ), scaffoldBackgroundColor: bg, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + hoverColor: primaryLight.withOpacity(0.5), fontFamily: null, - appBarTheme: const AppBarTheme( - backgroundColor: surface, + appBarTheme: AppBarTheme( + backgroundColor: bg, foregroundColor: text, elevation: 0, - centerTitle: true, + centerTitle: false, scrolledUnderElevation: 0, - titleTextStyle: TextStyle(fontSize: 23, fontWeight: FontWeight.w600, color: text), + titleTextStyle: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: text), ), cardTheme: CardThemeData( @@ -95,7 +121,7 @@ class AppTheme { inputDecorationTheme: InputDecorationTheme( filled: true, - fillColor: bg, + fillColor: bgSoft, contentPadding: const EdgeInsets.symmetric(horizontal: sLg, vertical: 16), border: OutlineInputBorder(borderRadius: BorderRadius.circular(rMd), borderSide: BorderSide.none), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(rMd), borderSide: BorderSide.none), @@ -103,7 +129,7 @@ class AppTheme { borderRadius: BorderRadius.circular(rMd), borderSide: const BorderSide(color: primary, width: 2), ), - hintStyle: const TextStyle(color: textHint, fontSize: 20), + hintStyle: TextStyle(color: textHint, fontSize: 16), ), elevatedButtonTheme: ElevatedButtonThemeData( @@ -112,28 +138,29 @@ class AppTheme { foregroundColor: Colors.white, minimumSize: const Size(double.infinity, 56), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rPill)), - textStyle: const TextStyle(fontSize: 21, fontWeight: FontWeight.w600), + textStyle: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), elevation: 0, + shadowColor: primary.withOpacity(0.3), ), ), dialogTheme: DialogThemeData(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rXl))), textTheme: const TextTheme( - headlineLarge: TextStyle(fontSize: 32, fontWeight: FontWeight.w700, color: text, letterSpacing: -0.5), - titleLarge: TextStyle(fontSize: 23, fontWeight: FontWeight.w600, color: text), - titleMedium: TextStyle(fontSize: 21, fontWeight: FontWeight.w600, color: text), - bodyLarge: TextStyle(fontSize: 21, color: text, height: 1.5), - bodyMedium: TextStyle(fontSize: 20, color: textSub, height: 1.4), - labelMedium: TextStyle(fontSize: 18, color: textSub), - labelSmall: TextStyle(fontSize: 16, color: textHint), + headlineLarge: TextStyle(fontSize: 28, fontWeight: FontWeight.w700, color: text, letterSpacing: -0.3), + titleLarge: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: text), + titleMedium: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: text), + bodyLarge: TextStyle(fontSize: 16, color: text, height: 1.5), + bodyMedium: TextStyle(fontSize: 14, color: textSub, height: 1.4), + labelMedium: TextStyle(fontSize: 13, color: textSub), + labelSmall: TextStyle(fontSize: 12, color: textHint), ), ); - // ── Shadcn UI 主题 ── + // ── Shadcn UI 主题(紫蓝系) ── static ShadThemeData get shadTheme => ShadThemeData( brightness: Brightness.light, - colorScheme: const ShadVioletColorScheme.light( + colorScheme: ShadVioletColorScheme.light( background: bg, foreground: text, card: surface, @@ -142,9 +169,9 @@ class AppTheme { popoverForeground: text, primary: primary, primaryForeground: Colors.white, - secondary: divider, - secondaryForeground: text, - muted: divider, + secondary: primaryLight, + secondaryForeground: primaryDark, + muted: bgSoft, mutedForeground: textSub, accent: accent, accentForeground: Colors.white, @@ -153,7 +180,7 @@ class AppTheme { border: border, input: border, ring: primary, - selection: primaryGlow, + selection: primaryLight, ), radius: BorderRadius.circular(rMd), ); diff --git a/health_app/lib/models/bp_reading.dart b/health_app/lib/models/bp_reading.dart new file mode 100644 index 0000000..5cb56d0 --- /dev/null +++ b/health_app/lib/models/bp_reading.dart @@ -0,0 +1,33 @@ +/// 血压测量数据模型 +class BpReading { + final int systolic; + final int diastolic; + final int? pulse; + final DateTime timestamp; + final String? userId; + final bool isKpa; + final bool hasBodyMovement; + final bool hasIrregularPulse; + + const BpReading({ + required this.systolic, + required this.diastolic, + this.pulse, + required this.timestamp, + this.userId, + this.isKpa = false, + this.hasBodyMovement = false, + this.hasIrregularPulse = false, + }); + + String get display => '$systolic/$diastolic'; + + Map toHealthRecord() => { + 'type': 'BloodPressure', + 'systolic': systolic, + 'diastolic': diastolic, + 'source': 'Device', + 'unit': 'mmHg', + 'recordedAt': timestamp.toUtc().toIso8601String(), + }; +} diff --git a/health_app/lib/pages/device/device_scan_page.dart b/health_app/lib/pages/device/device_scan_page.dart new file mode 100644 index 0000000..8d1a029 --- /dev/null +++ b/health_app/lib/pages/device/device_scan_page.dart @@ -0,0 +1,216 @@ +import 'dart:async'; +import 'package:flutter/material.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 '../../providers/auth_provider.dart'; +import '../../providers/omron_device_provider.dart'; +import '../../services/omron_ble_service.dart'; + +class DeviceScanPage extends ConsumerStatefulWidget { + const DeviceScanPage({super.key}); + @override ConsumerState createState() => _DeviceScanPageState(); +} + +class _DeviceScanPageState extends ConsumerState { + final _results = []; + bool _scanning = false; + String? _connectingId; + StreamSubscription? _scanSub; + + @override void initState() { + super.initState(); + // 一次性订阅,持续整个页面生命周期 + _scanSub = FlutterBluePlus.scanResults.listen((list) { + if (!mounted) return; + setState(() { _results.clear(); _results.addAll(list); }); + }); + _start(); + } + + @override void dispose() { + _scanSub?.cancel(); + FlutterBluePlus.stopScan(); + super.dispose(); + } + + Future _start() async { + setState(() => _scanning = true); + + await [Permission.bluetoothScan, Permission.bluetoothConnect].request(); + + try { await FlutterBluePlus.turnOn(); } catch (_) {} + + try { await FlutterBluePlus.stopScan(); } catch (_) {} + await FlutterBluePlus.startScan( + timeout: const Duration(seconds: 30), + androidScanMode: AndroidScanMode.lowLatency, + ); + + Future.delayed(const Duration(seconds: 30), () { + FlutterBluePlus.stopScan(); + if (mounted) setState(() => _scanning = false); + }); + } + + Future _connect(ScanResult r) async { + setState(() => _connectingId = r.device.remoteId.toString()); + FlutterBluePlus.stopScan(); + + try { + final ble = ref.read(omronBleServiceProvider); + await ble.connect(r.device); + final name = r.advertisementData.localName.isNotEmpty + ? r.advertisementData.localName + : (r.device.platformName.isNotEmpty ? r.device.platformName : '血压计'); + + 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 (_) {} + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('已连接 $name'), backgroundColor: AppColors.success), + ); + Navigator.pop(context); + } + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('连接失败,请重试'), backgroundColor: AppColors.error), + ); + _start(); + } + } finally { + if (mounted) setState(() => _connectingId = null); + } + } + + // ═══════════════════ UI ═══════════════════ + + @override Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar(backgroundColor: AppColors.cardBackground, title: const Text('添加血压计')), + body: Column(children: [ + Container( + width: double.infinity, padding: const EdgeInsets.all(16), + color: AppColors.iconBackground, + 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.iconColor, + side: const BorderSide(color: Color(0xFF5B8DEF)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + )), + ), + ]), + ); + } + + Widget _buildTile(ScanResult r) { + final isBP = OmronBleService.isBpDevice(r); + 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), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.cardBackground, borderRadius: BorderRadius.circular(16), + boxShadow: AppColors.cardShadow, + border: isBP ? Border.all(color: AppColors.iconColor, width: 1.5) : null, + ), + child: Row(children: [ + Container( + width: 44, height: 44, + decoration: BoxDecoration(color: isBP ? AppColors.iconBackground : AppColors.backgroundSecondary, borderRadius: BorderRadius.circular(12)), + child: Icon(Icons.bluetooth, size: 24, color: isBP ? AppColors.iconColor : AppColors.textHint), + ), + 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.iconColor, 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) + const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF5B8DEF))) + else + GestureDetector( + onTap: () => _connect(r), + child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration(gradient: AppColors.purpleBlueGradient, borderRadius: BorderRadius.circular(12)), child: const Text('连接', style: TextStyle(fontSize: 15, 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.iconColor, 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 '弱'; + } +} diff --git a/health_app/lib/pages/profile/profile_page.dart b/health_app/lib/pages/profile/profile_page.dart index 5abb455..6798810 100644 --- a/health_app/lib/pages/profile/profile_page.dart +++ b/health_app/lib/pages/profile/profile_page.dart @@ -71,7 +71,6 @@ class ProfilePage extends ConsumerWidget { ), const SizedBox(height: AppTheme.sMd), AppMenuItem(icon: LucideIcons.folderArchive, title: '健康档案', onTap: () => pushRoute(ref, 'healthArchive')), - AppMenuItem(icon: LucideIcons.monitorSmartphone, title: '设备管理', onTap: () => pushRoute(ref, 'devices')), AppMenuItem(icon: LucideIcons.settings, title: '设置', onTap: () => pushRoute(ref, 'settings')), const SizedBox(height: 40), // 退出登录 diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index 619920b..f051698 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import '../core/app_colors.dart'; import '../core/app_theme.dart'; import '../core/navigation_provider.dart'; import '../providers/auth_provider.dart'; import '../providers/data_providers.dart'; +import '../providers/omron_device_provider.dart'; /// 饮食记录列表(左滑删除) class DietRecordListPage extends ConsumerStatefulWidget { @@ -783,10 +785,214 @@ class StaticTextPage extends ConsumerWidget { } } -/// 设备管理(占位) +/// 血压计设备管理(已绑定/未绑定) class DeviceManagementPage extends ConsumerWidget { const DeviceManagementPage({super.key}); - @override Widget build(BuildContext context, WidgetRef ref) => _empty(context, '设备管理', '暂无绑定设备'); + + @override Widget build(BuildContext context, WidgetRef ref) { + final device = ref.watch(omronDeviceProvider); + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.cardBackground, + title: const Text('蓝牙血压计'), + ), + body: device.isBound ? _buildBoundCard(context, ref, device) : _buildEmptyState(context, ref), + ); + } + + Widget _buildEmptyState(BuildContext context, WidgetRef ref) => Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container( + width: 88, height: 88, + decoration: BoxDecoration( + color: AppColors.iconBackground, + borderRadius: BorderRadius.circular(28), + ), + child: Icon(Icons.bluetooth_disabled, size: 44, color: AppColors.textHint), + ), + const SizedBox(height: 20), + const Text('未绑定设备', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + const SizedBox(height: 8), + const Text('连接欧姆龙血压计,自动同步测量数据', style: TextStyle(fontSize: 15, color: AppColors.textHint)), + const SizedBox(height: 28), + SizedBox( + width: 220, + height: 52, + child: ElevatedButton.icon( + onPressed: () => pushRoute(ref, 'deviceScan'), + icon: const Icon(Icons.add, size: 22), + label: const Text('添加设备', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.iconColor, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + ), + ), + ]), + ), + ); + + Widget _buildBoundCard(BuildContext context, WidgetRef ref, DeviceBindState device) => SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column(children: [ + // 设备信息卡片 + Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: AppColors.cardBackground, + borderRadius: BorderRadius.circular(20), + boxShadow: AppColors.cardShadow, + ), + child: Column(children: [ + Container( + width: 72, height: 72, + decoration: BoxDecoration( + gradient: AppColors.purpleBlueGradient, + borderRadius: BorderRadius.circular(22), + ), + child: const Icon(Icons.bluetooth_connected, size: 36, color: Colors.white), + ), + const SizedBox(height: 16), + 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)), + if (device.lastSync != null) ...[ + const SizedBox(height: 4), + Text('上次同步: ${device.lastSync}', style: const TextStyle(fontSize: 13, color: AppColors.textHint)), + ], + const SizedBox(height: 24), + 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(14)), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + ), + 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.iconColor, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + ), + ]), + ]), + ), + + // 最后读数卡片 + if (device.lastReading != null) ...[ + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppColors.cardBackground, + borderRadius: BorderRadius.circular(20), + boxShadow: AppColors.cardShadow, + ), + child: Column(children: [ + Row(children: [ + Container( + width: 6, height: 18, + decoration: BoxDecoration( + gradient: AppColors.purpleBlueGradient, + borderRadius: BorderRadius.circular(3), + ), + ), + const SizedBox(width: 8), + const Text('最近一次测量', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + ]), + const SizedBox(height: 16), + Row(children: [ + Container( + width: 56, height: 56, + decoration: BoxDecoration( + gradient: AppColors.lightGradient, + borderRadius: BorderRadius.circular(16), + ), + child: const Text('🩺', style: TextStyle(fontSize: 28)), + ), + const SizedBox(width: 16), + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(device.lastReading!.display, style: const TextStyle(fontSize: 32, fontWeight: FontWeight.w800, color: AppColors.textPrimary, letterSpacing: -0.5)), + Text('mmHg', style: TextStyle(fontSize: 14, color: AppColors.textHint)), + ]), + const Spacer(), + if (device.lastReading!.pulse != null) + Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ + Text('${device.lastReading!.pulse}', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.iconColor)), + const Text('bpm', style: TextStyle(fontSize: 13, color: AppColors.textHint)), + ]), + ]), + ]), + ), + ], + + // 使用说明 + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppColors.iconBackground, + borderRadius: BorderRadius.circular(16), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Icon(Icons.lightbulb_outline, size: 18, color: AppColors.iconColor), + const SizedBox(width: 8), + const Text('使用说明', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + ]), + const SizedBox(height: 10), + _guideItem('1', '血压计装好电池,绑好袖带'), + _guideItem('2', '血压计按开始键开始测量'), + _guideItem('3', '测量完成后数据自动同步到 App'), + _guideItem('4', '打开此页面时保持蓝牙开启'), + ]), + ), + ]), + ); + + Widget _guideItem(String num, String text) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('$num.', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.iconColor)), + const SizedBox(width: 6), + Expanded(child: Text(text, style: const TextStyle(fontSize: 14, color: AppColors.textSecondary))), + ]), + ); + + void _unbind(BuildContext context, WidgetRef ref) async { + final ok = await showDialog(context: context, builder: (ctx) => AlertDialog( + title: const Text('解绑设备'), + content: const Text('解绑后需重新扫描连接,确定吗?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定解绑', style: TextStyle(color: AppColors.error))), + ], + )); + if (ok == true) { + await ref.read(omronDeviceProvider.notifier).unbind(); + } + } } // ═══════════════════ 自定义滑动删除组件 ═══════════════════ diff --git a/health_app/lib/providers/omron_device_provider.dart b/health_app/lib/providers/omron_device_provider.dart new file mode 100644 index 0000000..7e1d6cf --- /dev/null +++ b/health_app/lib/providers/omron_device_provider.dart @@ -0,0 +1,92 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'auth_provider.dart'; +import 'data_providers.dart'; +import '../models/bp_reading.dart'; +import '../services/omron_ble_service.dart'; + +/// BLE 服务单例 +final omronBleServiceProvider = Provider((ref) { + return OmronBleService(); +}); + +/// 设备绑定状态 +class DeviceBindState { + final bool isBound; + final String? mac; + final String? name; + final String? lastSync; + final bool isConnected; + final BpReading? lastReading; + + const DeviceBindState({ + this.isBound = false, + this.mac, + this.name, + this.lastSync, + this.isConnected = false, + this.lastReading, + }); + + DeviceBindState copyWith({ + bool? isBound, + String? mac, + String? name, + String? lastSync, + bool? isConnected, + BpReading? lastReading, + }) => DeviceBindState( + isBound: isBound ?? this.isBound, + mac: mac ?? this.mac, + name: name ?? this.name, + lastSync: lastSync ?? this.lastSync, + isConnected: isConnected ?? this.isConnected, + lastReading: lastReading ?? this.lastReading, + ); +} + +class DeviceBindNotifier extends Notifier { + @override + DeviceBindState build() { + _loadBinding(); + return const DeviceBindState(); + } + + Future _loadBinding() async { + final db = ref.read(localDbProvider); + final mac = await db.read('bp_device_mac'); + final name = await db.read('bp_device_name'); + final lastSync = await db.read('bp_last_sync'); + if (mac != null && mac.isNotEmpty) { + state = state.copyWith(isBound: true, mac: mac, name: name, lastSync: lastSync); + } + } + + Future bind(String mac, String name) async { + final db = ref.read(localDbProvider); + await db.write('bp_device_mac', mac); + await db.write('bp_device_name', name); + state = state.copyWith(isBound: true, mac: mac, name: name); + } + + Future unbind() async { + final db = ref.read(localDbProvider); + await db.delete('bp_device_mac'); + await db.delete('bp_device_name'); + await db.delete('bp_last_sync'); + state = const DeviceBindState(); + } + + void setConnected(bool connected) { + state = state.copyWith(isConnected: connected); + } + + Future onReading(BpReading reading) async { + final db = ref.read(localDbProvider); + final lastSync = '${reading.timestamp.month}/${reading.timestamp.day} ${reading.timestamp.hour}:${reading.timestamp.minute.toString().padLeft(2, '0')}'; + await db.write('bp_last_sync', lastSync); + state = state.copyWith(lastSync: lastSync, lastReading: reading); + ref.invalidate(latestHealthProvider); + } +} + +final omronDeviceProvider = NotifierProvider(DeviceBindNotifier.new); diff --git a/health_app/lib/services/omron_ble_service.dart b/health_app/lib/services/omron_ble_service.dart new file mode 100644 index 0000000..f05ea84 --- /dev/null +++ b/health_app/lib/services/omron_ble_service.dart @@ -0,0 +1,165 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import '../models/bp_reading.dart'; + +/// 欧姆龙蓝牙血压计 BLE 服务 +class OmronBleService { + static const bpServiceUuid = '00001810-0000-1000-8000-00805F9B34FB'; + static const bpMeasurementUuid = '00002A35-0000-1000-8000-00805F9B34FB'; + static const bpFeatureUuid = '00002A49-0000-1000-8000-00805F9B34FB'; + static const dateTimeUuid = '00002A08-0000-1000-8000-00805F9B34FB'; + + BluetoothDevice? _device; + StreamSubscription>? _subscription; + final _readingsController = StreamController.broadcast(); + Stream get readings => _readingsController.stream; + + bool get isConnected => _device?.isConnected ?? false; + + /// 扫描血压计设备 + Stream scan({Duration timeout = const Duration(seconds: 15)}) async* { + await FlutterBluePlus.startScan(timeout: timeout); + await for (final list in FlutterBluePlus.scanResults) { + for (final r in list) { + yield r; + } + } + } + + /// 判断是否为血压计设备 + static bool isBpDevice(ScanResult r) { + final name = [ + r.advertisementData.localName, + r.device.platformName, + ].where((n) => n.isNotEmpty).join(' ').toUpperCase(); + return name.contains('OMRON') || + name.contains('HEM') || + name.contains('BLESMART') || + name.contains('J735') || + name.contains('BPM') || + name.contains('BP'); + } + + /// 连接设备并订阅测量数据 + Future connect(BluetoothDevice device) async { + _device = device; + await device.connect(autoConnect: false); + await device.discoverServices(); + + final services = await device.discoverServices(); + final bpService = services.firstWhere( + (s) => s.uuid.str128.toUpperCase() == bpServiceUuid.toUpperCase(), + ); + + final measurementChar = bpService.characteristics.firstWhere( + (c) => c.uuid.str128.toUpperCase() == bpMeasurementUuid.toUpperCase(), + ); + + await measurementChar.setNotifyValue(true); + _subscription = measurementChar.lastValueStream.listen(_parseReading); + await _syncTime(bpService); + } + + /// 断开连接 + Future disconnect() async { + _subscription?.cancel(); + _subscription = null; + await _device?.disconnect(); + _device = null; + } + + void dispose() { + disconnect(); + _readingsController.close(); + } + + // ── 解析 BLE 测量数据(Bluetooth SIG BP Service 1.1) ── + + void _parseReading(List raw) { + if (raw.length < 7) return; + + final flags = raw[0]; + final isKpa = (flags & 0x01) != 0; + final hasTimestamp = (flags & 0x02) != 0; + final hasPulse = (flags & 0x04) != 0; + final hasUserId = (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(); + + 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? pulse; + if (hasPulse && raw.length >= offset + 2) { + pulse = _decodeSFloat(raw[offset] | (raw[offset + 1] << 8)).round(); + offset += 2; + } + + String? userId; + if (hasUserId && raw.length > offset) { + userId = raw[offset].toString(); + offset += 1; + } + + 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, + )); + } + + /// SFLOAT IEEE-11073 16-bit 解码 + static double _decodeSFloat(int raw) { + int mantissa = raw & 0x0FFF; + if (mantissa & 0x0800 != 0) mantissa |= 0xFFFFF000; + int exponent = (raw >> 12) & 0x0F; + if (exponent & 0x08 != 0) exponent |= 0xFFFFFFF0; + return (mantissa * pow(10, exponent)).toDouble(); + } + + /// 同步当前时间到设备 + Future _syncTime(BluetoothService service) async { + try { + final dtChar = service.characteristics.firstWhere( + (c) => c.uuid.str128.toUpperCase() == dateTimeUuid.toUpperCase(), + ); + final now = DateTime.now(); + await dtChar.write([ + now.year & 0xFF, now.year >> 8, + now.month, now.day, + now.hour, now.minute, now.second, + ]); + } catch (_) {} + } +} diff --git a/health_app/lib/widgets/health_drawer.dart b/health_app/lib/widgets/health_drawer.dart index 9e5919a..e76f96a 100644 --- a/health_app/lib/widgets/health_drawer.dart +++ b/health_app/lib/widgets/health_drawer.dart @@ -151,6 +151,12 @@ class HealthDrawer extends ConsumerWidget { _FeatureChip(icon: Icons.event_note_outlined, label: '复查随访', onTap: () => pushRoute(ref, 'followups')), ]), ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 14), + child: Row(children: [ + _FeatureChip(icon: Icons.bluetooth_rounded, label: '蓝牙设备', onTap: () => pushRoute(ref, 'devices')), + ]), + ), ], ), ), diff --git a/health_app/pubspec.lock b/health_app/pubspec.lock index ecd0e34..2a7670a 100644 --- a/health_app/pubspec.lock +++ b/health_app/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + bluez: + dependency: transitive + description: + name: bluez + sha256: "61a7204381925896a374301498f2f5399e59827c6498ae1e924aaa598751b545" + url: "https://pub.dev" + source: hosted + version: "0.8.3" boolean_selector: dependency: transitive description: @@ -270,6 +278,54 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.2" + flutter_blue_plus: + dependency: "direct main" + description: + name: flutter_blue_plus + sha256: "69a8c87c11fc792e8cf0f997d275484fbdb5143ac9f0ac4d424429700cb4e0ed" + url: "https://pub.dev" + source: hosted + version: "1.36.8" + flutter_blue_plus_android: + dependency: transitive + description: + name: flutter_blue_plus_android + sha256: "6f7fe7e69659c30af164a53730707edc16aa4d959e4c208f547b893d940f853d" + url: "https://pub.dev" + source: hosted + version: "7.0.4" + flutter_blue_plus_darwin: + dependency: transitive + description: + name: flutter_blue_plus_darwin + sha256: "682982862c1d964f4d54a3fb5fccc9e59a066422b93b7e22079aeecd9c0d38f8" + url: "https://pub.dev" + source: hosted + version: "7.0.3" + flutter_blue_plus_linux: + dependency: transitive + description: + name: flutter_blue_plus_linux + sha256: "56b0c45edd0a2eec8f85bd97a26ac3cd09447e10d0094fed55587bf0592e3347" + url: "https://pub.dev" + source: hosted + version: "7.0.3" + flutter_blue_plus_platform_interface: + dependency: transitive + description: + name: flutter_blue_plus_platform_interface + sha256: "84fbd180c50a40c92482f273a92069960805ce324e3673ad29c41d2faaa7c5c2" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + flutter_blue_plus_web: + dependency: transitive + description: + name: flutter_blue_plus_web + sha256: a1aceee753d171d24c0e0cdadb37895b5e9124862721f25f60bb758e20b72c99 + url: "https://pub.dev" + source: hosted + version: "7.0.2" flutter_lints: dependency: "direct dev" description: @@ -685,6 +741,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + url: "https://pub.dev" + source: hosted + version: "11.4.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + url: "https://pub.dev" + source: hosted + version: "12.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: e20daf680eef1ca62ffe8c8c526b778cc386d50137c77ac71c8ec9c88c13fb9d + url: "https://pub.dev" + source: hosted + version: "9.4.9" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" petitparser: dependency: transitive description: @@ -741,6 +845,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" serial_csv: dependency: transitive description: diff --git a/health_app/pubspec.yaml b/health_app/pubspec.yaml index d735883..f9e7aef 100644 --- a/health_app/pubspec.yaml +++ b/health_app/pubspec.yaml @@ -35,6 +35,10 @@ dependencies: # 推送(后期集成) # jpush_flutter: ^3.4.5 + # 蓝牙 BLE + flutter_blue_plus: ^1.34.0 + permission_handler: ^11.3.0 + # 基础图标 cupertino_icons: ^1.0.8 signalr_netcore: ^1.4.4