410 lines
13 KiB
Dart
410 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_svg/flutter_svg.dart';
|
|
import '../theme/design_tokens.dart';
|
|
import '../widgets/gradient_button.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> {
|
|
// Tab State
|
|
String _activeTab = 'text'; // 'text' or 'video'
|
|
bool _isPlaying = false;
|
|
bool _hasGeneratedVideo = false;
|
|
bool _isLoadingVideo = false;
|
|
|
|
// Mock Content from HTML
|
|
final Map<String, dynamic> _defaultStory = {
|
|
'title': "星际忍者的茶话会",
|
|
'content': """
|
|
在遥远的银河系边缘,有一个被星云包裹的神秘茶馆。今天,这里迎来了两位特殊的客人:刚执行完火星探测任务的宇航员波波,和正在追捕暗影怪兽的忍者小次郎。
|
|
|
|
“这儿的重力好像有点不对劲?”波波飘在半空中,试图抓住飞来飞去的茶杯。小次郎则冷静地倒挂在天花板上,手里紧握着一枚手里剑——其实那是用来切月饼的。
|
|
|
|
突然,桌上的魔法茶壶“噗”地一声喷出了七彩烟雾,一只会说话的卡皮巴拉钻了出来:“别打架,别打架,喝了这杯‘银河气泡茶’,我们都是好朋友!”
|
|
|
|
于是,宇宙中最奇怪的组合诞生了。他们决定,下一站,去黑洞边缘钓星星。
|
|
""",
|
|
};
|
|
|
|
Map<String, dynamic> get _currentStory => widget.story ?? _defaultStory;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Logic from HTML: if mode is read, we might start with text.
|
|
// HTML defaults to text tab.
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: AppColors.storyBackground, // #FDF9F3
|
|
body: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
// Header
|
|
_buildHeader(),
|
|
|
|
// Tab Switcher (Visible if video generated or implies interactability)
|
|
// HTML hides it initially (`style="display:none;"`), shows when generating.
|
|
if (_hasGeneratedVideo || _isLoadingVideo) _buildTabSwitcher(),
|
|
|
|
// Content Card (Scrollable)
|
|
Expanded(child: _buildContentCard()),
|
|
|
|
// Footer
|
|
_buildFooter(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHeader() {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: IconButton(
|
|
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
|
|
color: const Color(0xFF4B5563),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
),
|
|
Text(
|
|
_currentStory['title'],
|
|
style: const TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w700, // HTML: 700
|
|
color: AppColors.storyTitle, // #4B2404
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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(
|
|
24,
|
|
0,
|
|
24,
|
|
10,
|
|
), // HTML: 0 24px 110px padding on parent, paper fills flex
|
|
decoration: isVideoMode
|
|
? null
|
|
: BoxDecoration(
|
|
color: Colors.white.withOpacity(
|
|
0.6,
|
|
), // HTML says transparent for video, what about text? It implies simple text flow in HTML... wait.
|
|
// HTML: .story-paper has margin-bottom 10px. Scrollbar none.
|
|
// Actually HTML doesn't explicitly set white background on .story-paper unless implies by default?
|
|
// Ah, looking closely at styles.css or html structure:
|
|
// .story-paper { flex: 1; overflow-y: auto ... }
|
|
// .story-content { font-size: 16px ... }
|
|
// It seems the background is just the page background #FDF9F3.
|
|
// But in `story_detail_page.dart` (original), it had white card.
|
|
// HTML PRD: `body { background: #FDF9F3; }`. .story-paper doesn't have background color set, so it's transparent?
|
|
// Let's assume transparent to match "Paper" feel being part of background or if existing Flutter impl used white card, user might prefer that.
|
|
// BUT strict 1:1 implies following HTML. HTML has NO white card background on .story-paper.
|
|
// So I will remove the white background container.
|
|
),
|
|
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(
|
|
fontFamily: 'Inter',
|
|
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() {
|
|
// HTML: .generator-footer { padding: 0 24px 30px; ... } (Inferred from container padding bottom 110px? No, fixed to bottom?)
|
|
// Actually HTML has .generator-footer inside body? No, .result-container has padding-bottom 110px?
|
|
// Let's stick to a fixed bottom container.
|
|
|
|
return Container(
|
|
padding: EdgeInsets.fromLTRB(
|
|
24,
|
|
0,
|
|
24,
|
|
MediaQuery.of(context).padding.bottom + 20,
|
|
),
|
|
// HTML footer is customized per mode.
|
|
child: _activeTab == 'text' ? _buildTextFooter() : _buildVideoFooter(),
|
|
);
|
|
}
|
|
|
|
Widget _buildTextFooter() {
|
|
if (widget.mode == StoryMode.generated) {
|
|
// Generator Mode: Rewrite + Save
|
|
return Row(
|
|
children: [
|
|
// Rewrite (Secondary)
|
|
Expanded(
|
|
child: Container(
|
|
height: 50,
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: const Color(0xFFE5E7EB)),
|
|
borderRadius: BorderRadius.circular(25),
|
|
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: () {
|
|
Navigator.of(context).pop('saved');
|
|
},
|
|
gradient: const LinearGradient(
|
|
colors: AppColors.btnCapybaraGradient,
|
|
),
|
|
height: 50,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
} else {
|
|
// Read Mode: TTS + Make Picture Book
|
|
return Row(
|
|
children: [
|
|
// TTS
|
|
Expanded(
|
|
child: GestureDetector(
|
|
onTap: () => setState(() => _isPlaying = !_isPlaying),
|
|
child: Container(
|
|
height: 50,
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: const Color(0xFFE5E7EB)),
|
|
borderRadius: BorderRadius.circular(25),
|
|
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, // Simulate logic
|
|
gradient: const LinearGradient(
|
|
colors: AppColors.btnCapybaraGradient,
|
|
),
|
|
height: 50,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget _buildVideoFooter() {
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: GradientButton(
|
|
text: '↻ 重新生成',
|
|
onPressed: _startVideoGeneration,
|
|
gradient: const LinearGradient(
|
|
colors: AppColors.btnCapybaraGradient,
|
|
),
|
|
height: 50,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
void _startVideoGeneration() {
|
|
setState(() {
|
|
_isLoadingVideo = true;
|
|
_activeTab = 'video';
|
|
});
|
|
// Mock delay
|
|
Future.delayed(const Duration(seconds: 2), () {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isLoadingVideo = false;
|
|
_hasGeneratedVideo = true;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|