rtc_prd/airhub_app/lib/pages/bluetooth_page.dart
2026-02-10 18:21:21 +08:00

830 lines
25 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../core/services/ble_provisioning_service.dart';
import '../features/device/data/datasources/device_remote_data_source.dart';
import '../features/device/presentation/controllers/device_controller.dart';
import '../widgets/animated_gradient_background.dart';
import '../widgets/gradient_button.dart';
import '../widgets/glass_dialog.dart';
/// 设备类型
enum DeviceType { plush, badgeAi, badge }
/// 模拟设备数据模型
class MockDevice {
final String sn;
final String name;
final String macAddress;
final DeviceType type;
final bool hasAI;
final bool isNetworkRequired;
final BluetoothDevice? bleDevice;
const MockDevice({
required this.sn,
required this.name,
required this.macAddress,
required this.type,
required this.hasAI,
this.isNetworkRequired = true,
this.bleDevice,
});
String get iconPath {
switch (type) {
case DeviceType.plush:
return 'assets/www/icons/pixel-capybara.svg';
case DeviceType.badgeAi:
return 'assets/www/icons/pixel-badge-ai.svg';
case DeviceType.badge:
return 'assets/www/icons/pixel-badge-basic.svg';
}
}
String get typeLabel {
switch (type) {
case DeviceType.plush:
return '毛绒机芯';
case DeviceType.badgeAi:
return 'AI电子吧唧';
case DeviceType.badge:
return '普通电子吧唧';
}
}
}
/// 蓝牙搜索页面
class BluetoothPage extends ConsumerStatefulWidget {
const BluetoothPage({super.key});
@override
ConsumerState<BluetoothPage> createState() => _BluetoothPageState();
}
class _BluetoothPageState extends ConsumerState<BluetoothPage>
with TickerProviderStateMixin {
/// Airhub 设备名前缀(硬件广播格式: Airhub_ + MAC
static const _airhubPrefix = 'Airhub_';
// 状态
bool _isSearching = true;
bool _isBluetoothOn = false;
List<MockDevice> _devices = [];
int _currentIndex = 0;
// 已查询过的 MAC → 设备信息缓存(避免重复调 API
final Map<String, Map<String, dynamic>> _macInfoCache = {};
// 动画控制器
late AnimationController _searchAnimController;
// 滚轮控制器
late FixedExtentScrollController _wheelController;
// 蓝牙订阅
StreamSubscription<BluetoothAdapterState>? _bluetoothSubscription;
StreamSubscription<List<ScanResult>>? _scanSubscription;
// 是否已弹过蓝牙关闭提示(避免重复弹窗)
bool _hasShownBluetoothDialog = false;
@override
void initState() {
super.initState();
// 搜索动画 (神秘盒子浮动) - 800ms 周期2s 搜索内可完成多次弹跳
_searchAnimController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
)..repeat(reverse: true);
// 滚轮控制器
_wheelController = FixedExtentScrollController(initialItem: _currentIndex);
// 监听蓝牙适配器状态
_listenBluetoothState();
}
@override
void dispose() {
_bluetoothSubscription?.cancel();
_scanSubscription?.cancel();
FlutterBluePlus.stopScan();
_searchAnimController.dispose();
_wheelController.dispose();
super.dispose();
}
/// 监听蓝牙适配器状态
void _listenBluetoothState() {
_bluetoothSubscription = FlutterBluePlus.adapterState.listen((state) {
if (!mounted) return;
final isOn = state == BluetoothAdapterState.on;
setState(() => _isBluetoothOn = isOn);
if (isOn) {
_startSearch();
} else if (state == BluetoothAdapterState.off) {
FlutterBluePlus.stopScan();
setState(() {
_isSearching = false;
_devices.clear();
});
if (!_hasShownBluetoothDialog) {
_hasShownBluetoothDialog = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _showBluetoothOffDialog();
});
}
}
});
}
/// 从设备名中提取 MAC 地址(格式: Airhub_XXXXXXXXXXXX 或 Airhub_XX:XX:XX:XX:XX:XX
/// 返回标准格式 XX:XX:XX:XX:XX:XX大写带冒号或 null
String? _extractMacFromName(String bleName) {
if (!bleName.startsWith(_airhubPrefix)) return null;
final rawMac = bleName.substring(_airhubPrefix.length).trim();
if (rawMac.isEmpty) return null;
// 移除冒号/横杠,统一处理
final hex = rawMac.replaceAll(RegExp(r'[:\-]'), '').toUpperCase();
if (hex.length != 12 || !RegExp(r'^[0-9A-F]{12}$').hasMatch(hex)) {
debugPrint('[BLE Scan] MAC 格式异常: $rawMac');
return null;
}
// 转为 XX:XX:XX:XX:XX:XX
return '${hex.substring(0, 2)}:${hex.substring(2, 4)}:${hex.substring(4, 6)}:'
'${hex.substring(6, 8)}:${hex.substring(8, 10)}:${hex.substring(10, 12)}';
}
// 暂存扫描到但尚未完成 API 查询的 Airhub 设备 BLE 句柄
final Map<String, BluetoothDevice> _pendingBleDevices = {};
/// 开始 BLE 扫描(持续扫描,直到找到设备并完成 API 查询)
Future<void> _startSearch() async {
if (!_isBluetoothOn) {
_showBluetoothOffDialog();
return;
}
await _requestPermissions();
if (!mounted) return;
setState(() {
_isSearching = true;
_devices.clear();
_currentIndex = 0;
});
_pendingBleDevices.clear();
_scanSubscription?.cancel();
_scanSubscription = FlutterBluePlus.onScanResults.listen((results) {
if (!mounted) return;
for (final r in results) {
final name = r.device.platformName;
if (name.isEmpty) continue;
final mac = _extractMacFromName(name);
if (mac == null) continue;
// 记录 BLE 句柄
_pendingBleDevices[mac] = r.device;
// 如果没查过这个 MAC发起 API 查询
if (!_macInfoCache.containsKey(mac)) {
_macInfoCache[mac] = {}; // 占位,避免重复查询
_queryDeviceByMac(mac);
}
}
});
// 持续扫描(不设超时),由 _queryDeviceByMac 成功后停止
await FlutterBluePlus.startScan(
timeout: const Duration(seconds: 30),
androidUsesFineLocation: true,
);
// 30 秒兜底超时:如果始终没找到设备
if (mounted && _isSearching) {
setState(() => _isSearching = false);
}
}
/// 通过 MAC 调用后端 API 查询设备信息
/// 查询成功后:添加设备到列表、停止扫描、结束搜索状态
Future<void> _queryDeviceByMac(String mac) async {
try {
final dataSource = ref.read(deviceRemoteDataSourceProvider);
debugPrint('[Bluetooth] queryByMac: $mac');
final data = await dataSource.queryByMac(mac);
debugPrint('[Bluetooth] queryByMac 返回: $data');
if (!mounted) return;
_macInfoCache[mac] = data;
final deviceTypeName = data['device_type']?['name'] as String? ?? '';
final sn = data['sn'] as String? ?? '';
final isNetworkRequired = data['device_type']?['is_network_required'] as bool? ?? true;
final bleDevice = _pendingBleDevices[mac];
// API 返回了有效设备名 → 添加到列表
final displayName = deviceTypeName.isNotEmpty ? deviceTypeName : 'Airhub 设备';
setState(() {
// 避免重复添加
if (!_devices.any((d) => d.macAddress == mac)) {
_devices.add(MockDevice(
sn: sn,
name: displayName,
macAddress: mac,
type: _inferDeviceType(displayName),
hasAI: _inferHasAI(displayName),
isNetworkRequired: isNetworkRequired,
bleDevice: bleDevice,
));
}
// 有设备了,结束搜索状态
_isSearching = false;
});
// 停止扫描
try { await FlutterBluePlus.stopScan(); } catch (_) {}
debugPrint('[Bluetooth] 设备已就绪: $mac$displayName');
} catch (e) {
debugPrint('[Bluetooth] queryByMac 失败($mac): $e');
// API 查询失败时,用 BLE 名作为 fallback 也显示出来
if (!mounted) return;
final bleDevice = _pendingBleDevices[mac];
setState(() {
if (!_devices.any((d) => d.macAddress == mac)) {
_devices.add(MockDevice(
sn: '',
name: '${_airhubPrefix}设备',
macAddress: mac,
type: DeviceType.plush,
hasAI: true,
bleDevice: bleDevice,
));
}
_isSearching = false;
});
try { await FlutterBluePlus.stopScan(); } catch (_) {}
}
}
/// 根据设备名称推断设备类型
DeviceType _inferDeviceType(String name) {
final lower = name.toLowerCase();
if (lower.contains('plush') || lower.contains('卡皮巴拉') || lower.contains('机芯') || lower.contains('airhub')) {
return DeviceType.plush;
}
if (lower.contains('ai') || lower.contains('智能')) {
return DeviceType.badgeAi;
}
return DeviceType.badge;
}
/// 根据设备名称推断是否支持 AI
bool _inferHasAI(String name) {
final lower = name.toLowerCase();
return lower.contains('ai') || lower.contains('plush') ||
lower.contains('卡皮巴拉') || lower.contains('机芯') || lower.contains('智能') || lower.contains('airhub');
}
/// 请求蓝牙权限
Future<void> _requestPermissions() async {
try {
if (Platform.isAndroid) {
// Android 需要位置权限才能扫描 BLE
await Permission.bluetoothScan.request();
await Permission.bluetoothConnect.request();
await Permission.location.request();
} else {
// iOS 只需蓝牙权限,不需要位置
await Permission.bluetooth.request();
}
} catch (e) {
debugPrint('[Bluetooth] 权限请求异常: $e');
}
}
/// 蓝牙未开启弹窗
void _showBluetoothOffDialog() {
if (!mounted) return;
showGlassDialog(
context: context,
title: '蓝牙未开启',
description: '请开启蓝牙以搜索附近的设备',
cancelText: '取消',
confirmText: Platform.isAndroid ? '开启蓝牙' : '去设置',
onConfirm: () {
Navigator.of(context).pop();
if (Platform.isAndroid) {
// Android 可直接请求开启蓝牙
FlutterBluePlus.turnOn();
} else {
// iOS 无法直接开启,引导到系统设置
openAppSettings();
}
},
);
}
bool _isConnecting = false;
/// 连接设备
Future<void> _handleConnect() async {
if (_devices.isEmpty || _isConnecting) return;
// 检查蓝牙状态
if (!_isBluetoothOn) {
_showBluetoothOffDialog();
return;
}
final device = _devices[_currentIndex];
debugPrint('[Bluetooth] 连接设备: sn=${device.sn}, mac=${device.macAddress}, needsNetwork=${device.isNetworkRequired}');
if (!device.isNetworkRequired) {
// 不需要联网 -> 直接去设备控制页
context.go('/device-control');
return;
}
// Web 环境:跳过 BLE 和 WiFi 配网,直接绑定设备
if (kIsWeb) {
debugPrint('[Bluetooth] Web 环境,直接绑定 sn=${device.sn}');
setState(() => _isConnecting = true);
if (device.sn.isNotEmpty) {
await ref.read(deviceControllerProvider.notifier).bindDevice(device.sn);
}
if (!mounted) return;
setState(() => _isConnecting = false);
context.go('/device-control');
return;
}
// 需要联网 -> BLE 连接后进入 WiFi 配网
final bleDevice = device.bleDevice;
if (bleDevice == null) {
debugPrint('[Bluetooth] 无 BLE 句柄,无法连接');
return;
}
setState(() => _isConnecting = true);
// 连接前先停止扫描iOS 上扫描和连接并发会冲突)
try {
await FlutterBluePlus.stopScan();
} catch (_) {}
await Future.delayed(const Duration(milliseconds: 300));
final provService = BleProvisioningService();
final ok = await provService.connect(bleDevice);
if (!mounted) return;
setState(() => _isConnecting = false);
if (!ok) {
showGlassDialog(
context: context,
title: '连接失败',
description: '无法连接到设备,请确认设备已开机并靠近手机',
confirmText: '确定',
onConfirm: () => Navigator.of(context).pop(),
);
return;
}
// BLE 连接成功,跳转 WiFi 配网页并传递 service
context.go('/wifi-config', extra: {
'provService': provService,
'sn': device.sn,
'name': device.name,
'mac': device.macAddress,
'type': device.type.name,
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: Stack(
children: [
// 渐变背景
_buildGradientBackground(),
// 内容
SafeArea(
child: Column(
children: [
// Header
_buildHeader(),
// 设备数量提示
_buildCountLabel(),
// 主内容区域
Expanded(
child: _isSearching
? _buildSearchingState()
: _buildDeviceCards(),
),
// Footer
_buildFooter(),
],
),
),
],
),
);
}
/// 渐变背景
Widget _buildGradientBackground() {
return const AnimatedGradientBackground();
}
/// Header - HTML: padding 16px 20px (vertical horizontal)
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
children: [
// 返回按钮 - CSS: border-radius: 12px, bg: rgba(255,255,255,0.6), no border
GestureDetector(
onTap: () => context.go('/home'),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12), // Rounded square, not circle
color: Colors.white.withOpacity(0.6),
// No border per HTML
),
child: const Icon(
Icons.arrow_back_ios_new,
size: 18,
color: Color(0xFF4B5563), // Gray per HTML, not purple
),
),
),
// 标题
Expanded(
child: Text(
'搜索设备',
textAlign: TextAlign.center,
style: GoogleFonts.outfit(
fontSize: 17,
fontWeight: FontWeight.w600,
color: const Color(0xFF1F2937),
),
),
),
// 占位
const SizedBox(width: 40),
],
),
);
}
/// 设备数量标签
Widget _buildCountLabel() {
return AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity: _isSearching ? 0 : 1,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 20),
child: _devices.isEmpty
? const SizedBox.shrink()
: Text.rich(
TextSpan(
text: '找到 ',
style: TextStyle(
fontSize: 14,
color: const Color(0xFF9CA3AF),
),
children: [
TextSpan(
text: '${_devices.length}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: const Color(0xFF8B5CF6),
),
),
TextSpan(
text: _devices.length > 1 ? ' 个设备 · 滑动切换' : ' 个设备',
),
],
),
textAlign: TextAlign.center,
),
),
);
}
/// 搜索中状态
Widget _buildSearchingState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 神秘盒子动画
AnimatedBuilder(
animation: _searchAnimController,
builder: (context, child) {
return Transform.translate(
offset: Offset(0, -15 * _searchAnimController.value),
child: child,
);
},
// HTML: mystery-box is transparent, icon is 120x120 with amber drop-shadow
child: SvgPicture.asset(
'assets/www/icons/pixel-mystery-box.svg',
width: 120,
height: 120,
placeholderBuilder: (_) => Text(
'?',
style: GoogleFonts.outfit(
fontSize: 48,
fontWeight: FontWeight.w700,
color: const Color(0xFFF59E0B), // Amber color per HTML
),
),
),
),
const SizedBox(height: 24),
// 搜索状态文字
Text(
'正在搜索附近设备',
style: TextStyle(
fontSize: 16,
color: const Color(0xFF4B5563),
),
),
],
),
);
}
/// 设备卡片区域 - 滚轮选择器风格
Widget _buildDeviceCards() {
if (_devices.isEmpty) {
return Center(
child: Text(
'未找到设备',
style: TextStyle(
fontSize: 16,
color: const Color(0xFF9CA3AF),
),
),
);
}
final itemExtent = 180.0;
return SizedBox(
height: MediaQuery.of(context).size.height * 0.46,
child: Stack(
children: [
// 滚轮视图 + ShaderMask 实现上下边缘渐隐
ShaderMask(
shaderCallback: (Rect rect) {
return const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black,
Colors.black,
Colors.transparent,
],
stops: [0.05, 0.38, 0.62, 0.95],
).createShader(rect);
},
blendMode: BlendMode.dstIn,
child: ListWheelScrollView.useDelegate(
controller: _wheelController,
itemExtent: itemExtent,
diameterRatio: 1.8,
perspective: 0.006,
physics: const FixedExtentScrollPhysics(),
onSelectedItemChanged: (index) {
setState(() => _currentIndex = index);
},
childDelegate: ListWheelChildBuilderDelegate(
childCount: _devices.length,
builder: (context, index) {
return AnimatedBuilder(
animation: _wheelController,
builder: (context, child) {
final offset = _wheelController.hasClients
? _wheelController.offset
: _currentIndex * itemExtent;
// 当前 item 中心与视口中心的距离
final distFromCenter =
(index * itemExtent - offset).abs();
// 归一化到 0~1 (0=居中, 1=远离)
final t = (distFromCenter / itemExtent).clamp(0.0, 1.5);
// 居中=1.0, 远离=0.65
final scale = 1.0 - (t * 0.25);
return Transform.scale(
scale: scale.clamp(0.65, 1.0),
child: child,
);
},
child: Center(child: _buildDeviceCard(_devices[index])),
);
},
),
),
),
// 右侧圆点指示器
if (_devices.length > 1)
Positioned(
right: 20,
top: 0,
bottom: 0,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: List.generate(
_devices.length,
(index) => _buildDot(index == _currentIndex),
),
),
),
),
],
),
);
}
/// 单个设备卡片
Widget _buildDeviceCard(MockDevice device) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// 图标 + AI 徽章
Stack(
clipBehavior: Clip.none,
children: [
// 设备图标 - HTML: no background wrapper, icon is 120x120
SizedBox(
width: 120,
height: 120,
child: _buildDeviceIcon(device),
),
// AI 徽章
if (device.hasAI)
Positioned(
top: -4,
right: -4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF8B5CF6), Color(0xFF6366F1)],
),
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: const Color(0xFF8B5CF6).withOpacity(0.4),
offset: const Offset(0, 4),
blurRadius: 10,
),
],
),
child: Text(
'AI',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
),
],
),
const SizedBox(height: 10),
// 设备名称
Text(
device.name,
style: GoogleFonts.outfit(
fontSize: 18,
fontWeight: FontWeight.w600,
color: const Color(0xFF1F2937),
),
),
],
);
}
/// 设备图标 - HTML: 120x120 per CSS .card-icon-img
Widget _buildDeviceIcon(MockDevice device) {
return SvgPicture.asset(
device.iconPath,
width: 120,
height: 120,
fit: BoxFit.contain,
placeholderBuilder: (_) {
IconData icon;
switch (device.type) {
case DeviceType.plush:
icon = Icons.pets;
case DeviceType.badgeAi:
icon = Icons.smart_toy;
case DeviceType.badge:
icon = Icons.badge;
}
return Icon(icon, size: 80, color: const Color(0xFF8B5CF6));
},
);
}
/// 指示器圆点
Widget _buildDot(bool isActive) {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
margin: const EdgeInsets.symmetric(vertical: 4),
width: 6,
height: isActive ? 18 : 6,
decoration: BoxDecoration(
color: isActive
? const Color(0xFF8B5CF6)
: const Color(0xFF8B5CF6).withOpacity(0.2),
borderRadius: BorderRadius.circular(isActive ? 3 : 3),
),
);
}
/// Footer - HTML: padding 20px 20px 60px, gap 16px, centered buttons
Widget _buildFooter() {
return Container(
padding: EdgeInsets.fromLTRB(
20, // HTML: 20px sides
20, // HTML: 20px top
20,
MediaQuery.of(context).padding.bottom + 60, // HTML: safe-area + 60px
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 取消按钮 - HTML: frosted glass with border
GestureDetector(
onTap: () => context.go('/home'),
child: ClipRRect(
borderRadius: BorderRadius.circular(25),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.8),
borderRadius: BorderRadius.circular(25),
border: Border.all(color: const Color(0xFFE5E7EB)),
),
child: Text(
_isSearching ? '取消搜索' : '取消',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Color(0xFF6B7280),
),
),
),
),
),
// 连接按钮 (搜索完成后显示)
if (!_isSearching && _devices.isNotEmpty) ...[
const SizedBox(width: 16), // HTML: gap 16px
GradientButton(
text: _isConnecting ? '连接中...' : '连接设备',
width: 180,
height: 52,
onPressed: _isConnecting ? null : _handleConnect,
),
],
],
),
);
}
}