rtc_prd/airhub_app/lib/pages/story_detail_page.dart
seaislee1209 f9666d4aa3 feat: UI规范化 + 故事吸入动画 + 音乐页面优化
- 全局字体统一(Outfit/DM Sans), 头部/按钮/Toast规范化
- 故事详情页: Genie Suck吸入动画(标题+卡片一起缩小模糊消失)
- 书架页: bookPop弹出+粒子效果(三段式动画完整链路)
- 音乐页面: 心情卡片emoji换Material图标+彩色圆块横排布局
- 音乐页面: 进度条胶囊宽度对齐, 播放按钮位置修复, 间距均匀化
- 音乐播放: 接入just_audio, 支持播放暂停进度拖拽自动切歌
- 新增: iOS风格毛玻璃Toast, 渐变背景组件, 通知页面
- 阶段总结文档更新

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 19:34:53 +08:00

531 lines
15 KiB
Dart

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<String, dynamic>? 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<StoryDetailPage> createState() => _StoryDetailPageState();
}
class _StoryDetailPageState extends State<StoryDetailPage>
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<String, dynamic> _defaultStory = {
'title': "星际忍者的茶话会",
'content': """
在遥远的银河系边缘,有一个被星云包裹的神秘茶馆。今天,这里迎来了两位特殊的客人:刚执行完火星探测任务的宇航员波波,和正在追捕暗影怪兽的忍者小次郎。
“这儿的重力好像有点不对劲?”波波飘在半空中,试图抓住飞来飞去的茶杯。小次郎则冷静地倒挂在天花板上,手里紧握着一枚手里剑——其实那是用来切月饼的。
突然,桌上的魔法茶壶“噗”地一声喷出了七彩烟雾,一只会说话的卡皮巴拉钻了出来:“别打架,别打架,喝了这杯‘银河气泡茶’,我们都是好朋友!”
于是,宇宙中最奇怪的组合诞生了。他们决定,下一站,去黑洞边缘钓星星。
""",
};
late Map<String, dynamic> _currentStory;
Map<String, dynamic> _initStory() {
final source = widget.story ?? _defaultStory;
final result = Map<String, dynamic>.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<String>(
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;
});
}
});
}
}