import 'dart:async'; import 'dart:io' show Platform; import 'package:flutter/material.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:permission_handler/permission_handler.dart'; import '../../core/app_colors.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../models/ble_device.dart'; import '../../models/bp_reading.dart'; import '../../providers/auth_provider.dart'; import '../../providers/omron_device_provider.dart'; import '../../services/health_ble_service.dart'; import '../../widgets/ble_sync_dialog.dart'; 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 _disposed = false; @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)); } @override void dispose() { _disposed = true; _scanSub?.cancel(); _scanCtrl.dispose(); unawaited(FlutterBluePlus.stopScan()); unawaited(_bleService.disconnect()); super.dispose(); } Future _startForegroundScan() async { await [Permission.bluetoothScan, Permission.bluetoothConnect].request(); if (_disposed || !mounted) return; if (Platform.isAndroid) { final locStatus = await Permission.locationWhenInUse.serviceStatus; if (_disposed || !mounted) return; if (locStatus != ServiceStatus.enabled && mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('请先打开定位服务,否则可能无法发现蓝牙设备'), backgroundColor: AppColors.warning, ), ); } } final adapterState = await FlutterBluePlus.adapterState.first; if (_disposed || !mounted) return; if (adapterState != BluetoothAdapterState.on) { try { await FlutterBluePlus.turnOn(); } catch (_) {} } try { await FlutterBluePlus.stopScan(); } catch (_) {} if (_disposed || !mounted) return; final boundIds = ref .read(omronDeviceProvider) .devices .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); } catch (e) { if (mounted) setState(() => _scanning = 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; }); 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); } }, onMeasurementReceived: () { // Measurement has arrived; parsing and persistence happen below. }, ); if (syncResult is BloodPressureSyncResult) { if (_disposed || !mounted) return; final saved = await _persistBloodPressure( syncResult.device, syncResult.reading, ); if (!_disposed && mounted && saved) { await _showBloodPressureDialog(syncResult.reading); } } } on TimeoutException { if (!_disposed && mounted && _connectedDeviceId == bound.id) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('已连接设备,但本次未收到测量数据'), backgroundColor: AppColors.warning, ), ); } } on Exception { if (!_disposed && mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('数据录入失败,请稍后重试'), backgroundColor: AppColors.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); await api.post('/api/health-records', data: reading.toHealthRecord()); final heartRateRecord = reading.toHeartRateRecord(); if (heartRateRecord != null) { await api.post('/api/health-records', data: heartRateRecord); } await notifier.recordSuccessfulSync(device: device, reading: reading); return true; } Future _showBloodPressureDialog(BpReading reading) { return showBleSyncDialog(context, reading: reading); } @override Widget build(BuildContext context) { final state = ref.watch(omronDeviceProvider); final devices = state.devices; final connected = _connectedDeviceId != null; final boundKey = _boundIdsKey(devices.map((device) => device.id).toList()); if (devices.isNotEmpty && !_scanning && _busyDeviceId == null && _activeScanBoundKey != boundKey) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!_disposed && mounted) unawaited(_startForegroundScan()); }); } ref.listen(omronDeviceProvider, (prev, next) { final nextIds = next.devices.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) { 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: const Color(0xFFEFF6FF), foregroundColor: const Color(0xFF2563EB), fixedSize: const Size(40, 40), ), icon: const Icon(Icons.add_rounded), ), ), ], ), body: Stack( children: [ ListView( padding: const EdgeInsets.fromLTRB(18, 10, 18, 120), children: [ _DevicesHeader(deviceCount: devices.length), 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: connected, ), ), ], ), ); } Future _confirmUnbind(BoundBleDevice device) async { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( 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.error), child: const Text('解绑'), ), ], ), ); if (ok == true) { await ref.read(omronDeviceProvider.notifier).unbind(device.id); } } } class _DevicesHeader extends StatelessWidget { final int deviceCount; const _DevicesHeader({required this.deviceCount}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), border: Border.all(color: AppColors.border), boxShadow: AppColors.cardShadowLight, ), child: Row( children: [ Container( width: 42, height: 42, decoration: BoxDecoration( color: const Color(0xFFEFF6FF), borderRadius: BorderRadius.circular(8), ), child: const Icon( Icons.bluetooth_audio_rounded, color: Color(0xFF2563EB), size: 23, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '已绑定设备', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w900, color: AppColors.textPrimary, ), ), const SizedBox(height: 3), Text( '$deviceCount 台设备', style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w700, 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 Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(bottom: 10), child: Row( children: [ Container( width: 30, height: 30, decoration: BoxDecoration( color: const Color(0xFFF8FAFC), borderRadius: BorderRadius.circular(8), border: Border.all(color: AppColors.borderLight), ), child: Icon( type.icon, size: 17, color: const Color(0xFF2563EB), ), ), const SizedBox(width: 9), Text( type.label, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w900, color: AppColors.textPrimary, ), ), const SizedBox(width: 8), Text( '${devices.length}', style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w800, color: AppColors.textHint, ), ), ], ), ), for (final device in devices) Padding( padding: const EdgeInsets.only(bottom: 10), child: _BoundDeviceTile( device: device, connected: connectedDeviceId == device.id, onUnbind: () => onUnbind(device), ), ), ], ); } } class _BoundDeviceTile extends StatelessWidget { final BoundBleDevice device; final bool connected; final VoidCallback onUnbind; const _BoundDeviceTile({ required this.device, required this.connected, required this.onUnbind, }); @override Widget build(BuildContext context) { final accent = connected ? AppColors.success : AppColors.textHint; return AnimatedContainer( duration: const Duration(milliseconds: 180), padding: const EdgeInsets.fromLTRB(14, 12, 8, 12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), border: Border.all( color: connected ? AppColors.success : AppColors.border, width: connected ? 1.4 : 1.1, ), boxShadow: connected ? [ BoxShadow( color: AppColors.success.withValues(alpha: 0.14), blurRadius: 18, offset: const Offset(0, 8), ), ] : AppColors.cardShadowLight, ), child: Row( children: [ AnimatedContainer( duration: const Duration(milliseconds: 180), width: 42, height: 42, decoration: BoxDecoration( color: connected ? AppColors.successLight : const Color(0xFFF8FAFC), borderRadius: BorderRadius.circular(8), ), child: Icon(device.type.icon, color: accent, size: 22), ), const SizedBox(width: 13), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( device.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w800, color: AppColors.textPrimary, ), ), const SizedBox(height: 3), Text( '最近同步:${_formatLastSync(device.lastSyncAt)}', style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.textHint, ), ), ], ), ), if (connected) Container( margin: const EdgeInsets.only(right: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), decoration: BoxDecoration( color: AppColors.successLight, borderRadius: BorderRadius.circular(999), ), child: const Text( '已连接', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w900, color: AppColors.success, ), ), ), IconButton( tooltip: '解绑设备', onPressed: connected ? null : onUnbind, icon: const Icon(Icons.delete_outline_rounded), color: connected ? AppColors.textHint : AppColors.error, ), ], ), ); } 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, padding: const EdgeInsets.fromLTRB(24, 42, 24, 42), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), border: Border.all(color: AppColors.borderLight), ), child: Column( children: [ Container( width: 62, height: 62, decoration: BoxDecoration( color: const Color(0xFFEFF6FF), borderRadius: BorderRadius.circular(8), ), child: const Icon( Icons.bluetooth_disabled_rounded, color: Color(0xFF2563EB), size: 31, ), ), const SizedBox(height: 16), const Text( '还没有绑定设备', style: TextStyle( fontSize: 18, fontWeight: FontWeight.w900, color: AppColors.textPrimary, ), ), const SizedBox(height: 8), const Text( '右上角添加设备', textAlign: TextAlign.center, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.textSecondary, ), ), ], ), ); } } 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: const Color(0xFF2563EB).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: const Color(0xFF2563EB).withValues(alpha: 0.18), ), ), ), ], Container( width: 58, height: 58, decoration: BoxDecoration( color: const Color(0xFF2563EB), shape: BoxShape.circle, boxShadow: [ BoxShadow( color: const Color(0xFF2563EB).withValues(alpha: 0.24), blurRadius: 18, offset: const Offset(0, 8), ), ], ), child: Icon( syncing ? Icons.bluetooth_connected_rounded : Icons.bluetooth_searching_rounded, color: Colors.white, size: 28, ), ), ], ), ); } }