import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../theme/design_tokens.dart'; import '../widgets/gradient_button.dart'; import 'story_loading_page.dart'; enum StoryMode { generated, read } class StoryDetailPage extends StatefulWidget { final Map? story; // Pass story object final StoryMode mode; const StoryDetailPage({ super.key, this.story, this.mode = StoryMode.read, // Default: Read mode (from HTML logic) }); @override State createState() => _StoryDetailPageState(); } class _StoryDetailPageState extends State with SingleTickerProviderStateMixin { // Tab State String _activeTab = 'text'; // 'text' or 'video' bool _isPlaying = false; bool _hasGeneratedVideo = false; bool _isLoadingVideo = false; // Genie Suck Animation bool _isSaving = false; AnimationController? _genieController; double _targetDY = 250; // fallback, recalculated at runtime // Mock Content from HTML final Map _defaultStory = { 'title': "星际忍者的茶话会", 'content': """ 在遥远的银河系边缘,有一个被星云包裹的神秘茶馆。今天,这里迎来了两位特殊的客人:刚执行完火星探测任务的宇航员波波,和正在追捕暗影怪兽的忍者小次郎。 “这儿的重力好像有点不对劲?”波波飘在半空中,试图抓住飞来飞去的茶杯。小次郎则冷静地倒挂在天花板上,手里紧握着一枚手里剑——其实那是用来切月饼的。 突然,桌上的魔法茶壶“噗”地一声喷出了七彩烟雾,一只会说话的卡皮巴拉钻了出来:“别打架,别打架,喝了这杯‘银河气泡茶’,我们都是好朋友!” 于是,宇宙中最奇怪的组合诞生了。他们决定,下一站,去黑洞边缘钓星星。 """, }; late Map _currentStory; Map _initStory() { final source = widget.story ?? _defaultStory; final result = Map.from(source); // 兜底:如果没有 content 就用默认故事内容 result['content'] ??= _defaultStory['content']; result['title'] ??= _defaultStory['title']; return result; } @override void initState() { super.initState(); _currentStory = _initStory(); } @override void dispose() { _genieController?.dispose(); super.dispose(); } /// Trigger Genie Suck animation matching HTML: /// CSS: animation: genieSuck 0.8s cubic-bezier(0.6, -0.28, 0.735, 0.045) forwards /// Phase 1 (0→15%): card scales up to 1.05 (tension) /// Phase 2 (15%→100%): card shrinks to 0.05, moves toward bottom, blurs & fades void _triggerGenieSuck() { if (_isSaving) return; _genieController = AnimationController( vsync: this, duration: const Duration(milliseconds: 800), ); // Calculate how far the card should travel downward (toward the save button) final screenHeight = MediaQuery.of(context).size.height; _targetDY = screenHeight * 0.35; _genieController!.addStatusListener((status) { if (status == AnimationStatus.completed && mounted) { Navigator.of(context).pop('saved'); } }); setState(() { _isSaving = true; }); _genieController!.forward(); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.storyBackground, // #FDF9F3 body: SafeArea( child: Column( children: [ // Header + Content Card — animated together during genie suck Expanded(child: _buildAnimatedBody()), // Footer _buildFooter(), ], ), ), ); } /// Wraps header + content card in genie suck animation Widget _buildAnimatedBody() { Widget body = Column( children: [ _buildHeader(), if (_hasGeneratedVideo || _isLoadingVideo) _buildTabSwitcher(), Expanded(child: _buildContentCard()), ], ); if (_isSaving && _genieController != null) { return AnimatedBuilder( animation: _genieController!, builder: (context, child) { final t = _genieController!.value; // linear 0→1 double scale; double translateY; double opacity; double blur; if (t <= 0.15) { // Phase 1: tension — whole area scales up slightly final p = t / 0.15; scale = 1.0 + 0.05 * Curves.easeOut.transform(p); translateY = 0; opacity = 1.0; blur = 0; } else { // Phase 2: suck — shrinks, moves down, fades and blurs final p = ((t - 0.15) / 0.85).clamp(0.0, 1.0); final curved = const Cubic(0.6, -0.28, 0.735, 0.045).transform(p); scale = 1.05 - 1.0 * curved; translateY = _targetDY * curved; opacity = 1.0 - curved; blur = 8.0 * curved; } return Transform.translate( offset: Offset(0, translateY), child: Transform.scale( scale: scale.clamp(0.01, 1.5), child: Opacity( opacity: opacity.clamp(0.0, 1.0), child: blur > 0.5 ? ImageFiltered( imageFilter: ui.ImageFilter.blur( sigmaX: blur, sigmaY: blur, ), child: child, ) : child, ), ), ); }, child: body, ); } return body; } Widget _buildHeader() { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Stack( alignment: Alignment.center, children: [ Align( alignment: Alignment.centerLeft, child: GestureDetector( onTap: () => Navigator.of(context).pop(), child: Container( width: 40, height: 40, decoration: BoxDecoration( color: Colors.white.withOpacity(0.6), borderRadius: BorderRadius.circular(12), ), child: const Icon( Icons.arrow_back_ios_new, size: 18, color: Color(0xFF4B5563), ), ), ), ), Text( _currentStory['title'], style: const TextStyle( fontSize: 17, fontWeight: FontWeight.w600, color: Color(0xFF1F2937), ), ), ], ), ); } Widget _buildTabSwitcher() { return Container( margin: const EdgeInsets.only(bottom: 16), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _buildTabBtn('📄 故事', 'text'), const SizedBox(width: 8), _buildTabBtn('🎬 绘本', 'video'), ], ), ); } Widget _buildTabBtn(String label, String key) { bool isActive = _activeTab == key; return GestureDetector( onTap: () { setState(() { _activeTab = key; }); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: isActive ? Colors.white : Colors.transparent, borderRadius: BorderRadius.circular(20), boxShadow: isActive ? [ BoxShadow( color: Colors.black.withOpacity(0.05), blurRadius: 4, offset: const Offset(0, 2), ), ] : null, ), child: Text( label, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: isActive ? AppColors.storyTitle : AppColors.textSecondary, ), ), ), ); } Widget _buildContentCard() { // HTML: .story-paper bool isVideoMode = _activeTab == 'video'; return Container( margin: const EdgeInsets.fromLTRB(16, 8, 16, 0), decoration: BoxDecoration( color: Colors.white.withOpacity(0.6), borderRadius: BorderRadius.circular(24), ), padding: const EdgeInsets.fromLTRB(24, 24, 24, 16), child: isVideoMode ? _buildVideoView() : _buildTextView(), ); } Widget _buildTextView() { return SingleChildScrollView( physics: const BouncingScrollPhysics(), child: Text( _currentStory['content'] .toString() .replaceAll(RegExp(r'\n+'), '\n\n') .trim(), // Simple paragraph spacing style: const TextStyle( fontSize: 16, // HTML: 16px height: 2.0, // HTML: line-height 2.0 color: AppColors.storyText, // #374151 ), textAlign: TextAlign.justify, ), ); } Widget _buildVideoView() { if (_isLoadingVideo) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const SizedBox( width: 40, height: 40, child: CircularProgressIndicator( color: Color(0xFFF43F5E), // HTML: #F43F5E strokeWidth: 3, ), ), const SizedBox(height: 16), const Text( 'AI 正在绘制动态绘本...', style: TextStyle( fontWeight: FontWeight.w600, color: Color(0xFF4B5563), ), ), const SizedBox(height: 8), const Text( '消耗 10 SP', style: TextStyle(fontSize: 12, color: Color(0xFF9CA3AF)), ), ], ), ); } return Stack( alignment: Alignment.center, children: [ AspectRatio( aspectRatio: 16 / 9, // Assume landscape video child: Container( color: Colors.black, child: const Center( child: Icon(Icons.videocam, color: Colors.white54, size: 48), ), // Placeholder for Video Player ), ), // Play Button Overlay Container( width: 48, height: 48, decoration: BoxDecoration( color: Colors.white.withOpacity(0.8), shape: BoxShape.circle, ), child: const Icon(Icons.play_arrow, color: Colors.black), ), ], ); } Widget _buildFooter() { Widget footer = Container( padding: EdgeInsets.fromLTRB( 24, 8, 24, MediaQuery.of(context).padding.bottom + 8, ), child: _activeTab == 'text' ? _buildTextFooter() : _buildVideoFooter(), ); // Fade out footer during genie suck animation if (_isSaving) { return IgnorePointer( child: AnimatedOpacity( opacity: 0.0, duration: const Duration(milliseconds: 300), child: footer, ), ); } return footer; } void _handleRewrite() async { // 跳到 loading 页重新生成 final result = await Navigator.of(context).push( MaterialPageRoute(builder: (context) => const StoryLoadingPage()), ); // loading 完成后返回结果 if (mounted && result == 'saved') { Navigator.of(context).pop('saved'); } } Widget _buildTextFooter() { if (widget.mode == StoryMode.generated) { // Generator Mode: Rewrite + Save return Row( children: [ // Rewrite (Secondary) Expanded( child: GestureDetector( onTap: _handleRewrite, child: Container( height: 48, decoration: BoxDecoration( border: Border.all(color: const Color(0xFFE5E7EB)), borderRadius: BorderRadius.circular(24), color: Colors.white.withOpacity(0.8), ), alignment: Alignment.center, child: const Text( '↻ 重写', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF4B5563), ), ), ), ), ), const SizedBox(width: 16), // Save (Primary) - Returns 'saved' to trigger add book animation Expanded( child: GradientButton( text: '保存故事', onPressed: _triggerGenieSuck, gradient: const LinearGradient( colors: AppColors.btnCapybaraGradient, ), height: 48, ), ), ], ); } else { // Read Mode: TTS + Make Picture Book return Row( children: [ // TTS Expanded( child: GestureDetector( onTap: () => setState(() => _isPlaying = !_isPlaying), child: Container( height: 48, decoration: BoxDecoration( border: Border.all(color: const Color(0xFFE5E7EB)), borderRadius: BorderRadius.circular(24), color: Colors.white.withOpacity(0.8), ), alignment: Alignment.center, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( _isPlaying ? Icons.pause : Icons.headphones, size: 20, color: const Color(0xFF4B5563), ), const SizedBox(width: 6), Text( _isPlaying ? '暂停' : '朗读', style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF4B5563), ), ), ], ), ), ), ), const SizedBox(width: 16), // Make Picture Book Expanded( child: GradientButton( text: '变绘本', onPressed: _startVideoGeneration, gradient: const LinearGradient( colors: AppColors.btnCapybaraGradient, ), height: 48, ), ), ], ); } } Widget _buildVideoFooter() { return Row( children: [ Expanded( child: GradientButton( text: '↻ 重新生成', onPressed: _startVideoGeneration, gradient: const LinearGradient( colors: AppColors.btnCapybaraGradient, ), height: 48, ), ), ], ); } void _startVideoGeneration() { setState(() { _isLoadingVideo = true; _activeTab = 'video'; }); // Mock delay Future.delayed(const Duration(seconds: 2), () { if (mounted) { setState(() { _isLoadingVideo = false; _hasGeneratedVideo = true; }); } }); } }