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 createState() => _DeviceControlPageState(); } class _DeviceControlPageState extends State 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> _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> 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 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 _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, }); }