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

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';
/// Smartcar(AI车载香薰)的扫描 / 连接页——走 [DeviceHub] 里的 Smartcar 插件。
///
/// 与支架那页结构一致,两点不同:
/// 1. **不需要后台自启动授权**:Smartcar 的连接是纯 GATT,没有向系统注册
/// 「设备靠近就唤醒本应用」那种后台扫连,不构成自启动行为。
/// 2. 卡片上多显示一行 MAC —— 它来自 BLE 广播,是服务端确权的依据,
/// 连不上时看一眼就知道是不是扫到了别的设备。
class SmartcarScanView extends StatefulWidget {
const SmartcarScanView({super.key});
@override
State<SmartcarScanView> createState() => _SmartcarScanViewState();
}
class _SmartcarScanViewState extends State<SmartcarScanView> {
DevicePlugin get _plugin => DeviceHub.to.plugin(DeviceVendors.smartcar)!;
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 == 'smartcar.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('smartcarConnected'.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.changeTab(MainTabController.tabDevice);
}
});
} else {
// ⚠️ 失败原因**留在页面上**,不能只弹一下就没了:
// 这些信息(设备实际暴露了哪些服务、握手卡在哪条命令)是排查的全部依据,
// snackbar 几秒就消失,用户根本来不及看清更别说转述。
_scanError.value = failReason;
Get.snackbar('smartcarConnectFailed'.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('smartcar.a2dp_status');
if (r == null || r['connected'] == true) return;
Get.snackbar(
'smartcarA2dpTitle'.tr,
'smartcarA2dpHint'.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('smartcarConnecting'.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
? 'smartcarSearching'.tr
: 'smartcarNotFound'.tr,
style: TextStyle(
fontSize: 15.sp,
color: AppPalette.textSecondary(isDark)),
)),
SizedBox(height: 6.h),
Text(
'smartcarScanHint'.tr,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12.sp, color: AppPalette.textHint(isDark)),
),
SizedBox(height: 20.h),
_diagnostics(isDark),
],
),
);
/// 诊断面板:把附近扫到的 BLE 设备摊开。
///
/// 判据(厂商 ID 0x8505 / 名字含 smartcar)对不对,看这里最快:
/// - 列表里根本没有目标设备 → 设备没在播,或不在范围内
/// - 有、但 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)),
],
),
),
);
}
}