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