690 lines
20 KiB
Dart
690 lines
20 KiB
Dart
import 'dart:async';
|
|
import 'dart:math';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
import 'package:flutter_svg/flutter_svg.dart';
|
|
import '../theme/app_colors.dart';
|
|
|
|
/// 设备类型
|
|
enum DeviceType { plush, badgeAi, badge }
|
|
|
|
/// 模拟设备数据模型
|
|
class MockDevice {
|
|
final String sn;
|
|
final String name;
|
|
final DeviceType type;
|
|
final bool hasAI;
|
|
|
|
const MockDevice({
|
|
required this.sn,
|
|
required this.name,
|
|
required this.type,
|
|
required this.hasAI,
|
|
});
|
|
|
|
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 StatefulWidget {
|
|
const BluetoothPage({super.key});
|
|
|
|
@override
|
|
State<BluetoothPage> createState() => _BluetoothPageState();
|
|
}
|
|
|
|
class _BluetoothPageState extends State<BluetoothPage>
|
|
with TickerProviderStateMixin {
|
|
// 状态
|
|
bool _isSearching = true;
|
|
List<MockDevice> _devices = [];
|
|
int _currentIndex = 0;
|
|
bool _isAnimating = false;
|
|
|
|
// 动画控制器
|
|
late AnimationController _searchAnimController;
|
|
late AnimationController _cardAnimController;
|
|
late Animation<double> _cardAnimation;
|
|
|
|
// 模拟设备数据
|
|
static const List<MockDevice> _mockDevices = [
|
|
MockDevice(
|
|
sn: 'PLUSH_01',
|
|
name: '卡皮巴拉-001',
|
|
type: DeviceType.plush,
|
|
hasAI: true,
|
|
),
|
|
MockDevice(
|
|
sn: 'BADGE_01',
|
|
name: 'AI电子吧唧-001',
|
|
type: DeviceType.badgeAi,
|
|
hasAI: true,
|
|
),
|
|
MockDevice(
|
|
sn: 'BADGE_02',
|
|
name: '电子吧唧-001',
|
|
type: DeviceType.badge,
|
|
hasAI: false,
|
|
),
|
|
MockDevice(
|
|
sn: 'PLUSH_02',
|
|
name: '卡皮巴拉-002',
|
|
type: DeviceType.plush,
|
|
hasAI: true,
|
|
),
|
|
];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
// 搜索动画 (神秘盒子浮动)
|
|
_searchAnimController = AnimationController(
|
|
duration: const Duration(seconds: 2),
|
|
vsync: this,
|
|
)..repeat(reverse: true);
|
|
|
|
// 卡片切换动画
|
|
_cardAnimController = AnimationController(
|
|
duration: const Duration(milliseconds: 500),
|
|
vsync: this,
|
|
);
|
|
_cardAnimation = Tween<double>(begin: 0, end: 1).animate(
|
|
CurvedAnimation(parent: _cardAnimController, curve: Curves.easeOutCubic),
|
|
);
|
|
|
|
// 模拟搜索延迟
|
|
_startSearch();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchAnimController.dispose();
|
|
_cardAnimController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// 开始搜索 (模拟)
|
|
Future<void> _startSearch() async {
|
|
// 请求蓝牙权限
|
|
await _requestPermissions();
|
|
|
|
// 模拟 2 秒搜索延迟
|
|
await Future.delayed(const Duration(seconds: 2));
|
|
|
|
if (mounted) {
|
|
// 随机选择 1-4 个设备
|
|
final count = Random().nextInt(4) + 1;
|
|
setState(() {
|
|
_devices = _mockDevices.take(count).toList();
|
|
_isSearching = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
/// 请求蓝牙权限
|
|
Future<void> _requestPermissions() async {
|
|
// 检查蓝牙状态
|
|
await Permission.bluetooth.request();
|
|
await Permission.bluetoothScan.request();
|
|
await Permission.bluetoothConnect.request();
|
|
await Permission.location.request();
|
|
}
|
|
|
|
/// 切换到下一个设备
|
|
void _swipeUp() {
|
|
if (_isAnimating || _devices.length <= 1) return;
|
|
_animateToIndex((_currentIndex + 1) % _devices.length, isUp: true);
|
|
}
|
|
|
|
/// 切换到上一个设备
|
|
void _swipeDown() {
|
|
if (_isAnimating || _devices.length <= 1) return;
|
|
_animateToIndex(
|
|
(_currentIndex - 1 + _devices.length) % _devices.length,
|
|
isUp: false,
|
|
);
|
|
}
|
|
|
|
/// 动画切换到指定索引
|
|
void _animateToIndex(int newIndex, {required bool isUp}) {
|
|
_isAnimating = true;
|
|
_cardAnimController.forward(from: 0).then((_) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_currentIndex = newIndex;
|
|
_isAnimating = false;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/// 连接设备
|
|
void _handleConnect() {
|
|
if (_devices.isEmpty) return;
|
|
|
|
final device = _devices[_currentIndex];
|
|
// TODO: 保存设备信息到本地存储
|
|
|
|
if (device.type == DeviceType.badge) {
|
|
// 普通吧唧 -> 设备控制页
|
|
Navigator.of(context).pushReplacementNamed('/device-control');
|
|
} else {
|
|
// 其他 -> WiFi 配网页
|
|
Navigator.of(context).pushReplacementNamed('/wifi-config');
|
|
}
|
|
}
|
|
|
|
@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() {
|
|
final size = MediaQuery.of(context).size;
|
|
return Positioned.fill(
|
|
child: Stack(
|
|
children: [
|
|
// Layer 1 - Pink
|
|
Positioned(
|
|
bottom: -size.width * 0.5,
|
|
left: -size.width * 0.5,
|
|
width: size.width * 2,
|
|
height: size.width * 2,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: RadialGradient(
|
|
colors: [
|
|
const Color(0xFFFFC8DC).withOpacity(0.6),
|
|
Colors.transparent,
|
|
],
|
|
radius: 0.5,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// Layer 2 - Cyan
|
|
Positioned(
|
|
top: -size.width * 0.5,
|
|
right: -size.width * 0.5,
|
|
width: size.width * 2,
|
|
height: size.width * 2,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: RadialGradient(
|
|
colors: [
|
|
const Color(0xFFB4F0F0).withOpacity(0.5),
|
|
Colors.transparent,
|
|
],
|
|
radius: 0.5,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// Layer 3 - Lavender
|
|
Positioned(
|
|
top: size.height * 0.2,
|
|
left: size.width * 0.1,
|
|
width: size.width * 1.2,
|
|
height: size.width * 1.2,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: RadialGradient(
|
|
colors: [
|
|
const Color(0xFFE6D2FA).withOpacity(0.45),
|
|
Colors.transparent,
|
|
],
|
|
radius: 0.5,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 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: () => Navigator.of(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: TextStyle(fontFamily: 'Inter',
|
|
fontSize: 18,
|
|
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(fontFamily: 'Inter',
|
|
fontSize: 14,
|
|
color: const Color(0xFF9CA3AF),
|
|
),
|
|
children: [
|
|
TextSpan(
|
|
text: '${_devices.length}',
|
|
style: TextStyle(fontFamily: 'Inter',
|
|
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: TextStyle(fontFamily: 'Inter',
|
|
fontSize: 48,
|
|
fontWeight: FontWeight.w700,
|
|
color: const Color(0xFFF59E0B), // Amber color per HTML
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
// 搜索状态文字
|
|
Text(
|
|
'正在搜索附近设备',
|
|
style: TextStyle(fontFamily: 'Inter',
|
|
fontSize: 16,
|
|
color: const Color(0xFF4B5563),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 设备卡片区域
|
|
Widget _buildDeviceCards() {
|
|
if (_devices.isEmpty) {
|
|
return Center(
|
|
child: Text(
|
|
'未找到设备',
|
|
style: TextStyle(fontFamily: 'Inter',
|
|
fontSize: 16,
|
|
color: const Color(0xFF9CA3AF),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Stack(
|
|
children: [
|
|
// 卡片容器 (支持滑动)
|
|
GestureDetector(
|
|
onVerticalDragEnd: (details) {
|
|
if (details.primaryVelocity == null) return;
|
|
if (details.primaryVelocity! < -50) {
|
|
_swipeUp();
|
|
} else if (details.primaryVelocity! > 50) {
|
|
_swipeDown();
|
|
}
|
|
},
|
|
child: Container(
|
|
color: Colors.transparent,
|
|
child: Center(child: _buildDeviceCard(_devices[_currentIndex])),
|
|
),
|
|
),
|
|
// 右侧指示器
|
|
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(fontFamily: 'Inter',
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w700,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
// 设备名称
|
|
Text(
|
|
device.name,
|
|
style: TextStyle(fontFamily: 'Inter',
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.w600,
|
|
color: const Color(0xFF1F2937),
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
// 设备类型
|
|
Text(
|
|
device.typeLabel,
|
|
style: TextStyle(fontFamily: 'Inter',
|
|
fontSize: 15,
|
|
color: const Color(0xFF6B7280),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 设备图标 - 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: () => Navigator.of(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(
|
|
fontFamily: 'Inter',
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w500,
|
|
color: Color(0xFF6B7280),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// 连接按钮 (搜索完成后显示)
|
|
if (!_isSearching && _devices.isNotEmpty) ...[
|
|
const SizedBox(width: 16), // HTML: gap 16px
|
|
GestureDetector(
|
|
onTap: _handleConnect,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
|
|
decoration: BoxDecoration(
|
|
gradient: AppColors.btnPrimaryGradient,
|
|
borderRadius: BorderRadius.circular(29), // HTML: 29px
|
|
// HTML: 5-layer glow effect
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: const Color(0xFF22D3EE).withOpacity(0.35),
|
|
offset: Offset.zero,
|
|
blurRadius: 15,
|
|
),
|
|
BoxShadow(
|
|
color: const Color(0xFF6366F1).withOpacity(0.25),
|
|
offset: Offset.zero,
|
|
blurRadius: 30,
|
|
),
|
|
BoxShadow(
|
|
color: const Color(0xFF6366F1).withOpacity(0.4),
|
|
offset: const Offset(0, 6),
|
|
blurRadius: 20,
|
|
),
|
|
],
|
|
),
|
|
child: Stack(
|
|
children: [
|
|
// Shine overlay
|
|
Positioned.fill(
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(29),
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Colors.white.withOpacity(0.15),
|
|
Colors.transparent,
|
|
],
|
|
stops: const [0.0, 0.5],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Text(
|
|
'连接设备',
|
|
style: const TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 17, // HTML: 17px
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|