import 'dart:async'; import 'package:device_plugin_interface/device_plugin_interface.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import '../../../core/theme/app_palette.dart'; import '../../../devices/device_hub.dart'; import '../../../devices/device_vendors.dart'; import '../../../routes/app_routes.dart'; import '../../main_tab/controllers/main_tab_controller.dart'; /// Smartcar(AI车载香薰)的扫描 / 连接页——走 [DeviceHub] 里的 Smartcar 插件。 /// /// 与支架那页结构一致,两点不同: /// 1. **不需要后台自启动授权**:Smartcar 的连接是纯 GATT,没有向系统注册 /// 「设备靠近就唤醒本应用」那种后台扫连,不构成自启动行为。 /// 2. 卡片上多显示一行 MAC —— 它来自 BLE 广播,是服务端确权的依据, /// 连不上时看一眼就知道是不是扫到了别的设备。 class SmartcarScanView extends StatefulWidget { const SmartcarScanView({super.key}); @override State createState() => _SmartcarScanViewState(); } class _SmartcarScanViewState extends State { DevicePlugin get _plugin => DeviceHub.to.plugin(DeviceVendors.smartcar)!; final RxList _scanned = [].obs; final RxBool _isScanning = false.obs; /// 附近**所有** BLE 设备(含没命中判定的),按 id 去重。 /// 只在一台都没扫到时给人看——「扫不到」这个故障, /// 不摊开设备到底播了什么就只能靠反复出包猜。 final RxMap> _nearby = >{}.obs; final RxBool _showDiag = false.obs; /// 扫描起不来时的原因。**必须显示出来**:第一版失败是静默的, /// 界面只会一直转圈,分不清「没扫到设备」和「压根没扫起来」。 final RxnString _scanError = RxnString(); StreamSubscription? _sub; bool _connecting = false; @override void initState() { super.initState(); _sub = _plugin.eventStream.listen((e) { switch (e.type) { case DevicePluginEventType.scanStarted: _isScanning.value = true; break; case DevicePluginEventType.scanStopped: _isScanning.value = false; break; case DevicePluginEventType.deviceDiscovered: final d = e.discovered; if (d == null || d.id.isEmpty) break; // 按 id 去重累积:BLE 扫描会对同一台设备反复上报 final i = _scanned.indexWhere((x) => x.id == d.id); if (i >= 0) { _scanned[i] = d; } else { _scanned.add(d); } break; case DevicePluginEventType.customEvent: // 厂商插件转发的扫描诊断(业务层不能直接 import 厂商目录) if (e.customKey == 'smartcar.scan_diagnostic') { final d = e.customPayload; if (d is Map) { final id = '${d['id']}'; if (id.isNotEmpty) _nearby[id] = d; } } break; case DevicePluginEventType.error: _scanError.value = e.errorMessage ?? e.errorCode; _isScanning.value = false; break; default: break; } }); WidgetsBinding.instance.addPostFrameCallback((_) => _startScan()); } Future _startScan() async { _scanned.clear(); _nearby.clear(); _scanError.value = null; if (!await _plugin.ensureReady()) return; await _plugin.startScan(); } @override void dispose() { _sub?.cancel(); _plugin.stopScan(); super.dispose(); } Future _connect(DiscoveredDevice d) async { if (_connecting) return; setState(() => _connecting = true); String? failReason; try { await _plugin.connect( d.id, // MAC 只在广播里有,连上就拿不到了。这里带过去给确权用, // 免得连上之后再回头找。 options: DeviceConnectOptions(extra: {'mac': d.metadata['mac']}), ); await _plugin.rememberDevice(d); } on DeviceException catch (e) { failReason = e.message ?? e.code; } catch (e) { failReason = '$e'; } if (!mounted) return; setState(() => _connecting = false); if (failReason == null) { Get.snackbar('smartcarConnected'.tr, d.name, snackPosition: SnackPosition.TOP, duration: const Duration(seconds: 2)); // BLE 通了不等于听得见:AI 回复和音乐走的是**经典蓝牙 A2DP**(见落地文档 7.0)。 // 没接上时提示一次,别让用户以为"连上了却没声音"是坏了。 unawaited(_hintClassicAudioIfNeeded(d.name)); Get.offAllNamed(Routes.home); WidgetsBinding.instance.addPostFrameCallback((_) { if (Get.isRegistered()) { MainTabController.to.changeTab(MainTabController.tabDevice); } }); } else { // ⚠️ 失败原因**留在页面上**,不能只弹一下就没了: // 这些信息(设备实际暴露了哪些服务、握手卡在哪条命令)是排查的全部依据, // snackbar 几秒就消失,用户根本来不及看清更别说转述。 _scanError.value = failReason; Get.snackbar('smartcarConnectFailed'.tr, failReason, snackPosition: SnackPosition.TOP, duration: const Duration(seconds: 5)); } } /// 经典蓝牙没接上就提示一次。 /// /// - Android:插件已经在连接后自动 `createBond` 试过一次,这里只兜底提示 /// - iOS:**没有**发起经典蓝牙配对的公开 API,只能引导用户去系统设置 Future _hintClassicAudioIfNeeded(String name) async { try { final r = await _plugin.activeSession ?.invokeFeature('smartcar.a2dp_status'); if (r == null || r['connected'] == true) return; Get.snackbar( 'smartcarA2dpTitle'.tr, 'smartcarA2dpHint'.tr.replaceAll('@name', name), snackPosition: SnackPosition.TOP, duration: const Duration(seconds: 6), ); } catch (_) { // 查不到就不提示——这只是体验提示,不该把连接流程搅黄 } } @override Widget build(BuildContext context) { final bool isDark = Theme.of(context).brightness == Brightness.dark; return Scaffold( backgroundColor: AppPalette.background(isDark), appBar: AppBar( backgroundColor: AppPalette.background(isDark), elevation: 0, title: Text('catCarAroma'.tr), actions: [ Obx(() => _isScanning.value ? Padding( padding: EdgeInsets.only(right: 16.w), child: Center( child: SizedBox( width: 18.w, height: 18.w, child: const CircularProgressIndicator(strokeWidth: 2), ), ), ) : IconButton( icon: const Icon(Icons.refresh), onPressed: _startScan, )), ], ), body: _connecting ? Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator(), SizedBox(height: 16.h), Text('smartcarConnecting'.tr), ], ), ) : Obx(() { final list = _scanned; if (list.isEmpty) return _empty(isDark); return ListView.separated( padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h), itemCount: list.length, separatorBuilder: (_, __) => SizedBox(height: 12.h), itemBuilder: (context, i) => _card(list[i], isDark), ); }), ); } Widget _empty(bool isDark) => Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.bluetooth_searching, size: 56.sp, color: AppPalette.brand300), SizedBox(height: 12.h), Obx(() => Text( _isScanning.value ? 'smartcarSearching'.tr : 'smartcarNotFound'.tr, style: TextStyle( fontSize: 15.sp, color: AppPalette.textSecondary(isDark)), )), SizedBox(height: 6.h), Text( 'smartcarScanHint'.tr, textAlign: TextAlign.center, style: TextStyle( fontSize: 12.sp, color: AppPalette.textHint(isDark)), ), SizedBox(height: 20.h), _diagnostics(isDark), ], ), ); /// 诊断面板:把附近扫到的 BLE 设备摊开。 /// /// 判据(厂商 ID 0x8505 / 名字含 smartcar)对不对,看这里最快: /// - 列表里根本没有目标设备 → 设备没在播,或不在范围内 /// - 有、但 mfg 那栏的厂商 ID 不是 0x8505 → 判据要按实际值改 /// - 有、厂商 ID 对但长度不足 22 → 广播段布局和文档对不上 Widget _diagnostics(bool isDark) => Obx(() { final list = _nearby.values.toList(); final err = _scanError.value; // ⚠️ 0 台也要显示。「附近 0 台」和「面板不出现」是两回事: // 前者说明扫描跑了但什么都没收到,后者说明扫描压根没起来。 return Padding( padding: EdgeInsets.symmetric(horizontal: 24.w), child: Column( children: [ if (err != null) Container( width: double.infinity, margin: EdgeInsets.only(bottom: 8.h), padding: EdgeInsets.all(10.w), decoration: BoxDecoration( color: Colors.red.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(8.r), ), child: SelectableText('失败详情:$err', style: TextStyle(fontSize: 11.sp, color: Colors.red)), ), TextButton.icon( onPressed: () => _showDiag.value = !_showDiag.value, icon: Icon( _showDiag.value ? Icons.expand_less : Icons.expand_more, size: 18.sp), label: Text('附近 ${list.length} 台蓝牙设备', style: TextStyle(fontSize: 12.sp)), ), if (_showDiag.value && list.isEmpty) Text('一台都没收到 —— 蓝牙可能没开、权限被拒,或周围确实没有设备', textAlign: TextAlign.center, style: TextStyle( fontSize: 11.sp, color: AppPalette.textHint(isDark))), if (_showDiag.value && list.isNotEmpty) Container( constraints: BoxConstraints(maxHeight: 260.h), padding: EdgeInsets.all(10.w), decoration: BoxDecoration( color: AppPalette.surface(isDark), borderRadius: BorderRadius.circular(10.r), border: Border.all(color: AppPalette.border(isDark)), ), child: ListView.builder( shrinkWrap: true, itemCount: list.length, itemBuilder: (_, i) { final d = list[i]; final matched = d['matched'] == true; final name = '${d['name']}'; return Padding( padding: EdgeInsets.symmetric(vertical: 4.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${matched ? '✓ ' : ''}' '${name.isEmpty ? '(无名)' : name} ${d['rssi']}dBm', style: TextStyle( fontSize: 11.sp, fontWeight: matched ? FontWeight.w700 : FontWeight.w400, color: AppPalette.textPrimary(isDark), ), ), // 可长按复制:MAC 解错时要把这串原始字节发出来分析 SelectableText( 'mfg=${d['mfg']}\n' '解出MAC=${d['parsedMac']}\n' 'svc=${d['services']}', style: TextStyle( fontSize: 10.sp, color: AppPalette.textHint(isDark)), ), ], ), ); }, ), ), ], ), ); }); Widget _card(DiscoveredDevice d, bool isDark) { final mac = '${d.metadata['mac'] ?? ''}'; return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => _connect(d), child: Container( padding: EdgeInsets.all(14.w), decoration: BoxDecoration( color: AppPalette.surface(isDark), borderRadius: BorderRadius.circular(14.r), border: Border.all(color: AppPalette.border(isDark)), ), child: Row( children: [ Icon(Icons.air, size: 26.sp, color: AppPalette.brand500), SizedBox(width: 12.w), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( d.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 15.sp, fontWeight: FontWeight.w600, color: AppPalette.textPrimary(isDark), ), ), if (mac.isNotEmpty) ...[ SizedBox(height: 3.h), Text( mac, style: TextStyle( fontSize: 11.sp, color: AppPalette.textHint(isDark), ), ), ], ], ), ), if (d.rssi != null) Text( '${d.rssi} dBm', style: TextStyle( fontSize: 11.sp, color: AppPalette.textHint(isDark)), ), SizedBox(width: 6.w), Icon(Icons.chevron_right, size: 20.sp, color: AppPalette.textHint(isDark)), ], ), ), ); } }