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.
 
 
 
 
 
 

331 lines
13 KiB

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<BesScanView> createState() => _BesScanViewState();
}
class _BesScanViewState extends State<BesScanView> {
/// 展示用的设备名。当前固件的广播名。
static const String _targetName = 'Echo-one';
/// 认哪些广播名:设备端 2026-08-31 把广播名从 `DEEPVOICE` 改成 `Echo-one`,
/// 但 Android 的配对列表给的是系统缓存的配对名,改名前配过对的手机在重新配对
/// 之前仍然报旧名。旧名可在存量设备全部换代后删除。
static const List<String> _acceptedNames = ['Echo-one', 'DEEPVOICE'];
DevicePlugin get _plugin => DeviceHub.to.plugin(DeviceVendors.bes)!;
final RxList<DiscoveredDevice> _devices = <DiscoveredDevice>[].obs;
final RxBool _loading = true.obs;
bool _connecting = false;
Worker? _sessionsWorker;
bool _finished = false;
@override
void initState() {
super.initState();
// 主动连接失败后耳机才连上手机的场景:原生靠 ACL 广播自动补 RFCOMM,
// 一旦上线,这页也要把绑定存下来并跳回设备页。
// `_connecting` 期间不开火:候选循环自己会收尾。
_sessionsWorker = ever<List<DeviceSession>>(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<void> _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>()) {
MainTabController.to.showDeviceAfterConnect();
}
});
}
Future<void> _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<List<DiscoveredDevice>> _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<void> _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 = <String>[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)),
],
),
),
);
}
}