- 全局字体统一(Outfit/DM Sans), 头部/按钮/Toast规范化 - 故事详情页: Genie Suck吸入动画(标题+卡片一起缩小模糊消失) - 书架页: bookPop弹出+粒子效果(三段式动画完整链路) - 音乐页面: 心情卡片emoji换Material图标+彩色圆块横排布局 - 音乐页面: 进度条胶囊宽度对齐, 播放按钮位置修复, 间距均匀化 - 音乐播放: 接入just_audio, 支持播放暂停进度拖拽自动切歌 - 新增: iOS风格毛玻璃Toast, 渐变背景组件, 通知页面 - 阶段总结文档更新 Co-authored-by: Cursor <cursoragent@cursor.com>
578 lines
16 KiB
Dart
578 lines
16 KiB
Dart
import 'dart:async';
|
||
import 'dart:math';
|
||
import 'package:flutter/material.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 '../widgets/animated_gradient_background.dart';
|
||
import '../theme/app_colors.dart';
|
||
import '../widgets/gradient_button.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;
|
||
|
||
// 动画控制器
|
||
late AnimationController _searchAnimController;
|
||
|
||
// 滚轮控制器
|
||
late FixedExtentScrollController _wheelController;
|
||
|
||
// 模拟设备数据
|
||
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();
|
||
|
||
// 搜索动画 (神秘盒子浮动) - 800ms 周期,2s 搜索内可完成多次弹跳
|
||
_searchAnimController = AnimationController(
|
||
duration: const Duration(milliseconds: 800),
|
||
vsync: this,
|
||
)..repeat(reverse: true);
|
||
|
||
// 滚轮控制器
|
||
_wheelController = FixedExtentScrollController(initialItem: _currentIndex);
|
||
|
||
// 模拟搜索延迟
|
||
_startSearch();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_searchAnimController.dispose();
|
||
_wheelController.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;
|
||
});
|
||
}
|
||
}
|
||
|
||
/// 请求蓝牙权限(模拟器上可能失败,不影响 mock 搜索)
|
||
Future<void> _requestPermissions() async {
|
||
try {
|
||
await Permission.bluetooth.request();
|
||
await Permission.bluetoothScan.request();
|
||
await Permission.bluetoothConnect.request();
|
||
await Permission.location.request();
|
||
} catch (_) {
|
||
// 模拟器上蓝牙不可用,忽略权限错误,继续用 mock 数据
|
||
}
|
||
}
|
||
|
||
/// 连接设备
|
||
void _handleConnect() {
|
||
if (_devices.isEmpty) return;
|
||
|
||
final device = _devices[_currentIndex];
|
||
// TODO: 保存设备信息到本地存储
|
||
|
||
if (device.type == DeviceType.badge) {
|
||
// 普通吧唧 -> 设备控制页
|
||
context.go('/device-control');
|
||
} else {
|
||
// 其他 -> WiFi 配网页
|
||
context.go('/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() {
|
||
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: '连接设备',
|
||
width: 180,
|
||
height: 52,
|
||
onPressed: _handleConnect,
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|