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.
 
 
 
 
 
 

318 lines
12 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> {
/// 目前恒玄这条链路只对接 DEEPVOICE 耳机,其它已配对的经典蓝牙设备
/// (车机、音箱、别家耳机)都不该出现在这个页面里,直接按名字写死过滤。
static const String _targetName = '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,
// 状态一旦变成「已连接」,这页也要把绑定存下来并跳回设备页。
_statusWorker = ever<int>(_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<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();
// 进页面时就已经连上了(冷启动自动回连 / 上个页面连过),
// 不用再让用户点一次,直接按连接成功处理
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<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 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<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 {
// Android 上耳机是 TWS 双地址:系统配对列表给的地址不一定是能跑 SPP 的那个
// (实测配对列表是 …C1:C4,SPP 实际走 …BE:F8)。iOS 上 address 是 CBPeripheral
// UUID,不存在双地址问题,但多试一个也不会有副作用。所以统一按
// 「原生当前已连接的 → 上次连成功过的 → 列表里的」依次试。
final candidates = <String>[];
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<bool> _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<String, dynamic> 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)),
],
),
),
);
}
}