diff --git a/airhub_app/lib/pages/device_control_page.dart b/airhub_app/lib/pages/device_control_page.dart index edccf4b..4a59ac2 100644 --- a/airhub_app/lib/pages/device_control_page.dart +++ b/airhub_app/lib/pages/device_control_page.dart @@ -1,11 +1,10 @@ -import 'dart:convert'; 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 'package:http/http.dart' as http; +import '../core/network/api_client.dart'; import 'story_detail_page.dart'; import 'product_selection_page.dart'; import 'settings_page.dart'; @@ -42,41 +41,9 @@ class _DeviceControlPageState extends ConsumerState // 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巫婆掉进了自己的烤炉里。兄妹俩找到了巫婆藏的宝石和金币,高高兴兴地回了家。爸爸看到他们回来,高兴得流下了眼泪。', - }, - ]; + // Shelves loaded from backend: [{id, name, capacity, story_count, stories: [...]}] + List> _shelves = []; + bool _shelvesLoading = true; @override void initState() { @@ -94,43 +61,49 @@ class _DeviceControlPageState extends ConsumerState }); }); - // Load historical stories from backend - _loadHistoricalStories(); + // Load shelves and stories from backend + _loadShelves(); } - /// Fetch saved stories from backend and prepend to bookshelf - Future _loadHistoricalStories() async { + /// Fetch shelves and their stories from backend + Future _loadShelves() async { try { - final resp = await http.get(Uri.parse('http://localhost:3000/api/stories')); - if (resp.statusCode == 200) { - final data = jsonDecode(resp.body); - final List stories = data['stories'] ?? []; - if (stories.isEmpty) return; + final api = ref.read(apiClientProvider); - // Collect titles already in the mock list to avoid duplicates - final existingTitles = _mockStories.map((s) => s['title'] as String).toSet(); + // Load shelves + final shelvesData = await api.get('/stories/shelves/') as List; + final shelves = >[]; - final newStories = >[]; - for (final s in stories) { - final title = s['title'] as String? ?? ''; - if (title.isNotEmpty && !existingTitles.contains(title)) { - newStories.add({ - 'title': title, - 'cover': null, // No cover yet for generated stories - 'locked': false, - 'content': s['content'] as String? ?? '', - }); - } - } + for (final shelf in shelvesData) { + final shelfId = shelf['id']; + // Load stories for this shelf + final storiesData = await api.get( + '/stories/', + queryParameters: {'shelf_id': shelfId, 'page_size': 10}, + ); + final stories = (storiesData['items'] as List?) + ?.map((s) => Map.from(s)) + .toList() ?? []; - if (newStories.isNotEmpty && mounted) { - setState(() { - _mockStories.addAll(newStories); - }); - } + shelves.add({ + ...Map.from(shelf), + 'stories': stories, + }); + } + + if (mounted) { + setState(() { + _shelves = shelves; + _shelvesLoading = false; + }); } } catch (e) { - debugPrint('Failed to load historical stories: $e'); + debugPrint('Failed to load shelves: $e'); + if (mounted) { + setState(() { + _shelvesLoading = false; + }); + } } } @@ -418,35 +391,38 @@ class _DeviceControlPageState extends ConsumerState // 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(), - ); - } - }, - ), + height: MediaQuery.of(context).size.height * 0.64, + child: _shelvesLoading + ? const Center(child: CircularProgressIndicator()) + : PageView.builder( + controller: _bookshelfController, + clipBehavior: Clip.none, + padEnds: false, + itemCount: _shelves.length + 1, // shelves + 1 locked placeholder + itemBuilder: (context, index) { + if (index < _shelves.length) { + final shelf = _shelves[index]; + final stories = (shelf['stories'] as List?) + ?.cast>() ?? []; + final capacity = shelf['capacity'] as int? ?? 10; + final count = '${stories.length}/$capacity'; + return Padding( + padding: const EdgeInsets.only(left: 16, right: 6), + child: _buildBookshelfSlide( + shelf['name'] as String? ?? '书架', + count, + stories, + shelfId: shelf['id'] as int, + ), + ); + } else { + return Padding( + padding: const EdgeInsets.only(left: 6, right: 16), + child: _buildLockedShelf(), + ); + } + }, + ), ), // Flexible bottom space @@ -495,7 +471,7 @@ class _DeviceControlPageState extends ConsumerState ), ); if (saveResult is Map && saveResult['action'] == 'saved') { - _addNewBookWithAnimation( + await _addNewBookWithAnimation( title: saveResult['title'] as String? ?? '新故事', content: saveResult['content'] as String? ?? '', ); @@ -508,8 +484,9 @@ class _DeviceControlPageState extends ConsumerState Widget _buildBookshelfSlide( String title, String count, - List> stories, - ) { + List> stories, { + required int shelfId, + }) { // PRD: .bookshelf-container height: 600px, .story-book height: 100% return Container( margin: const EdgeInsets.only(bottom: 12), @@ -595,7 +572,8 @@ class _DeviceControlPageState extends ConsumerState } Widget _buildStorySlot(Map 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; // Empty/Clickable Slot — no content, just a "+" to create new story @@ -647,11 +625,17 @@ class _DeviceControlPageState extends ConsumerState // Cover widget: real image or "未生成封面" placeholder Widget coverWidget; if (hasCover) { - coverWidget = Image.asset( - story['cover'], - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Container(color: Colors.grey.shade200), - ); + coverWidget = coverUrl.startsWith('http') + ? Image.network( + coverUrl, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container(color: Colors.grey.shade200), + ) + : Image.asset( + coverUrl, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container(color: Colors.grey.shade200), + ); } else { // No cover — show soft placeholder coverWidget = Container( @@ -889,48 +873,79 @@ class _DeviceControlPageState extends ConsumerState showGlassDialog( context: context, title: '解锁新书架', - description: '确认消耗 500 积分以永久解锁该书架?', + description: '确认消耗 100 积分以永久解锁该书架?', confirmText: '确认解锁', - onConfirm: () { + onConfirm: () async { 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, '解锁成功!新书架已添加'); + try { + 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 + Future.delayed(const Duration(milliseconds: 300), () { + if (mounted) { + _bookshelfController.animateToPage( + _shelves.length - 1, + duration: const Duration(milliseconds: 500), + curve: Curves.easeOutCubic, + ); + } + }); + if (mounted) AppToast.show(context, '解锁成功!新书架已添加'); + } catch (e) { + if (mounted) AppToast.show(context, '解锁失败: ${e.toString()}'); + } }, ); } - void _addNewBookWithAnimation({String title = '新故事', String content = ''}) { - setState(() { - _mockStories.add({ - 'title': title, - 'cover': null, // No cover yet for generated stories - 'type': 'new', - 'locked': false, - 'content': content, - }); - _newBookIndex = _mockStories.length - 1; - }); + Future _addNewBookWithAnimation({String title = '新故事', String content = ''}) async { + // Find the first shelf that has space + int? targetShelfId; + for (final shelf in _shelves) { + final stories = shelf['stories'] as List? ?? []; + final capacity = shelf['capacity'] as int? ?? 10; + if (stories.length < capacity) { + targetShelfId = shelf['id'] as int; + break; + } + } + + 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) { - setState(() { - _newBookIndex = null; + // 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(() { + _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()}'); + } } } diff --git a/airhub_app/lib/pages/story_detail_page.dart b/airhub_app/lib/pages/story_detail_page.dart index fa96b33..9f5090a 100644 --- a/airhub_app/lib/pages/story_detail_page.dart +++ b/airhub_app/lib/pages/story_detail_page.dart @@ -196,7 +196,8 @@ class _StoryDetailPageState extends State case TTSButtonState.error: final title = _currentStory['title'] 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; case TTSButtonState.generating: break; diff --git a/airhub_app/lib/pages/story_loading_page.dart b/airhub_app/lib/pages/story_loading_page.dart index 061b9a7..ee7ff0b 100644 --- a/airhub_app/lib/pages/story_loading_page.dart +++ b/airhub_app/lib/pages/story_loading_page.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; 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'; class StoryLoadingPage extends StatefulWidget { @@ -22,8 +24,6 @@ class StoryLoadingPage extends StatefulWidget { } class _StoryLoadingPageState extends State { - static const String _kServerBase = 'http://localhost:3000'; - double _progress = 0.0; String _loadingText = '正在收集灵感碎片...'; bool _hasError = false; @@ -34,14 +34,23 @@ class _StoryLoadingPageState extends State { _generateStory(); } + Future _getToken() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString('access_token'); + } + Future _generateStory() async { try { - // ── Start SSE request ── + final token = await _getToken(); + final request = http.Request( 'POST', - Uri.parse('$_kServerBase/api/create_story'), + Uri.parse('${ApiConfig.fullBaseUrl}/stories/generate/'), ); request.headers['Content-Type'] = 'application/json'; + if (token != null) { + request.headers['Authorization'] = 'Bearer $token'; + } request.body = jsonEncode({ 'characters': widget.characters, 'scenes': widget.scenes, @@ -69,11 +78,18 @@ class _StoryLoadingPageState extends State { while (buffer.contains('\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); - if (!line.startsWith('data: ')) continue; - final jsonStr = line.substring(6); + // Extract the data line from the SSE block (may contain event: + data:) + String? jsonStr; + for (final line in block.split('\n')) { + if (line.startsWith('data: ')) { + jsonStr = line.substring(6); + break; + } + } + if (jsonStr == null) continue; try { final event = jsonDecode(jsonStr) as Map; diff --git a/airhub_app/lib/services/tts_service.dart b/airhub_app/lib/services/tts_service.dart index 5c6458a..97e1650 100644 --- a/airhub_app/lib/services/tts_service.dart +++ b/airhub_app/lib/services/tts_service.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; 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. /// Survives page navigation — when user leaves and comes back, @@ -9,7 +11,10 @@ class TTSService extends ChangeNotifier { TTSService._(); static final TTSService instance = TTSService._(); - static const String _kServerBase = 'http://localhost:3000'; + Future _getToken() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString('access_token'); + } // ── Current task state ── bool _isGenerating = false; @@ -59,20 +64,23 @@ class TTSService extends ChangeNotifier { notifyListeners(); } - /// Check server for existing audio file. - Future checkExistingAudio(String title) async { - if (title.isEmpty) return; + /// Check server for existing audio file by story ID. + Future checkExistingAudio(String title, {int? storyId}) async { + if (title.isEmpty || storyId == null) return; try { + final token = await _getToken(); final resp = await http.get( - Uri.parse( - '$_kServerBase/api/tts_check?title=${Uri.encodeComponent(title)}', - ), + Uri.parse('${ApiConfig.fullBaseUrl}/stories/$storyId/tts/'), + headers: { + if (token != null) 'Authorization': 'Bearer $token', + }, ); if (resp.statusCode == 200) { - final data = jsonDecode(resp.body); - if (data['exists'] == true && data['audio_url'] != null) { + final body = jsonDecode(resp.body); + final data = body['data']; + if (data != null && data['exists'] == true && data['audio_url'] != null) { _completedStoryTitle = title; - _audioUrl = '$_kServerBase/${data['audio_url']}'; + _audioUrl = data['audio_url'] as String; notifyListeners(); } } @@ -80,9 +88,11 @@ class TTSService extends ChangeNotifier { } /// Start TTS generation. Safe to call even if page navigates away. + /// [storyId] is required to call the Django backend TTS endpoint. Future generate({ required String title, required String content, + required int storyId, }) async { if (_isGenerating) return; @@ -97,21 +107,39 @@ class TTSService extends ChangeNotifier { notifyListeners(); try { + final token = await _getToken(); final client = http.Client(); final request = http.Request( 'POST', - Uri.parse('$_kServerBase/api/create_tts'), + Uri.parse('${ApiConfig.fullBaseUrl}/stories/$storyId/tts/'), ); 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); + String buffer = ''; await for (final chunk in streamed.stream.transform(utf8.decoder)) { - for (final line in chunk.split('\n')) { - if (!line.startsWith('data: ')) continue; + buffer += chunk; + + 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 { - final data = jsonDecode(line.substring(6)); + final data = jsonDecode(jsonStr); final stage = data['stage'] as String? ?? ''; final message = data['message'] as String? ?? ''; @@ -127,7 +155,7 @@ class TTSService extends ChangeNotifier { break; case 'done': if (data['audio_url'] != null) { - _audioUrl = '$_kServerBase/${data['audio_url']}'; + _audioUrl = data['audio_url'] as String; _completedStoryTitle = title; _justCompleted = true; _updateProgress(1.0, '生成完成'); @@ -136,7 +164,6 @@ class TTSService extends ChangeNotifier { case 'error': throw Exception(message); default: - // Progress slowly increases during generation if (_progress < 0.85) { _updateProgress(_progress + 0.02, message); }