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 DeviceScanPage extends ConsumerStatefulWidget { const DeviceScanPage({super.key}); @override ConsumerState createState() => _DeviceScanPageState(); } class _DeviceScanPageState extends ConsumerState with SingleTickerProviderStateMixin { static const _visibleDeviceWindow = Duration(seconds: 12); final _results = []; StreamSubscription>? _scanSub; Timer? _scanStopTimer; Timer? _resultPruneTimer; String? _connectingId; DateTime? _scanStartedAt; bool _scanning = false; late final AnimationController _pulseCtrl; late final Animation _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 _startScan() async { setState(() { _scanning = true; _connectingId = null; _results.clear(); }); await [Permission.bluetoothScan, Permission.bluetoothConnect].request(); if (Platform.isAndroid) { final locStatus = await Permission.locationWhenInUse.serviceStatus; if (locStatus != ServiceStatus.enabled && mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('请先打开定位服务,否则可能无法发现蓝牙设备'), backgroundColor: AppColors.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 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 _visibleResults(List 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 _connectBindAndSync(ScanResult result) async { final remoteId = result.device.remoteId.toString(); if (_connectingId != null) return; final scanStartedAt = _scanStartedAt; if (scanStartedAt == null || result.timeStamp.isBefore(scanStartedAt) || !result.advertisementData.connectable || DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('设备已离线,请重新进入通信状态'))); return; } if (ref.read(omronDeviceProvider).isBound && ref.read(omronDeviceProvider).findById(remoteId) != null) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('该设备已绑定'))); return; } setState(() => _connectingId = remoteId); await FlutterBluePlus.stopScan(); final ble = ref.read(healthBleServiceProvider); BoundBleDevice? boundDevice; try { boundDevice = await ble .connectAndIdentify( device: result.device, name: _deviceNameOf(result), ) .timeout(const Duration(seconds: 15)); 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); } } } else if (mounted) { popRoute(ref); } if (mounted) popRoute(ref); } on TimeoutException { if (mounted && boundDevice != null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('${boundDevice.type.label}已绑定,但本次未收到数据')), ); popRoute(ref); } else if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('连接超时,请确认设备仍处于通信状态'), backgroundColor: AppColors.error, ), ); unawaited(_startScan()); } } on UnsupportedBleDeviceException catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(e.message), backgroundColor: AppColors.error), ); unawaited(_startScan()); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('连接失败:$e'), backgroundColor: AppColors.error), ); unawaited(_startScan()); } } finally { await ble.disconnect(); if (mounted) setState(() => _connectingId = null); } } 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) { return GradientScaffold( appBar: AppBar( backgroundColor: Colors.white.withValues(alpha: 0.92), elevation: 0, 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.separated( padding: const EdgeInsets.fromLTRB(16, 12, 16, 20), itemCount: _results.length, separatorBuilder: (context, index) => const SizedBox(height: 10), itemBuilder: (context, index) { final result = _results[index]; return _DeviceResultTile( result: result, connecting: _connectingId == result.device.remoteId.toString(), onConnect: () => _connectBindAndSync(result), ); }, ), ); } 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 current, List next) { if (current.length != next.length) return false; for (var i = 0; i < current.length; i++) { if (current[i].device.remoteId != next[i].device.remoteId) return false; } return true; } } class _ScanningEmpty extends StatelessWidget { final Animation 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.w800, 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 VoidCallback onConnect; const _DeviceResultTile({ required this.result, required this.connecting, required this.onConnect, }); @override Widget build(BuildContext context) { final type = HealthBleService.deviceTypeFromScan(result); final name = _deviceNameOf(result, type); return Container( padding: const EdgeInsets.all(15), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.border, width: 1.1), boxShadow: [AppTheme.shadowLight], ), child: Row( children: [ Container( width: 48, height: 48, decoration: BoxDecoration( color: AppColors.primary.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(14), border: Border.all( color: AppColors.primary.withValues(alpha: 0.10), ), ), child: Icon( type?.icon ?? Icons.bluetooth_rounded, color: AppColors.primary, size: 25, ), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( type?.label ?? '健康设备', style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w800, color: AppColors.textPrimary, ), ), const SizedBox(height: 4), Text( name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 14, color: AppColors.textHint, ), ), ], ), ), const SizedBox(width: 10), SizedBox( height: 38, child: FilledButton( onPressed: connecting ? null : onConnect, style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), textStyle: const TextStyle( fontSize: 14, fontWeight: FontWeight.w800, ), ), 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 animation; const _ScanPulse({required this.animation}); @override Widget build(BuildContext context) { return Stack( alignment: Alignment.center, children: [ AnimatedBuilder( animation: animation, builder: (context, child) => Container( width: 78 * animation.value, height: 78 * animation.value, decoration: BoxDecoration( shape: BoxShape.circle, color: AppColors.primary.withValues(alpha: 0.08), ), ), ), Container( width: 64, height: 64, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), border: Border.all(color: AppColors.borderLight), ), child: const Icon( Icons.bluetooth_searching_rounded, size: 32, color: AppColors.primary, ), ), ], ); } }