rtc_prd/airhub_app/lib/pages/device_control_page.dart
2026-02-06 16:03:32 +08:00

1082 lines
34 KiB
Dart

import 'dart:math';
import 'dart:ui';
import 'package:flutter/material.dart';
// import 'package:google_fonts/google_fonts.dart'; (Removed)
import 'package:flutter_svg/flutter_svg.dart';
import 'story_detail_page.dart';
import 'product_selection_page.dart';
import 'settings_page.dart';
import '../widgets/glass_dialog.dart';
import '../widgets/story_generator_modal.dart';
import 'story_loading_page.dart';
import 'profile/profile_page.dart';
import '../theme/design_tokens.dart';
import '../widgets/dashed_rect.dart';
class DeviceControlPage extends StatefulWidget {
const DeviceControlPage({super.key});
@override
State<DeviceControlPage> createState() => _DeviceControlPageState();
}
class _DeviceControlPageState extends State<DeviceControlPage>
with SingleTickerProviderStateMixin {
int _currentIndex = 0; // 0: Home, 1: Story, 2: Music, 3: User
// Animation for mascot
late AnimationController _mascotAnimController;
// PageController for bookshelf scroll tracking
late PageController _bookshelfController;
double _bookshelfScrollOffset = 0.0;
// Animation for new book
int? _newBookIndex;
final List<Map<String, dynamic>> _mockStories = [
{
'title': '卡皮巴拉的奇幻漂流',
'cover': 'assets/www/story_covers/capybara_adventure.png',
'locked': false,
},
{
'title': '勇敢的小裁缝',
'cover': 'assets/www/story_covers/brave_tailor.png',
'locked': false,
},
{
'title': '小红帽与大灰狼',
'cover': 'assets/www/story_covers/red_riding_hood.png',
'locked': false,
},
{
'title': '杰克与魔豆',
'cover': 'assets/www/story_covers/jack_and_beanstalk.png',
'locked': false,
},
{
'title': '糖果屋历险记',
'cover': 'assets/www/story_covers/hansel_and_gretel.png',
'locked': false,
},
];
@override
void initState() {
super.initState();
_mascotAnimController = AnimationController(
vsync: this,
duration: const Duration(seconds: 4),
)..repeat(reverse: true);
// Initialize bookshelf PageController
_bookshelfController = PageController(viewportFraction: 0.85);
_bookshelfController.addListener(() {
setState(() {
_bookshelfScrollOffset = _bookshelfController.page ?? 0.0;
});
});
}
@override
void dispose() {
_mascotAnimController.dispose();
_bookshelfController.dispose();
super.dispose();
}
void _onTabTapped(int index) {
setState(() {
_currentIndex = index;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: Stack(
children: [
// Global Gradient Background
_buildGradientBackground(),
// Main Content Area
// Main Content Area
IndexedStack(
index: _currentIndex,
children: [
SafeArea(bottom: false, child: _buildHomeView()),
SafeArea(bottom: false, child: _buildStoryView()),
SafeArea(
bottom: false,
child: _buildPlaceholderView('Music Coming Soon'),
),
const ProfilePage(), // No SafeArea here to allow full background
],
),
// Header (Visible on Home and Story tabs, but maybe different style?)
// For now, keep it fixed on top for both, as per design.
// Note: In story view, header might overlay content.
// Header (Only visible on Home tab)
if (_currentIndex == 0)
Positioned(top: 0, left: 0, right: 0, child: _buildHeader()),
// Custom Bottom Navigation Bar
Positioned(
left: 0,
right: 0,
bottom: MediaQuery.of(context).padding.bottom,
child: _buildBottomNavBar(),
),
],
),
);
}
Widget _buildGradientBackground() {
return Container(
decoration: const BoxDecoration(color: Colors.white),
child: Stack(
children: [
Positioned(
top: -100,
left: -100,
child: Container(
width: 300,
height: 300,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
const Color(0xFFC4B5FD).withOpacity(0.3),
Colors.transparent,
],
radius: 0.6,
),
),
),
),
],
),
);
}
// --- Header --- HTML: padding-top: calc(env(safe-area-inset-top) + 48px)
Widget _buildHeader() {
return Container(
padding: EdgeInsets.fromLTRB(
20,
MediaQuery.of(context).padding.top + 48, // HTML: +48px
20,
10,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Switch Device Button
_buildIconBtn(
'assets/www/icons/icon-switch.svg',
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const ProductSelectionPage(),
),
);
},
),
// Add Animation Trigger Logic for testing or real use
// We'll hook this up to the Generator Modal return value.
// Status Pill
Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.25),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Colors.white.withOpacity(0.4)),
),
child: Row(
children: [
// Live Dot
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: const Color(0xFF22C55E), // Green
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: const Color(0xFF22C55E).withOpacity(0.2),
blurRadius: 0,
spreadRadius: 2,
),
],
),
),
const SizedBox(width: 8),
Text(
'在线',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 13,
fontWeight: FontWeight.w600,
color: const Color(0xFF1F2937),
),
),
// Divider
Container(
margin: const EdgeInsets.symmetric(horizontal: 12),
width: 1,
height: 16,
color: Colors.black.withOpacity(0.1),
),
// Battery
SvgPicture.asset(
'assets/www/icons/icon-battery-full.svg',
width: 18,
height: 18,
colorFilter: const ColorFilter.mode(
Color(0xFF1F2937),
BlendMode.srcIn,
),
),
const SizedBox(width: 4),
Text(
'85%',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 13,
fontWeight: FontWeight.w600,
color: const Color(0xFF1F2937),
),
),
],
),
),
// Settings Button
_buildIconBtn(
'assets/www/icons/icon-settings-pixel.svg',
onTap: () {
Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const SettingsPage(),
transitionsBuilder:
(context, animation, secondaryAnimation, child) {
const begin = Offset(0.0, 1.0);
const end = Offset.zero;
const curve = Cubic(0.2, 0.8, 0.2, 1.0);
var tween = Tween(
begin: begin,
end: end,
).chain(CurveTween(curve: curve));
return SlideTransition(
position: animation.drive(tween),
child: child,
);
},
),
);
},
),
],
),
);
}
Widget _buildIconBtn(String iconPath, {VoidCallback? onTap}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.25),
borderRadius: BorderRadius.circular(22),
border: Border.all(color: Colors.white.withOpacity(0.4)),
),
alignment: Alignment.center,
child: SvgPicture.asset(
iconPath,
width: 20,
height: 20,
colorFilter: const ColorFilter.mode(
Color(0xFF1F2937),
BlendMode.srcIn,
),
),
),
);
}
// --- Home View ---
Widget _buildHomeView() {
return Center(
child: AnimatedBuilder(
animation: _mascotAnimController,
builder: (context, child) {
return Transform.translate(
offset: Offset(
0,
10 * _mascotAnimController.value - 5,
), // Float +/- 5
child: child,
);
},
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Mascot Image
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: const Color(0xFF8B5CF6).withOpacity(0.2),
blurRadius: 50,
spreadRadius: 10,
),
],
),
child: Image.asset(
'assets/www/Capybara.png',
width: 250,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) =>
const Icon(Icons.smart_toy, size: 150, color: Colors.amber),
),
),
],
),
),
);
}
// --- Story View ---
Widget _buildStoryView() {
return Stack(
children: [
// Main Content Column
Column(
children: [
// Top Spacer - HTML: .story-header-spacer { height: 40px }
const SizedBox(height: 40),
// Bookshelf PageView - Fixed height instead of Expanded
SizedBox(
height: 510 + 24, // bookshelf height + bottom margin
child: PageView.builder(
controller: _bookshelfController,
clipBehavior: Clip.none,
padEnds: false,
itemCount: 2,
itemBuilder: (context, index) {
if (index == 0) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: _buildBookshelfSlide(
'我的故事书 #1',
'${_mockStories.length}/10',
_mockStories,
),
);
} else {
// Pass scroll offset for position animation
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: _buildLockedShelf(),
);
}
},
),
),
// Flexible spacer to push content up
const Spacer(),
],
),
// Create Story Button (.story-actions-wrapper)
Positioned(
bottom: 120, // env(safe-area-inset-bottom) + 120px
left: 0,
right: 0,
child: Center(child: _buildCreateStoryButton()),
),
],
);
}
// Create Story Button per PRD (.create-story-btn)
Widget _buildCreateStoryButton() {
return GestureDetector(
onTap: () async {
final result = await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => const StoryGeneratorModal(),
);
if (result == 'start_generation') {
final saveResult = await Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const StoryLoadingPage()),
);
if (saveResult == 'saved') {
_addNewBookWithAnimation();
}
}
},
child: Container(
padding: StoryBookSpacing.createBtnPadding,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: AppColors.btnCapybaraGradient,
),
borderRadius: BorderRadius.circular(StoryBookRadius.createBtn),
boxShadow: AppShadows.createBtn,
),
child: Stack(
children: [
// PRD: ::before shine effect
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(StoryBookRadius.createBtn),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.center,
colors: [
Colors.white.withOpacity(0.15),
Colors.transparent,
],
),
),
),
),
),
// Button content
Row(
mainAxisSize: MainAxisSize.min,
children: [
// PRD: .btn-icon { font-size: 18px; font-weight: 700; }
const Text(
'+',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(width: 8),
Text('创作新故事', style: AppTextStyles.createStoryBtn),
],
),
],
),
),
);
}
Widget _buildBookshelfSlide(
String title,
String count,
List<Map<String, dynamic>> stories,
) {
// PRD: .bookshelf-container height: 600px, .story-book height: 100%
// Adjusted for mobile viewport - 510px for proper 5-row grid proportions
return Container(
margin: const EdgeInsets.only(bottom: 24),
height: 510, // Adjusted height
decoration: BoxDecoration(
color: AppColors.bookshelfBg, // .story-book bg rgba(255,255,255,0.55)
borderRadius: BorderRadius.circular(24), // 24px
border: Border.all(
color: AppColors.bookshelfBorder,
), // 1px solid rgba(255,255,255,0.6)
boxShadow: const [
BoxShadow(
color: Color(0x08000000), // rgba(0,0,0,0.03)
blurRadius: 40,
offset: Offset(0, 10),
),
],
),
padding: const EdgeInsets.all(24), // .story-book padding
child: Column(
children: [
// Header (.book-cover)
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(title, style: AppTextStyles.bookTitle),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.bookCountBg,
borderRadius: BorderRadius.circular(12),
),
child: Text(count, style: AppTextStyles.bookCount),
),
],
),
),
// Grid (.story-grid) 2 cols, 5 rows
// PRD: grid-template-rows: repeat(5, minmax(0, 1fr))
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
// Calculate aspect ratio based on available space
// 5 rows with 12px gaps (4 gaps total = 48px)
final gridHeight = constraints.maxHeight;
final gridWidth = constraints.maxWidth;
final rowHeight = (gridHeight - 48) / 5; // 5 rows, 4 gaps
final colWidth = (gridWidth - 12) / 2; // 2 cols, 1 gap
final aspectRatio = colWidth / rowHeight;
return GridView.builder(
padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: aspectRatio,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: 10, // Fixed 10 slots per book (2x5)
itemBuilder: (context, index) {
if (index < stories.length) {
// Check if this is a newly added book
final isNewBook = _newBookIndex == index;
return _buildStorySlot(stories[index], isNew: isNewBook);
} else {
// Empty clickable slot with +
return _buildStorySlot({'type': 'empty_slot'});
}
},
);
},
),
),
],
),
);
}
Widget _buildStorySlot(Map<String, dynamic> story, {bool isNew = false}) {
bool isFilled = story.containsKey('cover') && story['cover'] != null;
// Empty/Clickable Slot (.story-slot.clickable)
// PRD: border: 1px dashed rgba(0, 0, 0, 0.05)
if (!isFilled) {
return GestureDetector(
onTap: () async {
final result = await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => const StoryGeneratorModal(),
);
if (result == 'start_generation') {
final saveResult = await Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const StoryLoadingPage()),
);
if (saveResult == 'saved') {
_addNewBookWithAnimation();
}
}
},
child: DashedRect(
color: AppColors.slotBorder, // rgba(0, 0, 0, 0.05)
strokeWidth: 1,
gap: 4,
borderRadius: BorderRadius.circular(StoryBookRadius.slot),
child: Container(
decoration: BoxDecoration(
color: AppColors.slotClickableBg, // rgba(255,255,255,0.4)
borderRadius: BorderRadius.circular(StoryBookRadius.slot),
),
alignment: Alignment.center,
child: Text('+', style: AppTextStyles.emptyPlus),
),
),
);
}
// Filled Slot (.story-slot.filled)
Widget slotContent = GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => StoryDetailPage(story: story),
),
);
},
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(StoryBookRadius.slot),
boxShadow: const [AppShadows.storySlotFilled],
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
// Cover Image (.story-cover-img)
Positioned.fill(
child: Image.asset(
story['cover'],
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Container(color: Colors.grey.shade200),
),
),
// Title Bar (.story-title-bar)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
color: AppColors.slotTitleBarBg,
padding: StoryBookSpacing.titleBarPadding,
child: Text(
story['title'] ?? '',
style: AppTextStyles.slotTitle,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
),
);
// Wrap with animation if this is a new book
// PRD: animation: bookPop 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards
if (isNew) {
return _NewBookAnimation(child: slotContent);
}
return slotContent;
}
// Locked Bookshelf Placeholder per PRD (.add-book-placeholder)
// Animates from left-aligned to centered based on scroll position
Widget _buildLockedShelf() {
// Calculate alignment based on scroll offset
// At offset 0 (viewing first bookshelf): align to left edge (-1.0)
// At offset 1 (viewing this bookshelf): align center (0)
final scrollProgress = _bookshelfScrollOffset.clamp(0.0, 1.0);
// Interpolate from -1.0 (left edge) to 0 (center)
final alignX = -1.0 * (1.0 - scrollProgress);
return GestureDetector(
onTap: _showUnlockDialog,
child: Container(
height: 510, // Match bookshelf height
margin: const EdgeInsets.only(bottom: 24),
child: DashedRect(
color: const Color(0x80C99672), // rgba(201,150,114,0.5)
strokeWidth: 2,
gap: 6,
borderRadius: BorderRadius.circular(20),
child: Container(
decoration: BoxDecoration(
color: const Color(0x66FFFFFF), // rgba(255,255,255,0.4)
borderRadius: BorderRadius.circular(20),
),
// Animate alignment from left edge to center
alignment: Alignment(alignX, 0),
padding: const EdgeInsets.only(left: 16), // Stick close to left edge
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// .add-icon
const Text(
'+',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w300,
color: Color(0xFF9CA3AF),
),
),
const SizedBox(height: 4),
// .placeholder-text (解锁\n新书架)
const Text(
'解锁\n新书架',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF9CA3AF),
height: 1.3,
),
textAlign: TextAlign.center,
),
],
),
),
),
),
);
}
Widget _buildPlaceholderView(String title) {
return Center(
child: Text(
title,
style: TextStyle(fontFamily: 'Inter', fontSize: 16, color: Colors.grey),
),
);
}
Widget _buildBottomNavBar() {
return Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(32),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
width: 320, // HTML: max-width 320px
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.6),
borderRadius: BorderRadius.circular(32),
border: Border.all(color: Colors.white.withOpacity(0.8)),
boxShadow: [
BoxShadow(
color: const Color(0xFF8B5CF6).withOpacity(0.15),
offset: const Offset(0, 10),
blurRadius: 30,
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildNavItem(0, 'home', Icons.home),
_buildNavItem(1, 'story', Icons.auto_stories),
_buildNavItem(2, 'music', Icons.music_note),
_buildNavItem(3, 'user', Icons.person),
],
),
),
),
),
);
}
Widget _buildNavItem(int index, String id, IconData defaultIcon) {
bool isActive = _currentIndex == index;
String iconPath = 'assets/www/icons/icon-$id-pixel.svg';
if (id == 'home') iconPath = 'assets/www/icons/icon-home-capybara.svg';
return GestureDetector(
onTap: () => _onTabTapped(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: 56,
height: 56,
decoration: BoxDecoration(
color: isActive ? null : Colors.transparent,
gradient: isActive
? const LinearGradient(
colors: [
Color(0xFFE6B98D),
Color(0xFFD4A373),
Color(0xFFB07D5A),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
)
: null,
borderRadius: BorderRadius.circular(28),
boxShadow: isActive
? [
BoxShadow(
color: const Color(0xFFD4A373).withOpacity(0.4),
offset: const Offset(0, 4),
blurRadius: 15,
),
]
: null,
),
alignment: Alignment.center,
child: SvgPicture.asset(
iconPath,
width: isActive ? 30 : 28,
height: isActive ? 30 : 28,
colorFilter: ColorFilter.mode(
isActive ? Colors.white : const Color(0xFF6B7280).withOpacity(0.6),
BlendMode.srcIn,
),
placeholderBuilder: (_) => Icon(
defaultIcon,
color: isActive ? Colors.white : const Color(0xFF6B7280),
size: 24,
),
),
),
);
}
void _showUnlockDialog() {
showGlassDialog(
context: context,
title: '解锁新书架',
description: '确认消耗 500 积分以永久解锁该书架?',
confirmText: '确认解锁',
onConfirm: () {
Navigator.pop(context);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('解锁成功!')));
},
// Insert custom icon if needed? GlassDialog supports 'content'.
// But GlassDialog design logic (Step 1590) puts content *after* description.
// Unlock dialog had an Icon above title.
// GlassDialog puts Title first.
// If strict 1:1, title should mean text. Icon is separate.
// I can add Icon to 'content' but GlassDialog specific layout puts content BELOW desc.
// If I want Icon ABOVE title, I need to modify GlassDialog or accept Title First.
// Web CSS .modal-title is top.
// Web HTML lines 201-209: .modal-title, .modal-desc, .modal-actions.
// NO ICON in Web HTML structure!
// So my previous Icon(Icons.lock_open) was EXTRA?
// User said "1:1". Web HTML has NO Icon.
// So I should REMOVE the Icon to match Web.
// So just Title + Desc + Buttons.
// This matches showGlassDialog perfectly.
);
}
void _addNewBookWithAnimation() {
setState(() {
_mockStories.add({
'title': '星际忍者的茶话会',
'cover':
'assets/www/story_covers/brave_tailor.png', // Temporary mock cover
'type': 'new',
'locked': false,
});
_newBookIndex = _mockStories.length - 1;
});
// Clear animation flag after animation completes
Future.delayed(const Duration(milliseconds: 800), () {
if (mounted) {
setState(() {
_newBookIndex = null;
});
}
});
}
}
/// New Book Animation Widget matching PRD
/// PRD: animation: bookPop 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards
/// Plus magic particle effect with sparkleFloat animation
class _NewBookAnimation extends StatefulWidget {
final Widget child;
const _NewBookAnimation({required this.child});
@override
State<_NewBookAnimation> createState() => _NewBookAnimationState();
}
class _NewBookAnimationState extends State<_NewBookAnimation>
with TickerProviderStateMixin {
late AnimationController _popController;
late AnimationController _particleController;
late Animation<double> _scaleAnimation;
late Animation<double> _opacityAnimation;
// PRD: 20 particles with random angles/distances
final List<_Particle> _particles = [];
// PRD particle colors: [#FFD700, #FF6B6B, #4ECDC4, #A78BFA, #FCD34D]
static const List<Color> _particleColors = [
Color(0xFFFFD700), // Gold
Color(0xFFFF6B6B), // Coral
Color(0xFF4ECDC4), // Teal
Color(0xFFA78BFA), // Purple
Color(0xFFFCD34D), // Yellow
];
@override
void initState() {
super.initState();
// PRD: bookPop 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275)
_popController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
// PRD: sparkleFloat 0.8s
_particleController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
);
// PRD cubic-bezier(0.175, 0.885, 0.32, 1.275) - overshoot curve
const prdCurve = Cubic(0.175, 0.885, 0.32, 1.275);
_scaleAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _popController, curve: prdCurve),
);
_opacityAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _popController,
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
),
);
// Generate 20 particles with random properties
_generateParticles();
// Start animations
_popController.forward();
_particleController.forward();
}
void _generateParticles() {
final random = Random();
for (int i = 0; i < 20; i++) {
// PRD: random angle 0-360, distance 50-100px, size 5-10px
final angle = random.nextDouble() * 2 * pi; // 0-360 degrees in radians
final distance = 50.0 + random.nextDouble() * 50; // 50-100px
final size = 5.0 + random.nextDouble() * 5; // 5-10px
final colorIndex = random.nextInt(_particleColors.length);
final delay = random.nextDouble() * 0.3; // 0-0.3s delay
_particles.add(_Particle(
angle: angle,
distance: distance,
size: size,
color: _particleColors[colorIndex],
delay: delay,
));
}
}
@override
void dispose() {
_popController.dispose();
_particleController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: Listenable.merge([_popController, _particleController]),
builder: (context, child) {
return Stack(
clipBehavior: Clip.none,
children: [
// Main book with pop animation
Transform.scale(
scale: _scaleAnimation.value,
child: Opacity(
opacity: _opacityAnimation.value.clamp(0.0, 1.0),
child: widget.child,
),
),
// Magic particles overlay
..._particles.map((particle) {
// PRD sparkleFloat: 0% scale(0) opacity(0), 50% opacity(1), 100% scale(0) opacity(0)
final progress = _particleController.value;
final adjustedProgress =
((progress - particle.delay) / (1 - particle.delay))
.clamp(0.0, 1.0);
// Calculate opacity: 0 -> 1 -> 0
double opacity;
if (adjustedProgress < 0.5) {
opacity = adjustedProgress * 2;
} else {
opacity = (1 - adjustedProgress) * 2;
}
// Calculate scale: 0 -> 1 -> 0
double scale;
if (adjustedProgress < 0.5) {
scale = adjustedProgress * 2;
} else {
scale = (1 - adjustedProgress) * 2;
}
// Calculate position using proper trigonometry
// Particles radiate outward from center
final dx = cos(particle.angle) * particle.distance * adjustedProgress;
final dy = sin(particle.angle) * particle.distance * adjustedProgress;
return Positioned(
left: 0,
right: 0,
top: 0,
bottom: 0,
child: Center(
child: Transform.translate(
offset: Offset(dx, dy),
child: Transform.scale(
scale: scale,
child: Opacity(
opacity: opacity.clamp(0.0, 1.0),
child: Container(
width: particle.size,
height: particle.size,
decoration: BoxDecoration(
color: particle.color,
shape: BoxShape.circle,
),
),
),
),
),
),
);
}),
],
);
},
);
}
}
class _Particle {
final double angle;
final double distance;
final double size;
final Color color;
final double delay;
_Particle({
required this.angle,
required this.distance,
required this.size,
required this.color,
required this.delay,
});
}