feat: 蓝牙血压计基础架构 + UI配色体系升级
- 新增 AppColors 配色体系(紫蓝渐变 + 超大圆角 + 漂浮卡片) - 新增 OmronBleService(BLE 扫描/连接/SFLOAT 协议解析) - 新增 BpReading 数据模型 + 血压数据自动同步后端 - 新增 DeviceScanPage(血压计扫描绑定页)+ DeviceBindPage(已绑定管理页) - 侧边栏新增"蓝牙设备"入口,ProfilePage 移除设备管理 - Android/iOS BLE 权限配置(BLUETOOTH_SCAN/CONNECT) - AppTheme 升级:新阴影系统、新语义色、圆角放大
This commit is contained in:
@@ -3,6 +3,14 @@
|
|||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||||
|
|
||||||
|
<!-- BLE 蓝牙血压计 -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
||||||
<application
|
<application
|
||||||
android:label="health_app"
|
android:label="health_app"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
@@ -41,6 +41,10 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
|
<string>需要使用蓝牙连接欧姆龙血压计以同步测量数据</string>
|
||||||
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||||
|
<string>需要使用蓝牙连接血压计</string>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
|
|||||||
@@ -1,161 +1,197 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// 统一配色方案 - 清爽紫蓝风格(参考蚂蚁阿福)
|
/// 统一配色方案 - 蚂蚁阿福风格:紫蓝渐变 + 淡紫背景 + 圆角卡片
|
||||||
class AppColors {
|
class AppColors {
|
||||||
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 cardBackground = Color(0xFFFFFFFF);
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
/// 卡片内小卡片背景(极淡紫灰)
|
||||||
// 紫色系(清爽浅紫)
|
static const Color cardInner = Color(0xFFF8FAFF);
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
/// 主紫色(浅紫,蚂蚁阿福风格)
|
|
||||||
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 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 Color iconBg = Color(0xFFEEF2FF);
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
// 渐变组合
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
/// 紫蓝渐变(用于标题背景、图标背景)
|
|
||||||
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 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 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(
|
static const LinearGradient primaryGradient = LinearGradient(
|
||||||
colors: [primaryPurple, primaryBlue],
|
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,
|
begin: Alignment.centerLeft,
|
||||||
end: Alignment.centerRight,
|
end: Alignment.centerRight,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// 成功按钮渐变
|
/// 青色渐变(药管家等)
|
||||||
static const LinearGradient successButtonGradient = LinearGradient(
|
static const LinearGradient cyanGradient = LinearGradient(
|
||||||
colors: [Color(0xFF81E6A0), success],
|
colors: [Color(0xFF67E8F9), Color(0xFF22D3EE)],
|
||||||
begin: Alignment.centerLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.centerRight,
|
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<BoxShadow> cardShadow = [
|
static List<BoxShadow> cardShadow = [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: primaryPurple.withAlpha(15),
|
color: Color(0xFF6366F1).withOpacity(0.08),
|
||||||
blurRadius: 20,
|
blurRadius: 24,
|
||||||
offset: const Offset(0, 6),
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 轻卡片阴影
|
||||||
|
static List<BoxShadow> cardShadowLight = [
|
||||||
|
BoxShadow(
|
||||||
|
color: Color(0xFF6366F1).withOpacity(0.05),
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// 按钮阴影
|
/// 按钮阴影
|
||||||
static List<BoxShadow> buttonShadow = [
|
static List<BoxShadow> buttonShadow = [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: primaryPurple.withAlpha(20),
|
color: Color(0xFF6366F1).withOpacity(0.25),
|
||||||
blurRadius: 12,
|
blurRadius: 16,
|
||||||
offset: const Offset(0, 4),
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 悬浮操作按钮阴影(右下角大号按钮)
|
||||||
|
static List<BoxShadow> fabShadow = [
|
||||||
|
BoxShadow(
|
||||||
|
color: Color(0xFF6366F1).withOpacity(0.35),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import '../pages/settings/notification_prefs_page.dart';
|
|||||||
import '../pages/profile/profile_page.dart';
|
import '../pages/profile/profile_page.dart';
|
||||||
import '../pages/profile/service_package_detail_page.dart';
|
import '../pages/profile/service_package_detail_page.dart';
|
||||||
import '../pages/diet/diet_capture_page.dart';
|
import '../pages/diet/diet_capture_page.dart';
|
||||||
|
import '../pages/device/device_scan_page.dart';
|
||||||
import '../pages/remaining_pages.dart';
|
import '../pages/remaining_pages.dart';
|
||||||
|
|
||||||
/// 根据路由信息返回对应页面
|
/// 根据路由信息返回对应页面
|
||||||
@@ -54,6 +55,8 @@ Widget buildPage(RouteInfo route) {
|
|||||||
return const ProfilePage();
|
return const ProfilePage();
|
||||||
case 'devices':
|
case 'devices':
|
||||||
return const DeviceManagementPage();
|
return const DeviceManagementPage();
|
||||||
|
case 'deviceScan':
|
||||||
|
return const DeviceScanPage();
|
||||||
case 'healthArchive':
|
case 'healthArchive':
|
||||||
return const HealthArchivePage();
|
return const HealthArchivePage();
|
||||||
case 'followups':
|
case 'followups':
|
||||||
|
|||||||
@@ -2,19 +2,23 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
import 'app_colors.dart';
|
import 'app_colors.dart';
|
||||||
|
|
||||||
/// 健康管家 — 清爽紫蓝风
|
/// 健康管家 —— 蚂蚁阿福风格:紫蓝渐变 + 超大圆角 + 漂浮卡片
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
AppTheme._();
|
AppTheme._();
|
||||||
|
|
||||||
// ── 主色调 ──
|
// ── 主色调 ──
|
||||||
static const Color primary = AppColors.primaryPurple;
|
static const Color primary = AppColors.primary;
|
||||||
static const Color primaryLight = AppColors.iconBackground;
|
static const Color primaryLight = AppColors.primaryLight;
|
||||||
static const Color primaryDark = AppColors.iconColor;
|
static const Color primaryDark = AppColors.primaryDark;
|
||||||
static const Color primaryGlow = AppColors.purpleGradientStart;
|
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 bg = AppColors.background;
|
||||||
|
static const Color bgSoft = AppColors.backgroundSoft;
|
||||||
static const Color surface = AppColors.cardBackground;
|
static const Color surface = AppColors.cardBackground;
|
||||||
|
static const Color surfaceInner = AppColors.cardInner;
|
||||||
static const Color text = AppColors.textPrimary;
|
static const Color text = AppColors.textPrimary;
|
||||||
static const Color textSub = AppColors.textSecondary;
|
static const Color textSub = AppColors.textSecondary;
|
||||||
static const Color textHint = AppColors.textHint;
|
static const Color textHint = AppColors.textHint;
|
||||||
@@ -23,17 +27,18 @@ class AppTheme {
|
|||||||
|
|
||||||
// ── 语义色 ──
|
// ── 语义色 ──
|
||||||
static const Color success = AppColors.success;
|
static const Color success = AppColors.success;
|
||||||
|
static const Color successLight = AppColors.successLight;
|
||||||
static const Color error = AppColors.error;
|
static const Color error = AppColors.error;
|
||||||
|
static const Color errorLight = AppColors.errorLight;
|
||||||
static const Color warning = AppColors.warning;
|
static const Color warning = AppColors.warning;
|
||||||
static const Color accent = AppColors.primaryBlue;
|
static const Color warningLight = AppColors.warningLight;
|
||||||
static const Color info = AppColors.primaryBlue;
|
|
||||||
|
|
||||||
// ── 圆角 ──
|
// ── 圆角(阿福风格:超大圆角)──
|
||||||
static const double rXs = 6;
|
static const double rXs = 8;
|
||||||
static const double rSm = 10;
|
static const double rSm = 12;
|
||||||
static const double rMd = 14;
|
static const double rMd = 18;
|
||||||
static const double rLg = 20;
|
static const double rLg = 24;
|
||||||
static const double rXl = 28;
|
static const double rXl = 32;
|
||||||
static const double rPill = 999;
|
static const double rPill = 999;
|
||||||
|
|
||||||
// ── 间距 ──
|
// ── 间距 ──
|
||||||
@@ -44,23 +49,37 @@ class AppTheme {
|
|||||||
static const double sXl = 20;
|
static const double sXl = 20;
|
||||||
static const double sXxl = 28;
|
static const double sXxl = 28;
|
||||||
|
|
||||||
// ── 阴影 ──
|
// ── 阴影(淡紫柔和阴影)──
|
||||||
static BoxShadow get shadowCard =>
|
static BoxShadow get shadowCard => BoxShadow(
|
||||||
BoxShadow(color: primary.withAlpha(14), blurRadius: 20, offset: const Offset(0, 4));
|
color: AppColors.primary.withOpacity(0.08),
|
||||||
static BoxShadow get shadowLight =>
|
blurRadius: 24,
|
||||||
BoxShadow(color: primary.withAlpha(8), blurRadius: 12, offset: const Offset(0, 2));
|
offset: const Offset(0, 8),
|
||||||
static BoxShadow get shadowElevated =>
|
);
|
||||||
BoxShadow(color: primary.withAlpha(20), blurRadius: 30, 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<String, Color> agentColors = {
|
static const Map<String, Color> agentColors = {
|
||||||
'default': AppColors.iconBackground,
|
'default': AppColors.primaryLight,
|
||||||
'consultation': Color(0xFFE0F0FF),
|
'consultation': Color(0xFFE0E7FF),
|
||||||
'health': Color(0xFFE8FFE8),
|
'health': Color(0xFFDCFCE7),
|
||||||
'diet': Color(0xFFFFF0E0),
|
'diet': Color(0xFFFEF3C7),
|
||||||
'medication': Color(0xFFFFE8EC),
|
'medication': Color(0xFFFCE7F3),
|
||||||
'report': Color(0xFFE8F0FF),
|
'report': Color(0xFFDBEAFE),
|
||||||
'exercise': Color(0xFFF0E8FF),
|
'exercise': Color(0xFFEDE9FE),
|
||||||
};
|
};
|
||||||
|
|
||||||
static Color agentLight(String? name) => agentColors[name] ?? primaryLight;
|
static Color agentLight(String? name) => agentColors[name] ?? primaryLight;
|
||||||
@@ -71,19 +90,26 @@ class AppTheme {
|
|||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
seedColor: primary,
|
seedColor: primary,
|
||||||
primary: primary,
|
primary: primary,
|
||||||
surface: bg,
|
primaryContainer: primaryLight,
|
||||||
|
onPrimary: Colors.white,
|
||||||
|
secondary: accent,
|
||||||
|
surface: surface,
|
||||||
|
background: bg,
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
),
|
),
|
||||||
scaffoldBackgroundColor: bg,
|
scaffoldBackgroundColor: bg,
|
||||||
|
splashColor: Colors.transparent,
|
||||||
|
highlightColor: Colors.transparent,
|
||||||
|
hoverColor: primaryLight.withOpacity(0.5),
|
||||||
fontFamily: null,
|
fontFamily: null,
|
||||||
|
|
||||||
appBarTheme: const AppBarTheme(
|
appBarTheme: AppBarTheme(
|
||||||
backgroundColor: surface,
|
backgroundColor: bg,
|
||||||
foregroundColor: text,
|
foregroundColor: text,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
centerTitle: true,
|
centerTitle: false,
|
||||||
scrolledUnderElevation: 0,
|
scrolledUnderElevation: 0,
|
||||||
titleTextStyle: TextStyle(fontSize: 23, fontWeight: FontWeight.w600, color: text),
|
titleTextStyle: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: text),
|
||||||
),
|
),
|
||||||
|
|
||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
@@ -95,7 +121,7 @@ class AppTheme {
|
|||||||
|
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: bg,
|
fillColor: bgSoft,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: sLg, vertical: 16),
|
contentPadding: const EdgeInsets.symmetric(horizontal: sLg, vertical: 16),
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(rMd), borderSide: BorderSide.none),
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(rMd), borderSide: BorderSide.none),
|
||||||
enabledBorder: 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),
|
borderRadius: BorderRadius.circular(rMd),
|
||||||
borderSide: const BorderSide(color: primary, width: 2),
|
borderSide: const BorderSide(color: primary, width: 2),
|
||||||
),
|
),
|
||||||
hintStyle: const TextStyle(color: textHint, fontSize: 20),
|
hintStyle: TextStyle(color: textHint, fontSize: 16),
|
||||||
),
|
),
|
||||||
|
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
@@ -112,28 +138,29 @@ class AppTheme {
|
|||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
minimumSize: const Size(double.infinity, 56),
|
minimumSize: const Size(double.infinity, 56),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rPill)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rPill)),
|
||||||
textStyle: const TextStyle(fontSize: 21, fontWeight: FontWeight.w600),
|
textStyle: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
|
shadowColor: primary.withOpacity(0.3),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
dialogTheme: DialogThemeData(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rXl))),
|
dialogTheme: DialogThemeData(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rXl))),
|
||||||
|
|
||||||
textTheme: const TextTheme(
|
textTheme: const TextTheme(
|
||||||
headlineLarge: TextStyle(fontSize: 32, fontWeight: FontWeight.w700, color: text, letterSpacing: -0.5),
|
headlineLarge: TextStyle(fontSize: 28, fontWeight: FontWeight.w700, color: text, letterSpacing: -0.3),
|
||||||
titleLarge: TextStyle(fontSize: 23, fontWeight: FontWeight.w600, color: text),
|
titleLarge: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: text),
|
||||||
titleMedium: TextStyle(fontSize: 21, fontWeight: FontWeight.w600, color: text),
|
titleMedium: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: text),
|
||||||
bodyLarge: TextStyle(fontSize: 21, color: text, height: 1.5),
|
bodyLarge: TextStyle(fontSize: 16, color: text, height: 1.5),
|
||||||
bodyMedium: TextStyle(fontSize: 20, color: textSub, height: 1.4),
|
bodyMedium: TextStyle(fontSize: 14, color: textSub, height: 1.4),
|
||||||
labelMedium: TextStyle(fontSize: 18, color: textSub),
|
labelMedium: TextStyle(fontSize: 13, color: textSub),
|
||||||
labelSmall: TextStyle(fontSize: 16, color: textHint),
|
labelSmall: TextStyle(fontSize: 12, color: textHint),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Shadcn UI 主题 ──
|
// ── Shadcn UI 主题(紫蓝系) ──
|
||||||
static ShadThemeData get shadTheme => ShadThemeData(
|
static ShadThemeData get shadTheme => ShadThemeData(
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
colorScheme: const ShadVioletColorScheme.light(
|
colorScheme: ShadVioletColorScheme.light(
|
||||||
background: bg,
|
background: bg,
|
||||||
foreground: text,
|
foreground: text,
|
||||||
card: surface,
|
card: surface,
|
||||||
@@ -142,9 +169,9 @@ class AppTheme {
|
|||||||
popoverForeground: text,
|
popoverForeground: text,
|
||||||
primary: primary,
|
primary: primary,
|
||||||
primaryForeground: Colors.white,
|
primaryForeground: Colors.white,
|
||||||
secondary: divider,
|
secondary: primaryLight,
|
||||||
secondaryForeground: text,
|
secondaryForeground: primaryDark,
|
||||||
muted: divider,
|
muted: bgSoft,
|
||||||
mutedForeground: textSub,
|
mutedForeground: textSub,
|
||||||
accent: accent,
|
accent: accent,
|
||||||
accentForeground: Colors.white,
|
accentForeground: Colors.white,
|
||||||
@@ -153,7 +180,7 @@ class AppTheme {
|
|||||||
border: border,
|
border: border,
|
||||||
input: border,
|
input: border,
|
||||||
ring: primary,
|
ring: primary,
|
||||||
selection: primaryGlow,
|
selection: primaryLight,
|
||||||
),
|
),
|
||||||
radius: BorderRadius.circular(rMd),
|
radius: BorderRadius.circular(rMd),
|
||||||
);
|
);
|
||||||
|
|||||||
33
health_app/lib/models/bp_reading.dart
Normal file
33
health_app/lib/models/bp_reading.dart
Normal file
@@ -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<String, dynamic> toHealthRecord() => {
|
||||||
|
'type': 'BloodPressure',
|
||||||
|
'systolic': systolic,
|
||||||
|
'diastolic': diastolic,
|
||||||
|
'source': 'Device',
|
||||||
|
'unit': 'mmHg',
|
||||||
|
'recordedAt': timestamp.toUtc().toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
216
health_app/lib/pages/device/device_scan_page.dart
Normal file
216
health_app/lib/pages/device/device_scan_page.dart
Normal file
@@ -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<DeviceScanPage> createState() => _DeviceScanPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DeviceScanPageState extends ConsumerState<DeviceScanPage> {
|
||||||
|
final _results = <ScanResult>[];
|
||||||
|
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<void> _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<void> _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 '弱';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,7 +71,6 @@ class ProfilePage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: AppTheme.sMd),
|
const SizedBox(height: AppTheme.sMd),
|
||||||
AppMenuItem(icon: LucideIcons.folderArchive, title: '健康档案', onTap: () => pushRoute(ref, 'healthArchive')),
|
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')),
|
AppMenuItem(icon: LucideIcons.settings, title: '设置', onTap: () => pushRoute(ref, 'settings')),
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 40),
|
||||||
// 退出登录
|
// 退出登录
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
import '../core/app_colors.dart';
|
||||||
import '../core/app_theme.dart';
|
import '../core/app_theme.dart';
|
||||||
import '../core/navigation_provider.dart';
|
import '../core/navigation_provider.dart';
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
import '../providers/data_providers.dart';
|
import '../providers/data_providers.dart';
|
||||||
|
import '../providers/omron_device_provider.dart';
|
||||||
|
|
||||||
/// 饮食记录列表(左滑删除)
|
/// 饮食记录列表(左滑删除)
|
||||||
class DietRecordListPage extends ConsumerStatefulWidget {
|
class DietRecordListPage extends ConsumerStatefulWidget {
|
||||||
@@ -783,10 +785,214 @@ class StaticTextPage extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设备管理(占位)
|
/// 血压计设备管理(已绑定/未绑定)
|
||||||
class DeviceManagementPage extends ConsumerWidget {
|
class DeviceManagementPage extends ConsumerWidget {
|
||||||
const DeviceManagementPage({super.key});
|
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<bool>(context: context, builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('解绑设备'),
|
||||||
|
content: const Text('解绑后需重新扫描连接,确定吗?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定解绑', style: TextStyle(color: AppColors.error))),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
if (ok == true) {
|
||||||
|
await ref.read(omronDeviceProvider.notifier).unbind();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════ 自定义滑动删除组件 ═══════════════════
|
// ═══════════════════ 自定义滑动删除组件 ═══════════════════
|
||||||
|
|||||||
92
health_app/lib/providers/omron_device_provider.dart
Normal file
92
health_app/lib/providers/omron_device_provider.dart
Normal file
@@ -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<OmronBleService>((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<DeviceBindState> {
|
||||||
|
@override
|
||||||
|
DeviceBindState build() {
|
||||||
|
_loadBinding();
|
||||||
|
return const DeviceBindState();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> 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<void> 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<void> 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, DeviceBindState>(DeviceBindNotifier.new);
|
||||||
165
health_app/lib/services/omron_ble_service.dart
Normal file
165
health_app/lib/services/omron_ble_service.dart
Normal file
@@ -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<List<int>>? _subscription;
|
||||||
|
final _readingsController = StreamController<BpReading>.broadcast();
|
||||||
|
Stream<BpReading> get readings => _readingsController.stream;
|
||||||
|
|
||||||
|
bool get isConnected => _device?.isConnected ?? false;
|
||||||
|
|
||||||
|
/// 扫描血压计设备
|
||||||
|
Stream<ScanResult> scan({Duration timeout = const Duration(seconds: 15)}) async* {
|
||||||
|
await FlutterBluePlus.startScan(timeout: timeout);
|
||||||
|
await for (final list in FlutterBluePlus.scanResults) {
|
||||||
|
for (final r in list) {
|
||||||
|
yield r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断是否为血压计设备
|
||||||
|
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<void> 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<void> 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<int> 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<void> _syncTime(BluetoothService service) async {
|
||||||
|
try {
|
||||||
|
final dtChar = service.characteristics.firstWhere(
|
||||||
|
(c) => c.uuid.str128.toUpperCase() == dateTimeUuid.toUpperCase(),
|
||||||
|
);
|
||||||
|
final now = DateTime.now();
|
||||||
|
await dtChar.write([
|
||||||
|
now.year & 0xFF, now.year >> 8,
|
||||||
|
now.month, now.day,
|
||||||
|
now.hour, now.minute, now.second,
|
||||||
|
]);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -151,6 +151,12 @@ class HealthDrawer extends ConsumerWidget {
|
|||||||
_FeatureChip(icon: Icons.event_note_outlined, label: '复查随访', onTap: () => pushRoute(ref, 'followups')),
|
_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')),
|
||||||
|
]),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.1"
|
version: "2.13.1"
|
||||||
|
bluez:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: bluez
|
||||||
|
sha256: "61a7204381925896a374301498f2f5399e59827c6498ae1e924aaa598751b545"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.8.3"
|
||||||
boolean_selector:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -270,6 +278,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.2"
|
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:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -685,6 +741,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
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:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -741,6 +845,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.1"
|
version: "3.2.1"
|
||||||
|
rxdart:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: rxdart
|
||||||
|
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.28.0"
|
||||||
serial_csv:
|
serial_csv:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ dependencies:
|
|||||||
# 推送(后期集成)
|
# 推送(后期集成)
|
||||||
# jpush_flutter: ^3.4.5
|
# jpush_flutter: ^3.4.5
|
||||||
|
|
||||||
|
# 蓝牙 BLE
|
||||||
|
flutter_blue_plus: ^1.34.0
|
||||||
|
permission_handler: ^11.3.0
|
||||||
|
|
||||||
# 基础图标
|
# 基础图标
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
signalr_netcore: ^1.4.4
|
signalr_netcore: ^1.4.4
|
||||||
|
|||||||
Reference in New Issue
Block a user