fix story
This commit is contained in:
parent
8a65d49a44
commit
1140d2c440
@ -1,11 +1,10 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import '../core/network/api_client.dart';
|
||||||
import 'story_detail_page.dart';
|
import 'story_detail_page.dart';
|
||||||
import 'product_selection_page.dart';
|
import 'product_selection_page.dart';
|
||||||
import 'settings_page.dart';
|
import 'settings_page.dart';
|
||||||
@ -42,41 +41,9 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
|||||||
// Animation for new book
|
// Animation for new book
|
||||||
int? _newBookIndex;
|
int? _newBookIndex;
|
||||||
|
|
||||||
// Track unlocked shelves (start with 1 shelf + 1 locked placeholder)
|
// Shelves loaded from backend: [{id, name, capacity, story_count, stories: [...]}]
|
||||||
int _unlockedShelves = 1;
|
List<Map<String, dynamic>> _shelves = [];
|
||||||
|
bool _shelvesLoading = true;
|
||||||
final List<Map<String, dynamic>> _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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -94,43 +61,49 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load historical stories from backend
|
// Load shelves and stories from backend
|
||||||
_loadHistoricalStories();
|
_loadShelves();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch saved stories from backend and prepend to bookshelf
|
/// Fetch shelves and their stories from backend
|
||||||
Future<void> _loadHistoricalStories() async {
|
Future<void> _loadShelves() async {
|
||||||
try {
|
try {
|
||||||
final resp = await http.get(Uri.parse('http://localhost:3000/api/stories'));
|
final api = ref.read(apiClientProvider);
|
||||||
if (resp.statusCode == 200) {
|
|
||||||
final data = jsonDecode(resp.body);
|
|
||||||
final List stories = data['stories'] ?? [];
|
|
||||||
if (stories.isEmpty) return;
|
|
||||||
|
|
||||||
// Collect titles already in the mock list to avoid duplicates
|
// Load shelves
|
||||||
final existingTitles = _mockStories.map((s) => s['title'] as String).toSet();
|
final shelvesData = await api.get('/stories/shelves/') as List;
|
||||||
|
final shelves = <Map<String, dynamic>>[];
|
||||||
|
|
||||||
final newStories = <Map<String, dynamic>>[];
|
for (final shelf in shelvesData) {
|
||||||
for (final s in stories) {
|
final shelfId = shelf['id'];
|
||||||
final title = s['title'] as String? ?? '';
|
// Load stories for this shelf
|
||||||
if (title.isNotEmpty && !existingTitles.contains(title)) {
|
final storiesData = await api.get(
|
||||||
newStories.add({
|
'/stories/',
|
||||||
'title': title,
|
queryParameters: {'shelf_id': shelfId, 'page_size': 10},
|
||||||
'cover': null, // No cover yet for generated stories
|
);
|
||||||
'locked': false,
|
final stories = (storiesData['items'] as List?)
|
||||||
'content': s['content'] as String? ?? '',
|
?.map((s) => Map<String, dynamic>.from(s))
|
||||||
|
.toList() ?? [];
|
||||||
|
|
||||||
|
shelves.add({
|
||||||
|
...Map<String, dynamic>.from(shelf),
|
||||||
|
'stories': stories,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (newStories.isNotEmpty && mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_mockStories.addAll(newStories);
|
_shelves = shelves;
|
||||||
|
_shelvesLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Failed to load historical stories: $e');
|
debugPrint('Failed to load shelves: $e');
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_shelvesLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -418,28 +391,31 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
|||||||
|
|
||||||
// Bookshelf PageView - constrained height for proper proportions
|
// Bookshelf PageView - constrained height for proper proportions
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: MediaQuery.of(context).size.height * 0.64, // ~64% of screen height for bookshelf
|
height: MediaQuery.of(context).size.height * 0.64,
|
||||||
child: PageView.builder(
|
child: _shelvesLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: PageView.builder(
|
||||||
controller: _bookshelfController,
|
controller: _bookshelfController,
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
padEnds: false,
|
padEnds: false,
|
||||||
itemCount: _unlockedShelves + 1, // unlocked shelves + 1 locked placeholder
|
itemCount: _shelves.length + 1, // shelves + 1 locked placeholder
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (index < _unlockedShelves) {
|
if (index < _shelves.length) {
|
||||||
// Unlocked shelf
|
final shelf = _shelves[index];
|
||||||
final shelfNumber = index + 1;
|
final stories = (shelf['stories'] as List?)
|
||||||
final stories = index == 0 ? _mockStories : <Map<String, dynamic>>[];
|
?.cast<Map<String, dynamic>>() ?? [];
|
||||||
final count = '${stories.length}/10';
|
final capacity = shelf['capacity'] as int? ?? 10;
|
||||||
|
final count = '${stories.length}/$capacity';
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(left: 16, right: 6),
|
padding: const EdgeInsets.only(left: 16, right: 6),
|
||||||
child: _buildBookshelfSlide(
|
child: _buildBookshelfSlide(
|
||||||
'我的故事书 #$shelfNumber',
|
shelf['name'] as String? ?? '书架',
|
||||||
count,
|
count,
|
||||||
stories,
|
stories,
|
||||||
|
shelfId: shelf['id'] as int,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Last item is always the locked placeholder
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(left: 6, right: 16),
|
padding: const EdgeInsets.only(left: 6, right: 16),
|
||||||
child: _buildLockedShelf(),
|
child: _buildLockedShelf(),
|
||||||
@ -495,7 +471,7 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (saveResult is Map && saveResult['action'] == 'saved') {
|
if (saveResult is Map && saveResult['action'] == 'saved') {
|
||||||
_addNewBookWithAnimation(
|
await _addNewBookWithAnimation(
|
||||||
title: saveResult['title'] as String? ?? '新故事',
|
title: saveResult['title'] as String? ?? '新故事',
|
||||||
content: saveResult['content'] as String? ?? '',
|
content: saveResult['content'] as String? ?? '',
|
||||||
);
|
);
|
||||||
@ -508,8 +484,9 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
|||||||
Widget _buildBookshelfSlide(
|
Widget _buildBookshelfSlide(
|
||||||
String title,
|
String title,
|
||||||
String count,
|
String count,
|
||||||
List<Map<String, dynamic>> stories,
|
List<Map<String, dynamic>> stories, {
|
||||||
) {
|
required int shelfId,
|
||||||
|
}) {
|
||||||
// PRD: .bookshelf-container height: 600px, .story-book height: 100%
|
// PRD: .bookshelf-container height: 600px, .story-book height: 100%
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
@ -595,7 +572,8 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStorySlot(Map<String, dynamic> story, {bool isNew = false}) {
|
Widget _buildStorySlot(Map<String, dynamic> story, {bool isNew = false}) {
|
||||||
final bool hasCover = story['cover'] != null && (story['cover'] as String).isNotEmpty;
|
final coverUrl = story['cover_url'] as String? ?? story['cover'] as String? ?? '';
|
||||||
|
final bool hasCover = coverUrl.isNotEmpty;
|
||||||
final bool hasContent = story['content'] != null && (story['content'] as String).isNotEmpty;
|
final bool hasContent = story['content'] != null && (story['content'] as String).isNotEmpty;
|
||||||
|
|
||||||
// Empty/Clickable Slot — no content, just a "+" to create new story
|
// Empty/Clickable Slot — no content, just a "+" to create new story
|
||||||
@ -647,8 +625,14 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
|||||||
// Cover widget: real image or "未生成封面" placeholder
|
// Cover widget: real image or "未生成封面" placeholder
|
||||||
Widget coverWidget;
|
Widget coverWidget;
|
||||||
if (hasCover) {
|
if (hasCover) {
|
||||||
coverWidget = Image.asset(
|
coverWidget = coverUrl.startsWith('http')
|
||||||
story['cover'],
|
? Image.network(
|
||||||
|
coverUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => Container(color: Colors.grey.shade200),
|
||||||
|
)
|
||||||
|
: Image.asset(
|
||||||
|
coverUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) => Container(color: Colors.grey.shade200),
|
errorBuilder: (_, __, ___) => Container(color: Colors.grey.shade200),
|
||||||
);
|
);
|
||||||
@ -889,48 +873,79 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
|||||||
showGlassDialog(
|
showGlassDialog(
|
||||||
context: context,
|
context: context,
|
||||||
title: '解锁新书架',
|
title: '解锁新书架',
|
||||||
description: '确认消耗 500 积分以永久解锁该书架?',
|
description: '确认消耗 100 积分以永久解锁该书架?',
|
||||||
confirmText: '确认解锁',
|
confirmText: '确认解锁',
|
||||||
onConfirm: () {
|
onConfirm: () async {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
setState(() {
|
try {
|
||||||
_unlockedShelves++;
|
final api = ref.read(apiClientProvider);
|
||||||
});
|
await api.post('/stories/shelves/unlock/');
|
||||||
|
// Reload shelves to get the new one
|
||||||
|
await _loadShelves();
|
||||||
// Auto-scroll to the newly unlocked shelf
|
// Auto-scroll to the newly unlocked shelf
|
||||||
Future.delayed(const Duration(milliseconds: 300), () {
|
Future.delayed(const Duration(milliseconds: 300), () {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_bookshelfController.animateToPage(
|
_bookshelfController.animateToPage(
|
||||||
_unlockedShelves - 1, // scroll to the new shelf (0-indexed)
|
_shelves.length - 1,
|
||||||
duration: const Duration(milliseconds: 500),
|
duration: const Duration(milliseconds: 500),
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
AppToast.show(context, '解锁成功!新书架已添加');
|
if (mounted) AppToast.show(context, '解锁成功!新书架已添加');
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) AppToast.show(context, '解锁失败: ${e.toString()}');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _addNewBookWithAnimation({String title = '新故事', String content = ''}) {
|
Future<void> _addNewBookWithAnimation({String title = '新故事', String content = ''}) async {
|
||||||
setState(() {
|
// Find the first shelf that has space
|
||||||
_mockStories.add({
|
int? targetShelfId;
|
||||||
'title': title,
|
for (final shelf in _shelves) {
|
||||||
'cover': null, // No cover yet for generated stories
|
final stories = shelf['stories'] as List? ?? [];
|
||||||
'type': 'new',
|
final capacity = shelf['capacity'] as int? ?? 10;
|
||||||
'locked': false,
|
if (stories.length < capacity) {
|
||||||
'content': content,
|
targetShelfId = shelf['id'] as int;
|
||||||
});
|
break;
|
||||||
_newBookIndex = _mockStories.length - 1;
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
if (targetShelfId == null) {
|
||||||
|
if (mounted) AppToast.show(context, '所有书架已满,请解锁新书架');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final api = ref.read(apiClientProvider);
|
||||||
|
await api.post('/stories/', data: {
|
||||||
|
'title': title,
|
||||||
|
'content': content,
|
||||||
|
'shelf_id': targetShelfId,
|
||||||
|
});
|
||||||
|
// Reload to get the new story
|
||||||
|
await _loadShelves();
|
||||||
|
|
||||||
// Clear animation flag after animation completes
|
|
||||||
Future.delayed(const Duration(milliseconds: 800), () {
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
// Find the shelf index and trigger animation
|
||||||
|
for (int i = 0; i < _shelves.length; i++) {
|
||||||
|
if (_shelves[i]['id'] == targetShelfId) {
|
||||||
|
final stories = _shelves[i]['stories'] as List? ?? [];
|
||||||
setState(() {
|
setState(() {
|
||||||
_newBookIndex = null;
|
_newBookIndex = stories.length - 1;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Future.delayed(const Duration(milliseconds: 800), () {
|
||||||
|
if (mounted) setState(() => _newBookIndex = null);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
} catch (e) {
|
||||||
|
debugPrint('Save story failed: $e');
|
||||||
|
if (mounted) AppToast.show(context, '保存失败: ${e.toString()}');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -196,7 +196,8 @@ class _StoryDetailPageState extends State<StoryDetailPage>
|
|||||||
case TTSButtonState.error:
|
case TTSButtonState.error:
|
||||||
final title = _currentStory['title'] as String? ?? '';
|
final title = _currentStory['title'] as String? ?? '';
|
||||||
final content = _currentStory['content'] as String? ?? '';
|
final content = _currentStory['content'] as String? ?? '';
|
||||||
_ttsService.generate(title: title, content: content);
|
final storyId = _currentStory['id'] as int? ?? 0;
|
||||||
|
_ttsService.generate(title: title, content: content, storyId: storyId);
|
||||||
break;
|
break;
|
||||||
case TTSButtonState.generating:
|
case TTSButtonState.generating:
|
||||||
break;
|
break;
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../core/network/api_config.dart';
|
||||||
import 'story_detail_page.dart';
|
import 'story_detail_page.dart';
|
||||||
|
|
||||||
class StoryLoadingPage extends StatefulWidget {
|
class StoryLoadingPage extends StatefulWidget {
|
||||||
@ -22,8 +24,6 @@ class StoryLoadingPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _StoryLoadingPageState extends State<StoryLoadingPage> {
|
class _StoryLoadingPageState extends State<StoryLoadingPage> {
|
||||||
static const String _kServerBase = 'http://localhost:3000';
|
|
||||||
|
|
||||||
double _progress = 0.0;
|
double _progress = 0.0;
|
||||||
String _loadingText = '正在收集灵感碎片...';
|
String _loadingText = '正在收集灵感碎片...';
|
||||||
bool _hasError = false;
|
bool _hasError = false;
|
||||||
@ -34,14 +34,23 @@ class _StoryLoadingPageState extends State<StoryLoadingPage> {
|
|||||||
_generateStory();
|
_generateStory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String?> _getToken() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString('access_token');
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _generateStory() async {
|
Future<void> _generateStory() async {
|
||||||
try {
|
try {
|
||||||
// ── Start SSE request ──
|
final token = await _getToken();
|
||||||
|
|
||||||
final request = http.Request(
|
final request = http.Request(
|
||||||
'POST',
|
'POST',
|
||||||
Uri.parse('$_kServerBase/api/create_story'),
|
Uri.parse('${ApiConfig.fullBaseUrl}/stories/generate/'),
|
||||||
);
|
);
|
||||||
request.headers['Content-Type'] = 'application/json';
|
request.headers['Content-Type'] = 'application/json';
|
||||||
|
if (token != null) {
|
||||||
|
request.headers['Authorization'] = 'Bearer $token';
|
||||||
|
}
|
||||||
request.body = jsonEncode({
|
request.body = jsonEncode({
|
||||||
'characters': widget.characters,
|
'characters': widget.characters,
|
||||||
'scenes': widget.scenes,
|
'scenes': widget.scenes,
|
||||||
@ -69,11 +78,18 @@ class _StoryLoadingPageState extends State<StoryLoadingPage> {
|
|||||||
|
|
||||||
while (buffer.contains('\n\n')) {
|
while (buffer.contains('\n\n')) {
|
||||||
final idx = buffer.indexOf('\n\n');
|
final idx = buffer.indexOf('\n\n');
|
||||||
final line = buffer.substring(0, idx).trim();
|
final block = buffer.substring(0, idx).trim();
|
||||||
buffer = buffer.substring(idx + 2);
|
buffer = buffer.substring(idx + 2);
|
||||||
|
|
||||||
if (!line.startsWith('data: ')) continue;
|
// Extract the data line from the SSE block (may contain event: + data:)
|
||||||
final jsonStr = line.substring(6);
|
String? jsonStr;
|
||||||
|
for (final line in block.split('\n')) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
jsonStr = line.substring(6);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jsonStr == null) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final event = jsonDecode(jsonStr) as Map<String, dynamic>;
|
final event = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../core/network/api_config.dart';
|
||||||
|
|
||||||
/// Singleton service that manages TTS generation in the background.
|
/// Singleton service that manages TTS generation in the background.
|
||||||
/// Survives page navigation — when user leaves and comes back,
|
/// Survives page navigation — when user leaves and comes back,
|
||||||
@ -9,7 +11,10 @@ class TTSService extends ChangeNotifier {
|
|||||||
TTSService._();
|
TTSService._();
|
||||||
static final TTSService instance = TTSService._();
|
static final TTSService instance = TTSService._();
|
||||||
|
|
||||||
static const String _kServerBase = 'http://localhost:3000';
|
Future<String?> _getToken() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString('access_token');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Current task state ──
|
// ── Current task state ──
|
||||||
bool _isGenerating = false;
|
bool _isGenerating = false;
|
||||||
@ -59,20 +64,23 @@ class TTSService extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check server for existing audio file.
|
/// Check server for existing audio file by story ID.
|
||||||
Future<void> checkExistingAudio(String title) async {
|
Future<void> checkExistingAudio(String title, {int? storyId}) async {
|
||||||
if (title.isEmpty) return;
|
if (title.isEmpty || storyId == null) return;
|
||||||
try {
|
try {
|
||||||
|
final token = await _getToken();
|
||||||
final resp = await http.get(
|
final resp = await http.get(
|
||||||
Uri.parse(
|
Uri.parse('${ApiConfig.fullBaseUrl}/stories/$storyId/tts/'),
|
||||||
'$_kServerBase/api/tts_check?title=${Uri.encodeComponent(title)}',
|
headers: {
|
||||||
),
|
if (token != null) 'Authorization': 'Bearer $token',
|
||||||
|
},
|
||||||
);
|
);
|
||||||
if (resp.statusCode == 200) {
|
if (resp.statusCode == 200) {
|
||||||
final data = jsonDecode(resp.body);
|
final body = jsonDecode(resp.body);
|
||||||
if (data['exists'] == true && data['audio_url'] != null) {
|
final data = body['data'];
|
||||||
|
if (data != null && data['exists'] == true && data['audio_url'] != null) {
|
||||||
_completedStoryTitle = title;
|
_completedStoryTitle = title;
|
||||||
_audioUrl = '$_kServerBase/${data['audio_url']}';
|
_audioUrl = data['audio_url'] as String;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -80,9 +88,11 @@ class TTSService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Start TTS generation. Safe to call even if page navigates away.
|
/// Start TTS generation. Safe to call even if page navigates away.
|
||||||
|
/// [storyId] is required to call the Django backend TTS endpoint.
|
||||||
Future<void> generate({
|
Future<void> generate({
|
||||||
required String title,
|
required String title,
|
||||||
required String content,
|
required String content,
|
||||||
|
required int storyId,
|
||||||
}) async {
|
}) async {
|
||||||
if (_isGenerating) return;
|
if (_isGenerating) return;
|
||||||
|
|
||||||
@ -97,21 +107,39 @@ class TTSService extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final token = await _getToken();
|
||||||
final client = http.Client();
|
final client = http.Client();
|
||||||
final request = http.Request(
|
final request = http.Request(
|
||||||
'POST',
|
'POST',
|
||||||
Uri.parse('$_kServerBase/api/create_tts'),
|
Uri.parse('${ApiConfig.fullBaseUrl}/stories/$storyId/tts/'),
|
||||||
);
|
);
|
||||||
request.headers['Content-Type'] = 'application/json';
|
request.headers['Content-Type'] = 'application/json';
|
||||||
request.body = jsonEncode({'title': title, 'content': content});
|
if (token != null) {
|
||||||
|
request.headers['Authorization'] = 'Bearer $token';
|
||||||
|
}
|
||||||
|
|
||||||
final streamed = await client.send(request);
|
final streamed = await client.send(request);
|
||||||
|
|
||||||
|
String buffer = '';
|
||||||
await for (final chunk in streamed.stream.transform(utf8.decoder)) {
|
await for (final chunk in streamed.stream.transform(utf8.decoder)) {
|
||||||
for (final line in chunk.split('\n')) {
|
buffer += chunk;
|
||||||
if (!line.startsWith('data: ')) continue;
|
|
||||||
|
while (buffer.contains('\n\n')) {
|
||||||
|
final idx = buffer.indexOf('\n\n');
|
||||||
|
final block = buffer.substring(0, idx).trim();
|
||||||
|
buffer = buffer.substring(idx + 2);
|
||||||
|
|
||||||
|
String? jsonStr;
|
||||||
|
for (final line in block.split('\n')) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
jsonStr = line.substring(6);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jsonStr == null) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final data = jsonDecode(line.substring(6));
|
final data = jsonDecode(jsonStr);
|
||||||
final stage = data['stage'] as String? ?? '';
|
final stage = data['stage'] as String? ?? '';
|
||||||
final message = data['message'] as String? ?? '';
|
final message = data['message'] as String? ?? '';
|
||||||
|
|
||||||
@ -127,7 +155,7 @@ class TTSService extends ChangeNotifier {
|
|||||||
break;
|
break;
|
||||||
case 'done':
|
case 'done':
|
||||||
if (data['audio_url'] != null) {
|
if (data['audio_url'] != null) {
|
||||||
_audioUrl = '$_kServerBase/${data['audio_url']}';
|
_audioUrl = data['audio_url'] as String;
|
||||||
_completedStoryTitle = title;
|
_completedStoryTitle = title;
|
||||||
_justCompleted = true;
|
_justCompleted = true;
|
||||||
_updateProgress(1.0, '生成完成');
|
_updateProgress(1.0, '生成完成');
|
||||||
@ -136,7 +164,6 @@ class TTSService extends ChangeNotifier {
|
|||||||
case 'error':
|
case 'error':
|
||||||
throw Exception(message);
|
throw Exception(message);
|
||||||
default:
|
default:
|
||||||
// Progress slowly increases during generation
|
|
||||||
if (_progress < 0.85) {
|
if (_progress < 0.85) {
|
||||||
_updateProgress(_progress + 0.02, message);
|
_updateProgress(_progress + 0.02, message);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user