refactor: 重构蓝牙设备页与新增设备页的扫描-连接-录入流程
- 通用 BLE 健康设备识别(血压计/血糖仪/体重秤/血氧仪 标准 service UUID) - 蓝牙设备页静默自动同步:仅匹配已绑定的设备名+类型,收到数据后变绿 - 新增设备页:扫描所有支持类型设备 → 选连接 → 绑定 + 同步首次数据 - 修复 read 缓存导致的重复录入循环(删除主动 read,依赖 indicate 推送) - 修复 isConnected 缓存状态导致连接异常(强制先 disconnect 再 connect) - 录入弹窗:血压心率同等级展示、3 秒自动关闭/手动确认 - 顺手更新本机开发 IP 与清理过时设计文档
This commit is contained in:
@@ -6,7 +6,7 @@ import 'local_database.dart';
|
||||
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
|
||||
const String baseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'http://10.4.202.36:5000',
|
||||
defaultValue: 'http://10.4.232.251:5000',
|
||||
);
|
||||
|
||||
class ApiException implements Exception {
|
||||
|
||||
187
health_app/lib/models/ble_device.dart
Normal file
187
health_app/lib/models/ble_device.dart
Normal file
@@ -0,0 +1,187 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
|
||||
import 'bp_reading.dart';
|
||||
|
||||
enum BleDeviceType { bloodPressure, glucose, weightScale, pulseOximeter }
|
||||
|
||||
enum SupportedMetric { bloodPressure, heartRate, glucose, weight, spo2 }
|
||||
|
||||
extension BleDeviceTypeX on BleDeviceType {
|
||||
String get code => switch (this) {
|
||||
BleDeviceType.bloodPressure => 'bloodPressure',
|
||||
BleDeviceType.glucose => 'glucose',
|
||||
BleDeviceType.weightScale => 'weightScale',
|
||||
BleDeviceType.pulseOximeter => 'pulseOximeter',
|
||||
};
|
||||
|
||||
String get label => switch (this) {
|
||||
BleDeviceType.bloodPressure => '血压计',
|
||||
BleDeviceType.glucose => '血糖仪',
|
||||
BleDeviceType.weightScale => '体重秤',
|
||||
BleDeviceType.pulseOximeter => '血氧仪',
|
||||
};
|
||||
|
||||
String get serviceUuid => switch (this) {
|
||||
BleDeviceType.bloodPressure => BleDeviceIdentifier.bloodPressureServiceUuid,
|
||||
BleDeviceType.glucose => BleDeviceIdentifier.glucoseServiceUuid,
|
||||
BleDeviceType.weightScale => BleDeviceIdentifier.weightScaleServiceUuid,
|
||||
BleDeviceType.pulseOximeter => BleDeviceIdentifier.pulseOximeterServiceUuid,
|
||||
};
|
||||
|
||||
List<SupportedMetric> get metrics => switch (this) {
|
||||
BleDeviceType.bloodPressure => [
|
||||
SupportedMetric.bloodPressure,
|
||||
SupportedMetric.heartRate,
|
||||
],
|
||||
BleDeviceType.glucose => [SupportedMetric.glucose],
|
||||
BleDeviceType.weightScale => [SupportedMetric.weight],
|
||||
BleDeviceType.pulseOximeter => [
|
||||
SupportedMetric.spo2,
|
||||
SupportedMetric.heartRate,
|
||||
],
|
||||
};
|
||||
|
||||
IconData get icon => switch (this) {
|
||||
BleDeviceType.bloodPressure => Icons.speed_rounded,
|
||||
BleDeviceType.glucose => Icons.water_drop_rounded,
|
||||
BleDeviceType.weightScale => Icons.monitor_weight_outlined,
|
||||
BleDeviceType.pulseOximeter => Icons.air_rounded,
|
||||
};
|
||||
|
||||
static BleDeviceType? fromCode(String? code) {
|
||||
for (final type in BleDeviceType.values) {
|
||||
if (type.code == code) return type;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
extension SupportedMetricX on SupportedMetric {
|
||||
String get code => switch (this) {
|
||||
SupportedMetric.bloodPressure => 'BloodPressure',
|
||||
SupportedMetric.heartRate => 'HeartRate',
|
||||
SupportedMetric.glucose => 'Glucose',
|
||||
SupportedMetric.weight => 'Weight',
|
||||
SupportedMetric.spo2 => 'SpO2',
|
||||
};
|
||||
|
||||
String get label => switch (this) {
|
||||
SupportedMetric.bloodPressure => '血压',
|
||||
SupportedMetric.heartRate => '心率',
|
||||
SupportedMetric.glucose => '血糖',
|
||||
SupportedMetric.weight => '体重',
|
||||
SupportedMetric.spo2 => '血氧',
|
||||
};
|
||||
}
|
||||
|
||||
class BoundBleDevice {
|
||||
final String id;
|
||||
final String name;
|
||||
final BleDeviceType type;
|
||||
final String serviceUuid;
|
||||
final DateTime? lastSyncAt;
|
||||
|
||||
const BoundBleDevice({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.serviceUuid,
|
||||
this.lastSyncAt,
|
||||
});
|
||||
|
||||
List<SupportedMetric> get metrics => type.metrics;
|
||||
|
||||
BoundBleDevice copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
BleDeviceType? type,
|
||||
String? serviceUuid,
|
||||
DateTime? lastSyncAt,
|
||||
}) {
|
||||
return BoundBleDevice(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
type: type ?? this.type,
|
||||
serviceUuid: serviceUuid ?? this.serviceUuid,
|
||||
lastSyncAt: lastSyncAt ?? this.lastSyncAt,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'type': type.code,
|
||||
'serviceUuid': serviceUuid,
|
||||
'lastSyncAt': lastSyncAt?.toUtc().toIso8601String(),
|
||||
};
|
||||
|
||||
static BoundBleDevice? fromJson(Map<String, dynamic> json) {
|
||||
final type = BleDeviceTypeX.fromCode(json['type']?.toString());
|
||||
final id = json['id']?.toString();
|
||||
if (type == null || id == null || id.isEmpty) return null;
|
||||
|
||||
final lastSyncRaw = json['lastSyncAt']?.toString();
|
||||
return BoundBleDevice(
|
||||
id: id,
|
||||
name: json['name']?.toString() ?? type.label,
|
||||
type: type,
|
||||
serviceUuid: json['serviceUuid']?.toString() ?? type.serviceUuid,
|
||||
lastSyncAt: lastSyncRaw == null || lastSyncRaw.isEmpty
|
||||
? null
|
||||
: DateTime.tryParse(lastSyncRaw)?.toLocal(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BleDeviceIdentifier {
|
||||
static const glucoseServiceUuid = '00001808-0000-1000-8000-00805F9B34FB';
|
||||
static const bloodPressureServiceUuid =
|
||||
'00001810-0000-1000-8000-00805F9B34FB';
|
||||
static const weightScaleServiceUuid = '0000181D-0000-1000-8000-00805F9B34FB';
|
||||
static const pulseOximeterServiceUuid =
|
||||
'00001822-0000-1000-8000-00805F9B34FB';
|
||||
|
||||
static BleDeviceType? typeFromScan(ScanResult result) {
|
||||
return typeFromUuidStrings(
|
||||
result.advertisementData.serviceUuids.map((uuid) => uuid.str128),
|
||||
);
|
||||
}
|
||||
|
||||
static BleDeviceType? typeFromServices(List<BluetoothService> services) {
|
||||
return typeFromUuidStrings(services.map((service) => service.uuid.str128));
|
||||
}
|
||||
|
||||
static BleDeviceType? typeFromUuidStrings(Iterable<String> uuids) {
|
||||
final normalized = uuids.map((uuid) => uuid.toUpperCase()).toSet();
|
||||
if (normalized.contains(bloodPressureServiceUuid)) {
|
||||
return BleDeviceType.bloodPressure;
|
||||
}
|
||||
if (normalized.contains(glucoseServiceUuid)) {
|
||||
return BleDeviceType.glucose;
|
||||
}
|
||||
if (normalized.contains(weightScaleServiceUuid)) {
|
||||
return BleDeviceType.weightScale;
|
||||
}
|
||||
if (normalized.contains(pulseOximeterServiceUuid)) {
|
||||
return BleDeviceType.pulseOximeter;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool isSupportedScanResult(ScanResult result) {
|
||||
return typeFromScan(result) != null;
|
||||
}
|
||||
}
|
||||
|
||||
sealed class HealthBleSyncResult {
|
||||
const HealthBleSyncResult({required this.device});
|
||||
|
||||
final BoundBleDevice device;
|
||||
}
|
||||
|
||||
class BloodPressureSyncResult extends HealthBleSyncResult {
|
||||
const BloodPressureSyncResult({required super.device, required this.reading});
|
||||
|
||||
final BpReading reading;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ class BpReading {
|
||||
final bool isKpa;
|
||||
final bool hasBodyMovement;
|
||||
final bool hasIrregularPulse;
|
||||
final bool hasDeviceTimestamp;
|
||||
|
||||
const BpReading({
|
||||
required this.systolic,
|
||||
@@ -18,6 +19,7 @@ class BpReading {
|
||||
this.isKpa = false,
|
||||
this.hasBodyMovement = false,
|
||||
this.hasIrregularPulse = false,
|
||||
this.hasDeviceTimestamp = false,
|
||||
});
|
||||
|
||||
String get display => '$systolic/$diastolic';
|
||||
|
||||
@@ -78,7 +78,9 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
'id': r['id'],
|
||||
'type': t,
|
||||
'date':
|
||||
DateTime.tryParse(r['recordedAt']?.toString() ?? '') ??
|
||||
DateTime.tryParse(
|
||||
r['recordedAt']?.toString() ?? '',
|
||||
)?.toLocal() ??
|
||||
DateTime.now(),
|
||||
'systolic': r['systolic'],
|
||||
'diastolic': r['diastolic'],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,38 +1,42 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
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 '../../core/app_theme.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../models/ble_device.dart';
|
||||
import '../../models/bp_reading.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/omron_device_provider.dart';
|
||||
import '../../services/omron_ble_service.dart';
|
||||
import '../../services/health_ble_service.dart';
|
||||
import '../../widgets/ble_sync_dialog.dart';
|
||||
|
||||
class DeviceScanPage extends ConsumerStatefulWidget {
|
||||
const DeviceScanPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DeviceScanPage> createState() => _DeviceScanPageState();
|
||||
}
|
||||
|
||||
class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static const _visibleDeviceWindow = Duration(seconds: 12);
|
||||
|
||||
final _results = <ScanResult>[];
|
||||
bool _scanning = false;
|
||||
StreamSubscription<List<ScanResult>>? _scanSub;
|
||||
Timer? _scanStopTimer;
|
||||
Timer? _resultPruneTimer;
|
||||
String? _connectingId;
|
||||
StreamSubscription? _scanSub;
|
||||
DateTime? _scanStartedAt;
|
||||
bool _scanning = false;
|
||||
|
||||
bool _connected = false;
|
||||
String _deviceName = '';
|
||||
bool _readingReceived = false;
|
||||
int? _systolic, _diastolic, _pulse;
|
||||
StreamSubscription? _readingSub;
|
||||
StreamSubscription? _bleConnSub;
|
||||
|
||||
late AnimationController _pulseCtrl;
|
||||
late Animation<double> _pulseAnim;
|
||||
late final AnimationController _pulseCtrl;
|
||||
late final Animation<double> _pulseAnim;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -40,40 +44,32 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
||||
_pulseCtrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
);
|
||||
)..repeat(reverse: true);
|
||||
_pulseAnim = Tween(
|
||||
begin: 0.8,
|
||||
end: 1.4,
|
||||
begin: 0.82,
|
||||
end: 1.36,
|
||||
).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
|
||||
_pulseCtrl.repeat(reverse: true);
|
||||
|
||||
_scanSub = FlutterBluePlus.scanResults.listen((list) {
|
||||
if (!mounted) return;
|
||||
final bpList = list.where((r) => OmronBleService.isBpDevice(r)).toList();
|
||||
setState(() {
|
||||
_results.clear();
|
||||
_results.addAll(bpList);
|
||||
});
|
||||
});
|
||||
_start();
|
||||
unawaited(_startScan());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pulseCtrl.dispose();
|
||||
_scanStopTimer?.cancel();
|
||||
_resultPruneTimer?.cancel();
|
||||
_scanSub?.cancel();
|
||||
_readingSub?.cancel();
|
||||
_bleConnSub?.cancel();
|
||||
FlutterBluePlus.stopScan();
|
||||
unawaited(FlutterBluePlus.stopScan());
|
||||
unawaited(ref.read(healthBleServiceProvider).disconnect());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _start() async {
|
||||
Future<void> _startScan() async {
|
||||
setState(() {
|
||||
_scanning = true;
|
||||
_connected = false;
|
||||
_connectingId = null;
|
||||
_results.clear();
|
||||
});
|
||||
_results.clear();
|
||||
|
||||
await [Permission.bluetoothScan, Permission.bluetoothConnect].request();
|
||||
|
||||
@@ -82,7 +78,7 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
||||
if (locStatus != ServiceStatus.enabled && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('请先打开GPS定位,否则无法扫描蓝牙'),
|
||||
content: Text('请先打开定位服务,否则可能无法发现蓝牙设备'),
|
||||
backgroundColor: AppColors.warning,
|
||||
),
|
||||
);
|
||||
@@ -99,398 +95,331 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
||||
try {
|
||||
await FlutterBluePlus.stopScan();
|
||||
} catch (_) {}
|
||||
_scanStartedAt = DateTime.now();
|
||||
await _scanSub?.cancel();
|
||||
_scanSub = FlutterBluePlus.onScanResults.listen(_onScanResults);
|
||||
await FlutterBluePlus.startScan(
|
||||
timeout: const Duration(seconds: 30),
|
||||
androidScanMode: AndroidScanMode.lowLatency,
|
||||
oneByOne: true,
|
||||
);
|
||||
|
||||
Future.delayed(const Duration(seconds: 30), () {
|
||||
FlutterBluePlus.stopScan();
|
||||
_scanStopTimer?.cancel();
|
||||
_scanStopTimer = Timer(const Duration(seconds: 30), () {
|
||||
if (mounted) setState(() => _scanning = false);
|
||||
});
|
||||
_resultPruneTimer?.cancel();
|
||||
_resultPruneTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted || _connectingId != null) return;
|
||||
final pruned = _visibleResults(_results);
|
||||
if (_sameResults(_results, pruned)) return;
|
||||
setState(() {
|
||||
_results
|
||||
..clear()
|
||||
..addAll(pruned);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _connect(ScanResult r) async {
|
||||
setState(() => _connectingId = r.device.remoteId.toString());
|
||||
FlutterBluePlus.stopScan();
|
||||
_scanSub?.cancel();
|
||||
void _onScanResults(List<ScanResult> list) {
|
||||
if (!mounted || _connectingId != null) return;
|
||||
final scanStartedAt = _scanStartedAt;
|
||||
if (scanStartedAt == null) return;
|
||||
final boundDevices = ref.read(omronDeviceProvider).devices;
|
||||
final supported = list.where((result) {
|
||||
if (result.timeStamp.isBefore(scanStartedAt)) return false;
|
||||
if (!result.advertisementData.connectable) return false;
|
||||
if (DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) {
|
||||
return false;
|
||||
}
|
||||
if (!HealthBleService.isSupportedHealthDevice(result)) return false;
|
||||
return boundDevices.every(
|
||||
(device) => device.id != result.device.remoteId.toString(),
|
||||
);
|
||||
});
|
||||
|
||||
final next = [..._results];
|
||||
for (final result in supported) {
|
||||
final index = next.indexWhere(
|
||||
(item) => item.device.remoteId == result.device.remoteId,
|
||||
);
|
||||
if (index >= 0) {
|
||||
next[index] = result;
|
||||
} else {
|
||||
next.add(result);
|
||||
}
|
||||
}
|
||||
final visible = _visibleResults(next);
|
||||
if (_sameResults(_results, visible)) return;
|
||||
setState(() {
|
||||
_results
|
||||
..clear()
|
||||
..addAll(visible);
|
||||
});
|
||||
}
|
||||
|
||||
List<ScanResult> _visibleResults(List<ScanResult> results) {
|
||||
final visible =
|
||||
results
|
||||
.where(
|
||||
(result) =>
|
||||
DateTime.now().difference(result.timeStamp) <=
|
||||
_visibleDeviceWindow,
|
||||
)
|
||||
.toList()
|
||||
..sort(
|
||||
(a, b) => a.device.remoteId.toString().compareTo(
|
||||
b.device.remoteId.toString(),
|
||||
),
|
||||
);
|
||||
return visible;
|
||||
}
|
||||
|
||||
Future<void> _connectBindAndSync(ScanResult result) async {
|
||||
final remoteId = result.device.remoteId.toString();
|
||||
if (_connectingId != null) return;
|
||||
final scanStartedAt = _scanStartedAt;
|
||||
if (scanStartedAt == null ||
|
||||
result.timeStamp.isBefore(scanStartedAt) ||
|
||||
!result.advertisementData.connectable ||
|
||||
DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('设备已离线,请重新进入通信状态')));
|
||||
return;
|
||||
}
|
||||
if (ref.read(omronDeviceProvider).isBound &&
|
||||
ref.read(omronDeviceProvider).findById(remoteId) != null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('该设备已绑定')));
|
||||
return;
|
||||
}
|
||||
setState(() => _connectingId = remoteId);
|
||||
await FlutterBluePlus.stopScan();
|
||||
|
||||
final ble = ref.read(healthBleServiceProvider);
|
||||
BoundBleDevice? boundDevice;
|
||||
try {
|
||||
final ble = ref.read(omronBleServiceProvider);
|
||||
await ble.connect(r.device);
|
||||
final name = r.advertisementData.advName.isNotEmpty
|
||||
? r.advertisementData.advName
|
||||
: (r.device.platformName.isNotEmpty ? r.device.platformName : '血压计');
|
||||
boundDevice = await ble
|
||||
.connectAndIdentify(
|
||||
device: result.device,
|
||||
name: _deviceNameOf(result),
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
await ref.read(omronDeviceProvider.notifier).bind(boundDevice);
|
||||
|
||||
await ref
|
||||
.read(omronDeviceProvider.notifier)
|
||||
.bind(r.device.remoteId.toString(), name);
|
||||
|
||||
_bleConnSub = r.device.connectionState.listen((s) {
|
||||
if (s == BluetoothConnectionState.disconnected && mounted) {
|
||||
_readingSub?.cancel();
|
||||
ref.read(omronBleServiceProvider).disconnect();
|
||||
if (_readingReceived) {
|
||||
popRoute(ref);
|
||||
} else {
|
||||
setState(() {
|
||||
_connected = false;
|
||||
});
|
||||
_start();
|
||||
if (boundDevice.type == BleDeviceType.bloodPressure) {
|
||||
final syncResult = await ble.syncBoundDevice(
|
||||
result.device,
|
||||
boundDevice,
|
||||
timeout: const Duration(seconds: 30),
|
||||
);
|
||||
if (syncResult is BloodPressureSyncResult) {
|
||||
final saved = await _persistBloodPressure(
|
||||
syncResult.device,
|
||||
syncResult.reading,
|
||||
);
|
||||
if (mounted && saved) {
|
||||
await _showBloodPressureDialog(syncResult.reading);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_readingSub = ble.readings.listen((reading) async {
|
||||
debugPrint('[PAGE] 收到: ${reading.systolic}/${reading.diastolic}');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_readingReceived = true;
|
||||
_systolic = reading.systolic;
|
||||
_diastolic = reading.diastolic;
|
||||
_pulse = reading.pulse;
|
||||
});
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.post('/api/health-records', data: reading.toHealthRecord());
|
||||
final heartRateRecord = reading.toHeartRateRecord();
|
||||
if (heartRateRecord != null) {
|
||||
await api.post('/api/health-records', data: heartRateRecord);
|
||||
}
|
||||
await ref.read(omronDeviceProvider.notifier).onReading(reading);
|
||||
} catch (e) {
|
||||
debugPrint('[PAGE] 上报失败: $e');
|
||||
}
|
||||
Future.delayed(const Duration(milliseconds: 1500), () {
|
||||
if (mounted) popRoute(ref);
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if (mounted) {
|
||||
popRoute(ref);
|
||||
}
|
||||
|
||||
if (mounted) popRoute(ref);
|
||||
} on TimeoutException {
|
||||
if (mounted && boundDevice != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${boundDevice.type.label}已绑定,但本次未收到数据')),
|
||||
);
|
||||
popRoute(ref);
|
||||
} else if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('连接超时,请确认设备仍处于通信状态'),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
unawaited(_startScan());
|
||||
}
|
||||
} on UnsupportedBleDeviceException catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_connected = true;
|
||||
_deviceName = name;
|
||||
_connectingId = null;
|
||||
_readingReceived = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.message), backgroundColor: AppColors.error),
|
||||
);
|
||||
unawaited(_startScan());
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('连接失败: $e'), backgroundColor: AppColors.error),
|
||||
SnackBar(content: Text('连接失败:$e'), backgroundColor: AppColors.error),
|
||||
);
|
||||
_start();
|
||||
unawaited(_startScan());
|
||||
}
|
||||
} finally {
|
||||
await ble.disconnect();
|
||||
if (mounted) setState(() => _connectingId = null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _persistBloodPressure(
|
||||
BoundBleDevice device,
|
||||
BpReading reading,
|
||||
) async {
|
||||
final notifier = ref.read(omronDeviceProvider.notifier);
|
||||
if (await notifier.isDuplicateReading(device, reading)) return false;
|
||||
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.post('/api/health-records', data: reading.toHealthRecord());
|
||||
final heartRateRecord = reading.toHeartRateRecord();
|
||||
if (heartRateRecord != null) {
|
||||
await api.post('/api/health-records', data: heartRateRecord);
|
||||
}
|
||||
await notifier.recordSuccessfulSync(device: device, reading: reading);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _showBloodPressureDialog(BpReading reading) {
|
||||
return showBleSyncDialog(context, reading: reading);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GradientScaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white.withValues(alpha: 0.9),
|
||||
backgroundColor: Colors.white.withValues(alpha: 0.92),
|
||||
elevation: 0,
|
||||
title: Text(
|
||||
_connected ? _deviceName : '添加设备',
|
||||
style: const TextStyle(color: AppColors.textPrimary),
|
||||
title: const Text(
|
||||
'新增设备',
|
||||
style: TextStyle(color: AppColors.textPrimary),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
|
||||
onPressed: () {
|
||||
if (_connected) ref.read(omronBleServiceProvider).disconnect();
|
||||
popRoute(ref);
|
||||
},
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
),
|
||||
body: _connected ? _buildConnected() : _buildScan(),
|
||||
body: _results.isEmpty
|
||||
? _ScanningEmpty(animation: _pulseAnim, scanning: _scanning)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
|
||||
itemCount: _results.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, index) {
|
||||
final result = _results[index];
|
||||
return _DeviceResultTile(
|
||||
result: result,
|
||||
connecting:
|
||||
_connectingId == result.device.remoteId.toString(),
|
||||
onConnect: () => _connectBindAndSync(result),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 扫描视图 ──
|
||||
Widget _buildScan() => Column(
|
||||
children: [
|
||||
if (_scanning && _results.isEmpty)
|
||||
Expanded(child: _buildScanningCenter()),
|
||||
if (!_scanning && _results.isEmpty)
|
||||
Expanded(child: _buildScanningCenter()),
|
||||
if (_results.isNotEmpty)
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _results.length,
|
||||
itemBuilder: (_, i) => _buildTile(_results[i]),
|
||||
),
|
||||
),
|
||||
if (!_scanning)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _start,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('重新扫描'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.primary,
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
String _deviceNameOf(ScanResult result) {
|
||||
final advName = result.advertisementData.advName.trim();
|
||||
if (advName.isNotEmpty) return advName;
|
||||
final platformName = result.device.platformName.trim();
|
||||
if (platformName.isNotEmpty) return platformName;
|
||||
return HealthBleService.deviceTypeFromScan(result)?.label ?? '健康设备';
|
||||
}
|
||||
|
||||
Widget _buildScanningCenter() => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
bool _sameResults(List<ScanResult> current, List<ScanResult> next) {
|
||||
if (current.length != next.length) return false;
|
||||
for (var i = 0; i < current.length; i++) {
|
||||
if (current[i].device.remoteId != next[i].device.remoteId) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class _ScanningEmpty extends StatelessWidget {
|
||||
final Animation<double> animation;
|
||||
final bool scanning;
|
||||
|
||||
const _ScanningEmpty({required this.animation, required this.scanning});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(30),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: _pulseAnim,
|
||||
builder: (_, child) => Container(
|
||||
width: 80 * _pulseAnim.value,
|
||||
height: 80 * _pulseAnim.value,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.auraIndigo.withValues(alpha: 0.10),
|
||||
),
|
||||
_ScanPulse(animation: animation),
|
||||
const SizedBox(height: 22),
|
||||
Text(
|
||||
scanning ? '正在搜索设备' : '暂未发现设备',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.calmHealthGradient,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: AppColors.buttonShadow,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.bluetooth_searching,
|
||||
size: 32,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
_scanning ? '正在搜索蓝牙设备...' : '未发现设备',
|
||||
style: const TextStyle(fontSize: 16, color: AppColors.textHint),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_scanning ? '请确保血压计处于通信模式' : '点击下方按钮重新搜索',
|
||||
style: const TextStyle(fontSize: 13, color: Color(0xFFCCCCCC)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// ── 已连接视图 ──
|
||||
Widget _buildConnected() => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
gradient: _readingReceived
|
||||
? AppColors.calmHealthGradient
|
||||
: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
boxShadow: AppColors.buttonShadow,
|
||||
),
|
||||
child: Icon(
|
||||
_readingReceived
|
||||
? Icons.check_rounded
|
||||
: Icons.bluetooth_connected,
|
||||
size: 48,
|
||||
color: _readingReceived ? Colors.white : Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
_readingReceived ? '测量完成' : '设备已连接',
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
if (_readingReceived) ...[
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'$_systolic',
|
||||
style: const TextStyle(
|
||||
fontSize: 56,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Text(
|
||||
'/',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
color: AppColors.textHint.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$_diastolic',
|
||||
style: const TextStyle(
|
||||
fontSize: 56,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
'mmHg',
|
||||
style: TextStyle(fontSize: 15, color: AppColors.textHint),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_pulse != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.infoLight,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'脉搏 $_pulse bpm',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'数据已自动同步',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.textHint),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
const Text(
|
||||
'请按下血压计的开始键进行测量',
|
||||
style: TextStyle(fontSize: 15, color: AppColors.textHint),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
AnimatedBuilder(
|
||||
animation: _pulseAnim,
|
||||
builder: (_, _) => Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: AppColors.primary.withValues(alpha: 0.3),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Transform.scale(
|
||||
scale: _pulseAnim.value,
|
||||
child: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
'请让设备进入通信状态并靠近手机。',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.45,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 设备列表项 ──
|
||||
Widget _buildTile(ScanResult r) {
|
||||
final isConnecting = _connectingId == r.device.remoteId.toString();
|
||||
final name = r.advertisementData.advName.isNotEmpty
|
||||
? r.advertisementData.advName
|
||||
: (r.device.platformName.isNotEmpty ? r.device.platformName : '未知设备');
|
||||
class _DeviceResultTile extends StatelessWidget {
|
||||
final ScanResult result;
|
||||
final bool connecting;
|
||||
final VoidCallback onConnect;
|
||||
|
||||
const _DeviceResultTile({
|
||||
required this.result,
|
||||
required this.connecting,
|
||||
required this.onConnect,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final type = HealthBleService.deviceTypeFromScan(result);
|
||||
final name = _deviceNameOf(result, type);
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.surfaceGradient,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.calmHealthGradient,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(Icons.bluetooth, size: 22, color: Colors.white),
|
||||
),
|
||||
Icon(type?.icon ?? Icons.bluetooth_rounded, color: AppColors.primary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
type?.label ?? '健康设备',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'${r.device.remoteId} · 信号: ${_rssiLabel(r.rssi)}',
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textHint,
|
||||
@@ -499,43 +428,60 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isConnecting)
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
else
|
||||
GestureDetector(
|
||||
onTap: () => _connect(r),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: AppColors.buttonShadow,
|
||||
),
|
||||
child: const Text(
|
||||
'连接',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: connecting ? null : onConnect,
|
||||
child: Text(connecting ? '连接中' : '连接'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _rssiLabel(int r) {
|
||||
if (r > -55) return '强';
|
||||
if (r > -70) return '中';
|
||||
return '弱';
|
||||
String _deviceNameOf(ScanResult result, BleDeviceType? type) {
|
||||
final advName = result.advertisementData.advName.trim();
|
||||
if (advName.isNotEmpty) return advName;
|
||||
final platformName = result.device.platformName.trim();
|
||||
if (platformName.isNotEmpty) return platformName;
|
||||
return type?.label ?? '健康设备';
|
||||
}
|
||||
}
|
||||
|
||||
class _ScanPulse extends StatelessWidget {
|
||||
final Animation<double> animation;
|
||||
|
||||
const _ScanPulse({required this.animation});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) => Container(
|
||||
width: 78 * animation.value,
|
||||
height: 78 * animation.value,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.primary.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.bluetooth_searching_rounded,
|
||||
size: 32,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
_lastMsgCount = currentCount;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFFFFCFF),
|
||||
drawer: const HealthDrawer(),
|
||||
drawerEdgeDragWidth: MediaQuery.of(context).size.width * 0.35,
|
||||
body: AppBackground(
|
||||
|
||||
@@ -1,55 +1,63 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../models/ble_device.dart';
|
||||
import '../models/bp_reading.dart';
|
||||
import '../services/health_ble_service.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();
|
||||
const _boundDevicesKey = 'bound_ble_devices';
|
||||
const _readingFingerprintsKey = 'ble_reading_fingerprints';
|
||||
const _legacyBpMacKey = 'bp_device_mac';
|
||||
const _legacyBpNameKey = 'bp_device_name';
|
||||
const _legacyBpLastSyncKey = 'bp_last_sync';
|
||||
|
||||
final healthBleServiceProvider = Provider<HealthBleService>((ref) {
|
||||
final service = HealthBleService();
|
||||
ref.onDispose(service.dispose);
|
||||
return service;
|
||||
});
|
||||
|
||||
/// 设备绑定状态
|
||||
// Keep the old provider name as a compatibility alias while the feature is
|
||||
// being migrated away from the Omron-specific naming.
|
||||
final omronBleServiceProvider = healthBleServiceProvider;
|
||||
|
||||
class DeviceBindState {
|
||||
final bool isBound;
|
||||
final String? mac;
|
||||
final String? name;
|
||||
final String? lastSync;
|
||||
final List<BoundBleDevice> devices;
|
||||
final bool isConnected;
|
||||
final BpReading? lastReading;
|
||||
|
||||
const DeviceBindState({
|
||||
this.isBound = false,
|
||||
this.mac,
|
||||
this.name,
|
||||
this.lastSync,
|
||||
this.isConnected = false,
|
||||
this.lastReading,
|
||||
});
|
||||
const DeviceBindState({this.devices = const [], this.isConnected = false});
|
||||
|
||||
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,
|
||||
);
|
||||
bool get isBound => devices.isNotEmpty;
|
||||
|
||||
DeviceBindState copyWith({List<BoundBleDevice>? devices, bool? isConnected}) {
|
||||
return DeviceBindState(
|
||||
devices: devices ?? this.devices,
|
||||
isConnected: isConnected ?? this.isConnected,
|
||||
);
|
||||
}
|
||||
|
||||
List<BoundBleDevice> devicesOfType(BleDeviceType type) {
|
||||
return devices.where((device) => device.type == type).toList();
|
||||
}
|
||||
|
||||
BoundBleDevice? findById(String id) {
|
||||
for (final device in devices) {
|
||||
if (device.id == id) return device;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class DeviceBindNotifier extends Notifier<DeviceBindState> {
|
||||
StreamSubscription? _connSub;
|
||||
StreamSubscription<bool>? _connSub;
|
||||
|
||||
@override
|
||||
DeviceBindState build() {
|
||||
ref.onDispose(() => _connSub?.cancel());
|
||||
_loadBinding();
|
||||
_listenConnection();
|
||||
return const DeviceBindState();
|
||||
@@ -57,52 +65,182 @@ class DeviceBindNotifier extends Notifier<DeviceBindState> {
|
||||
|
||||
void _listenConnection() {
|
||||
_connSub?.cancel();
|
||||
_connSub = ref.read(omronBleServiceProvider).connectionState.listen((connected) {
|
||||
_connSub = ref.read(healthBleServiceProvider).connectionState.listen((
|
||||
connected,
|
||||
) {
|
||||
if (state.isConnected != connected) {
|
||||
state = state.copyWith(isConnected: connected);
|
||||
}
|
||||
if (!connected && state.isBound) {
|
||||
// 设备断开,但保持绑定信息(下次可以重连)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
await _migrateLegacyBloodPressureDevice();
|
||||
final raw = await db.read(_boundDevicesKey);
|
||||
final devices = _decodeDevices(raw);
|
||||
state = state.copyWith(devices: devices);
|
||||
}
|
||||
|
||||
Future<void> _migrateLegacyBloodPressureDevice() async {
|
||||
final db = ref.read(localDbProvider);
|
||||
final existing = await db.read(_boundDevicesKey);
|
||||
final legacyMac = await db.read(_legacyBpMacKey);
|
||||
if (existing != null || legacyMac == null || legacyMac.isEmpty) return;
|
||||
|
||||
final legacyName = await db.read(_legacyBpNameKey);
|
||||
final migrated = BoundBleDevice(
|
||||
id: legacyMac,
|
||||
name: legacyName?.isNotEmpty == true ? legacyName! : '血压计',
|
||||
type: BleDeviceType.bloodPressure,
|
||||
serviceUuid: BleDeviceType.bloodPressure.serviceUuid,
|
||||
);
|
||||
await db.write(_boundDevicesKey, jsonEncode([migrated.toJson()]));
|
||||
await db.delete(_legacyBpMacKey);
|
||||
await db.delete(_legacyBpNameKey);
|
||||
await db.delete(_legacyBpLastSyncKey);
|
||||
}
|
||||
|
||||
List<BoundBleDevice> _decodeDevices(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return [];
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) return [];
|
||||
return decoded
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(item) => BoundBleDevice.fromJson(Map<String, dynamic>.from(item)),
|
||||
)
|
||||
.whereType<BoundBleDevice>()
|
||||
.toList();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> bind(String mac, String name) async {
|
||||
Future<void> _saveDevices(List<BoundBleDevice> devices) 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);
|
||||
await db.write(
|
||||
_boundDevicesKey,
|
||||
jsonEncode(devices.map((device) => device.toJson()).toList()),
|
||||
);
|
||||
state = state.copyWith(devices: devices);
|
||||
}
|
||||
|
||||
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();
|
||||
Future<void> bind(BoundBleDevice device) async {
|
||||
final devices = [...state.devices];
|
||||
final index = devices.indexWhere((item) => item.id == device.id);
|
||||
if (index >= 0) {
|
||||
devices[index] = device.copyWith(lastSyncAt: devices[index].lastSyncAt);
|
||||
} else {
|
||||
devices.add(device);
|
||||
}
|
||||
await _saveDevices(devices);
|
||||
}
|
||||
|
||||
void setConnected(bool connected) {
|
||||
state = state.copyWith(isConnected: connected);
|
||||
Future<void> unbind(String id) async {
|
||||
await _saveDevices(
|
||||
state.devices.where((device) => device.id != id).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
Future<void> recordSuccessfulSync({
|
||||
required BoundBleDevice device,
|
||||
required BpReading reading,
|
||||
}) async {
|
||||
await _rememberReadingFingerprint(device, reading);
|
||||
final syncedAt = DateTime.now();
|
||||
final devices = state.devices.map((item) {
|
||||
if (item.id != device.id) return item;
|
||||
return item.copyWith(lastSyncAt: syncedAt);
|
||||
}).toList();
|
||||
await _saveDevices(devices);
|
||||
ref.invalidate(latestHealthProvider);
|
||||
}
|
||||
|
||||
Future<bool> isDuplicateReading(
|
||||
BoundBleDevice device,
|
||||
BpReading reading,
|
||||
) async {
|
||||
final fingerprints = await _loadFingerprints();
|
||||
final now = DateTime.now();
|
||||
final pruned = _pruneFingerprints(fingerprints, now);
|
||||
if (pruned.length != fingerprints.length) {
|
||||
await _saveFingerprints(pruned);
|
||||
}
|
||||
|
||||
final key = _readingFingerprint(device, reading);
|
||||
final lastSeenRaw = pruned[key];
|
||||
if (lastSeenRaw == null) return false;
|
||||
final lastSeen = DateTime.tryParse(lastSeenRaw)?.toLocal();
|
||||
if (lastSeen == null) return false;
|
||||
if (reading.hasDeviceTimestamp) return true;
|
||||
return now.difference(lastSeen).inSeconds < 10;
|
||||
}
|
||||
|
||||
Future<void> _rememberReadingFingerprint(
|
||||
BoundBleDevice device,
|
||||
BpReading reading,
|
||||
) async {
|
||||
final now = DateTime.now();
|
||||
final fingerprints = _pruneFingerprints(await _loadFingerprints(), now);
|
||||
fingerprints[_readingFingerprint(device, reading)] = now
|
||||
.toUtc()
|
||||
.toIso8601String();
|
||||
await _saveFingerprints(fingerprints);
|
||||
}
|
||||
|
||||
Future<Map<String, String>> _loadFingerprints() async {
|
||||
final db = ref.read(localDbProvider);
|
||||
final raw = await db.read(_readingFingerprintsKey);
|
||||
if (raw == null || raw.isEmpty) return {};
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map) return {};
|
||||
return decoded.map(
|
||||
(key, value) => MapEntry(key.toString(), value.toString()),
|
||||
);
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveFingerprints(Map<String, String> fingerprints) async {
|
||||
final db = ref.read(localDbProvider);
|
||||
await db.write(_readingFingerprintsKey, jsonEncode(fingerprints));
|
||||
}
|
||||
|
||||
Map<String, String> _pruneFingerprints(
|
||||
Map<String, String> fingerprints,
|
||||
DateTime now,
|
||||
) {
|
||||
final pruned = <String, String>{};
|
||||
for (final entry in fingerprints.entries) {
|
||||
final seenAt = DateTime.tryParse(entry.value)?.toLocal();
|
||||
if (seenAt == null) continue;
|
||||
if (now.difference(seenAt).inHours <= 24) {
|
||||
pruned[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
return pruned;
|
||||
}
|
||||
|
||||
String _readingFingerprint(BoundBleDevice device, BpReading reading) {
|
||||
final timePart = reading.hasDeviceTimestamp
|
||||
? reading.timestamp.toUtc().toIso8601String()
|
||||
: 'no-device-time';
|
||||
return [
|
||||
device.id,
|
||||
device.type.code,
|
||||
timePart,
|
||||
reading.systolic,
|
||||
reading.diastolic,
|
||||
reading.pulse ?? 0,
|
||||
].join('|');
|
||||
}
|
||||
}
|
||||
|
||||
final omronDeviceProvider = NotifierProvider<DeviceBindNotifier, DeviceBindState>(DeviceBindNotifier.new);
|
||||
final omronDeviceProvider =
|
||||
NotifierProvider<DeviceBindNotifier, DeviceBindState>(
|
||||
DeviceBindNotifier.new,
|
||||
);
|
||||
|
||||
316
health_app/lib/services/health_ble_service.dart
Normal file
316
health_app/lib/services/health_ble_service.dart
Normal file
@@ -0,0 +1,316 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart' show VoidCallback;
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
|
||||
import '../models/ble_device.dart';
|
||||
import '../models/bp_reading.dart';
|
||||
|
||||
class UnsupportedBleDeviceException implements Exception {
|
||||
final String message;
|
||||
|
||||
const UnsupportedBleDeviceException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class HealthBleService {
|
||||
static const bpMeasurementUuid = '00002A35-0000-1000-8000-00805F9B34FB';
|
||||
static const racpUuid = '00002A52-0000-1000-8000-00805F9B34FB';
|
||||
|
||||
BluetoothDevice? _device;
|
||||
StreamSubscription<BluetoothConnectionState>? _connStateSub;
|
||||
StreamSubscription<List<int>>? _measurementSub;
|
||||
final _connStateController = StreamController<bool>.broadcast();
|
||||
|
||||
Stream<bool> get connectionState => _connStateController.stream;
|
||||
|
||||
static bool isSupportedHealthDevice(ScanResult result) {
|
||||
return BleDeviceIdentifier.isSupportedScanResult(result);
|
||||
}
|
||||
|
||||
static BleDeviceType? deviceTypeFromScan(ScanResult result) {
|
||||
return BleDeviceIdentifier.typeFromScan(result);
|
||||
}
|
||||
|
||||
Future<BoundBleDevice> connectAndIdentify({
|
||||
required BluetoothDevice device,
|
||||
required String name,
|
||||
}) async {
|
||||
final services = await _connectAndDiscover(device);
|
||||
final type = BleDeviceIdentifier.typeFromServices(services);
|
||||
if (type == null) {
|
||||
throw const UnsupportedBleDeviceException('暂不支持该设备');
|
||||
}
|
||||
return BoundBleDevice(
|
||||
id: device.remoteId.toString(),
|
||||
name: name,
|
||||
type: type,
|
||||
serviceUuid: type.serviceUuid,
|
||||
);
|
||||
}
|
||||
|
||||
Future<HealthBleSyncResult> syncBoundDevice(
|
||||
BluetoothDevice device,
|
||||
BoundBleDevice bound, {
|
||||
Duration timeout = const Duration(seconds: 45),
|
||||
VoidCallback? onConnected,
|
||||
VoidCallback? onMeasurementReceived,
|
||||
}) async {
|
||||
final services = await _connectAndDiscover(device);
|
||||
onConnected?.call();
|
||||
final connectedType = BleDeviceIdentifier.typeFromServices(services);
|
||||
if (connectedType != bound.type) {
|
||||
throw const UnsupportedBleDeviceException('设备类型和绑定信息不一致');
|
||||
}
|
||||
|
||||
return switch (bound.type) {
|
||||
BleDeviceType.bloodPressure => BloodPressureSyncResult(
|
||||
device: bound,
|
||||
reading: await _syncBloodPressure(
|
||||
services,
|
||||
onMeasurementReceived: onMeasurementReceived,
|
||||
).timeout(timeout),
|
||||
),
|
||||
BleDeviceType.glucose => throw const UnsupportedBleDeviceException(
|
||||
'暂未支持血糖仪数据解析',
|
||||
),
|
||||
BleDeviceType.weightScale => throw const UnsupportedBleDeviceException(
|
||||
'暂未支持体重秤数据解析',
|
||||
),
|
||||
BleDeviceType.pulseOximeter => throw const UnsupportedBleDeviceException(
|
||||
'暂未支持血氧仪数据解析',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Future<List<BluetoothService>> _connectAndDiscover(
|
||||
BluetoothDevice device,
|
||||
) async {
|
||||
await _measurementSub?.cancel();
|
||||
_measurementSub = null;
|
||||
await _connStateSub?.cancel();
|
||||
_device = device;
|
||||
|
||||
_connStateSub = device.connectionState.listen((state) {
|
||||
_connStateController.add(state == BluetoothConnectionState.connected);
|
||||
});
|
||||
|
||||
if (device.isConnected) {
|
||||
try {
|
||||
await device.disconnect();
|
||||
} catch (_) {}
|
||||
}
|
||||
await device.connect(
|
||||
autoConnect: false,
|
||||
timeout: const Duration(seconds: 10),
|
||||
);
|
||||
try {
|
||||
await device.requestMtu(512);
|
||||
} catch (_) {
|
||||
// Some devices or platforms do not allow MTU negotiation.
|
||||
}
|
||||
final services = await device.discoverServices();
|
||||
return services;
|
||||
}
|
||||
|
||||
Future<BpReading> _syncBloodPressure(
|
||||
List<BluetoothService> services, {
|
||||
VoidCallback? onMeasurementReceived,
|
||||
}) async {
|
||||
BluetoothService? bpService;
|
||||
for (final service in services) {
|
||||
if (service.uuid.str128.toUpperCase() ==
|
||||
BleDeviceIdentifier.bloodPressureServiceUuid) {
|
||||
bpService = service;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (bpService == null) {
|
||||
throw const UnsupportedBleDeviceException('未找到血压服务');
|
||||
}
|
||||
|
||||
BluetoothCharacteristic? measurementChar;
|
||||
BluetoothCharacteristic? racpChar;
|
||||
for (final characteristic in bpService.characteristics) {
|
||||
final uuid = characteristic.uuid.str128.toUpperCase();
|
||||
if (uuid == bpMeasurementUuid) measurementChar = characteristic;
|
||||
if (uuid == racpUuid) racpChar = characteristic;
|
||||
}
|
||||
if (measurementChar == null) {
|
||||
throw const UnsupportedBleDeviceException('未找到血压测量特征');
|
||||
}
|
||||
|
||||
final completer = Completer<BpReading>();
|
||||
final readings = <BpReading>[];
|
||||
Timer? settleTimer;
|
||||
|
||||
void acceptReading(List<int> raw) {
|
||||
if (raw.isEmpty || completer.isCompleted) return;
|
||||
try {
|
||||
readings.add(_parseBloodPressureReading(raw));
|
||||
onMeasurementReceived?.call();
|
||||
settleTimer?.cancel();
|
||||
settleTimer = Timer(const Duration(milliseconds: 900), () {
|
||||
final reading = _selectCurrentBloodPressureReading(readings);
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(reading);
|
||||
}
|
||||
});
|
||||
} catch (e, stack) {
|
||||
if (!completer.isCompleted) completer.completeError(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
_measurementSub = measurementChar.onValueReceived.listen(
|
||||
(raw) {
|
||||
acceptReading(raw);
|
||||
},
|
||||
onError: (Object e, StackTrace stack) {
|
||||
if (!completer.isCompleted) completer.completeError(e, stack);
|
||||
},
|
||||
);
|
||||
|
||||
await _enableMeasurementUpdates(measurementChar);
|
||||
|
||||
if (racpChar != null) {
|
||||
try {
|
||||
await racpChar.write([0x01, 0x01]);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
try {
|
||||
return await completer.future;
|
||||
} finally {
|
||||
settleTimer?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
BpReading _selectCurrentBloodPressureReading(List<BpReading> readings) {
|
||||
if (readings.isEmpty) {
|
||||
throw const FormatException('未收到血压数据');
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final currentWindowStart = now.subtract(const Duration(minutes: 15));
|
||||
final currentWindowEnd = now.add(const Duration(minutes: 2));
|
||||
final timestampedCurrent = readings.where((reading) {
|
||||
if (!reading.hasDeviceTimestamp) return false;
|
||||
return !reading.timestamp.isBefore(currentWindowStart) &&
|
||||
!reading.timestamp.isAfter(currentWindowEnd);
|
||||
}).toList();
|
||||
|
||||
if (timestampedCurrent.isNotEmpty) {
|
||||
timestampedCurrent.sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
||||
return timestampedCurrent.first;
|
||||
}
|
||||
|
||||
final timestamped = readings
|
||||
.where((reading) => reading.hasDeviceTimestamp)
|
||||
.toList();
|
||||
if (timestamped.isNotEmpty) {
|
||||
timestamped.sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
||||
return timestamped.first;
|
||||
}
|
||||
|
||||
return readings.last;
|
||||
}
|
||||
|
||||
Future<void> _enableMeasurementUpdates(
|
||||
BluetoothCharacteristic characteristic,
|
||||
) async {
|
||||
final useIndication = characteristic.properties.indicate;
|
||||
await characteristic.setNotifyValue(true, forceIndications: useIndication);
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
await _measurementSub?.cancel();
|
||||
_measurementSub = null;
|
||||
await _connStateSub?.cancel();
|
||||
_connStateSub = null;
|
||||
await _device?.disconnect();
|
||||
_device = null;
|
||||
_connStateController.add(false);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
unawaited(disconnect());
|
||||
_connStateController.close();
|
||||
}
|
||||
|
||||
BpReading _parseBloodPressureReading(List<int> raw) {
|
||||
if (raw.length < 7) {
|
||||
throw const FormatException('血压数据长度不足');
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
var offset = 7;
|
||||
var timestamp = DateTime.now();
|
||||
if (hasTimestamp && raw.length >= offset + 7) {
|
||||
timestamp = DateTime(
|
||||
raw[offset] | (raw[offset + 1] << 8),
|
||||
raw[offset + 2],
|
||||
raw[offset + 3],
|
||||
raw[offset + 4],
|
||||
raw[offset + 5],
|
||||
raw[offset + 6],
|
||||
);
|
||||
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++;
|
||||
}
|
||||
|
||||
var hasBodyMovement = false;
|
||||
var hasIrregularPulse = false;
|
||||
if (hasStatus && raw.length >= offset + 2) {
|
||||
final status = raw[offset] | (raw[offset + 1] << 8);
|
||||
hasBodyMovement = (status & 0x0001) != 0;
|
||||
hasIrregularPulse = (status & 0x0800) != 0;
|
||||
}
|
||||
|
||||
return BpReading(
|
||||
systolic: systolic,
|
||||
diastolic: diastolic,
|
||||
pulse: pulse,
|
||||
timestamp: timestamp,
|
||||
userId: userId,
|
||||
isKpa: isKpa,
|
||||
hasBodyMovement: hasBodyMovement,
|
||||
hasIrregularPulse: hasIrregularPulse,
|
||||
hasDeviceTimestamp: hasTimestamp,
|
||||
);
|
||||
}
|
||||
|
||||
static double _decodeSFloat(int raw) {
|
||||
var mantissa = raw & 0x0FFF;
|
||||
if (mantissa & 0x0800 != 0) mantissa |= 0xFFFFF000;
|
||||
var exponent = (raw >> 12) & 0x0F;
|
||||
if (exponent & 0x08 != 0) exponent |= 0xFFFFFFF0;
|
||||
return (mantissa * pow(10, exponent)).toDouble();
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/foundation.dart';
|
||||
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';
|
||||
static const intermediateCuffUuid = '00002A36-0000-1000-8000-00805F9B34FB';
|
||||
static const racpUuid = '00002A52-0000-1000-8000-00805F9B34FB';
|
||||
|
||||
BluetoothDevice? _device;
|
||||
StreamSubscription<List<int>>? _measurementSub;
|
||||
StreamSubscription<BluetoothConnectionState>? _connStateSub;
|
||||
final _readingsController = StreamController<BpReading>.broadcast();
|
||||
final _connStateController = StreamController<bool>.broadcast();
|
||||
Stream<BpReading> get readings => _readingsController.stream;
|
||||
Stream<bool> get connectionState => _connStateController.stream;
|
||||
|
||||
bool get isConnected => _device?.isConnected ?? false;
|
||||
|
||||
/// 直接重连已知设备(通过MAC地址)
|
||||
Future<bool> reconnectByMac(String mac) async {
|
||||
debugPrint('[BLE] 直连设备: $mac');
|
||||
final bonded = await FlutterBluePlus.bondedDevices;
|
||||
BluetoothDevice? target;
|
||||
for (final d in bonded) {
|
||||
if (d.remoteId.toString() == mac) {
|
||||
target = d;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (target == null) {
|
||||
debugPrint('[BLE] 未在已配对设备中找到 $mac');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await connect(target);
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('[BLE] 直连失败: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static bool isBpDevice(ScanResult r) {
|
||||
final name = [
|
||||
r.advertisementData.advName,
|
||||
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;
|
||||
debugPrint('[BLE] ═══ 连接: "${device.platformName}" ═══');
|
||||
|
||||
_connStateSub = device.connectionState.listen((s) {
|
||||
debugPrint('[BLE] 连接状态: $s');
|
||||
_connStateController.add(s == BluetoothConnectionState.connected);
|
||||
});
|
||||
|
||||
// 1. GATT连接
|
||||
await device.connect(autoConnect: false);
|
||||
debugPrint('[BLE] ① 已连接, MTU=${device.mtuNow}');
|
||||
try { await device.requestMtu(512); } catch (_) {}
|
||||
|
||||
// 2. 发现服务
|
||||
final services = await device.discoverServices();
|
||||
debugPrint('[BLE] ② 发现${services.length}个服务');
|
||||
|
||||
// 遍历所有服务/特征
|
||||
BluetoothService? bpSvc;
|
||||
BluetoothCharacteristic? measChar, racpChar;
|
||||
for (final s in services) {
|
||||
debugPrint('[BLE] 服务: ${s.uuid.str128}');
|
||||
for (final c in s.characteristics) {
|
||||
final p = <String>[];
|
||||
if (c.properties.read) p.add('R');
|
||||
if (c.properties.write) p.add('W');
|
||||
if (c.properties.notify) p.add('N');
|
||||
if (c.properties.indicate) p.add('I');
|
||||
debugPrint('[BLE] ${c.uuid.str128} [$p]');
|
||||
for (final d in c.descriptors) {
|
||||
debugPrint('[BLE] desc: ${d.uuid.str128}');
|
||||
}
|
||||
}
|
||||
if (s.uuid.str128.toUpperCase() == bpServiceUuid.toUpperCase()) bpSvc = s;
|
||||
}
|
||||
|
||||
if (bpSvc == null) throw Exception('未找到血压服务0x1810');
|
||||
for (final c in bpSvc.characteristics) {
|
||||
final u = c.uuid.str128.toUpperCase();
|
||||
if (u == bpMeasurementUuid.toUpperCase()) measChar = c;
|
||||
if (u == racpUuid.toUpperCase()) racpChar = c;
|
||||
}
|
||||
if (measChar == null) throw Exception('未找到0x2A35');
|
||||
debugPrint('[BLE] ③ 0x2A35: indicate=${measChar.properties.indicate} read=${measChar.properties.read}');
|
||||
debugPrint('[BLE] RACP: ${racpChar != null ? "有" : "无"}');
|
||||
|
||||
// ★ 3. 先订阅数据流(在配置CCCD之前!)
|
||||
debugPrint('[BLE] ④ 订阅lastValueStream...');
|
||||
_measurementSub = measChar.lastValueStream.listen(
|
||||
(raw) {
|
||||
debugPrint('[BLE] ★★★ 收到数据! ${raw.length}字节 ★★★');
|
||||
debugPrint('[BLE] HEX: ${raw.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
|
||||
_parseReading(raw);
|
||||
},
|
||||
onError: (e) => debugPrint('[BLE] 流错误: $e'),
|
||||
cancelOnError: false,
|
||||
);
|
||||
|
||||
// 4. 配置CCCD
|
||||
final isIndicate = measChar.properties.indicate;
|
||||
final cccdVal = isIndicate ? [0x02, 0x00] : [0x01, 0x00];
|
||||
debugPrint('[BLE] ⑤ 配置CCCD → ${isIndicate ? "Indicate" : "Notify"}...');
|
||||
var ok = false;
|
||||
for (final d in measChar.descriptors) {
|
||||
if (d.uuid.str128 == '00002902-0000-1000-8000-00805F9B34FB') {
|
||||
final before = await d.read();
|
||||
await d.write(cccdVal);
|
||||
final after = await d.read();
|
||||
ok = after.length >= 2 && after[0] == cccdVal[0] && after[1] == cccdVal[1];
|
||||
debugPrint('[BLE] ⑤ CCCD: $before → $after ${ok ? "OK" : "FAIL"}');
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ok) {
|
||||
await measChar.setNotifyValue(true, forceIndications: isIndicate);
|
||||
debugPrint('[BLE] ⑤ 降级setNotifyValue, isNotifying=${measChar.isNotifying}');
|
||||
}
|
||||
|
||||
// 5. 主动读取缓存数据(设备存储的最后一次测量)
|
||||
debugPrint('[BLE] ⑥ 主动读取0x2A35...');
|
||||
if (measChar.properties.read) {
|
||||
try {
|
||||
final data = await measChar.read();
|
||||
if (data.isNotEmpty) {
|
||||
debugPrint('[BLE] ⑥ 读到${data.length}字节 HEX=${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
|
||||
_parseReading(data);
|
||||
} else {
|
||||
debugPrint('[BLE] ⑥ 空数据');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[BLE] ⑥ 读取失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 6. RACP请求存储记录
|
||||
if (racpChar != null) {
|
||||
debugPrint('[BLE] ⑦ RACP请求存储记录...');
|
||||
try {
|
||||
await racpChar.write([0x01, 0x01]); // ReportStoredRecords, All
|
||||
debugPrint('[BLE] ⑦ RACP已发送');
|
||||
} catch (e) {
|
||||
debugPrint('[BLE] ⑦ RACP失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('[BLE] ═══ 连接完成, 等待数据 ═══');
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_connStateSub?.cancel();
|
||||
_connStateSub = null;
|
||||
_measurementSub?.cancel();
|
||||
_measurementSub = null;
|
||||
await _device?.disconnect();
|
||||
_device = null;
|
||||
debugPrint('[BLE] 已断开');
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
disconnect();
|
||||
_readingsController.close();
|
||||
_connStateController.close();
|
||||
}
|
||||
|
||||
// ── 解析 BLE 测量数据 ──
|
||||
|
||||
void _parseReading(List<int> raw) {
|
||||
debugPrint('[BLE] 解析: flags=0x${raw[0].toRadixString(16)}, len=${raw.length}');
|
||||
if (raw.length < 7) { debugPrint('[BLE] 太短,跳过'); return; }
|
||||
|
||||
final flags = raw[0];
|
||||
final isKpa = (flags & 0x01) != 0;
|
||||
final hasTs = (flags & 0x02) != 0;
|
||||
final hasPulse = (flags & 0x04) != 0;
|
||||
final hasUid = (flags & 0x08) != 0;
|
||||
final hasStatus = (flags & 0x10) != 0;
|
||||
|
||||
int sys = _decodeSFloat(raw[1] | (raw[2] << 8)).round();
|
||||
int dia = _decodeSFloat(raw[3] | (raw[4] << 8)).round();
|
||||
if (isKpa) { sys = (sys * 7.50062).round(); dia = (dia * 7.50062).round(); }
|
||||
|
||||
int off = 7;
|
||||
DateTime ts = DateTime.now();
|
||||
if (hasTs && raw.length >= off + 7) {
|
||||
ts = DateTime(raw[off] | (raw[off + 1] << 8), raw[off + 2], raw[off + 3],
|
||||
raw[off + 4], raw[off + 5], raw[off + 6]);
|
||||
off += 7;
|
||||
}
|
||||
|
||||
int? pulse;
|
||||
if (hasPulse && raw.length >= off + 2) {
|
||||
pulse = _decodeSFloat(raw[off] | (raw[off + 1] << 8)).round();
|
||||
off += 2;
|
||||
}
|
||||
|
||||
String? uid;
|
||||
if (hasUid && raw.length > off) { uid = raw[off].toString(); off++; }
|
||||
|
||||
bool bm = false, ir = false;
|
||||
if (hasStatus && raw.length >= off + 2) {
|
||||
int s = raw[off] | (raw[off + 1] << 8);
|
||||
bm = (s & 0x0001) != 0;
|
||||
ir = (s & 0x0800) != 0;
|
||||
}
|
||||
|
||||
final r = BpReading(systolic: sys, diastolic: dia, pulse: pulse, timestamp: ts,
|
||||
userId: uid, isKpa: isKpa, hasBodyMovement: bm, hasIrregularPulse: ir);
|
||||
debugPrint('[BLE] ★ $sys/$dia mmHg, pulse=$pulse ★');
|
||||
_readingsController.add(r);
|
||||
}
|
||||
|
||||
static double _decodeSFloat(int raw) {
|
||||
int m = raw & 0x0FFF;
|
||||
if (m & 0x0800 != 0) m |= 0xFFFFF000;
|
||||
int e = (raw >> 12) & 0x0F;
|
||||
if (e & 0x08 != 0) e |= 0xFFFFFFF0;
|
||||
return (m * pow(10, e)).toDouble();
|
||||
}
|
||||
}
|
||||
160
health_app/lib/widgets/ble_sync_dialog.dart
Normal file
160
health_app/lib/widgets/ble_sync_dialog.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../core/app_colors.dart';
|
||||
import '../models/bp_reading.dart';
|
||||
|
||||
Future<void> showBleSyncDialog(
|
||||
BuildContext context, {
|
||||
required BpReading reading,
|
||||
}) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
barrierColor: Colors.black.withValues(alpha: 0.54),
|
||||
builder: (ctx) => Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
backgroundColor: Colors.transparent,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
constraints: const BoxConstraints(maxWidth: 380),
|
||||
padding: const EdgeInsets.fromLTRB(22, 28, 22, 22),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.14),
|
||||
blurRadius: 34,
|
||||
offset: const Offset(0, 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'数据已录入',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MetricCard(
|
||||
label: '收缩压',
|
||||
value: '${reading.systolic}',
|
||||
unit: 'mmHg',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _MetricCard(
|
||||
label: '舒张压',
|
||||
value: '${reading.diastolic}',
|
||||
unit: 'mmHg',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (reading.pulse != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MetricCard(
|
||||
label: '脉搏',
|
||||
value: '${reading.pulse}',
|
||||
unit: 'bpm',
|
||||
),
|
||||
),
|
||||
const Expanded(child: SizedBox.shrink()),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 28),
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(ctx),
|
||||
child: Container(
|
||||
height: 50,
|
||||
width: double.infinity,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.doctorGradient,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
boxShadow: AppColors.buttonShadow,
|
||||
),
|
||||
child: const Text(
|
||||
'知道了',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _MetricCard extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final String unit;
|
||||
|
||||
const _MetricCard({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.unit,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
unit,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -342,6 +342,12 @@ class _NavigationSection extends StatelessWidget {
|
||||
route: 'exercisePlan',
|
||||
colors: const [Color(0xFFA5F3FC), Color(0xFF67E8F9)],
|
||||
),
|
||||
_NavItem(
|
||||
icon: Icons.bluetooth_connected_rounded,
|
||||
title: '蓝牙设备',
|
||||
route: 'devices',
|
||||
colors: const [Color(0xFF60A5FA), Color(0xFFC4B5FD)],
|
||||
),
|
||||
];
|
||||
|
||||
return _Panel(
|
||||
@@ -383,6 +389,7 @@ class _NavTile extends StatelessWidget {
|
||||
'calendar' => '健康日历',
|
||||
'followups' => '复查随访',
|
||||
'exercisePlan' => '运动计划',
|
||||
'devices' => '蓝牙设备',
|
||||
_ => item.title,
|
||||
};
|
||||
|
||||
@@ -394,6 +401,7 @@ class _NavTile extends StatelessWidget {
|
||||
'calendar' => const [Color(0xFFC4B5FD), Color(0xFF8B5CF6)],
|
||||
'followups' => const [Color(0xFFF472B6), Color(0xFFDB2777)],
|
||||
'exercisePlan' => const [Color(0xFF7DD3FC), Color(0xFF60A5FA)],
|
||||
'devices' => const [Color(0xFF60A5FA), Color(0xFFC4B5FD)],
|
||||
_ => item.colors,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user