import 'dart:io'; 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 '../../../data/services/background_launch_consent.dart'; import '../../../devices/device_hub.dart'; import '../../../devices/device_vendors.dart'; import '../../../routes/app_routes.dart'; import '../../main_tab/controllers/main_tab_controller.dart'; /// AI 耳机(恒玄 Echo-one)的连接页——走 [DeviceHub] 里的耳机插件。 /// /// 两端的用户前提不同(插件按平台各写一套): /// - Android:经典蓝牙 RFCOMM/SPP,`bondedDevices` 给的是系统**已配对**列表; /// - iOS:CoreBluetooth GATT 桥接,给的是系统里**当前已连接**且带该 service 的耳机, /// id 是 CBPeripheral 的 UUID 而不是 MAC。 /// /// 所以 Android 只要配过对就能连;iOS 必须先在系统蓝牙里把耳机连上。页面文案按平台分开。 class BesScanView extends StatefulWidget { const BesScanView({super.key}); @override State createState() => _BesScanViewState(); } class _BesScanViewState extends State { /// 展示用的设备名。当前固件的广播名。 static const String _targetName = 'Echo-one'; /// 认哪些广播名:设备端 2026-08-31 把广播名从 `DEEPVOICE` 改成 `Echo-one`, /// 但 Android 的配对列表给的是系统缓存的配对名,改名前配过对的手机在重新配对 /// 之前仍然报旧名。旧名可在存量设备全部换代后删除。 static const List _acceptedNames = ['Echo-one', 'DEEPVOICE']; DevicePlugin get _plugin => DeviceHub.to.plugin(DeviceVendors.bes)!; final RxList _devices = [].obs; final RxBool _loading = true.obs; bool _connecting = false; Worker? _sessionsWorker; bool _finished = false; @override void initState() { super.initState(); // 主动连接失败后耳机才连上手机的场景:原生靠 ACL 广播自动补 RFCOMM, // 一旦上线,这页也要把绑定存下来并跳回设备页。 // `_connecting` 期间不开火:候选循环自己会收尾。 _sessionsWorker = ever>(DeviceHub.to.sessions, (_) { if (_liveSession != null && mounted && !_finished && !_connecting) { _finish(); } }); WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } DeviceSession? get _liveSession => DeviceHub.to.sessionOf(DeviceVendors.bes); @override void dispose() { _sessionsWorker?.dispose(); super.dispose(); } Future _finish() async { _finished = true; final s = _liveSession; if (s != null && s.deviceId.isNotEmpty) { await _plugin.rememberDevice( DiscoveredDevice(id: s.deviceId, name: s.info.name)); } final name = s?.info.name ?? ''; Get.snackbar('连接成功', '${name.isEmpty ? _targetName : name} 已连接', snackPosition: SnackPosition.TOP, duration: const Duration(seconds: 2)); Get.offAllNamed(Routes.home); WidgetsBinding.instance.addPostFrameCallback((_) { if (Get.isRegistered()) { MainTabController.to.showDeviceAfterConnect(); } }); } Future _load() async { _loading.value = true; try { await _plugin.ensureReady(); // 进页面时已经连上**不再直接跳回主页**:这页要展示已配对的耳机并把 // 「当前连着的那台」高亮出来。想回主页点那一行即可(走 _finish)。 final target = await _findTargets(); _devices.assignAll(target); // 只有一台目标耳机时直接连;但用户拒绝过后台自动连接就不再自动发起。 if (target.length == 1 && _liveSession == null && !BackgroundLaunchConsent.declined) { await _connect(target.first, userInitiated: false); } } catch (e) { Get.snackbar('besReadFailed'.tr, '无法获取已配对设备:$e', snackPosition: SnackPosition.TOP); } finally { _loading.value = false; } } /// 取目标耳机列表。iOS 上 CBCentralManager 变成 .poweredOn 要等系统回调, /// 在那之前列表是空的,所以要轮询几次再判定「没找到」。 Future> _findTargets() async { final int attempts = Platform.isIOS ? 10 : 1; for (var i = 0; i < attempts; i++) { final list = await _plugin.bondedDevices(); final target = list.where((d) { final n = d.name.toUpperCase(); return _acceptedNames.any((t) => n.contains(t.toUpperCase())); }).toList(); if (target.isNotEmpty || i == attempts - 1) return target; await Future.delayed(const Duration(milliseconds: 300)); if (!mounted) return const []; } return const []; } Future _connect(DiscoveredDevice d, {bool userInitiated = true}) async { if (_connecting) return; if (d.id.isEmpty) return; // 绑定后耳机开机会自动回连,属于后台自动连接,先取得用户明示同意。 if (!await BackgroundLaunchConsent.ensure(userInitiated: userInitiated)) { if (userInitiated) { Get.snackbar( 'autoConnectRequired'.tr, BackgroundLaunchConsent.declined ? 'autoConnectReenableHint'.tr : 'autoConnectRequiredDesc'.tr, snackPosition: SnackPosition.TOP); } return; } setState(() => _connecting = true); try { // 候选 = 用户点的那台(永远第一)+ 列表里的同名设备。 // 身份由连上之后的 MAC 校验来定,连接阶段只要「拨通一条链路」即可—— // 同名的另一行既可能是同一只耳机的另一个 BR/EDR 地址(Android TWS 双地址), // 也可能是另一台同款耳机,两种都值得试。 final tappedName = d.name.trim().toUpperCase(); final candidates = [d.id]; for (final row in _devices) { final n = row.name.trim().toUpperCase(); if (row.id.isEmpty || n != tappedName) continue; if (candidates.any((c) => c.toUpperCase() == row.id.toUpperCase())) { continue; } candidates.add(row.id); } bool ok = false; for (final addr in candidates) { try { await _plugin.connect(addr, options: DeviceConnectOptions(extra: {'name': d.name})); ok = true; } on DeviceException catch (e) { debugPrint('bes connect $addr failed: ${e.code}'); ok = false; } if (ok || !mounted) break; } if (!mounted) return; if (ok) { if (!_finished) await _finish(); } else { Get.snackbar( '连接失败', Platform.isIOS ? '请确认 $_targetName 在系统蓝牙里显示"已连接",再回到这里重试' : '请确认 $_targetName 已开机、在手机附近,且没有被其它手机/电脑占用;' '耳机与手机连上后会自动重连', snackPosition: SnackPosition.TOP, duration: const Duration(seconds: 4), ); } } finally { if (mounted) setState(() => _connecting = false); } } @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: const Text('添加 AI 耳机($_targetName)'), actions: [ IconButton(icon: const Icon(Icons.refresh), onPressed: _load), ], ), body: _connecting ? Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator(), const SizedBox(height: 16), Text('besConnecting'.tr), ], ), ) : Obx(() { if (_loading.value) { return const Center(child: CircularProgressIndicator()); } if (_devices.isEmpty) return _empty(isDark); // 连接态必须在**这里**读出来再传下去:ListView 的 itemBuilder 是 // 延迟调用的,在 _tile 里读 Rx 不会被这个 Obx 收集到。 DeviceHub.to.sessions.length; final live = _liveSession; return ListView.separated( padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 24.h), itemCount: _devices.length, separatorBuilder: (_, __) => SizedBox(height: 12.h), itemBuilder: (context, i) => _tile( _devices[i], isDark, connected: live != null && _isLiveDevice(_devices[i], live), ), ); }), ); } Widget _empty(bool isDark) { return Center( child: Padding( padding: EdgeInsets.symmetric(horizontal: 32.w), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.headset_off, size: 56.sp, color: AppPalette.brand300), SizedBox(height: 12.h), Text('没有找到 $_targetName 耳机', style: TextStyle( fontSize: 15.sp, color: AppPalette.textSecondary(isDark))), SizedBox(height: 6.h), Text( Platform.isIOS ? '请先在系统「设置 → 蓝牙」里连接 $_targetName(显示"已连接"),再回到这里' : '$_targetName 走经典蓝牙,请先在系统「设置 → 蓝牙」里完成配对,再回到这里连接', textAlign: TextAlign.center, style: TextStyle( fontSize: 12.sp, color: AppPalette.textHint(isDark)), ), ], ), ), ); } /// 这一行是不是当前正连着的那台。不能只比地址:Android 上耳机是 TWS 双地址, /// 系统配对列表给的地址不一定是真正跑 SPP 的那个。地址对不上时退回比设备名。 bool _isLiveDevice(DiscoveredDevice d, DeviceSession live) { final addr = d.id.toUpperCase(); final localId = (live.info.metadata['localId'] as String?) ?? live.deviceId; if (addr.isNotEmpty && addr == localId.toUpperCase()) return true; final n = d.name.trim().toUpperCase(); final cn = live.info.name.trim().toUpperCase(); return n.isNotEmpty && n == cn; } Widget _tile(DiscoveredDevice d, bool isDark, {bool connected = false}) { final name = d.name.isEmpty ? 'bleUnknownDevice'.tr : d.name; const Color accent = AppPalette.accent; final Color idle = AppPalette.textHint(isDark); return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => connected ? _finish() : _connect(d), child: Container( padding: EdgeInsets.all(14.w), decoration: BoxDecoration( color: connected ? accent.withValues(alpha: isDark ? 0.16 : 0.08) : AppPalette.surface(isDark), borderRadius: BorderRadius.circular(14.r), border: Border.all( color: connected ? accent : AppPalette.border(isDark), width: connected ? 1.5 : 1, ), ), child: Row( children: [ Icon(Icons.headphones, size: 26.sp, color: connected ? accent : idle), SizedBox(width: 12.w), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(name, style: TextStyle( fontSize: 15.sp, fontWeight: connected ? FontWeight.w700 : FontWeight.w500, color: connected ? AppPalette.textPrimary(isDark) : AppPalette.textSecondary(isDark))), SizedBox(height: 2.h), Text(d.id, style: TextStyle(fontSize: 11.sp, color: idle)), ], ), ), if (connected) ...[ Icon(Icons.check_circle, size: 16.sp, color: accent), SizedBox(width: 4.w), ], Text(connected ? 'connected'.tr : 'connect'.tr, style: TextStyle( fontSize: 13.sp, fontWeight: FontWeight.w600, color: connected ? accent : idle)), ], ), ), ); } }