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 '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; // Track unlocked shelves (start with 1 shelf + 1 locked placeholder) int _unlockedShelves = 1; final List> _mockStories = [ { 'title': '卡皮巴拉的奇幻漂流', 'cover': 'assets/www/story_covers/capybara_adventure.png', 'locked': false, 'content': '在一条蜿蜒的小河边,住着一只名叫咖啡的卡皮巴拉。咖啡最喜欢做的事情,就是泡在温泉里,顶着一颗橘子发呆。\n\n有一天,河水突然变成了七彩的颜色!一个写着"冒险邀请函"的漂流瓶飘到了咖啡面前。\n\n"亲爱的咖啡,彩虹尽头有一座糖果山,里面藏着能让所有动物快乐的魔法种子。你愿意来找它吗?"\n\n咖啡想了想,把橘子吃掉,跳进了七彩的河流。一路上,它遇到了会唱歌的青蛙、爱画画的松鼠、和一只总是迷路的猫头鹰。它们组成了最奇怪也最温暖的冒险小队。\n\n经过重重挑战,它们终于到达了糖果山。魔法种子发出金色的光芒,落在每个小伙伴的头顶上。从此以后,每个人路过这条小河,都会不自觉地微笑起来。', }, { 'title': '勇敢的小裁缝', 'cover': 'assets/www/story_covers/brave_tailor.png', 'locked': false, 'content': '从前有一个小裁缝,他住在一座热闹的小镇上。虽然他的个子不高,手艺却是全镇最好的。\n\n一天早上,小裁缝正在缝一件漂亮的外套,七只苍蝇飞来偷吃他的果酱面包。他一巴掌打下去——"啪!一下打死了七个!"\n\n小裁缝得意极了,在腰带上绣了一行大字:"一下打死七个!"然后他出门去闯荡世界。\n\n一路上,所有人都以为他打死的是七个巨人!连国王都请他去消灭山里的两个巨人。小裁缝靠着机智和勇气,用石头让两个巨人互相打了起来。\n\n最终,小裁缝不仅消灭了巨人,还救了公主。国王为他举办了盛大的庆典。小裁缝笑着说:"勇气不在于个子大小,而在于心有多大。"', }, { 'title': '小红帽与大灰狼', 'cover': 'assets/www/story_covers/red_riding_hood.png', 'locked': false, 'content': '在森林边的小村庄里,住着一个总是戴红帽子的小女孩,大家都叫她小红帽。\n\n有一天,妈妈让小红帽给生病的外婆送一篮子蛋糕和葡萄酒。"走大路,不要在森林里乱跑哦。"妈妈叮嘱道。\n\n小红帽刚进森林,就遇到了一只看起来很友善的大灰狼。"你要去哪里呀,小红帽?""我去看望外婆!"\n\n大灰狼眼珠一转,抄近路先跑到了外婆家,假扮成外婆躺在床上。等小红帽到了,它假装生病的外婆说话。\n\n"外婆,你的耳朵怎么这么大?""为了更好地听你说话呀。"\n"外婆,你的嘴巴怎么这么大?""为了——"\n\n就在这时,经过的猎人听到了动静。他冲进来赶走了大灰狼,救出了外婆和小红帽。从此以后,小红帽再也不在森林里跟陌生人说话了。', }, { 'title': '杰克与魔豆', 'cover': 'assets/www/story_covers/jack_and_beanstalk.png', 'locked': false, 'content': '杰克和妈妈住在一间破旧的小屋里,家里穷得只剩下一头老奶牛。妈妈让杰克把牛拿去集市上卖掉。\n\n路上,一个神秘的老人用五颗"魔法豆子"换走了杰克的牛。妈妈气坏了,把豆子扔出窗外。\n\n第二天早上,杰克发现窗外长出了一棵直冲云霄的巨大豆茎!他鼓起勇气爬了上去,在云端发现了一座巨人的城堡。\n\n城堡里有一只会下金蛋的鹅和一把会自己弹奏的金竖琴。杰克趁巨人睡着,偷偷带走了金鹅。巨人醒来追了出来!\n\n杰克飞快地顺着豆茎滑下来,拿起斧头砍断了豆茎。"轰!"巨人连同豆茎一起掉了下来。\n\n从此,杰克和妈妈靠着金蛋过上了幸福的生活。杰克明白了:勇气和机智,才是最大的财富。', }, { 'title': '糖果屋历险记', 'cover': 'assets/www/story_covers/hansel_and_gretel.png', 'locked': false, 'content': '汉赛尔和格蕾特是一对兄妹。有一天,他们在森林里迷了路,又累又饿。\n\n突然,一座用糖果和饼干做成的小屋出现在眼前!屋顶是巧克力,窗户是透明的硬糖,门把手是一根棒棒糖。兄妹俩开心极了,忍不住吃了起来。\n\n"嘿嘿嘿,是谁在啃我的房子?"门开了,一个笑眯眯的老婆婆走出来。她请兄妹俩进屋吃饭休息。可是,这个老婆婆其实是一个坏巫婆!\n\n巫婆把汉赛尔关进笼子,想把他养胖了吃掉。聪明的格蕾特想出了一个办法:她假装不会用烤炉,让巫婆弯腰演示——然后用力一推!\n\n巫婆掉进了自己的烤炉里。兄妹俩找到了巫婆藏的宝石和金币,高高兴兴地回了家。爸爸看到他们回来,高兴得流下了眼泪。', }, ]; @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()), const MusicCreationPage(isTab: true), 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, // ~64% of screen height for bookshelf child: PageView.builder( controller: _bookshelfController, clipBehavior: Clip.none, padEnds: false, itemCount: _unlockedShelves + 1, // unlocked shelves + 1 locked placeholder itemBuilder: (context, index) { if (index < _unlockedShelves) { // Unlocked shelf final shelfNumber = index + 1; final stories = index == 0 ? _mockStories : >[]; final count = '${stories.length}/10'; return Padding( padding: const EdgeInsets.only(left: 16, right: 6), child: _buildBookshelfSlide( '我的故事书 #$shelfNumber', count, stories, ), ); } else { // Last item is always the locked placeholder 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 == 'start_generation') { final saveResult = await Navigator.of(context).push( MaterialPageRoute(builder: (context) => const StoryLoadingPage()), ); if (saveResult == 'saved') { _addNewBookWithAnimation(); } } }, ); } Widget _buildBookshelfSlide( String title, String count, List> stories, ) { // 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}) { 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) // 解锁新书架 — 单组内容丝滑从 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: '确认消耗 500 积分以永久解锁该书架?', confirmText: '确认解锁', onConfirm: () { Navigator.pop(context); setState(() { _unlockedShelves++; }); // Auto-scroll to the newly unlocked shelf Future.delayed(const Duration(milliseconds: 300), () { if (mounted) { _bookshelfController.animateToPage( _unlockedShelves - 1, // scroll to the new shelf (0-indexed) duration: const Duration(milliseconds: 500), curve: Curves.easeOutCubic, ); } }); AppToast.show(context, '解锁成功!新书架已添加'); }, ); } 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, }); }