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 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(); }); 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 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) { 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 _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( 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 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; } Future _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( 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 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 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.deviceLight, borderRadius: AppRadius.smBorder, ), child: Icon( type?.icon ?? Icons.bluetooth_rounded, color: AppColors.device, 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.w800, 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.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.device.withValues(alpha: 0.08), ), ), ), Container( width: 64, height: 64, decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.xlBorder, border: Border.all(color: AppColors.borderLight), ), child: const Icon( Icons.bluetooth_searching_rounded, size: 32, color: AppColors.device, ), ), ], ); } }