feat: 蓝牙血压计基础架构 + UI配色体系升级

- 新增 AppColors 配色体系(紫蓝渐变 + 超大圆角 + 漂浮卡片)
- 新增 OmronBleService(BLE 扫描/连接/SFLOAT 协议解析)
- 新增 BpReading 数据模型 + 血压数据自动同步后端
- 新增 DeviceScanPage(血压计扫描绑定页)+ DeviceBindPage(已绑定管理页)
- 侧边栏新增"蓝牙设备"入口,ProfilePage 移除设备管理
- Android/iOS BLE 权限配置(BLUETOOTH_SCAN/CONNECT)
- AppTheme 升级:新阴影系统、新语义色、圆角放大
This commit is contained in:
MingNian
2026-06-09 18:48:58 +08:00
parent f01fc9268d
commit 3964cf2bcb
14 changed files with 1068 additions and 157 deletions

View File

@@ -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<BoxShadow> 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<BoxShadow> cardShadowLight = [
BoxShadow(
color: Color(0xFF6366F1).withOpacity(0.05),
blurRadius: 16,
offset: const Offset(0, 4),
),
];
/// 按钮阴影
static List<BoxShadow> 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<BoxShadow> fabShadow = [
BoxShadow(
color: Color(0xFF6366F1).withOpacity(0.35),
blurRadius: 20,
offset: const Offset(0, 8),
),
];
}

View File

@@ -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':

View File

@@ -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<String, Color> 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),
);

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

View 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 '';
}
}

View File

@@ -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),
// 退出登录

View File

@@ -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<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();
}
}
}
// ═══════════════════ 自定义滑动删除组件 ═══════════════════

View 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);

View 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 (_) {}
}
}

View File

@@ -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')),
]),
),
],
),
),