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:
@@ -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/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/omron_device_provider.dart';
|
||||
// iOS 审核版:蓝牙相关已移除
|
||||
// import '../../providers/omron_device_provider.dart';
|
||||
|
||||
class SettingsPage extends ConsumerWidget {
|
||||
const SettingsPage({super.key});
|
||||
@@ -51,11 +52,7 @@ class SettingsPage extends ConsumerWidget {
|
||||
onTap: () => pushRoute(ref, 'elderMode'),
|
||||
),
|
||||
if (!Platform.isIOS)
|
||||
_SettingsTile(
|
||||
icon: LucideIcons.bluetooth,
|
||||
title: '蓝牙设备',
|
||||
onTap: () => pushRoute(ref, 'devices'),
|
||||
),
|
||||
// iOS 审核版:蓝牙设备入口已移除
|
||||
_SettingsTile(
|
||||
icon: LucideIcons.bell,
|
||||
title: '消息通知',
|
||||
@@ -180,7 +177,7 @@ class SettingsPage extends ConsumerWidget {
|
||||
);
|
||||
if (ok == true) {
|
||||
await ref.read(apiClientProvider).delete('/api/user/account');
|
||||
await ref.read(omronDeviceProvider.notifier).clearCurrentAccountData();
|
||||
// iOS 审核版:omronDeviceProvider 已移除
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
goRoute(ref, 'login');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user