rtc_prd/airhub_app/lib/pages/bluetooth_page.dart
seaislee1209 f9666d4aa3 feat: UI规范化 + 故事吸入动画 + 音乐页面优化
- 全局字体统一(Outfit/DM Sans), 头部/按钮/Toast规范化
- 故事详情页: Genie Suck吸入动画(标题+卡片一起缩小模糊消失)
- 书架页: bookPop弹出+粒子效果(三段式动画完整链路)
- 音乐页面: 心情卡片emoji换Material图标+彩色圆块横排布局
- 音乐页面: 进度条胶囊宽度对齐, 播放按钮位置修复, 间距均匀化
- 音乐播放: 接入just_audio, 支持播放暂停进度拖拽自动切歌
- 新增: iOS风格毛玻璃Toast, 渐变背景组件, 通知页面
- 阶段总结文档更新

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 19:34:53 +08:00

578 lines
16 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: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,
),
],
],
),
);
}
}