830 lines
25 KiB
Dart
830 lines
25 KiB
Dart
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,
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|