fix: 移除 BLE 蓝牙依赖,消除 CoreLocation API 引用
- 注释 flutter_blue_plus 和 permission_handler 依赖 - 删除蓝牙设备页、BLE service、omron provider 等 6 个源文件 - 移除路由和设备设置入口 - 清理 2 个 BLE 相关测试文件 - 解决 Transporter 报 NSLocationWhenInUseUsageDescription 缺失 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -70,9 +70,9 @@
|
|||||||
</Testables>
|
</Testables>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Release"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = ""
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||||
launchStyle = "0"
|
launchStyle = "0"
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "NO"
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ import '../pages/profile/profile_page.dart';
|
|||||||
import '../pages/profile/profile_edit_page.dart';
|
import '../pages/profile/profile_edit_page.dart';
|
||||||
import '../pages/diet/diet_capture_page.dart';
|
import '../pages/diet/diet_capture_page.dart';
|
||||||
import '../pages/exercise/exercise_plan_page.dart';
|
import '../pages/exercise/exercise_plan_page.dart';
|
||||||
import '../pages/device/device_scan_page.dart';
|
// iOS 审核版:蓝牙设备页已移除
|
||||||
import '../pages/device/device_management_page.dart';
|
// import '../pages/device/device_scan_page.dart';
|
||||||
|
// import '../pages/device/device_management_page.dart';
|
||||||
import '../pages/remaining_pages.dart';
|
import '../pages/remaining_pages.dart';
|
||||||
import '../pages/doctor/doctor_home_page.dart';
|
import '../pages/doctor/doctor_home_page.dart';
|
||||||
import '../pages/doctor/doctor_patient_detail_page.dart';
|
import '../pages/doctor/doctor_patient_detail_page.dart';
|
||||||
@@ -118,10 +119,9 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
|||||||
return id == null ? _missingParamPage() : DoctorReportDetailPage(id: id);
|
return id == null ? _missingParamPage() : DoctorReportDetailPage(id: id);
|
||||||
case 'doctorFollowUpEdit':
|
case 'doctorFollowUpEdit':
|
||||||
return DoctorFollowUpEditPage(id: params['id']);
|
return DoctorFollowUpEditPage(id: params['id']);
|
||||||
case 'devices':
|
// iOS 审核版:蓝牙设备路由已移除
|
||||||
return const DeviceManagementPage();
|
// case 'devices': return const DeviceManagementPage();
|
||||||
case 'deviceScan':
|
// case 'deviceScan': return const DeviceScanPage();
|
||||||
return const DeviceScanPage();
|
|
||||||
case 'healthArchive':
|
case 'healthArchive':
|
||||||
return const HealthArchivePage();
|
return const HealthArchivePage();
|
||||||
case 'followups':
|
case 'followups':
|
||||||
|
|||||||
@@ -1,187 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,917 +0,0 @@
|
|||||||
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/api_client.dart';
|
|
||||||
import '../../core/app_module_visuals.dart';
|
|
||||||
import '../../core/app_design_tokens.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/health_ble_service.dart';
|
|
||||||
import '../../widgets/ble_sync_dialog.dart';
|
|
||||||
import '../../widgets/app_toast.dart';
|
|
||||||
import '../../widgets/app_empty_state.dart';
|
|
||||||
import 'device_sync_ui_logic.dart';
|
|
||||||
|
|
||||||
const _deviceVisual = AppModuleVisuals.device;
|
|
||||||
|
|
||||||
class DeviceManagementPage extends ConsumerStatefulWidget {
|
|
||||||
const DeviceManagementPage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<DeviceManagementPage> createState() =>
|
|
||||||
_DeviceManagementPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
static const _freshScanWindow = Duration(seconds: 2);
|
|
||||||
static const _sameDeviceRetryWindow = Duration(seconds: 3);
|
|
||||||
|
|
||||||
StreamSubscription<List<ScanResult>>? _scanSub;
|
|
||||||
late final HealthBleService _bleService;
|
|
||||||
late final AnimationController _scanCtrl;
|
|
||||||
late final Animation<double> _scanAnim;
|
|
||||||
final _recentAttempts = <String, DateTime>{};
|
|
||||||
String? _busyDeviceId;
|
|
||||||
String? _connectedDeviceId;
|
|
||||||
DateTime? _scanStartedAt;
|
|
||||||
String _activeScanBoundKey = '';
|
|
||||||
bool _scanning = false;
|
|
||||||
bool _permissionBlocked = false;
|
|
||||||
bool _disposed = false;
|
|
||||||
String? _pageMessage;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_bleService = ref.read(healthBleServiceProvider);
|
|
||||||
_scanCtrl = AnimationController(
|
|
||||||
vsync: this,
|
|
||||||
duration: const Duration(milliseconds: 1400),
|
|
||||||
)..repeat(reverse: true);
|
|
||||||
_scanAnim = Tween(
|
|
||||||
begin: 0.85,
|
|
||||||
end: 1.35,
|
|
||||||
).animate(CurvedAnimation(parent: _scanCtrl, curve: Curves.easeInOut));
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted) unawaited(_startForegroundScan());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_disposed = true;
|
|
||||||
_scanSub?.cancel();
|
|
||||||
unawaited(FlutterBluePlus.stopScan());
|
|
||||||
unawaited(_bleService.disconnect());
|
|
||||||
_scanCtrl.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _startForegroundScan() async {
|
|
||||||
if (!await _ensureBlePermissions()) {
|
|
||||||
if (mounted) setState(() => _scanning = false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_disposed || !mounted) return;
|
|
||||||
|
|
||||||
if (Platform.isAndroid) {
|
|
||||||
final locStatus = await Permission.locationWhenInUse.serviceStatus;
|
|
||||||
if (_disposed || !mounted) return;
|
|
||||||
if (locStatus != ServiceStatus.enabled && mounted) {
|
|
||||||
AppToast.show(
|
|
||||||
context,
|
|
||||||
'请先打开定位服务,否则可能无法发现蓝牙设备',
|
|
||||||
type: AppToastType.warning,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final adapterState = await FlutterBluePlus.adapterState.first;
|
|
||||||
if (_disposed || !mounted) return;
|
|
||||||
if (adapterState != BluetoothAdapterState.on) {
|
|
||||||
try {
|
|
||||||
await FlutterBluePlus.turnOn();
|
|
||||||
} catch (_) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() => _pageMessage = '蓝牙未开启,无法查找已绑定设备');
|
|
||||||
AppToast.show(
|
|
||||||
context,
|
|
||||||
deviceSyncErrorMessage(
|
|
||||||
const DeviceSyncFailure(DeviceSyncFailureStage.bluetooth),
|
|
||||||
),
|
|
||||||
type: AppToastType.warning,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await FlutterBluePlus.stopScan();
|
|
||||||
} catch (_) {}
|
|
||||||
if (_disposed || !mounted) return;
|
|
||||||
final boundIds = ref
|
|
||||||
.read(omronDeviceProvider)
|
|
||||||
.devices
|
|
||||||
.where((device) => HealthBleService.isSyncImplemented(device.type))
|
|
||||||
.map((device) => device.id)
|
|
||||||
.toList();
|
|
||||||
if (boundIds.isEmpty) {
|
|
||||||
_activeScanBoundKey = '';
|
|
||||||
if (mounted) setState(() => _scanning = false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_activeScanBoundKey = _boundIdsKey(boundIds);
|
|
||||||
_scanStartedAt = DateTime.now();
|
|
||||||
await _scanSub?.cancel();
|
|
||||||
_scanSub = FlutterBluePlus.onScanResults.listen(_onScanResults);
|
|
||||||
try {
|
|
||||||
await FlutterBluePlus.startScan(
|
|
||||||
withRemoteIds: boundIds,
|
|
||||||
androidScanMode: AndroidScanMode.lowLatency,
|
|
||||||
oneByOne: true,
|
|
||||||
);
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_scanning = true;
|
|
||||||
_pageMessage = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_scanning = false;
|
|
||||||
_pageMessage = '设备查找失败,请检查蓝牙状态后重试';
|
|
||||||
});
|
|
||||||
AppToast.show(context, _pageMessage!, type: AppToastType.error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> _ensureBlePermissions() async {
|
|
||||||
Map<Permission, PermissionStatus> statuses;
|
|
||||||
try {
|
|
||||||
statuses = await [
|
|
||||||
Permission.bluetoothScan,
|
|
||||||
Permission.bluetoothConnect,
|
|
||||||
].request();
|
|
||||||
} catch (_) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() => _pageMessage = '无法读取蓝牙权限状态');
|
|
||||||
AppToast.show(context, _pageMessage!, type: AppToastType.error);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
final granted = statuses.values.every((status) => status.isGranted);
|
|
||||||
if (granted) {
|
|
||||||
_permissionBlocked = false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
_permissionBlocked = true;
|
|
||||||
if (mounted) setState(() => _pageMessage = '需要蓝牙权限才能同步设备');
|
|
||||||
if (_disposed || !mounted) return false;
|
|
||||||
AppToast.show(context, '请开启蓝牙权限后再同步设备', type: AppToastType.warning);
|
|
||||||
await showDialog<void>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
title: const Text('需要蓝牙权限'),
|
|
||||||
content: const Text('同步已绑定设备需要蓝牙权限,请在系统设置中开启后重试。'),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: const Text('稍后再说'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(ctx);
|
|
||||||
openAppSettings();
|
|
||||||
},
|
|
||||||
child: const Text('去设置'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onScanResults(List<ScanResult> results) {
|
|
||||||
if (!mounted || _busyDeviceId != null) return;
|
|
||||||
final scanStartedAt = _scanStartedAt;
|
|
||||||
if (scanStartedAt == null) return;
|
|
||||||
final boundDevices = ref.read(omronDeviceProvider).devices;
|
|
||||||
if (boundDevices.isEmpty) return;
|
|
||||||
|
|
||||||
final candidates = results.where((result) {
|
|
||||||
final id = result.device.remoteId.toString();
|
|
||||||
if (result.timeStamp.isBefore(scanStartedAt)) return false;
|
|
||||||
if (!result.advertisementData.connectable) return false;
|
|
||||||
if (DateTime.now().difference(result.timeStamp) > _freshScanWindow) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
final boundDevice = ref.read(omronDeviceProvider).findById(id);
|
|
||||||
if (boundDevice == null) return false;
|
|
||||||
if (!_isSameBoundDeviceName(result, boundDevice)) return false;
|
|
||||||
final scanType = HealthBleService.deviceTypeFromScan(result);
|
|
||||||
if (scanType != null && scanType != boundDevice.type) return false;
|
|
||||||
final lastAttempt = _recentAttempts[id];
|
|
||||||
if (lastAttempt == null) return true;
|
|
||||||
return DateTime.now().difference(lastAttempt) > _sameDeviceRetryWindow;
|
|
||||||
}).toList()..sort((a, b) => b.rssi.compareTo(a.rssi));
|
|
||||||
|
|
||||||
if (candidates.isEmpty) return;
|
|
||||||
final result = candidates.first;
|
|
||||||
final bound = ref
|
|
||||||
.read(omronDeviceProvider)
|
|
||||||
.findById(result.device.remoteId.toString());
|
|
||||||
if (bound == null) return;
|
|
||||||
unawaited(_syncDevice(result, bound));
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _isSameBoundDeviceName(ScanResult result, BoundBleDevice bound) {
|
|
||||||
final scannedName = _scanDeviceName(result);
|
|
||||||
if (scannedName.isEmpty) return false;
|
|
||||||
return _normalizeDeviceName(scannedName) ==
|
|
||||||
_normalizeDeviceName(bound.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _scanDeviceName(ScanResult result) {
|
|
||||||
final advName = result.advertisementData.advName.trim();
|
|
||||||
if (advName.isNotEmpty) return advName;
|
|
||||||
return result.device.platformName.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
String _normalizeDeviceName(String value) {
|
|
||||||
return value.trim().toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
String _boundIdsKey(List<String> ids) {
|
|
||||||
final sorted = [...ids]..sort();
|
|
||||||
return sorted.join('|');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _syncDevice(ScanResult result, BoundBleDevice bound) async {
|
|
||||||
if (_disposed || !mounted || _busyDeviceId != null) return;
|
|
||||||
_recentAttempts[bound.id] = DateTime.now();
|
|
||||||
setState(() {
|
|
||||||
_busyDeviceId = bound.id;
|
|
||||||
_connectedDeviceId = null;
|
|
||||||
_pageMessage = '正在连接 ${bound.name}';
|
|
||||||
});
|
|
||||||
|
|
||||||
await FlutterBluePlus.stopScan();
|
|
||||||
if (_disposed || !mounted) return;
|
|
||||||
setState(() => _scanning = false);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final syncResult = await _bleService.syncBoundDevice(
|
|
||||||
result.device,
|
|
||||||
bound,
|
|
||||||
timeout: const Duration(seconds: 35),
|
|
||||||
onConnected: () {
|
|
||||||
if (!_disposed && mounted) {
|
|
||||||
setState(() {
|
|
||||||
_connectedDeviceId = bound.id;
|
|
||||||
_pageMessage = '已连接,等待设备测量数据';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onMeasurementReceived: () {
|
|
||||||
// Measurement has arrived; parsing and persistence happen below.
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (syncResult is BloodPressureSyncResult) {
|
|
||||||
if (_disposed || !mounted) return;
|
|
||||||
setState(() => _pageMessage = '已读取数据,正在安全上传');
|
|
||||||
final saved = await _persistBloodPressure(
|
|
||||||
syncResult.device,
|
|
||||||
syncResult.reading,
|
|
||||||
);
|
|
||||||
if (!_disposed && mounted && saved) {
|
|
||||||
setState(() => _pageMessage = '同步完成');
|
|
||||||
await _showBloodPressureDialog(syncResult.reading);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} on TimeoutException {
|
|
||||||
if (!_disposed && mounted) {
|
|
||||||
final message = _connectedDeviceId == bound.id
|
|
||||||
? deviceSyncErrorMessage(
|
|
||||||
const DeviceSyncFailure(DeviceSyncFailureStage.measurement),
|
|
||||||
)
|
|
||||||
: deviceSyncErrorMessage(
|
|
||||||
const DeviceSyncFailure(DeviceSyncFailureStage.connection),
|
|
||||||
);
|
|
||||||
setState(() => _pageMessage = message);
|
|
||||||
AppToast.show(context, message, type: AppToastType.warning);
|
|
||||||
}
|
|
||||||
} on UnsupportedBleDeviceException catch (error) {
|
|
||||||
if (!_disposed && mounted) {
|
|
||||||
setState(() => _pageMessage = error.message);
|
|
||||||
AppToast.show(context, error.message, type: AppToastType.warning);
|
|
||||||
}
|
|
||||||
} on ApiException catch (error) {
|
|
||||||
if (!_disposed && mounted) {
|
|
||||||
final message = deviceSyncErrorMessage(
|
|
||||||
DeviceSyncFailure(DeviceSyncFailureStage.upload, error.message),
|
|
||||||
);
|
|
||||||
setState(() => _pageMessage = message);
|
|
||||||
AppToast.show(context, message, type: AppToastType.error);
|
|
||||||
}
|
|
||||||
} on Exception catch (error) {
|
|
||||||
if (!_disposed && mounted) {
|
|
||||||
final message = deviceSyncErrorMessage(
|
|
||||||
DeviceSyncFailure(DeviceSyncFailureStage.connection, '$error'),
|
|
||||||
);
|
|
||||||
setState(() => _pageMessage = message);
|
|
||||||
AppToast.show(context, message, type: AppToastType.error);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await _bleService.disconnect();
|
|
||||||
if (!_disposed && mounted) {
|
|
||||||
setState(() {
|
|
||||||
_busyDeviceId = null;
|
|
||||||
_connectedDeviceId = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!_disposed && mounted) unawaited(_startForegroundScan());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
final records = <Map<String, dynamic>>[reading.toHealthRecord()];
|
|
||||||
final heartRateRecord = reading.toHeartRateRecord();
|
|
||||||
if (heartRateRecord != null) {
|
|
||||||
records.add(heartRateRecord);
|
|
||||||
}
|
|
||||||
await api.post('/api/health-records/batch', data: records);
|
|
||||||
await notifier.recordSuccessfulSync(device: device, reading: reading);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _showBloodPressureDialog(BpReading reading) {
|
|
||||||
return showBleSyncDialog(context, reading: reading);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final state = ref.watch(omronDeviceProvider);
|
|
||||||
final devices = state.devices;
|
|
||||||
final syncableDevices = devices
|
|
||||||
.where((device) => HealthBleService.isSyncImplemented(device.type))
|
|
||||||
.toList();
|
|
||||||
final boundKey = _boundIdsKey(
|
|
||||||
syncableDevices.map((device) => device.id).toList(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (syncableDevices.isNotEmpty &&
|
|
||||||
!_scanning &&
|
|
||||||
_busyDeviceId == null &&
|
|
||||||
!_permissionBlocked &&
|
|
||||||
_activeScanBoundKey != boundKey) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (!_disposed && mounted) unawaited(_startForegroundScan());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.listen<DeviceBindState>(omronDeviceProvider, (prev, next) {
|
|
||||||
final nextIds = next.devices
|
|
||||||
.where((device) => HealthBleService.isSyncImplemented(device.type))
|
|
||||||
.map((device) => device.id)
|
|
||||||
.toList();
|
|
||||||
final nextKey = _boundIdsKey(nextIds);
|
|
||||||
if (nextIds.isEmpty) {
|
|
||||||
_activeScanBoundKey = '';
|
|
||||||
unawaited(FlutterBluePlus.stopScan());
|
|
||||||
if (!_disposed && mounted) setState(() => _scanning = false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (nextKey != _activeScanBoundKey && _busyDeviceId == null) {
|
|
||||||
if (_permissionBlocked) return;
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (!_disposed && mounted) unawaited(_startForegroundScan());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return GradientScaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('蓝牙设备'),
|
|
||||||
leading: IconButton(
|
|
||||||
icon: const Icon(Icons.arrow_back),
|
|
||||||
onPressed: () => popRoute(ref),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 10),
|
|
||||||
child: IconButton(
|
|
||||||
tooltip: '新增设备',
|
|
||||||
onPressed: () => pushRoute(ref, 'deviceScan'),
|
|
||||||
style: IconButton.styleFrom(
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
foregroundColor: AppColors.device,
|
|
||||||
fixedSize: const Size(40, 40),
|
|
||||||
side: const BorderSide(color: AppColors.deviceBorder),
|
|
||||||
shape: const CircleBorder(),
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.add_rounded),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: Stack(
|
|
||||||
children: [
|
|
||||||
ListView(
|
|
||||||
padding: const EdgeInsets.fromLTRB(18, 10, 18, 120),
|
|
||||||
children: [
|
|
||||||
_DevicesHeader(deviceCount: devices.length),
|
|
||||||
if (devices.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_SyncStatusBar(
|
|
||||||
message:
|
|
||||||
_pageMessage ??
|
|
||||||
(syncableDevices.isEmpty
|
|
||||||
? '当前设备已绑定,暂不支持自动同步'
|
|
||||||
: _scanning
|
|
||||||
? '正在等待已绑定设备的测量数据'
|
|
||||||
: '设备同步已暂停'),
|
|
||||||
active: _scanning || _busyDeviceId != null,
|
|
||||||
onRetry: _busyDeviceId == null ? _startForegroundScan : null,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
if (devices.isEmpty)
|
|
||||||
const _EmptyDevices()
|
|
||||||
else
|
|
||||||
for (final type in BleDeviceType.values)
|
|
||||||
if (state.devicesOfType(type).isNotEmpty) ...[
|
|
||||||
_DeviceSection(
|
|
||||||
type: type,
|
|
||||||
devices: state.devicesOfType(type),
|
|
||||||
connectedDeviceId: _connectedDeviceId,
|
|
||||||
busyDeviceId: _busyDeviceId,
|
|
||||||
onUnbind: _confirmUnbind,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (devices.isNotEmpty)
|
|
||||||
Positioned(
|
|
||||||
right: 18,
|
|
||||||
bottom: 20,
|
|
||||||
child: _ScanIndicator(
|
|
||||||
animation: _scanAnim,
|
|
||||||
scanning: _scanning,
|
|
||||||
syncing: _connectedDeviceId != null || _busyDeviceId != null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _confirmUnbind(BoundBleDevice device) async {
|
|
||||||
final ok = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: AppRadius.xlBorder),
|
|
||||||
title: const Text('解绑设备'),
|
|
||||||
content: Text('确定解绑这台${device.type.label}吗?'),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
|
||||||
child: const Text('取消'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx, true),
|
|
||||||
style: TextButton.styleFrom(foregroundColor: AppColors.errorText),
|
|
||||||
child: const Text('解绑'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (ok == true) {
|
|
||||||
await ref.read(omronDeviceProvider.notifier).unbind(device.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ScanIndicator extends StatelessWidget {
|
|
||||||
final Animation<double> animation;
|
|
||||||
final bool scanning;
|
|
||||||
final bool syncing;
|
|
||||||
|
|
||||||
const _ScanIndicator({
|
|
||||||
required this.animation,
|
|
||||||
required this.scanning,
|
|
||||||
required this.syncing,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final active = scanning || syncing;
|
|
||||||
return SizedBox(
|
|
||||||
width: 88,
|
|
||||||
height: 88,
|
|
||||||
child: Stack(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
if (active) ...[
|
|
||||||
AnimatedBuilder(
|
|
||||||
animation: animation,
|
|
||||||
builder: (context, child) => Container(
|
|
||||||
width: 78 * animation.value,
|
|
||||||
height: 78 * animation.value,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: AppColors.device.withValues(alpha: 0.10),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
AnimatedBuilder(
|
|
||||||
animation: animation,
|
|
||||||
builder: (context, child) => Container(
|
|
||||||
width: 64 * animation.value,
|
|
||||||
height: 64 * animation.value,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: AppColors.device.withValues(alpha: 0.18),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
Container(
|
|
||||||
width: 58,
|
|
||||||
height: 58,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.device,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: AppColors.device.withValues(alpha: 0.22),
|
|
||||||
blurRadius: 16,
|
|
||||||
offset: const Offset(0, 7),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
syncing
|
|
||||||
? Icons.bluetooth_connected_rounded
|
|
||||||
: Icons.bluetooth_searching_rounded,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 28,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DevicesHeader extends StatelessWidget {
|
|
||||||
final int deviceCount;
|
|
||||||
|
|
||||||
const _DevicesHeader({required this.deviceCount});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 18),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: AppRadius.lgBorder,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.device,
|
|
||||||
borderRadius: AppRadius.smBorder,
|
|
||||||
),
|
|
||||||
child: Icon(_deviceVisual.icon, color: Colors.white, size: 27),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'已绑定设备',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 19,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'$deviceCount 台设备',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DeviceSection extends StatelessWidget {
|
|
||||||
final BleDeviceType type;
|
|
||||||
final List<BoundBleDevice> devices;
|
|
||||||
final String? connectedDeviceId;
|
|
||||||
final String? busyDeviceId;
|
|
||||||
final ValueChanged<BoundBleDevice> onUnbind;
|
|
||||||
|
|
||||||
const _DeviceSection({
|
|
||||||
required this.type,
|
|
||||||
required this.devices,
|
|
||||||
required this.connectedDeviceId,
|
|
||||||
required this.busyDeviceId,
|
|
||||||
required this.onUnbind,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ClipRRect(
|
|
||||||
borderRadius: AppRadius.lgBorder,
|
|
||||||
child: ColoredBox(
|
|
||||||
color: Colors.white,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(14, 13, 14, 10),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 34,
|
|
||||||
height: 34,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.device,
|
|
||||||
borderRadius: AppRadius.smBorder,
|
|
||||||
),
|
|
||||||
child: Icon(type.icon, size: 19, color: Colors.white),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Text(
|
|
||||||
type.label,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Text(
|
|
||||||
'${devices.length} 台',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: AppColors.textHint,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
for (var index = 0; index < devices.length; index++)
|
|
||||||
_BoundDeviceTile(
|
|
||||||
device: devices[index],
|
|
||||||
connected: connectedDeviceId == devices[index].id,
|
|
||||||
busy: busyDeviceId == devices[index].id,
|
|
||||||
showDivider: index < devices.length - 1,
|
|
||||||
onUnbind: () => onUnbind(devices[index]),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _BoundDeviceTile extends StatelessWidget {
|
|
||||||
final BoundBleDevice device;
|
|
||||||
final bool connected;
|
|
||||||
final bool busy;
|
|
||||||
final bool showDivider;
|
|
||||||
final VoidCallback onUnbind;
|
|
||||||
|
|
||||||
const _BoundDeviceTile({
|
|
||||||
required this.device,
|
|
||||||
required this.connected,
|
|
||||||
required this.busy,
|
|
||||||
required this.showDivider,
|
|
||||||
required this.onUnbind,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final availability = deviceSyncAvailabilityLabel(device.type);
|
|
||||||
return AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 180),
|
|
||||||
padding: const EdgeInsets.only(left: 14),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: connected
|
|
||||||
? AppColors.successLight.withValues(alpha: 0.45)
|
|
||||||
: Colors.white,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 180),
|
|
||||||
width: 42,
|
|
||||||
height: 42,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.device,
|
|
||||||
borderRadius: AppRadius.smBorder,
|
|
||||||
),
|
|
||||||
child: Icon(device.type.icon, color: Colors.white, size: 22),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Container(
|
|
||||||
constraints: const BoxConstraints(minHeight: 70),
|
|
||||||
padding: const EdgeInsets.fromLTRB(0, 12, 6, 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: showDivider
|
|
||||||
? const Border(
|
|
||||||
bottom: BorderSide(
|
|
||||||
color: AppColors.divider,
|
|
||||||
width: 0.7,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
device.name,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 17,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
availability ??
|
|
||||||
(busy
|
|
||||||
? connected
|
|
||||||
? '已连接,正在读取数据'
|
|
||||||
: '正在连接设备'
|
|
||||||
: '最近同步:${_formatLastSync(device.lastSyncAt)}'),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: availability == null
|
|
||||||
? AppColors.textHint
|
|
||||||
: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (connected)
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.only(right: 2),
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 9,
|
|
||||||
vertical: 5,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.successLight,
|
|
||||||
borderRadius: AppRadius.pillBorder,
|
|
||||||
),
|
|
||||||
child: const Text(
|
|
||||||
'已连接',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
height: 1,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.successText,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: '解绑设备',
|
|
||||||
onPressed: busy ? null : onUnbind,
|
|
||||||
icon: const Icon(Icons.delete_outline_rounded, size: 21),
|
|
||||||
color: AppColors.textHint,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatLastSync(DateTime? value) {
|
|
||||||
if (value == null) return '暂无';
|
|
||||||
final now = DateTime.now();
|
|
||||||
final dayLabel =
|
|
||||||
value.year == now.year &&
|
|
||||||
value.month == now.month &&
|
|
||||||
value.day == now.day
|
|
||||||
? '今天'
|
|
||||||
: '${value.month}-${value.day}';
|
|
||||||
final minute = value.minute.toString().padLeft(2, '0');
|
|
||||||
return '$dayLabel ${value.hour}:$minute';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EmptyDevices extends StatelessWidget {
|
|
||||||
const _EmptyDevices();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: AppRadius.lgBorder,
|
|
||||||
),
|
|
||||||
child: AppEmptyState(
|
|
||||||
icon: _deviceVisual.icon,
|
|
||||||
iconColor: _deviceVisual.color,
|
|
||||||
title: '还没有绑定设备',
|
|
||||||
subtitle: '点击右上角添加设备',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SyncStatusBar extends StatelessWidget {
|
|
||||||
final String message;
|
|
||||||
final bool active;
|
|
||||||
final Future<void> Function()? onRetry;
|
|
||||||
|
|
||||||
const _SyncStatusBar({
|
|
||||||
required this.message,
|
|
||||||
required this.active,
|
|
||||||
required this.onRetry,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => Container(
|
|
||||||
padding: const EdgeInsets.fromLTRB(14, 11, 8, 11),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: active ? AppColors.deviceLight : Colors.white,
|
|
||||||
borderRadius: AppRadius.mdBorder,
|
|
||||||
border: Border.all(color: AppColors.borderLight),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
active ? Icons.sync_rounded : Icons.info_outline_rounded,
|
|
||||||
size: 20,
|
|
||||||
color: active ? AppColors.device : AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
message,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!active && onRetry != null)
|
|
||||||
TextButton(onPressed: onRetry, child: const Text('重试')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,581 +0,0 @@
|
|||||||
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_design_tokens.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/health_ble_service.dart';
|
|
||||||
import '../../widgets/app_toast.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>[];
|
|
||||||
StreamSubscription<List<ScanResult>>? _scanSub;
|
|
||||||
Timer? _scanStopTimer;
|
|
||||||
Timer? _resultPruneTimer;
|
|
||||||
String? _connectingId;
|
|
||||||
DateTime? _scanStartedAt;
|
|
||||||
bool _scanning = false;
|
|
||||||
|
|
||||||
late final AnimationController _pulseCtrl;
|
|
||||||
late final Animation<double> _pulseAnim;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_pulseCtrl = AnimationController(
|
|
||||||
vsync: this,
|
|
||||||
duration: const Duration(milliseconds: 1200),
|
|
||||||
)..repeat(reverse: true);
|
|
||||||
_pulseAnim = Tween(
|
|
||||||
begin: 0.82,
|
|
||||||
end: 1.36,
|
|
||||||
).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
|
|
||||||
|
|
||||||
unawaited(_startScan());
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_pulseCtrl.dispose();
|
|
||||||
_scanStopTimer?.cancel();
|
|
||||||
_resultPruneTimer?.cancel();
|
|
||||||
_scanSub?.cancel();
|
|
||||||
unawaited(FlutterBluePlus.stopScan());
|
|
||||||
unawaited(ref.read(healthBleServiceProvider).disconnect());
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _startScan() async {
|
|
||||||
setState(() {
|
|
||||||
_scanning = true;
|
|
||||||
_connectingId = null;
|
|
||||||
_results.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!await _ensureBlePermissions()) {
|
|
||||||
if (mounted) setState(() => _scanning = false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Platform.isAndroid) {
|
|
||||||
final locStatus = await Permission.locationWhenInUse.serviceStatus;
|
|
||||||
if (locStatus != ServiceStatus.enabled && mounted) {
|
|
||||||
AppToast.show(
|
|
||||||
context,
|
|
||||||
'请先打开定位服务,否则可能无法发现蓝牙设备',
|
|
||||||
type: AppToastType.warning,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final adapterState = await FlutterBluePlus.adapterState.first;
|
|
||||||
if (adapterState != BluetoothAdapterState.on) {
|
|
||||||
try {
|
|
||||||
await FlutterBluePlus.turnOn();
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
_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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
AppToast.show(context, '设备已离线,请重新进入通信状态', type: AppToastType.warning);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ref.read(omronDeviceProvider).isBound &&
|
|
||||||
ref.read(omronDeviceProvider).findById(remoteId) != null) {
|
|
||||||
AppToast.show(context, '该设备已绑定', type: AppToastType.info);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() => _connectingId = remoteId);
|
|
||||||
await FlutterBluePlus.stopScan();
|
|
||||||
|
|
||||||
final ble = ref.read(healthBleServiceProvider);
|
|
||||||
BoundBleDevice? boundDevice;
|
|
||||||
try {
|
|
||||||
boundDevice = await ble
|
|
||||||
.connectAndIdentify(
|
|
||||||
device: result.device,
|
|
||||||
name: _deviceNameOf(result),
|
|
||||||
)
|
|
||||||
.timeout(const Duration(seconds: 15));
|
|
||||||
if (!HealthBleService.isSyncImplemented(boundDevice.type)) {
|
|
||||||
if (mounted) {
|
|
||||||
AppToast.show(
|
|
||||||
context,
|
|
||||||
'${boundDevice.type.label}数据同步暂未开通,暂不绑定',
|
|
||||||
type: AppToastType.info,
|
|
||||||
);
|
|
||||||
unawaited(_startScan());
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await ref.read(omronDeviceProvider.notifier).bind(boundDevice);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted) popRoute(ref);
|
|
||||||
} on TimeoutException {
|
|
||||||
if (mounted && boundDevice != null) {
|
|
||||||
AppToast.show(
|
|
||||||
context,
|
|
||||||
'${boundDevice.type.label}已绑定,但本次未收到数据',
|
|
||||||
type: AppToastType.warning,
|
|
||||||
);
|
|
||||||
popRoute(ref);
|
|
||||||
} else if (mounted) {
|
|
||||||
AppToast.show(context, '连接超时,请确认设备仍处于通信状态', type: AppToastType.error);
|
|
||||||
unawaited(_startScan());
|
|
||||||
}
|
|
||||||
} on UnsupportedBleDeviceException catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
AppToast.show(context, e.message, type: AppToastType.error);
|
|
||||||
unawaited(_startScan());
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
AppToast.show(context, '连接失败:$e', type: AppToastType.error);
|
|
||||||
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);
|
|
||||||
final records = <Map<String, dynamic>>[reading.toHealthRecord()];
|
|
||||||
final heartRateRecord = reading.toHeartRateRecord();
|
|
||||||
if (heartRateRecord != null) {
|
|
||||||
records.add(heartRateRecord);
|
|
||||||
}
|
|
||||||
await api.post('/api/health-records/batch', data: records);
|
|
||||||
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(
|
|
||||||
title: const Text(
|
|
||||||
'新增设备',
|
|
||||||
style: TextStyle(color: AppColors.textPrimary),
|
|
||||||
),
|
|
||||||
leading: IconButton(
|
|
||||||
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
|
|
||||||
onPressed: () => popRoute(ref),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: _results.isEmpty
|
|
||||||
? _ScanningEmpty(animation: _pulseAnim, scanning: _scanning)
|
|
||||||
: ListView(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
|
|
||||||
children: [
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: AppRadius.lgBorder,
|
|
||||||
child: ColoredBox(
|
|
||||||
color: Colors.white,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
for (var index = 0; index < _results.length; index++)
|
|
||||||
_DeviceResultTile(
|
|
||||||
result: _results[index],
|
|
||||||
connecting:
|
|
||||||
_connectingId ==
|
|
||||||
_results[index].device.remoteId.toString(),
|
|
||||||
showDivider: index < _results.length - 1,
|
|
||||||
onConnect: () =>
|
|
||||||
_connectBindAndSync(_results[index]),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ?? '健康设备';
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> _ensureBlePermissions() async {
|
|
||||||
final statuses = await [
|
|
||||||
Permission.bluetoothScan,
|
|
||||||
Permission.bluetoothConnect,
|
|
||||||
].request();
|
|
||||||
final granted = statuses.values.every((status) => status.isGranted);
|
|
||||||
if (granted) return true;
|
|
||||||
if (!mounted) return false;
|
|
||||||
AppToast.show(context, '请开启蓝牙权限后再搜索设备', type: AppToastType.warning);
|
|
||||||
await showDialog<void>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
title: const Text('需要蓝牙权限'),
|
|
||||||
content: const Text('搜索和连接健康设备需要蓝牙权限,请在系统设置中开启后重试。'),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: const Text('稍后再说'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(ctx);
|
|
||||||
openAppSettings();
|
|
||||||
},
|
|
||||||
child: const Text('去设置'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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: [
|
|
||||||
_ScanPulse(animation: animation),
|
|
||||||
const SizedBox(height: 22),
|
|
||||||
Text(
|
|
||||||
scanning ? '正在搜索设备' : '暂未发现设备',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
const Text(
|
|
||||||
'请让设备进入通信状态并靠近手机。',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
height: 1.45,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DeviceResultTile extends StatelessWidget {
|
|
||||||
final ScanResult result;
|
|
||||||
final bool connecting;
|
|
||||||
final bool showDivider;
|
|
||||||
final VoidCallback onConnect;
|
|
||||||
|
|
||||||
const _DeviceResultTile({
|
|
||||||
required this.result,
|
|
||||||
required this.connecting,
|
|
||||||
required this.showDivider,
|
|
||||||
required this.onConnect,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final type = HealthBleService.deviceTypeFromScan(result);
|
|
||||||
final name = _deviceNameOf(result, type);
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 14),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 42,
|
|
||||||
height: 42,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.device,
|
|
||||||
borderRadius: AppRadius.smBorder,
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
type?.icon ?? Icons.bluetooth_rounded,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 25,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Container(
|
|
||||||
constraints: const BoxConstraints(minHeight: 70),
|
|
||||||
padding: const EdgeInsets.fromLTRB(0, 12, 12, 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: showDivider
|
|
||||||
? const Border(
|
|
||||||
bottom: BorderSide(
|
|
||||||
color: AppColors.divider,
|
|
||||||
width: 0.7,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
type?.label ?? '健康设备',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 17,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
name,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: AppColors.textHint,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
SizedBox(
|
|
||||||
height: 36,
|
|
||||||
child: OutlinedButton(
|
|
||||||
onPressed: connecting ? null : onConnect,
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
foregroundColor: AppColors.device,
|
|
||||||
disabledForegroundColor: AppColors.textHint,
|
|
||||||
side: BorderSide(
|
|
||||||
color: connecting
|
|
||||||
? AppColors.borderLight
|
|
||||||
: AppColors.device,
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 13),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: AppRadius.smBorder,
|
|
||||||
),
|
|
||||||
textStyle: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Text(connecting ? '连接中' : '连接'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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.device.withValues(alpha: 0.08),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
width: 64,
|
|
||||||
height: 64,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.device,
|
|
||||||
borderRadius: AppRadius.xlBorder,
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.bluetooth_searching_rounded,
|
|
||||||
size: 32,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import '../../models/ble_device.dart';
|
|
||||||
|
|
||||||
enum DeviceSyncFailureStage {
|
|
||||||
permission,
|
|
||||||
bluetooth,
|
|
||||||
connection,
|
|
||||||
measurement,
|
|
||||||
upload,
|
|
||||||
}
|
|
||||||
|
|
||||||
class DeviceSyncFailure implements Exception {
|
|
||||||
final DeviceSyncFailureStage stage;
|
|
||||||
final String? detail;
|
|
||||||
|
|
||||||
const DeviceSyncFailure(this.stage, [this.detail]);
|
|
||||||
}
|
|
||||||
|
|
||||||
String? deviceSyncAvailabilityLabel(BleDeviceType type) {
|
|
||||||
return type == BleDeviceType.bloodPressure ? null : '暂不支持自动同步';
|
|
||||||
}
|
|
||||||
|
|
||||||
String deviceSyncErrorMessage(DeviceSyncFailure failure) {
|
|
||||||
final detail = failure.detail?.trim();
|
|
||||||
return switch (failure.stage) {
|
|
||||||
DeviceSyncFailureStage.permission => '请开启蓝牙权限后再同步设备',
|
|
||||||
DeviceSyncFailureStage.bluetooth => '蓝牙未开启,请开启后重试',
|
|
||||||
DeviceSyncFailureStage.connection =>
|
|
||||||
detail?.isNotEmpty == true ? '设备连接失败:$detail' : '设备连接失败,请靠近设备后重试',
|
|
||||||
DeviceSyncFailureStage.measurement => '已连接设备,但本次未收到测量数据',
|
|
||||||
DeviceSyncFailureStage.upload =>
|
|
||||||
detail?.isNotEmpty == true ? '数据上传失败:$detail' : '数据已读取,但上传失败,请检查网络后重试',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,8 @@ import '../../core/app_design_tokens.dart';
|
|||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../providers/omron_device_provider.dart';
|
// iOS 审核版:蓝牙相关已移除
|
||||||
|
// import '../../providers/omron_device_provider.dart';
|
||||||
|
|
||||||
class SettingsPage extends ConsumerWidget {
|
class SettingsPage extends ConsumerWidget {
|
||||||
const SettingsPage({super.key});
|
const SettingsPage({super.key});
|
||||||
@@ -51,11 +52,7 @@ class SettingsPage extends ConsumerWidget {
|
|||||||
onTap: () => pushRoute(ref, 'elderMode'),
|
onTap: () => pushRoute(ref, 'elderMode'),
|
||||||
),
|
),
|
||||||
if (!Platform.isIOS)
|
if (!Platform.isIOS)
|
||||||
_SettingsTile(
|
// iOS 审核版:蓝牙设备入口已移除
|
||||||
icon: LucideIcons.bluetooth,
|
|
||||||
title: '蓝牙设备',
|
|
||||||
onTap: () => pushRoute(ref, 'devices'),
|
|
||||||
),
|
|
||||||
_SettingsTile(
|
_SettingsTile(
|
||||||
icon: LucideIcons.bell,
|
icon: LucideIcons.bell,
|
||||||
title: '消息通知',
|
title: '消息通知',
|
||||||
@@ -180,7 +177,7 @@ class SettingsPage extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
if (ok == true) {
|
if (ok == true) {
|
||||||
await ref.read(apiClientProvider).delete('/api/user/account');
|
await ref.read(apiClientProvider).delete('/api/user/account');
|
||||||
await ref.read(omronDeviceProvider.notifier).clearCurrentAccountData();
|
// iOS 审核版:omronDeviceProvider 已移除
|
||||||
await ref.read(authProvider.notifier).logout();
|
await ref.read(authProvider.notifier).logout();
|
||||||
goRoute(ref, 'login');
|
goRoute(ref, 'login');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,275 +0,0 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
class DeviceBindState {
|
|
||||||
final List<BoundBleDevice> devices;
|
|
||||||
final bool isConnected;
|
|
||||||
|
|
||||||
const DeviceBindState({this.devices = const [], this.isConnected = false});
|
|
||||||
|
|
||||||
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<bool>? _connSub;
|
|
||||||
late String? _sessionIdentity;
|
|
||||||
|
|
||||||
@override
|
|
||||||
DeviceBindState build() {
|
|
||||||
_sessionIdentity = ref.watch(userSessionIdentityProvider);
|
|
||||||
ref.onDispose(() => _connSub?.cancel());
|
|
||||||
if (_sessionIdentity != null) _loadBinding(_sessionIdentity!);
|
|
||||||
_listenConnection();
|
|
||||||
return const DeviceBindState();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _listenConnection() {
|
|
||||||
_connSub?.cancel();
|
|
||||||
_connSub = ref.read(healthBleServiceProvider).connectionState.listen((
|
|
||||||
connected,
|
|
||||||
) {
|
|
||||||
if (state.isConnected != connected) {
|
|
||||||
state = state.copyWith(isConnected: connected);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
String _accountKey(String baseKey, [String? session]) =>
|
|
||||||
'$baseKey:${session ?? _sessionIdentity}';
|
|
||||||
|
|
||||||
Future<void> _loadBinding(String session) async {
|
|
||||||
final db = ref.read(localDbProvider);
|
|
||||||
await _migrateLegacyBloodPressureDevice(session);
|
|
||||||
final raw = await db.read(_accountKey(_boundDevicesKey, session));
|
|
||||||
final devices = _decodeDevices(raw);
|
|
||||||
if (_sessionIdentity != session) return;
|
|
||||||
state = state.copyWith(devices: devices);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _migrateLegacyBloodPressureDevice(String session) async {
|
|
||||||
final db = ref.read(localDbProvider);
|
|
||||||
final accountKey = _accountKey(_boundDevicesKey, session);
|
|
||||||
final existing = await db.read(accountKey);
|
|
||||||
final oldSharedDevices = await db.read(_boundDevicesKey);
|
|
||||||
if (existing == null && oldSharedDevices != null) {
|
|
||||||
await db.write(accountKey, oldSharedDevices);
|
|
||||||
await db.delete(_boundDevicesKey);
|
|
||||||
final oldFingerprints = await db.read(_readingFingerprintsKey);
|
|
||||||
if (oldFingerprints != null) {
|
|
||||||
await db.write(
|
|
||||||
_accountKey(_readingFingerprintsKey, session),
|
|
||||||
oldFingerprints,
|
|
||||||
);
|
|
||||||
await db.delete(_readingFingerprintsKey);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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(accountKey, 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> _saveDevices(List<BoundBleDevice> devices) async {
|
|
||||||
final db = ref.read(localDbProvider);
|
|
||||||
await db.write(
|
|
||||||
_accountKey(_boundDevicesKey),
|
|
||||||
jsonEncode(devices.map((device) => device.toJson()).toList()),
|
|
||||||
);
|
|
||||||
state = state.copyWith(devices: devices);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> unbind(String id) async {
|
|
||||||
await _saveDevices(
|
|
||||||
state.devices.where((device) => device.id != id).toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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(_accountKey(_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(
|
|
||||||
_accountKey(_readingFingerprintsKey),
|
|
||||||
jsonEncode(fingerprints),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> clearCurrentAccountData() async {
|
|
||||||
final session = _sessionIdentity;
|
|
||||||
if (session == null) return;
|
|
||||||
final db = ref.read(localDbProvider);
|
|
||||||
await db.delete(_accountKey(_boundDevicesKey, session));
|
|
||||||
await db.delete(_accountKey(_readingFingerprintsKey, session));
|
|
||||||
state = const DeviceBindState();
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool isSyncImplemented(BleDeviceType type) {
|
|
||||||
return type == BleDeviceType.bloodPressure;
|
|
||||||
}
|
|
||||||
|
|
||||||
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('设备类型和绑定信息不一致');
|
|
||||||
}
|
|
||||||
if (!isSyncImplemented(bound.type)) {
|
|
||||||
throw UnsupportedBleDeviceException('${bound.type.label}数据同步暂未开通');
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -33,14 +33,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.1"
|
version: "2.13.1"
|
||||||
bluez:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: bluez
|
|
||||||
sha256: "61a7204381925896a374301498f2f5399e59827c6498ae1e924aaa598751b545"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.8.3"
|
|
||||||
boolean_selector:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -61,10 +53,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.1"
|
||||||
cli_config:
|
cli_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -278,54 +270,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.2"
|
version: "4.5.2"
|
||||||
flutter_blue_plus:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: flutter_blue_plus
|
|
||||||
sha256: "69a8c87c11fc792e8cf0f997d275484fbdb5143ac9f0ac4d424429700cb4e0ed"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.36.8"
|
|
||||||
flutter_blue_plus_android:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_blue_plus_android
|
|
||||||
sha256: "6f7fe7e69659c30af164a53730707edc16aa4d959e4c208f547b893d940f853d"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "7.0.4"
|
|
||||||
flutter_blue_plus_darwin:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_blue_plus_darwin
|
|
||||||
sha256: "682982862c1d964f4d54a3fb5fccc9e59a066422b93b7e22079aeecd9c0d38f8"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "7.0.3"
|
|
||||||
flutter_blue_plus_linux:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_blue_plus_linux
|
|
||||||
sha256: "56b0c45edd0a2eec8f85bd97a26ac3cd09447e10d0094fed55587bf0592e3347"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "7.0.3"
|
|
||||||
flutter_blue_plus_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_blue_plus_platform_interface
|
|
||||||
sha256: "84fbd180c50a40c92482f273a92069960805ce324e3673ad29c41d2faaa7c5c2"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "7.0.0"
|
|
||||||
flutter_blue_plus_web:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_blue_plus_web
|
|
||||||
sha256: a1aceee753d171d24c0e0cdadb37895b5e9124862721f25f60bb758e20b72c99
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "7.0.2"
|
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -617,18 +561,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.17"
|
version: "0.12.19"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.1"
|
version: "0.13.0"
|
||||||
message_pack_dart:
|
message_pack_dart:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -641,10 +585,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.18.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -741,54 +685,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
permission_handler:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: permission_handler
|
|
||||||
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "11.4.0"
|
|
||||||
permission_handler_android:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: permission_handler_android
|
|
||||||
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "12.1.0"
|
|
||||||
permission_handler_apple:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: permission_handler_apple
|
|
||||||
sha256: e20daf680eef1ca62ffe8c8c526b778cc386d50137c77ac71c8ec9c88c13fb9d
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "9.4.9"
|
|
||||||
permission_handler_html:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: permission_handler_html
|
|
||||||
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.1.3+5"
|
|
||||||
permission_handler_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: permission_handler_platform_interface
|
|
||||||
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "4.3.0"
|
|
||||||
permission_handler_windows:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: permission_handler_windows
|
|
||||||
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.2.1"
|
|
||||||
petitparser:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -909,14 +805,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.1"
|
version: "3.2.1"
|
||||||
rxdart:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: rxdart
|
|
||||||
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.28.0"
|
|
||||||
serial_csv:
|
serial_csv:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1150,26 +1038,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test
|
name: test
|
||||||
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
|
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.26.3"
|
version: "1.31.0"
|
||||||
test_api:
|
test_api:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.7"
|
version: "0.7.11"
|
||||||
test_core:
|
test_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_core
|
name: test_core
|
||||||
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
|
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.12"
|
version: "0.6.17"
|
||||||
theme_extensions_builder_annotation:
|
theme_extensions_builder_annotation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ dependencies:
|
|||||||
# jpush_flutter: ^3.4.5
|
# jpush_flutter: ^3.4.5
|
||||||
|
|
||||||
# 蓝牙 BLE
|
# 蓝牙 BLE
|
||||||
flutter_blue_plus: ^1.34.0
|
# flutter_blue_plus: ^1.34.0
|
||||||
permission_handler: ^11.3.0
|
# permission_handler: ^11.3.0
|
||||||
record: ^6.2.1
|
record: ^6.2.1
|
||||||
|
|
||||||
# Apple 登录
|
# Apple 登录
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:health_app/models/ble_device.dart';
|
|
||||||
import 'package:health_app/pages/chart/trend_page.dart';
|
|
||||||
import 'package:health_app/pages/remaining_pages.dart';
|
|
||||||
import 'package:health_app/services/health_ble_service.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
test('manual entry keyboard does not relayout the trend dashboard', () {
|
|
||||||
final source = File('lib/pages/chart/trend_page.dart').readAsStringSync();
|
|
||||||
|
|
||||||
expect(source, contains('resizeToAvoidBottomInset: false'));
|
|
||||||
expect(source, contains('class _KeyboardInsetPadding'));
|
|
||||||
expect(source, contains('MediaQuery.viewInsetsOf(context).bottom'));
|
|
||||||
expect(source, contains('child: RepaintBoundary('));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unknown trend metric falls back to blood pressure', () {
|
|
||||||
expect(normalizeTrendMetricType('unknown_metric'), 'blood_pressure');
|
|
||||||
expect(normalizeTrendMetricType(null), 'blood_pressure');
|
|
||||||
expect(normalizeTrendMetricType('spo2'), 'spo2');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('trend period includes today and the requested preceding days', () {
|
|
||||||
final now = DateTime(2026, 7, 13, 18);
|
|
||||||
final records = [
|
|
||||||
{'date': DateTime(2026, 7, 6, 23), 'value': 70},
|
|
||||||
{'date': DateTime(2026, 7, 7, 0), 'value': 71},
|
|
||||||
{'date': DateTime(2026, 7, 13, 8), 'value': 72},
|
|
||||||
];
|
|
||||||
|
|
||||||
final result = filterTrendRecordsForPeriod(records, 7, now);
|
|
||||||
|
|
||||||
expect(result.map((record) => record['value']), [71, 72]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('trend values do not show an unnecessary decimal', () {
|
|
||||||
expect(formatTrendNumber(88.0), '88');
|
|
||||||
expect(formatTrendNumber(88.5), '88.5');
|
|
||||||
expect(formatTrendNumber(null), '--');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('blood pressure abnormal label identifies the affected value', () {
|
|
||||||
expect(
|
|
||||||
trendStatusLabel({
|
|
||||||
'systolic': 113,
|
|
||||||
'diastolic': 97,
|
|
||||||
'isAbnormal': true,
|
|
||||||
}, 'blood_pressure'),
|
|
||||||
'舒张压偏高',
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
trendStatusLabel({
|
|
||||||
'systolic': 122,
|
|
||||||
'diastolic': 89,
|
|
||||||
'isAbnormal': false,
|
|
||||||
}, 'blood_pressure'),
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('calendar month switch keeps the selected day when possible', () {
|
|
||||||
expect(
|
|
||||||
calendarDateForMonth(DateTime(2026, 6), DateTime(2026, 7, 13)),
|
|
||||||
DateTime(2026, 6, 13),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
calendarDateForMonth(DateTime(2026, 2), DateTime(2026, 1, 31)),
|
|
||||||
DateTime(2026, 2, 28),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('health archive list input is normalized without conflicting none', () {
|
|
||||||
expect(normalizeArchiveItems('青霉素,海鲜\n花粉'), ['青霉素', '海鲜', '花粉']);
|
|
||||||
expect(normalizeArchiveItems('无'), ['无']);
|
|
||||||
expect(normalizeArchiveItems('无、青霉素'), ['青霉素']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('static compliance pages include collection and sdk lists', () {
|
|
||||||
expect(staticTextTitle('personalInfoList'), '个人信息收集清单');
|
|
||||||
expect(staticTextContent('personalInfoList'), contains('健康数据'));
|
|
||||||
|
|
||||||
expect(staticTextTitle('thirdPartySdkList'), '第三方 SDK 共享清单');
|
|
||||||
expect(staticTextContent('thirdPartySdkList'), contains('AI'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('all in-app compliance documents contain readable Chinese', () {
|
|
||||||
final source = File('lib/pages/remaining_pages.dart').readAsStringSync();
|
|
||||||
final start = source.indexOf(
|
|
||||||
'const Map<String, String> _extraStaticTextTitles',
|
|
||||||
);
|
|
||||||
expect(start, isNonNegative);
|
|
||||||
final complianceSection = source.substring(start);
|
|
||||||
|
|
||||||
expect(complianceSection, isNot(contains('锛')));
|
|
||||||
expect(complianceSection, isNot(contains('銆')));
|
|
||||||
expect(complianceSection, isNot(contains('€')));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ble sync is only implemented for blood pressure devices', () {
|
|
||||||
expect(
|
|
||||||
HealthBleService.isSyncImplemented(BleDeviceType.bloodPressure),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(HealthBleService.isSyncImplemented(BleDeviceType.glucose), false);
|
|
||||||
expect(
|
|
||||||
HealthBleService.isSyncImplemented(BleDeviceType.weightScale),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
HealthBleService.isSyncImplemented(BleDeviceType.pulseOximeter),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:health_app/models/ble_device.dart';
|
|
||||||
import 'package:health_app/pages/device/device_sync_ui_logic.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
test('unsupported bound devices are labelled without pretending to sync', () {
|
|
||||||
expect(deviceSyncAvailabilityLabel(BleDeviceType.glucose), '暂不支持自动同步');
|
|
||||||
expect(deviceSyncAvailabilityLabel(BleDeviceType.bloodPressure), isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('device sync errors keep connection and upload failures distinct', () {
|
|
||||||
expect(
|
|
||||||
deviceSyncErrorMessage(
|
|
||||||
const DeviceSyncFailure(DeviceSyncFailureStage.permission),
|
|
||||||
),
|
|
||||||
contains('蓝牙权限'),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
deviceSyncErrorMessage(
|
|
||||||
const DeviceSyncFailure(DeviceSyncFailureStage.upload),
|
|
||||||
),
|
|
||||||
contains('上传'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('bound device page keeps the automatic scan indicator', () {
|
|
||||||
final page = File(
|
|
||||||
'lib/pages/device/device_management_page.dart',
|
|
||||||
).readAsStringSync();
|
|
||||||
|
|
||||||
expect(page, contains('AnimationController('));
|
|
||||||
expect(page, contains('child: _ScanIndicator('));
|
|
||||||
expect(page, contains('scanning: _scanning'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('profile exposes a real edit route and keeps phone read only', () {
|
|
||||||
final profile = File(
|
|
||||||
'lib/pages/profile/profile_page.dart',
|
|
||||||
).readAsStringSync();
|
|
||||||
final editor = File(
|
|
||||||
'lib/pages/profile/profile_edit_page.dart',
|
|
||||||
).readAsStringSync();
|
|
||||||
final router = File('lib/core/app_router.dart').readAsStringSync();
|
|
||||||
|
|
||||||
expect(profile, contains("pushRoute(ref, 'profileEdit')"));
|
|
||||||
expect(editor, contains('手机号不可修改'));
|
|
||||||
expect(router, contains("case 'profileEdit':"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test(
|
|
||||||
'drawer does not refetch history when only current conversation changes',
|
|
||||||
() {
|
|
||||||
final provider = File(
|
|
||||||
'lib/providers/conversation_history_provider.dart',
|
|
||||||
).readAsStringSync();
|
|
||||||
final drawer = File('lib/widgets/health_drawer.dart').readAsStringSync();
|
|
||||||
|
|
||||||
expect(
|
|
||||||
provider,
|
|
||||||
isNot(
|
|
||||||
contains(
|
|
||||||
'ref.watch(chatProvider.select((state) => state.conversationId))',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(drawer, contains('currentConversationId'));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user