You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
402 lines
18 KiB
402 lines
18 KiB
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<BesScanView> createState() => _BesScanViewState();
|
|
}
|
|
|
|
class _BesScanViewState extends State<BesScanView> {
|
|
/// 展示用的设备名(提示文案、连接成功 snackbar 的兜底)。当前固件的广播名。
|
|
///
|
|
/// ⚠️ 大小写按设备端实际广播的写;**匹配**不要直接拿它比,走
|
|
/// [_acceptedNames]([_findTargets] 里统一转大写)——上一版常量恰好是全大写的
|
|
/// `DEEPVOICE`,跟大写后的设备名比才碰巧成立,改成混合大小写就会全部匹配不到。
|
|
static const String _targetName = 'Echo-one';
|
|
|
|
/// 认哪些广播名。恒玄这条链路只对接自家耳机,其它已配对的经典蓝牙设备
|
|
/// (车机、音箱、别家耳机)都不该出现在这个页面里,直接按名字写死过滤。
|
|
///
|
|
/// 之所以是列表而不是单个值:设备端 2026-08-31 把广播名从 `DEEPVOICE` 改成
|
|
/// `Echo-one`,但 Android 的 `getBondedDevices` 给的是**系统缓存的配对名**,
|
|
/// 改名前配过对的手机在重新配对之前仍然报旧名,只认新名会让这些机器扫不到。
|
|
/// 旧名可在存量设备全部换代后删除。
|
|
static const List<String> _acceptedNames = ['Echo-one', 'DEEPVOICE'];
|
|
|
|
late final BesBluetoothService _service;
|
|
final RxList<Map<String, dynamic>> _devices = <Map<String, dynamic>>[].obs;
|
|
final RxBool _loading = true.obs;
|
|
bool _connecting = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_service = Get.isRegistered<BesBluetoothService>()
|
|
? Get.find<BesBluetoothService>()
|
|
: Get.put(BesBluetoothService());
|
|
// 主动连接失败后耳机才连上手机的场景:原生靠 ACL 广播自动补 RFCOMM,
|
|
// 状态一旦变成「已连接」,这页也要把绑定存下来并跳回设备页。
|
|
//
|
|
// ⚠️ `_connecting` 期间不能开火:_connect 正在按候选列表逐个试,
|
|
// 第一个候选一连上就 _finish() 会把用户点的那台挤掉(存盘存成先连上的
|
|
// 那一台并直接跳走)。候选循环自己会收尾——
|
|
// · 回连发生在循环期间 → _waitConnected 看到 2 就 break,由 _connect 走 _finish;
|
|
// · 回连发生在循环之后 → finally 已把 _connecting 置回 false,这里照常开火。
|
|
// 两条路都有人收尾,不存在空档。
|
|
_statusWorker = ever<int>(_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<void> _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>()) {
|
|
MainTabController.to.changeTab(MainTabController.tabDevice);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _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<List<Map<String, dynamic>>> _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<void> _connect(Map<String, dynamic> 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 = <String>[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<bool> _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<String, dynamic> 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<String, dynamic> 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)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|