import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../core/services/ble_provisioning_service.dart'; import '../features/device/data/datasources/device_remote_data_source.dart'; import '../features/device/presentation/controllers/device_controller.dart'; import '../widgets/animated_gradient_background.dart'; import '../widgets/gradient_button.dart'; import '../widgets/glass_dialog.dart'; /// 设备类型 enum DeviceType { plush, badgeAi, badge } /// 模拟设备数据模型 class MockDevice { final String sn; final String name; final String macAddress; final DeviceType type; final bool hasAI; final bool isNetworkRequired; final BluetoothDevice? bleDevice; const MockDevice({ required this.sn, required this.name, required this.macAddress, required this.type, required this.hasAI, this.isNetworkRequired = true, this.bleDevice, }); String get iconPath { switch (type) { case DeviceType.plush: return 'assets/www/icons/pixel-capybara.svg'; case DeviceType.badgeAi: return 'assets/www/icons/pixel-badge-ai.svg'; case DeviceType.badge: return 'assets/www/icons/pixel-badge-basic.svg'; } } String get typeLabel { switch (type) { case DeviceType.plush: return '毛绒机芯'; case DeviceType.badgeAi: return 'AI电子吧唧'; case DeviceType.badge: return '普通电子吧唧'; } } } /// 蓝牙搜索页面 class BluetoothPage extends ConsumerStatefulWidget { const BluetoothPage({super.key}); @override ConsumerState createState() => _BluetoothPageState(); } class _BluetoothPageState extends ConsumerState with TickerProviderStateMixin { /// Airhub 设备名前缀(硬件广播格式: Airhub_ + MAC) static const _airhubPrefix = 'Airhub_'; // 状态 bool _isSearching = !kIsWeb; // Web 平台不自动搜索,需用户手势触发 bool _isBluetoothOn = false; List _devices = []; int _currentIndex = 0; // 已查询过的 MAC → 设备信息缓存(避免重复调 API) final Map> _macInfoCache = {}; // 动画控制器 late AnimationController _searchAnimController; // 滚轮控制器 late FixedExtentScrollController _wheelController; // 蓝牙订阅 StreamSubscription? _bluetoothSubscription; StreamSubscription>? _scanSubscription; // 是否已弹过蓝牙关闭提示(避免重复弹窗) bool _hasShownBluetoothDialog = false; @override void initState() { super.initState(); // 搜索动画 (神秘盒子浮动) - 800ms 周期,2s 搜索内可完成多次弹跳 _searchAnimController = AnimationController( duration: const Duration(milliseconds: 800), vsync: this, )..repeat(reverse: true); // 滚轮控制器 _wheelController = FixedExtentScrollController(initialItem: _currentIndex); // 监听蓝牙适配器状态 _listenBluetoothState(); } @override void dispose() { _bluetoothSubscription?.cancel(); _scanSubscription?.cancel(); FlutterBluePlus.stopScan(); _searchAnimController.dispose(); _wheelController.dispose(); super.dispose(); } /// 监听蓝牙适配器状态 void _listenBluetoothState() { _bluetoothSubscription = FlutterBluePlus.adapterState.listen((state) { if (!mounted) return; final isOn = state == BluetoothAdapterState.on; setState(() => _isBluetoothOn = isOn); if (isOn) { // Web 平台: BLE scan 必须由用户手势触发,不自动扫描 if (!kIsWeb) _startSearch(); } else if (state == BluetoothAdapterState.off) { FlutterBluePlus.stopScan(); setState(() { _isSearching = false; _devices.clear(); }); if (!_hasShownBluetoothDialog) { _hasShownBluetoothDialog = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _showBluetoothOffDialog(); }); } } }); } /// 从设备名中提取 MAC 地址(格式: Airhub_XXXXXXXXXXXX 或 Airhub_XX:XX:XX:XX:XX:XX) /// 返回标准格式 XX:XX:XX:XX:XX:XX(大写,带冒号),或 null String? _extractMacFromName(String bleName) { if (!bleName.startsWith(_airhubPrefix)) return null; final rawMac = bleName.substring(_airhubPrefix.length).trim(); if (rawMac.isEmpty) return null; // 移除冒号/横杠,统一处理 final hex = rawMac.replaceAll(RegExp(r'[:\-]'), '').toUpperCase(); if (hex.length != 12 || !RegExp(r'^[0-9A-F]{12}$').hasMatch(hex)) { debugPrint('[BLE Scan] MAC 格式异常: $rawMac'); return null; } // 转为 XX:XX:XX:XX:XX:XX return '${hex.substring(0, 2)}:${hex.substring(2, 4)}:${hex.substring(4, 6)}:' '${hex.substring(6, 8)}:${hex.substring(8, 10)}:${hex.substring(10, 12)}'; } // 暂存扫描到但尚未完成 API 查询的 Airhub 设备 BLE 句柄 final Map _pendingBleDevices = {}; /// 开始 BLE 扫描(持续扫描,直到找到设备并完成 API 查询) Future _startSearch() async { // Web 平台: 跳过蓝牙状态检查(Web Bluetooth API 会自行处理可用性) if (!kIsWeb && !_isBluetoothOn) { _showBluetoothOffDialog(); return; } // Web 平台: 不能在 startScan 前 await 任何异步操作, // 否则会丢失用户手势上下文(Web Bluetooth API 要求 // requestDevice 必须在用户手势的同步调用链中触发) if (!kIsWeb) { await _requestPermissions(); } if (!mounted) return; setState(() { _isSearching = true; _devices.clear(); _currentIndex = 0; }); _pendingBleDevices.clear(); _scanSubscription?.cancel(); _scanSubscription = FlutterBluePlus.onScanResults.listen((results) { if (!mounted) return; for (final r in results) { final name = r.device.platformName; if (name.isEmpty) continue; final mac = _extractMacFromName(name); if (mac == null) continue; // 记录 BLE 句柄 _pendingBleDevices[mac] = r.device; // 如果没查过这个 MAC,发起 API 查询 if (!_macInfoCache.containsKey(mac)) { _macInfoCache[mac] = {}; // 占位,避免重复查询 _queryDeviceByMac(mac); } } }); // 持续扫描(不设超时),由 _queryDeviceByMac 成功后停止 await FlutterBluePlus.startScan( timeout: const Duration(seconds: 30), androidUsesFineLocation: true, ); // 30 秒兜底超时:如果始终没找到设备 if (mounted && _isSearching) { setState(() => _isSearching = false); } } /// 通过 MAC 调用后端 API 查询设备信息 /// 查询成功后:添加设备到列表、停止扫描、结束搜索状态 Future _queryDeviceByMac(String mac) async { try { final dataSource = ref.read(deviceRemoteDataSourceProvider); debugPrint('[Bluetooth] queryByMac: $mac'); final data = await dataSource.queryByMac(mac); debugPrint('[Bluetooth] queryByMac 返回: $data'); if (!mounted) return; _macInfoCache[mac] = data; final deviceTypeName = data['device_type']?['name'] as String? ?? ''; final sn = data['sn'] as String? ?? ''; final isNetworkRequired = data['device_type']?['is_network_required'] as bool? ?? true; final bleDevice = _pendingBleDevices[mac]; // API 返回了有效设备名 → 添加到列表 final displayName = deviceTypeName.isNotEmpty ? deviceTypeName : 'Airhub 设备'; setState(() { // 避免重复添加 if (!_devices.any((d) => d.macAddress == mac)) { _devices.add(MockDevice( sn: sn, name: displayName, macAddress: mac, type: _inferDeviceType(displayName), hasAI: _inferHasAI(displayName), isNetworkRequired: isNetworkRequired, bleDevice: bleDevice, )); } // 有设备了,结束搜索状态 _isSearching = false; }); // 停止扫描 try { await FlutterBluePlus.stopScan(); } catch (_) {} debugPrint('[Bluetooth] 设备已就绪: $mac → $displayName'); } catch (e) { debugPrint('[Bluetooth] queryByMac 失败($mac): $e'); // API 查询失败时,用 BLE 名作为 fallback 也显示出来 if (!mounted) return; final bleDevice = _pendingBleDevices[mac]; setState(() { if (!_devices.any((d) => d.macAddress == mac)) { _devices.add(MockDevice( sn: '', name: '${_airhubPrefix}设备', macAddress: mac, type: DeviceType.plush, hasAI: true, bleDevice: bleDevice, )); } _isSearching = false; }); try { await FlutterBluePlus.stopScan(); } catch (_) {} } } /// 根据设备名称推断设备类型 DeviceType _inferDeviceType(String name) { final lower = name.toLowerCase(); if (lower.contains('plush') || lower.contains('卡皮巴拉') || lower.contains('机芯') || lower.contains('airhub')) { return DeviceType.plush; } if (lower.contains('ai') || lower.contains('智能')) { return DeviceType.badgeAi; } return DeviceType.badge; } /// 根据设备名称推断是否支持 AI bool _inferHasAI(String name) { final lower = name.toLowerCase(); return lower.contains('ai') || lower.contains('plush') || lower.contains('卡皮巴拉') || lower.contains('机芯') || lower.contains('智能') || lower.contains('airhub'); } /// 请求蓝牙权限 Future _requestPermissions() async { if (kIsWeb) return; // Web 平台无需请求原生权限 try { if (Platform.isAndroid) { // Android 需要位置权限才能扫描 BLE await Permission.bluetoothScan.request(); await Permission.bluetoothConnect.request(); await Permission.location.request(); } else { // iOS 只需蓝牙权限,不需要位置 await Permission.bluetooth.request(); } } catch (e) { debugPrint('[Bluetooth] 权限请求异常: $e'); } } /// 蓝牙未开启弹窗 void _showBluetoothOffDialog() { if (!mounted || kIsWeb) return; // Web 平台不弹原生蓝牙设置弹窗 showGlassDialog( context: context, title: '蓝牙未开启', description: '请开启蓝牙以搜索附近的设备', cancelText: '取消', confirmText: Platform.isAndroid ? '开启蓝牙' : '去设置', onConfirm: () { Navigator.of(context).pop(); if (Platform.isAndroid) { // Android 可直接请求开启蓝牙 FlutterBluePlus.turnOn(); } else { // iOS 无法直接开启,引导到系统设置 openAppSettings(); } }, ); } bool _isConnecting = false; /// 连接设备 Future _handleConnect() async { if (_devices.isEmpty || _isConnecting) return; // 检查蓝牙状态 if (!_isBluetoothOn) { _showBluetoothOffDialog(); return; } final device = _devices[_currentIndex]; debugPrint('[Bluetooth] 连接设备: sn=${device.sn}, mac=${device.macAddress}, needsNetwork=${device.isNetworkRequired}'); if (!device.isNetworkRequired) { // 不需要联网 -> 直接去设备控制页 context.go('/device-control'); return; } // Web 环境:跳过 BLE 和 WiFi 配网,直接绑定设备 if (kIsWeb) { debugPrint('[Bluetooth] Web 环境,直接绑定 sn=${device.sn}'); setState(() => _isConnecting = true); if (device.sn.isNotEmpty) { await ref.read(deviceControllerProvider.notifier).bindDevice(device.sn); } if (!mounted) return; setState(() => _isConnecting = false); context.go('/device-control'); return; } // 需要联网 -> BLE 连接后进入 WiFi 配网 final bleDevice = device.bleDevice; if (bleDevice == null) { debugPrint('[Bluetooth] 无 BLE 句柄,无法连接'); return; } setState(() => _isConnecting = true); // 连接前先停止扫描(iOS 上扫描和连接并发会冲突) try { await FlutterBluePlus.stopScan(); } catch (_) {} await Future.delayed(const Duration(milliseconds: 300)); final provService = BleProvisioningService(); final ok = await provService.connect(bleDevice); if (!mounted) return; setState(() => _isConnecting = false); if (!ok) { showGlassDialog( context: context, title: '连接失败', description: '无法连接到设备,请确认设备已开机并靠近手机', confirmText: '确定', onConfirm: () => Navigator.of(context).pop(), ); return; } // BLE 连接成功,跳转 WiFi 配网页并传递 service context.go('/wifi-config', extra: { 'provService': provService, 'sn': device.sn, 'name': device.name, 'mac': device.macAddress, 'type': device.type.name, }); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, body: Stack( children: [ // 渐变背景 _buildGradientBackground(), // 内容 SafeArea( child: Column( children: [ // Header _buildHeader(), // 设备数量提示 _buildCountLabel(), // 主内容区域 Expanded( child: _isSearching ? _buildSearchingState() : _buildDeviceCards(), ), // Footer _buildFooter(), ], ), ), ], ), ); } /// 渐变背景 Widget _buildGradientBackground() { return const AnimatedGradientBackground(); } /// Header - HTML: padding 16px 20px (vertical horizontal) Widget _buildHeader() { return Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: Row( children: [ // 返回按钮 - CSS: border-radius: 12px, bg: rgba(255,255,255,0.6), no border GestureDetector( onTap: () => context.go('/home'), child: Container( width: 40, height: 40, decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), // Rounded square, not circle color: Colors.white.withOpacity(0.6), // No border per HTML ), child: const Icon( Icons.arrow_back_ios_new, size: 18, color: Color(0xFF4B5563), // Gray per HTML, not purple ), ), ), // 标题 Expanded( child: Text( '搜索设备', textAlign: TextAlign.center, style: GoogleFonts.outfit( fontSize: 17, fontWeight: FontWeight.w600, color: const Color(0xFF1F2937), ), ), ), // 占位 const SizedBox(width: 40), ], ), ); } /// 设备数量标签 Widget _buildCountLabel() { return AnimatedOpacity( duration: const Duration(milliseconds: 300), opacity: _isSearching ? 0 : 1, child: Container( padding: const EdgeInsets.symmetric(vertical: 20), child: _devices.isEmpty ? const SizedBox.shrink() : Text.rich( TextSpan( text: '找到 ', style: TextStyle( fontSize: 14, color: const Color(0xFF9CA3AF), ), children: [ TextSpan( text: '${_devices.length}', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: const Color(0xFF8B5CF6), ), ), TextSpan( text: _devices.length > 1 ? ' 个设备 · 滑动切换' : ' 个设备', ), ], ), textAlign: TextAlign.center, ), ), ); } /// 搜索中状态 Widget _buildSearchingState() { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // 神秘盒子动画 AnimatedBuilder( animation: _searchAnimController, builder: (context, child) { return Transform.translate( offset: Offset(0, -15 * _searchAnimController.value), child: child, ); }, // HTML: mystery-box is transparent, icon is 120x120 with amber drop-shadow child: SvgPicture.asset( 'assets/www/icons/pixel-mystery-box.svg', width: 120, height: 120, placeholderBuilder: (_) => Text( '?', style: GoogleFonts.outfit( fontSize: 48, fontWeight: FontWeight.w700, color: const Color(0xFFF59E0B), // Amber color per HTML ), ), ), ), const SizedBox(height: 24), // 搜索状态文字 Text( '正在搜索附近设备', style: TextStyle( fontSize: 16, color: const Color(0xFF4B5563), ), ), ], ), ); } /// 设备卡片区域 - 滚轮选择器风格 Widget _buildDeviceCards() { if (_devices.isEmpty) { return Center( child: Text( '未找到设备', style: TextStyle( fontSize: 16, color: const Color(0xFF9CA3AF), ), ), ); } final itemExtent = 180.0; return SizedBox( height: MediaQuery.of(context).size.height * 0.46, child: Stack( children: [ // 滚轮视图 + ShaderMask 实现上下边缘渐隐 ShaderMask( shaderCallback: (Rect rect) { return const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.transparent, Colors.black, Colors.black, Colors.transparent, ], stops: [0.05, 0.38, 0.62, 0.95], ).createShader(rect); }, blendMode: BlendMode.dstIn, child: ListWheelScrollView.useDelegate( controller: _wheelController, itemExtent: itemExtent, diameterRatio: 1.8, perspective: 0.006, physics: const FixedExtentScrollPhysics(), onSelectedItemChanged: (index) { setState(() => _currentIndex = index); }, childDelegate: ListWheelChildBuilderDelegate( childCount: _devices.length, builder: (context, index) { return AnimatedBuilder( animation: _wheelController, builder: (context, child) { final offset = _wheelController.hasClients ? _wheelController.offset : _currentIndex * itemExtent; // 当前 item 中心与视口中心的距离 final distFromCenter = (index * itemExtent - offset).abs(); // 归一化到 0~1 (0=居中, 1=远离) final t = (distFromCenter / itemExtent).clamp(0.0, 1.5); // 居中=1.0, 远离=0.65 final scale = 1.0 - (t * 0.25); return Transform.scale( scale: scale.clamp(0.65, 1.0), child: child, ); }, child: Center(child: _buildDeviceCard(_devices[index])), ); }, ), ), ), // 右侧圆点指示器 if (_devices.length > 1) Positioned( right: 20, top: 0, bottom: 0, child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: List.generate( _devices.length, (index) => _buildDot(index == _currentIndex), ), ), ), ), ], ), ); } /// 单个设备卡片 Widget _buildDeviceCard(MockDevice device) { return Column( mainAxisSize: MainAxisSize.min, children: [ // 图标 + AI 徽章 Stack( clipBehavior: Clip.none, children: [ // 设备图标 - HTML: no background wrapper, icon is 120x120 SizedBox( width: 120, height: 120, child: _buildDeviceIcon(device), ), // AI 徽章 if (device.hasAI) Positioned( top: -4, right: -4, child: Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4, ), decoration: BoxDecoration( gradient: const LinearGradient( colors: [Color(0xFF8B5CF6), Color(0xFF6366F1)], ), borderRadius: BorderRadius.circular(8), boxShadow: [ BoxShadow( color: const Color(0xFF8B5CF6).withOpacity(0.4), offset: const Offset(0, 4), blurRadius: 10, ), ], ), child: Text( 'AI', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w700, color: Colors.white, ), ), ), ), ], ), const SizedBox(height: 10), // 设备名称 Text( device.name, style: GoogleFonts.outfit( fontSize: 18, fontWeight: FontWeight.w600, color: const Color(0xFF1F2937), ), ), ], ); } /// 设备图标 - HTML: 120x120 per CSS .card-icon-img Widget _buildDeviceIcon(MockDevice device) { return SvgPicture.asset( device.iconPath, width: 120, height: 120, fit: BoxFit.contain, placeholderBuilder: (_) { IconData icon; switch (device.type) { case DeviceType.plush: icon = Icons.pets; case DeviceType.badgeAi: icon = Icons.smart_toy; case DeviceType.badge: icon = Icons.badge; } return Icon(icon, size: 80, color: const Color(0xFF8B5CF6)); }, ); } /// 指示器圆点 Widget _buildDot(bool isActive) { return AnimatedContainer( duration: const Duration(milliseconds: 300), curve: Curves.easeOutCubic, margin: const EdgeInsets.symmetric(vertical: 4), width: 6, height: isActive ? 18 : 6, decoration: BoxDecoration( color: isActive ? const Color(0xFF8B5CF6) : const Color(0xFF8B5CF6).withOpacity(0.2), borderRadius: BorderRadius.circular(isActive ? 3 : 3), ), ); } /// Footer - HTML: padding 20px 20px 60px, gap 16px, centered buttons Widget _buildFooter() { return Container( padding: EdgeInsets.fromLTRB( 20, // HTML: 20px sides 16, // HTML: 20px top (reduced to prevent overflow) 20, MediaQuery.of(context).padding.bottom + 40, // HTML: safe-area + bottom padding ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ // 取消按钮 - HTML: frosted glass with border GestureDetector( onTap: () => context.go('/home'), child: ClipRRect( borderRadius: BorderRadius.circular(25), child: Container( padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14), decoration: BoxDecoration( color: Colors.white.withOpacity(0.8), borderRadius: BorderRadius.circular(25), border: Border.all(color: const Color(0xFFE5E7EB)), ), child: Text( _isSearching ? '取消搜索' : '取消', style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w500, color: Color(0xFF6B7280), ), ), ), ), ), // 搜索按钮 (Web 平台初始状态或无设备时显示) if (!_isSearching && _devices.isEmpty) ...[ const SizedBox(width: 16), GradientButton( text: '搜索设备', width: 180, height: 52, onPressed: _startSearch, ), ], // 连接按钮 (搜索完成后显示) if (!_isSearching && _devices.isNotEmpty) ...[ const SizedBox(width: 16), // HTML: gap 16px GradientButton( text: _isConnecting ? '连接中...' : '连接设备', width: 180, height: 52, onPressed: _isConnecting ? null : _handleConnect, ), ], ], ), ); } }