import 'dart:math'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../core/network/api_client.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 'music_creation_page.dart'; import '../theme/design_tokens.dart'; import '../widgets/dashed_rect.dart'; import '../widgets/ios_toast.dart'; import '../widgets/animated_gradient_background.dart'; import '../widgets/gradient_button.dart'; import '../features/device/presentation/controllers/device_controller.dart'; class DeviceControlPage extends ConsumerStatefulWidget { const DeviceControlPage({super.key}); @override ConsumerState createState() => _DeviceControlPageState(); } class _DeviceControlPageState extends ConsumerState 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; // Shelves loaded from backend: [{id, name, capacity, story_count, stories: [...]}] List> _shelves = []; bool _shelvesLoading = true; @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; }); }); // Load shelves and stories from backend _loadShelves(); } /// Fetch shelves and their stories from backend Future _loadShelves() async { try { final api = ref.read(apiClientProvider); // Load shelves final shelvesData = await api.get('/stories/shelves/') as List; final shelves = >[]; for (final shelf in shelvesData) { final shelfId = shelf['id']; // Load stories for this shelf final storiesData = await api.get( '/stories/', queryParameters: {'shelf_id': shelfId, 'page_size': 10}, ); final stories = (storiesData['items'] as List?) ?.map((s) => Map.from(s)) .toList() ?? []; shelves.add({ ...Map.from(shelf), 'stories': stories, }); } if (mounted) { setState(() { _shelves = shelves; _shelvesLoading = false; }); } } catch (e) { debugPrint('Failed to load shelves: $e'); if (mounted) { setState(() { _shelvesLoading = false; }); } } } @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()), MusicCreationPage(isTab: true, isVisible: _currentIndex == 2), 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 + 12, child: _buildBottomNavBar(), ), ], ), ); } Widget _buildGradientBackground() { return const AnimatedGradientBackground(); } // --- Header --- HTML: padding-top: calc(env(safe-area-inset-top) + 48px) Widget _buildHeader() { return Container( padding: EdgeInsets.fromLTRB( 20, MediaQuery.of(context).padding.top + 8, // Reduced from +48 to sit closer to top 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 — dynamic from device detail _buildStatusPill(), // 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(0xFF4B5563), BlendMode.srcIn, ), ), ), ); } Widget _buildStatusPill() { final devicesAsync = ref.watch(deviceControllerProvider); final devices = devicesAsync.value ?? []; final firstDevice = devices.isNotEmpty ? devices.first : null; // If we have a device, try to load its detail for status/battery String statusText = '离线'; Color dotColor = const Color(0xFF9CA3AF); String batteryText = '--'; if (firstDevice != null) { final detailAsync = ref.watch( deviceDetailControllerProvider(firstDevice.id), ); final detail = detailAsync.value; if (detail != null) { final isOnline = detail.status == 'online'; statusText = isOnline ? '在线' : '离线'; dotColor = isOnline ? const Color(0xFF22C55E) : const Color(0xFF9CA3AF); batteryText = '${detail.battery}%'; } } return 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: [ Container( width: 8, height: 8, decoration: BoxDecoration( color: dotColor, shape: BoxShape.circle, boxShadow: [ BoxShadow( color: dotColor.withOpacity(0.2), blurRadius: 0, spreadRadius: 2, ), ], ), ), const SizedBox(width: 8), Text( statusText, style: GoogleFonts.dmSans( fontSize: 13, fontWeight: FontWeight.w600, color: const Color(0xFF4B5563), ), ), Container( margin: const EdgeInsets.symmetric(horizontal: 12), width: 1, height: 16, color: Colors.black.withOpacity(0.1), ), SvgPicture.asset( 'assets/www/icons/icon-battery-full.svg', width: 18, height: 18, colorFilter: const ColorFilter.mode( Color(0xFF4B5563), BlendMode.srcIn, ), ), const SizedBox(width: 4), Text( batteryText, style: GoogleFonts.dmSans( fontSize: 13, fontWeight: FontWeight.w600, color: const Color(0xFF4B5563), ), ), ], ), ); } // --- Home View --- Widget _buildHomeView() { return Center( child: AnimatedBuilder( animation: _mascotAnimController, builder: (context, child) { return Transform.translate( offset: Offset( 0, 10 * _mascotAnimController.value - 25, ), // Float +/- 5 child: child, ); }, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // Mascot Image Container( decoration: BoxDecoration( shape: BoxShape.circle, boxShadow: [ BoxShadow( color: const Color(0xFFF9A8D4).withOpacity(0.25), 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: 20px } const SizedBox(height: 20), // Bookshelf PageView - constrained height for proper proportions SizedBox( height: MediaQuery.of(context).size.height * 0.64, child: _shelvesLoading ? const Center(child: CircularProgressIndicator()) : PageView.builder( controller: _bookshelfController, clipBehavior: Clip.none, padEnds: false, itemCount: _shelves.length + 1, // shelves + 1 locked placeholder itemBuilder: (context, index) { if (index < _shelves.length) { final shelf = _shelves[index]; final stories = (shelf['stories'] as List?) ?.cast>() ?? []; final capacity = shelf['capacity'] as int? ?? 10; final count = '${stories.length}/$capacity'; return Padding( padding: const EdgeInsets.only(left: 16, right: 6), child: _buildBookshelfSlide( shelf['name'] as String? ?? '书架', count, stories, shelfId: shelf['id'] as int, ), ); } else { return Padding( padding: const EdgeInsets.only(left: 6, right: 16), child: _buildLockedShelf(), ); } }, ), ), // Flexible bottom space const Spacer(), ], ), // Create Story Button (.story-actions-wrapper) Positioned( bottom: MediaQuery.of(context).padding.bottom + 120, left: 0, right: 0, child: Center(child: _buildCreateStoryButton()), ), ], ); } // Create Story Button per PRD (.create-story-btn) Widget _buildCreateStoryButton() { return GradientButton( text: '+ 创作新故事', width: 220, height: 52, gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: AppColors.btnCapybaraGradient, ), onPressed: () async { final result = await showModalBottomSheet>( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (context) => const StoryGeneratorModal(), ); if (result != null && result['action'] == 'start_generation') { final saveResult = await Navigator.of(context).push( MaterialPageRoute( builder: (context) => StoryLoadingPage( characters: List.from(result['characters'] ?? []), scenes: List.from(result['scenes'] ?? []), props: List.from(result['props'] ?? []), ), ), ); if (saveResult is Map && saveResult['action'] == 'saved') { await _addNewBookWithAnimation( title: saveResult['title'] as String? ?? '新故事', content: saveResult['content'] as String? ?? '', coverUrl: saveResult['cover_url'] as String? ?? '', ); } } }, ); } Widget _buildBookshelfSlide( String title, String count, List> stories, { required int shelfId, }) { // PRD: .bookshelf-container height: 600px, .story-book height: 100% return Container( margin: const EdgeInsets.only(bottom: 12), 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.fromLTRB(20, 20, 20, 16), // Tighter padding for better proportions child: Column( children: [ // Header (.book-cover) Padding( padding: const EdgeInsets.only(bottom: 14), 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 story, {bool isNew = false}) { final coverUrl = story['cover_url'] as String? ?? story['cover'] as String? ?? ''; final bool hasCover = coverUrl.isNotEmpty; final bool hasContent = story['content'] != null && (story['content'] as String).isNotEmpty; // Empty/Clickable Slot — no content, just a "+" to create new story if (!hasContent && !hasCover) { return GestureDetector( onTap: () async { final result = await showModalBottomSheet>( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (context) => const StoryGeneratorModal(), ); if (result != null && result['action'] == 'start_generation') { final saveResult = await Navigator.of(context).push( MaterialPageRoute( builder: (context) => StoryLoadingPage( characters: List.from(result['characters'] ?? []), scenes: List.from(result['scenes'] ?? []), props: List.from(result['props'] ?? []), ), ), ); if (saveResult is Map && saveResult['action'] == 'saved') { _addNewBookWithAnimation( title: saveResult['title'] as String? ?? '新故事', content: saveResult['content'] as String? ?? '', coverUrl: saveResult['cover_url'] as String? ?? '', ); } } }, 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), ), ), ); } // Cover widget: real image or "未生成封面" placeholder Widget coverWidget; if (hasCover) { coverWidget = coverUrl.startsWith('http') ? Image.network( coverUrl, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container(color: Colors.grey.shade200), ) : Image.asset( coverUrl, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container(color: Colors.grey.shade200), ); } else { // No cover — show soft placeholder coverWidget = Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ const Color(0xFFE8E0F0), const Color(0xFFD5CBE8), ], ), ), alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 12), child: const Text( '暂无封面', style: TextStyle( fontSize: 11, color: Color(0xFF9B8DB8), fontWeight: FontWeight.w500, ), textAlign: TextAlign.center, ), ); } // 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 or Placeholder Positioned.fill(child: coverWidget), // 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) // 解锁新书架 — 单组内容丝滑从 peek 位置滑到正中 Widget _buildLockedShelf() { final t = Curves.easeOut.transform( _bookshelfScrollOffset.clamp(0.0, 1.0), ); // --- 对齐 --- // t=0 时内容在可见 peek 区域中央 (alignX ≈ -0.7) // t=1 时内容在卡片正中 (alignX = 0) final alignX = lerpDouble(-0.92, 0.0, t)!; // --- 图标尺寸:从 20 → 32 --- final iconSize = lerpDouble(20, 32, t)!; // --- 文字大小:从 11 → 14 --- final fontSize = lerpDouble(11, 14, t)!; // --- 文字内容:竖排 → 横排 --- final isHorizontal = t > 0.5; return GestureDetector( onTap: _showUnlockDialog, child: Container( margin: const EdgeInsets.only(bottom: 12), child: DashedRect( color: const Color(0x80C99672), strokeWidth: 2, gap: 6, borderRadius: BorderRadius.circular(20), child: Container( decoration: BoxDecoration( color: const Color(0x66FFFFFF), borderRadius: BorderRadius.circular(20), ), child: Align( alignment: Alignment(alignX, 0), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.lock_outline, color: const Color(0xFFBBBBBB), size: iconSize, ), const SizedBox(height: 4), Text( isHorizontal ? '解锁新书架' : '解锁\n新书架', textAlign: TextAlign.center, style: TextStyle( fontSize: fontSize, fontWeight: FontWeight.w600, color: const Color(0xFF9CA3AF), height: 1.3, ), ), ], ), ), ), ), ), ); } 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(0xFF4B5563).withOpacity(0.08), 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: '确认消耗 100 积分以永久解锁该书架?', confirmText: '确认解锁', onConfirm: () async { Navigator.pop(context); try { final api = ref.read(apiClientProvider); await api.post('/stories/shelves/unlock/'); // Reload shelves to get the new one await _loadShelves(); // Auto-scroll to the newly unlocked shelf Future.delayed(const Duration(milliseconds: 300), () { if (mounted) { _bookshelfController.animateToPage( _shelves.length - 1, duration: const Duration(milliseconds: 500), curve: Curves.easeOutCubic, ); } }); if (mounted) AppToast.show(context, '解锁成功!新书架已添加'); } catch (e) { if (mounted) AppToast.show(context, '解锁失败: ${e.toString()}'); } }, ); } Future _addNewBookWithAnimation({String title = '新故事', String content = '', String coverUrl = ''}) async { // Find the first shelf that has space int? targetShelfId; for (final shelf in _shelves) { final stories = shelf['stories'] as List? ?? []; final capacity = shelf['capacity'] as int? ?? 10; if (stories.length < capacity) { targetShelfId = shelf['id'] as int; break; } } if (targetShelfId == null) { if (mounted) AppToast.show(context, '所有书架已满,请解锁新书架'); return; } try { final api = ref.read(apiClientProvider); await api.post('/stories/', data: { 'title': title, 'content': content, 'shelf_id': targetShelfId, if (coverUrl.isNotEmpty) 'cover_url': coverUrl, }); // Reload to get the new story await _loadShelves(); if (mounted) { // Find the shelf index and trigger animation for (int i = 0; i < _shelves.length; i++) { if (_shelves[i]['id'] == targetShelfId) { final stories = _shelves[i]['stories'] as List? ?? []; setState(() { _newBookIndex = stories.length - 1; }); break; } } Future.delayed(const Duration(milliseconds: 800), () { if (mounted) setState(() => _newBookIndex = null); }); } } catch (e) { debugPrint('Save story failed: $e'); if (mounted) AppToast.show(context, '保存失败: ${e.toString()}'); } } } /// 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 _scaleAnimation; late Animation _opacityAnimation; // PRD: 20 particles with random angles/distances final List<_Particle> _particles = []; // PRD particle colors: [#FFD700, #FF6B6B, #4ECDC4, #A78BFA, #FCD34D] static const List _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(begin: 0.0, end: 1.0).animate( CurvedAnimation(parent: _popController, curve: prdCurve), ); _opacityAnimation = Tween(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, }); }