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.
398 lines
15 KiB
398 lines
15 KiB
import 'dart:async';
|
|
|
|
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 '../../../devices/device_hub.dart';
|
|
import '../../../devices/device_vendors.dart';
|
|
import '../../../routes/app_routes.dart';
|
|
import '../../main_tab/controllers/main_tab_controller.dart';
|
|
|
|
/// EaiCar(AI车载香薰)的扫描 / 连接页——走 [DeviceHub] 里的 EaiCar 插件。
|
|
///
|
|
/// 与支架那页结构一致,两点不同:
|
|
/// 1. **不需要后台自启动授权**:EaiCar 的连接是纯 GATT,没有向系统注册
|
|
/// 「设备靠近就唤醒本应用」那种后台扫连,不构成自启动行为。
|
|
/// 2. 卡片上多显示一行 MAC —— 它来自 BLE 广播,是服务端确权的依据,
|
|
/// 连不上时看一眼就知道是不是扫到了别的设备。
|
|
class EaiCarScanView extends StatefulWidget {
|
|
const EaiCarScanView({super.key});
|
|
|
|
@override
|
|
State<EaiCarScanView> createState() => _EaiCarScanViewState();
|
|
}
|
|
|
|
class _EaiCarScanViewState extends State<EaiCarScanView> {
|
|
DevicePlugin get _plugin => DeviceHub.to.plugin(DeviceVendors.eaicar)!;
|
|
|
|
final RxList<DiscoveredDevice> _scanned = <DiscoveredDevice>[].obs;
|
|
final RxBool _isScanning = false.obs;
|
|
|
|
/// 附近**所有** BLE 设备(含没命中判定的),按 id 去重。
|
|
/// 只在一台都没扫到时给人看——「扫不到」这个故障,
|
|
/// 不摊开设备到底播了什么就只能靠反复出包猜。
|
|
final RxMap<String, Map<String, Object?>> _nearby =
|
|
<String, Map<String, Object?>>{}.obs;
|
|
final RxBool _showDiag = false.obs;
|
|
|
|
/// 扫描起不来时的原因。**必须显示出来**:第一版失败是静默的,
|
|
/// 界面只会一直转圈,分不清「没扫到设备」和「压根没扫起来」。
|
|
final RxnString _scanError = RxnString();
|
|
StreamSubscription<DevicePluginEvent>? _sub;
|
|
bool _connecting = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_sub = _plugin.eventStream.listen((e) {
|
|
switch (e.type) {
|
|
case DevicePluginEventType.scanStarted:
|
|
_isScanning.value = true;
|
|
break;
|
|
case DevicePluginEventType.scanStopped:
|
|
_isScanning.value = false;
|
|
break;
|
|
case DevicePluginEventType.deviceDiscovered:
|
|
final d = e.discovered;
|
|
if (d == null || d.id.isEmpty) break;
|
|
// 按 id 去重累积:BLE 扫描会对同一台设备反复上报
|
|
final i = _scanned.indexWhere((x) => x.id == d.id);
|
|
if (i >= 0) {
|
|
_scanned[i] = d;
|
|
} else {
|
|
_scanned.add(d);
|
|
}
|
|
break;
|
|
case DevicePluginEventType.customEvent:
|
|
// 厂商插件转发的扫描诊断(业务层不能直接 import 厂商目录)
|
|
if (e.customKey == 'eaicar.scan_diagnostic') {
|
|
final d = e.customPayload;
|
|
if (d is Map<String, Object?>) {
|
|
final id = '${d['id']}';
|
|
if (id.isNotEmpty) _nearby[id] = d;
|
|
}
|
|
}
|
|
break;
|
|
case DevicePluginEventType.error:
|
|
_scanError.value = e.errorMessage ?? e.errorCode;
|
|
_isScanning.value = false;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
});
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _startScan());
|
|
}
|
|
|
|
Future<void> _startScan() async {
|
|
_scanned.clear();
|
|
_nearby.clear();
|
|
_scanError.value = null;
|
|
if (!await _plugin.ensureReady()) return;
|
|
await _plugin.startScan();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_sub?.cancel();
|
|
_plugin.stopScan();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _connect(DiscoveredDevice d) async {
|
|
if (_connecting) return;
|
|
setState(() => _connecting = true);
|
|
|
|
String? failReason;
|
|
try {
|
|
await _plugin.connect(
|
|
d.id,
|
|
// MAC 只在广播里有,连上就拿不到了。这里带过去给确权用,
|
|
// 免得连上之后再回头找。
|
|
options: DeviceConnectOptions(extra: {'mac': d.metadata['mac']}),
|
|
);
|
|
await _plugin.rememberDevice(d);
|
|
} on DeviceException catch (e) {
|
|
failReason = e.message ?? e.code;
|
|
} catch (e) {
|
|
failReason = '$e';
|
|
}
|
|
if (!mounted) return;
|
|
setState(() => _connecting = false);
|
|
|
|
if (failReason == null) {
|
|
Get.snackbar('eaicarConnected'.tr, d.name,
|
|
snackPosition: SnackPosition.TOP,
|
|
duration: const Duration(seconds: 2));
|
|
// BLE 通了不等于听得见:AI 回复和音乐走的是**经典蓝牙 A2DP**(见落地文档 7.0)。
|
|
// 没接上时提示一次,别让用户以为"连上了却没声音"是坏了。
|
|
unawaited(_hintClassicAudioIfNeeded(d.name));
|
|
Get.offAllNamed(Routes.home);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (Get.isRegistered<MainTabController>()) {
|
|
MainTabController.to.showDeviceAfterConnect();
|
|
}
|
|
});
|
|
} else {
|
|
// ⚠️ 失败原因**留在页面上**,不能只弹一下就没了:
|
|
// 这些信息(设备实际暴露了哪些服务、握手卡在哪条命令)是排查的全部依据,
|
|
// snackbar 几秒就消失,用户根本来不及看清更别说转述。
|
|
_scanError.value = failReason;
|
|
Get.snackbar('eaicarConnectFailed'.tr, failReason,
|
|
snackPosition: SnackPosition.TOP,
|
|
duration: const Duration(seconds: 5));
|
|
}
|
|
}
|
|
|
|
/// 经典蓝牙没接上就提示一次。
|
|
///
|
|
/// - Android:插件已经在连接后自动 `createBond` 试过一次,这里只兜底提示
|
|
/// - iOS:**没有**发起经典蓝牙配对的公开 API,只能引导用户去系统设置
|
|
Future<void> _hintClassicAudioIfNeeded(String name) async {
|
|
try {
|
|
final r = await _plugin.activeSession
|
|
?.invokeFeature('eaicar.a2dp_status');
|
|
if (r == null || r['connected'] == true) return;
|
|
Get.snackbar(
|
|
'eaicarA2dpTitle'.tr,
|
|
'eaicarA2dpHint'.tr.replaceAll('@name', name),
|
|
snackPosition: SnackPosition.TOP,
|
|
duration: const Duration(seconds: 6),
|
|
);
|
|
} catch (_) {
|
|
// 查不到就不提示——这只是体验提示,不该把连接流程搅黄
|
|
}
|
|
}
|
|
|
|
@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: Text('catCarAroma'.tr),
|
|
actions: [
|
|
Obx(() => _isScanning.value
|
|
? Padding(
|
|
padding: EdgeInsets.only(right: 16.w),
|
|
child: Center(
|
|
child: SizedBox(
|
|
width: 18.w,
|
|
height: 18.w,
|
|
child: const CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
),
|
|
)
|
|
: IconButton(
|
|
icon: const Icon(Icons.refresh),
|
|
onPressed: _startScan,
|
|
)),
|
|
],
|
|
),
|
|
body: _connecting
|
|
? Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const CircularProgressIndicator(),
|
|
SizedBox(height: 16.h),
|
|
Text('eaicarConnecting'.tr),
|
|
],
|
|
),
|
|
)
|
|
: Obx(() {
|
|
final list = _scanned;
|
|
if (list.isEmpty) return _empty(isDark);
|
|
return ListView.separated(
|
|
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h),
|
|
itemCount: list.length,
|
|
separatorBuilder: (_, __) => SizedBox(height: 12.h),
|
|
itemBuilder: (context, i) => _card(list[i], isDark),
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
|
|
Widget _empty(bool isDark) => Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.bluetooth_searching,
|
|
size: 56.sp, color: AppPalette.brand300),
|
|
SizedBox(height: 12.h),
|
|
Obx(() => Text(
|
|
_isScanning.value
|
|
? 'eaicarSearching'.tr
|
|
: 'eaicarNotFound'.tr,
|
|
style: TextStyle(
|
|
fontSize: 15.sp,
|
|
color: AppPalette.textSecondary(isDark)),
|
|
)),
|
|
SizedBox(height: 6.h),
|
|
Text(
|
|
'eaicarScanHint'.tr,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 12.sp, color: AppPalette.textHint(isDark)),
|
|
),
|
|
SizedBox(height: 20.h),
|
|
_diagnostics(isDark),
|
|
],
|
|
),
|
|
);
|
|
|
|
/// 诊断面板:把附近扫到的 BLE 设备摊开。
|
|
///
|
|
/// 判据(厂商 ID 0x8505 / 名字含 eaicar)对不对,看这里最快:
|
|
/// - 列表里根本没有目标设备 → 设备没在播,或不在范围内
|
|
/// - 有、但 mfg 那栏的厂商 ID 不是 0x8505 → 判据要按实际值改
|
|
/// - 有、厂商 ID 对但长度不足 22 → 广播段布局和文档对不上
|
|
Widget _diagnostics(bool isDark) => Obx(() {
|
|
final list = _nearby.values.toList();
|
|
final err = _scanError.value;
|
|
// ⚠️ 0 台也要显示。「附近 0 台」和「面板不出现」是两回事:
|
|
// 前者说明扫描跑了但什么都没收到,后者说明扫描压根没起来。
|
|
return Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 24.w),
|
|
child: Column(
|
|
children: [
|
|
if (err != null)
|
|
Container(
|
|
width: double.infinity,
|
|
margin: EdgeInsets.only(bottom: 8.h),
|
|
padding: EdgeInsets.all(10.w),
|
|
decoration: BoxDecoration(
|
|
color: Colors.red.withValues(alpha: 0.08),
|
|
borderRadius: BorderRadius.circular(8.r),
|
|
),
|
|
child: SelectableText('失败详情:$err',
|
|
style: TextStyle(fontSize: 11.sp, color: Colors.red)),
|
|
),
|
|
TextButton.icon(
|
|
onPressed: () => _showDiag.value = !_showDiag.value,
|
|
icon: Icon(
|
|
_showDiag.value ? Icons.expand_less : Icons.expand_more,
|
|
size: 18.sp),
|
|
label: Text('附近 ${list.length} 台蓝牙设备',
|
|
style: TextStyle(fontSize: 12.sp)),
|
|
),
|
|
if (_showDiag.value && list.isEmpty)
|
|
Text('一台都没收到 —— 蓝牙可能没开、权限被拒,或周围确实没有设备',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 11.sp, color: AppPalette.textHint(isDark))),
|
|
if (_showDiag.value && list.isNotEmpty)
|
|
Container(
|
|
constraints: BoxConstraints(maxHeight: 260.h),
|
|
padding: EdgeInsets.all(10.w),
|
|
decoration: BoxDecoration(
|
|
color: AppPalette.surface(isDark),
|
|
borderRadius: BorderRadius.circular(10.r),
|
|
border: Border.all(color: AppPalette.border(isDark)),
|
|
),
|
|
child: ListView.builder(
|
|
shrinkWrap: true,
|
|
itemCount: list.length,
|
|
itemBuilder: (_, i) {
|
|
final d = list[i];
|
|
final matched = d['matched'] == true;
|
|
final name = '${d['name']}';
|
|
return Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 4.h),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'${matched ? '✓ ' : ''}'
|
|
'${name.isEmpty ? '(无名)' : name} ${d['rssi']}dBm',
|
|
style: TextStyle(
|
|
fontSize: 11.sp,
|
|
fontWeight: matched
|
|
? FontWeight.w700
|
|
: FontWeight.w400,
|
|
color: AppPalette.textPrimary(isDark),
|
|
),
|
|
),
|
|
// 可长按复制:MAC 解错时要把这串原始字节发出来分析
|
|
SelectableText(
|
|
'mfg=${d['mfg']}\n'
|
|
'解出MAC=${d['parsedMac']}\n'
|
|
'svc=${d['services']}',
|
|
style: TextStyle(
|
|
fontSize: 10.sp,
|
|
color: AppPalette.textHint(isDark)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
});
|
|
|
|
Widget _card(DiscoveredDevice d, bool isDark) {
|
|
final mac = '${d.metadata['mac'] ?? ''}';
|
|
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.air, size: 26.sp, color: AppPalette.brand500),
|
|
SizedBox(width: 12.w),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
d.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 15.sp,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppPalette.textPrimary(isDark),
|
|
),
|
|
),
|
|
if (mac.isNotEmpty) ...[
|
|
SizedBox(height: 3.h),
|
|
Text(
|
|
mac,
|
|
style: TextStyle(
|
|
fontSize: 11.sp,
|
|
color: AppPalette.textHint(isDark),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
if (d.rssi != null)
|
|
Text(
|
|
'${d.rssi} dBm',
|
|
style: TextStyle(
|
|
fontSize: 11.sp, color: AppPalette.textHint(isDark)),
|
|
),
|
|
SizedBox(width: 6.w),
|
|
Icon(Icons.chevron_right,
|
|
size: 20.sp, color: AppPalette.textHint(isDark)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|