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 createState() => _DeviceManagementPageState(); } class _DeviceManagementPageState extends ConsumerState with SingleTickerProviderStateMixin { static const _freshScanWindow = Duration(seconds: 2); static const _sameDeviceRetryWindow = Duration(seconds: 3); StreamSubscription>? _scanSub; late final HealthBleService _bleService; late final AnimationController _scanCtrl; late final Animation _scanAnim; final _recentAttempts = {}; 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 _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 _ensureBlePermissions() async { Map 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( 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 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 ids) { final sorted = [...ids]..sort(); return sorted.join('|'); } Future _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 _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 = >[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 _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(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 _confirmUnbind(BoundBleDevice device) async { final ok = await showDialog( 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 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 devices; final String? connectedDeviceId; final String? busyDeviceId; final ValueChanged 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 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('重试')), ], ), ); }