import 'dart:io'; 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 '../../../data/services/bes_bluetooth_service.dart'; import '../../../routes/app_routes.dart'; import '../../main_tab/controllers/main_tab_controller.dart'; /// 恒玄(BES)方案耳机的连接页。 /// /// 与蓝讯支架([HolderScanView],BLE 广播扫描)是**完全独立的一条通路**, /// 而且两端的底层实现也不一样(native 插件已按平台各写一套): /// - Android:经典蓝牙 RFCOMM/SPP,`getBondedDevices` 给的是系统**已配对**列表; /// - iOS:CoreBluetooth 走厂商私有 service 的 GATT 桥接(BR/EDR 承载), /// `getBondedDevices` 底层是 `retrieveConnectedPeripherals`, /// 给的是系统里**当前已连接**且带该 service 的耳机,address 是 CBPeripheral 的 /// UUID 而不是 MAC。 /// /// 所以两端的用户前提不同:Android 只要配过对就能连;iOS 必须先在系统蓝牙里 /// 把耳机连上(A2DP/HFP),App 才看得到它。页面文案按平台分开。 /// /// 三条链路各自持有自己的 service、各自的连接态和持久化,互不影响。 class BesScanView extends StatefulWidget { const BesScanView({super.key}); @override State createState() => _BesScanViewState(); } class _BesScanViewState extends State { /// 展示用的设备名(提示文案、连接成功 snackbar 的兜底)。当前固件的广播名。 /// /// ⚠️ 大小写按设备端实际广播的写;**匹配**不要直接拿它比,走 /// [_acceptedNames]([_findTargets] 里统一转大写)——上一版常量恰好是全大写的 /// `DEEPVOICE`,跟大写后的设备名比才碰巧成立,改成混合大小写就会全部匹配不到。 static const String _targetName = 'Echo-one'; /// 认哪些广播名。恒玄这条链路只对接自家耳机,其它已配对的经典蓝牙设备 /// (车机、音箱、别家耳机)都不该出现在这个页面里,直接按名字写死过滤。 /// /// 之所以是列表而不是单个值:设备端 2026-08-31 把广播名从 `DEEPVOICE` 改成 /// `Echo-one`,但 Android 的 `getBondedDevices` 给的是**系统缓存的配对名**, /// 改名前配过对的手机在重新配对之前仍然报旧名,只认新名会让这些机器扫不到。 /// 旧名可在存量设备全部换代后删除。 static const List _acceptedNames = ['Echo-one', 'DEEPVOICE']; late final BesBluetoothService _service; final RxList> _devices = >[].obs; final RxBool _loading = true.obs; bool _connecting = false; @override void initState() { super.initState(); _service = Get.isRegistered() ? Get.find() : Get.put(BesBluetoothService()); // 主动连接失败后耳机才连上手机的场景:原生靠 ACL 广播自动补 RFCOMM, // 状态一旦变成「已连接」,这页也要把绑定存下来并跳回设备页。 // // ⚠️ `_connecting` 期间不能开火:_connect 正在按候选列表逐个试, // 第一个候选一连上就 _finish() 会把用户点的那台挤掉(存盘存成先连上的 // 那一台并直接跳走)。候选循环自己会收尾—— // · 回连发生在循环期间 → _waitConnected 看到 2 就 break,由 _connect 走 _finish; // · 回连发生在循环之后 → finally 已把 _connecting 置回 false,这里照常开火。 // 两条路都有人收尾,不存在空档。 _statusWorker = ever(_service.deviceStatus, (v) { if (v == 2 && mounted && !_finished && !_connecting) _finish(); }); WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } Worker? _statusWorker; bool _finished = false; @override void dispose() { _statusWorker?.dispose(); super.dispose(); } Future _finish() async { _finished = true; final id = _service.connectedDeviceId.value; final name = _service.connectedDeviceName.value; if (id.isNotEmpty) { await _service.savePairedDevice({'address': id, 'name': 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.changeTab(MainTabController.tabDevice); } }); } Future _load() async { _loading.value = true; try { await _service.initialize(); // ⚠️ 进页面时已经连上(冷启动自动回连 / 上个页面连过)**不再直接跳回主页**。 // 这页要展示已配对的耳机并把「当前连着的那台」高亮出来,直接跳走的话 // 用户根本看不到这个区分。想回主页点那一行即可(走 _finish)。 final target = await _findTargets(); _devices.assignAll(target); // 只有一台目标耳机时不用再让用户点一次,直接连。 // 但用户拒绝过后台自动连接就不再自动发起——否则每次进页面都要被拦一次, // 变成「重复启动 + 重复弹窗」。这时列表照常展示,等用户自己点。 if (target.length == 1 && _service.deviceStatus.value != 2 && !BackgroundLaunchConsent.declined) { await _connect(target.first, userInitiated: false); } } catch (e) { Get.snackbar('besReadFailed'.tr, '无法获取已配对设备:$e', snackPosition: SnackPosition.TOP); } finally { _loading.value = false; } } /// 取目标耳机列表。 /// /// iOS 上 `initialize()` 只是把 CBCentralManager 建出来,state 变成 .poweredOn /// 要等系统回调;在那之前 `getBondedDevices` 底层的 /// `retrieveConnectedPeripherals` 会直接返回空。所以 iOS 要轮询几次再判定 /// 「没找到」,否则刚进页面必定误报。Android 是同步查系统配对列表,一次就够。 Future>> _findTargets() async { final int attempts = Platform.isIOS ? 10 : 1; for (var i = 0; i < attempts; i++) { final list = await _service.getBondedDevices(); final target = list.where((d) { final n = (d['name'] as String? ?? '').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(Map d, {bool userInitiated = true}) async { if (_connecting) return; final id = (d['address'] ?? '') as String; final name = (d['name'] ?? '') as String; if (id.isEmpty) return; // 绑定后耳机开机会自动回连(Android 靠 ACL 广播、iOS 靠 .peerConnected), // 属于后台自动连接,先取得用户明示同意。 // 只有用户亲手点连接才允许弹窗;自动路径拿不到授权就安静地不连。 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 校验来定(见 BesDeviceAuth 与 _listen 的回填), // 不由拨哪个地址来定。所以连接阶段只要「拨通一条链路」即可, // 不需要在地址层面追求精确——同名的另一行既可能是同一只耳机的另一个 // BR/EDR 地址(Android TWS 双地址:实测配对列表是 …C1:C4,SPP 走 …BE:F8), // 也可能是另一台同款耳机,两种都值得试。 // // ⚠️ **不要把 currentConnectedDevice() / lastDeviceAddress 加回来。** // 插件里没有任何扫描/发现路径,地址的唯一来源是 adapter.bondedDevices; // 而这两个值都只是把 App 自己拨过的地址回吐(lastDeviceId 只在 connect() // 里赋值,lastDeviceAddress 来自 savePairedDevice 存的 connectedDeviceId)。 // 它们永远产生不了一个「新地址」,唯一的实际效果是把**上一台耳机**塞进 // 候选并排在用户的选择前面——换耳机时就会连上旧的那台,或者在旧耳机 // 不在身边时白等一轮超时。 final tappedName = name.trim().toUpperCase(); final candidates = [id]; for (final row in _devices) { final a = (row['address'] as String? ?? ''); final n = (row['name'] as String? ?? '').trim().toUpperCase(); if (a.isEmpty || n != tappedName) continue; if (candidates.any((c) => c.toUpperCase() == a.toUpperCase())) continue; candidates.add(a); } bool ok = false; for (final addr in candidates) { await _service.connectToDevice(addr, name); ok = await _waitConnected(); 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); } } /// 等状态流推到「已连接」,超时返回当前状态。 /// /// 进过「连接中」又掉回 0 就提前判失败:原生 `connectBlocking` 失败时会 /// `publishBluetoothStatus(0)`,不必每个候选都干等满超时——遍历同名设备的 /// 总耗时会从「N × timeout」降到接近真实失败时间。 /// /// ⚠️ 仍要保留超时兜底:原生若把这次 connect 丢弃了(见 BluetoothManager.kt /// 的 isConnecting 门禁),状态会停在 connectToDevice 自己写的 1,等不到 0。 Future _waitConnected( {Duration timeout = const Duration(seconds: 8)}) async { final end = DateTime.now().add(timeout); bool sawConnecting = false; while (DateTime.now().isBefore(end)) { final s = _service.deviceStatus.value; if (s == 2) return true; if (s == 1) sawConnecting = true; if (sawConnecting && s == 0) return false; await Future.delayed(const Duration(milliseconds: 300)); } return _service.deviceStatus.value == 2; } @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: [ CircularProgressIndicator(), 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 收集到, // 耳机连上/断开时列表不会重绘。 final live = _service.deviceStatus.value == 2; final connId = _service.connectedDeviceId.value; final connName = _service.connectedDeviceName.value; 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 && _isLiveDevice(_devices[i], connId, connName), ), ); }), ); } 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 // iOS 只能看到「系统里已经连上的」耳机,配过对但没连上是看不到的 ? '请先在系统「设置 → 蓝牙」里连接 $_targetName(显示"已连接"),再回到这里' : '$_targetName 走经典蓝牙,请先在系统「设置 → 蓝牙」里完成配对,再回到这里连接', textAlign: TextAlign.center, style: TextStyle( fontSize: 12.sp, color: AppPalette.textHint(isDark)), ), ], ), ), ); } /// 这一行是不是当前正连着的那台。 /// /// ⚠️ 不能只比地址:Android 上耳机是 TWS 双地址,系统配对列表给的地址不一定 /// 是真正跑 SPP 的那个(实测配对列表是 …C1:C4,SPP 走 …BE:F8)。只比地址会让 /// 明明连着的那台显示成「未连接」。地址对不上时退回比设备名——本项目一次只 /// 连一台耳机,重名的风险可以忽略。 bool _isLiveDevice( Map d, String connId, String connName) { final addr = (d['address'] as String? ?? '').toUpperCase(); if (addr.isNotEmpty && addr == connId.toUpperCase()) return true; final n = (d['name'] as String? ?? '').trim().toUpperCase(); final cn = connName.trim().toUpperCase(); return n.isNotEmpty && n == cn; } /// 一行设备。[connected] 决定这一行是高亮态还是置灰态。 /// /// 置灰的是「配过对但此刻没连上」的存量设备——它们仍然可点(点了就去连), /// 只是视觉权重压下去,让用户一眼看出哪台是活的。 Widget _tile(Map d, bool isDark, {bool connected = false}) { final name = (d['name'] ?? 'bleUnknownDevice'.tr) as String; final addr = (d['address'] ?? '') as String; 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(addr, 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)), ], ), ), ); } }