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 { /// 目前恒玄这条链路只对接 DEEPVOICE 耳机,其它已配对的经典蓝牙设备 /// (车机、音箱、别家耳机)都不该出现在这个页面里,直接按名字写死过滤。 static const String _targetName = '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, // 状态一旦变成「已连接」,这页也要把绑定存下来并跳回设备页。 _statusWorker = ever(_service.deviceStatus, (v) { if (v == 2 && mounted && !_finished) _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(); // 进页面时就已经连上了(冷启动自动回连 / 上个页面连过), // 不用再让用户点一次,直接按连接成功处理 if (_service.deviceStatus.value == 2) { if (!_finished) await _finish(); return; } 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 n.contains(_targetName); }).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 { // Android 上耳机是 TWS 双地址:系统配对列表给的地址不一定是能跑 SPP 的那个 // (实测配对列表是 …C1:C4,SPP 实际走 …BE:F8)。iOS 上 address 是 CBPeripheral // UUID,不存在双地址问题,但多试一个也不会有副作用。所以统一按 // 「原生当前已连接的 → 上次连成功过的 → 列表里的」依次试。 final candidates = []; try { final live = await _service.currentConnectedDevice(); final liveId = live?['address'] ?? ''; if (liveId.isNotEmpty) candidates.add(liveId); } catch (_) {} final last = _service.lastDeviceAddress; if (last.isNotEmpty && !candidates.contains(last)) candidates.add(last); if (!candidates.contains(id)) candidates.add(id); 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); } } /// 等状态流推到「已连接」,超时返回当前状态 Future _waitConnected( {Duration timeout = const Duration(seconds: 8)}) async { final end = DateTime.now().add(timeout); while (DateTime.now().isBefore(end)) { if (_service.deviceStatus.value == 2) return true; 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); 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), ); }), ); } 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)), ), ], ), ), ); } Widget _tile(Map d, bool isDark) { final name = (d['name'] ?? 'bleUnknownDevice'.tr) as String; final addr = (d['address'] ?? '') as String; 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.headphones, size: 26.sp, color: AppPalette.accent), SizedBox(width: 12.w), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(name, style: TextStyle( fontSize: 15.sp, fontWeight: FontWeight.w600, color: AppPalette.textPrimary(isDark))), SizedBox(height: 2.h), Text(addr, style: TextStyle( fontSize: 11.sp, color: AppPalette.textHint(isDark))), ], ), ), Text('连接', style: TextStyle( fontSize: 13.sp, fontWeight: FontWeight.w600, color: AppPalette.accent)), ], ), ), ); } }