rtc_prd/airhub_app/lib/pages/bluetooth_page.dart
2026-03-26 10:07:08 +08:00

953 lines
30 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';
import '../theme/product_theme.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 String bindStatus; // unbound / bound_by_me / bound_by_other
final BluetoothDevice? bleDevice;
const MockDevice({
required this.sn,
required this.name,
required this.macAddress,
required this.type,
required this.hasAI,
this.isNetworkRequired = true,
this.bindStatus = 'unbound',
this.bleDevice,
});
bool get isBoundByOther => bindStatus == 'bound_by_other';
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 = !kIsWeb; // Web 平台不自动搜索,需用户手势触发
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) {
// Web 平台: BLE scan 必须由用户手势触发,不自动扫描
if (!kIsWeb) _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 {
// Web 平台: 跳过蓝牙状态检查Web Bluetooth API 会自行处理可用性)
if (!kIsWeb && !_isBluetoothOn) {
_showBluetoothOffDialog();
return;
}
// Web 平台: 不能在 startScan 前 await 任何异步操作,
// 否则会丢失用户手势上下文Web Bluetooth API 要求
// requestDevice 必须在用户手势的同步调用链中触发)
if (!kIsWeb) {
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 成功后停止
try {
await FlutterBluePlus.startScan(
timeout: const Duration(seconds: 30),
androidUsesFineLocation: true,
);
} catch (e) {
// Web 平台: 用户取消 requestDevice() 选择器会抛出 FlutterBluePlusException
debugPrint('[BLE Scan] startScan 异常(用户可能取消了选择器): $e');
if (mounted) {
setState(() => _isSearching = false);
}
return;
}
// 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 productCode = data['device_type']?['product_code'] as String? ?? '';
final sn = data['sn'] as String? ?? '';
final isNetworkRequired = data['device_type']?['is_network_required'] as bool? ?? true;
final bindStatus = data['bind_status'] as String? ?? 'unbound';
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: _inferDeviceTypeByCode(productCode, displayName),
hasAI: _inferHasAI(displayName),
isNetworkRequired: isNetworkRequired,
bindStatus: bindStatus,
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 (_) {}
}
}
/// 根据设备名称推断设备类型
/// 优先用 product_code 精确匹配fallback 到名称推断
DeviceType _inferDeviceTypeByCode(String productCode, String name) {
// product_code 精确匹配
switch (productCode) {
case 'KPBL-ON':
return DeviceType.plush;
case 'DZBJ-ON':
return DeviceType.badgeAi;
case 'DZBJ-OFF':
return DeviceType.badge;
}
// fallback: 名称推断
final lower = name.toLowerCase();
if (lower.contains('plush') || lower.contains('卡皮巴拉') || lower.contains('机芯')) {
return DeviceType.plush;
}
if (lower.contains('ai') || lower.contains('智能')) {
return DeviceType.badgeAi;
}
if (lower.contains('吧唧') || lower.contains('badge')) {
return DeviceType.badge;
}
return DeviceType.plush;
}
/// 根据设备名称推断是否支持 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 {
if (kIsWeb) return; // Web 平台无需请求原生权限
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 || kIsWeb) return; // Web 平台不弹原生蓝牙设置弹窗
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;
/// 根据设备类型设置产品主题并跳转到对应业务页面
void _setThemeAndNavigate(DeviceType type) {
switch (type) {
case DeviceType.badgeAi:
ref.read(currentProductTypeProvider.notifier).set(ProductType.badgeAi);
context.go('/badge-control');
case DeviceType.badge:
ref.read(currentProductTypeProvider.notifier).set(ProductType.badgeBasic);
context.go('/badge-basic-control');
case DeviceType.plush:
ref.read(currentProductTypeProvider.notifier).set(ProductType.capybara);
context.go('/device-control');
}
}
/// 连接设备
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}, bindStatus=${device.bindStatus}');
// 设备已被其他用户绑定 → 拦截
if (device.isBoundByOther) {
showGlassDialog(
context: context,
title: '无法连接',
description: '该设备已被其他用户绑定,无法使用。如需解绑请联系设备所有者。',
confirmText: '确定',
onConfirm: () => Navigator.of(context).pop(),
);
return;
}
if (!device.isNetworkRequired) {
// 不需要联网 -> 跳过配网,绑定设备后进入业务页
if (device.sn.isNotEmpty) {
setState(() => _isConnecting = true);
try {
final error = await ref.read(deviceControllerProvider.notifier).bindDevice(device.sn);
if (!mounted) return;
if (error != null) {
setState(() => _isConnecting = false);
showGlassDialog(
context: context,
title: '绑定失败',
description: error,
confirmText: '确定',
onConfirm: () => Navigator.of(context).pop(),
);
return;
}
} catch (e) {
debugPrint('[Bluetooth] bindDevice 异常: $e');
}
if (!mounted) return;
setState(() => _isConnecting = false);
}
_setThemeAndNavigate(device.type);
return;
}
// Web 环境:跳过 BLE 和 WiFi 配网,直接绑定设备
if (kIsWeb) {
debugPrint('[Bluetooth] Web 环境,直接绑定 sn=${device.sn}');
setState(() => _isConnecting = true);
if (device.sn.isNotEmpty) {
final error = await ref.read(deviceControllerProvider.notifier).bindDevice(device.sn);
if (!mounted) return;
if (error != null) {
setState(() => _isConnecting = false);
showGlassDialog(
context: context,
title: '绑定失败',
description: error,
confirmText: '确定',
onConfirm: () => Navigator.of(context).pop(),
);
return;
}
}
if (!mounted) return;
setState(() => _isConnecting = false);
_setThemeAndNavigate(device.type);
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.pop(),
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: device.isBoundByOther
? const Color(0xFF9CA3AF)
: const Color(0xFF1F2937),
),
),
// 已被其他用户绑定提示
if (device.isBoundByOther) ...[
const SizedBox(height: 4),
Text(
'已被其他用户绑定',
style: TextStyle(
fontSize: 12,
color: const Color(0xFFEF4444),
),
),
],
],
);
}
/// 设备图标 - 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
16, // HTML: 20px top (reduced to prevent overflow)
20,
MediaQuery.of(context).padding.bottom + 40, // HTML: safe-area + bottom padding
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 取消按钮 - HTML: frosted glass with border
GestureDetector(
onTap: () => context.pop(),
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),
),
),
),
),
),
// 搜索按钮 (Web 平台初始状态或无设备时显示)
if (!_isSearching && _devices.isEmpty) ...[
const SizedBox(width: 16),
GradientButton(
text: '搜索设备',
width: 180,
height: 52,
onPressed: _startSearch,
),
],
// 连接按钮 (搜索完成后显示)
if (!_isSearching && _devices.isNotEmpty) ...[
const SizedBox(width: 16), // HTML: gap 16px
GradientButton(
text: _isConnecting ? '连接中...' : '连接设备',
width: 180,
height: 52,
onPressed: _isConnecting ? null : _handleConnect,
),
],
],
),
);
}
}