feat: Enhance chapter list and TTS functionality
Build Android APK / build-apk (push) Failing after 4m37s
Build Android APK / build-apk (push) Failing after 4m37s
- Introduced ChapterListQuery and ChapterListPage classes for better chapter management. - Updated chapterListProvider to handle pagination and canonical ID resolution. - Improved ReaderScreen with enhanced TTS features, including auto-scroll to active paragraph and better handling of TTS state. - Added TtsPlayerWidget with compact mode and improved UI for TTS controls. - Enhanced TtsService to manage speech segments and background mode for TTS. - Implemented battery optimization checks for TTS background mode on Android. - Updated main.dart to ensure proper error handling in a zoned environment.
This commit is contained in:
@@ -5,18 +5,47 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/router/route_names.dart';
|
||||
import '../../../core/models/novel_model.dart';
|
||||
import '../../../core/storage/local_store.dart';
|
||||
import '../../bookshelf/providers/bookshelf_provider.dart';
|
||||
import '../providers/novels_provider.dart';
|
||||
|
||||
class NovelDetailScreen extends ConsumerWidget {
|
||||
final novelReadProgressProvider =
|
||||
FutureProvider.family<Map<String, dynamic>?, String>((ref, novelId) async {
|
||||
final localStore = ref.read(localStoreProvider);
|
||||
return localStore.loadProgress(novelId);
|
||||
});
|
||||
|
||||
class NovelDetailScreen extends ConsumerStatefulWidget {
|
||||
const NovelDetailScreen({super.key, required this.novelId});
|
||||
|
||||
final String novelId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<NovelDetailScreen> createState() => _NovelDetailScreenState();
|
||||
}
|
||||
|
||||
class _NovelDetailScreenState extends ConsumerState<NovelDetailScreen> {
|
||||
int _currentPage = 1;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant NovelDetailScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.novelId != widget.novelId && _currentPage != 1) {
|
||||
setState(() => _currentPage = 1);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final novelId = widget.novelId;
|
||||
final novelAsync = ref.watch(novelDetailProvider(novelId));
|
||||
final chaptersAsync = ref.watch(chapterListProvider(novelId));
|
||||
final chaptersAsync = ref.watch(
|
||||
chapterListProvider(ChapterListQuery(novelId: novelId, page: _currentPage)),
|
||||
);
|
||||
final firstChapterAsync = ref.watch(
|
||||
chapterListProvider(ChapterListQuery(novelId: novelId, page: 1)),
|
||||
);
|
||||
final readProgressAsync = ref.watch(novelReadProgressProvider(novelId));
|
||||
final isBookmarked = ref.watch(isBookmarkedProvider(novelId));
|
||||
|
||||
return Scaffold(
|
||||
@@ -56,20 +85,30 @@ class NovelDetailScreen extends ConsumerWidget {
|
||||
_StatsRow(novel: novel),
|
||||
const SizedBox(height: 16),
|
||||
// Read button
|
||||
chaptersAsync.when(
|
||||
firstChapterAsync.when(
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, error) => const SizedBox.shrink(),
|
||||
data: (chapters) {
|
||||
if (chapters.isEmpty) return const SizedBox.shrink();
|
||||
final first = chapters.first;
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
data: (firstPage) {
|
||||
final first =
|
||||
firstPage.chapters.isNotEmpty ? firstPage.chapters.first : null;
|
||||
if (first == null) return const SizedBox.shrink();
|
||||
|
||||
final progress = readProgressAsync.valueOrNull;
|
||||
final continueChapterId = progress?['chapterId'] as String?;
|
||||
final continueChapterNumber =
|
||||
(progress?['chapterNumber'] as num?)?.toInt();
|
||||
final hasProgress = continueChapterId != null && continueChapterId.isNotEmpty;
|
||||
final targetChapterId = hasProgress ? continueChapterId : first.id;
|
||||
final buttonLabel = hasProgress
|
||||
? 'Đọc tiếp chương ${continueChapterNumber ?? '?'}'
|
||||
: 'Đọc từ đầu';
|
||||
|
||||
return FilledButton.icon(
|
||||
onPressed: () => context.push(
|
||||
RouteNames.readerChapter(first.id),
|
||||
RouteNames.readerChapter(targetChapterId),
|
||||
),
|
||||
icon: const Icon(Icons.menu_book),
|
||||
label: Text(
|
||||
'Đọc Chương ${first.number}: ${first.title}',
|
||||
),
|
||||
label: Text(buttonLabel),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
@@ -83,8 +122,8 @@ class NovelDetailScreen extends ConsumerWidget {
|
||||
Text('Danh sách chương', style: Theme.of(context).textTheme.titleMedium),
|
||||
const Spacer(),
|
||||
chaptersAsync.whenOrNull(
|
||||
data: (chapters) => Text(
|
||||
'${chapters.length} chương',
|
||||
data: (pageData) => Text(
|
||||
'${pageData.totalChapters} chương • Trang ${pageData.currentPage}/${pageData.totalPages == 0 ? 1 : pageData.totalPages}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
) ?? const SizedBox.shrink(),
|
||||
@@ -102,12 +141,22 @@ class NovelDetailScreen extends ConsumerWidget {
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
error: (_, error) =>
|
||||
const SliverToBoxAdapter(child: SizedBox.shrink()),
|
||||
data: (chapters) => SliverList(
|
||||
error: (error, _) => SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Text(
|
||||
'Không tải được danh sách chương: $error',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
),
|
||||
data: (pageData) => SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final ch = chapters[index];
|
||||
final ch = pageData.chapters[index];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
title: Text(
|
||||
@@ -118,10 +167,43 @@ class NovelDetailScreen extends ConsumerWidget {
|
||||
onTap: () => context.push(RouteNames.readerChapter(ch.id)),
|
||||
);
|
||||
},
|
||||
childCount: chapters.length,
|
||||
childCount: pageData.chapters.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: chaptersAsync.when(
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
data: (pageData) {
|
||||
final totalPages = pageData.totalPages == 0 ? 1 : pageData.totalPages;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _currentPage > 1
|
||||
? () => setState(() => _currentPage = _currentPage - 1)
|
||||
: null,
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
label: const Text('Trang trước'),
|
||||
),
|
||||
const Spacer(),
|
||||
Text('Trang $_currentPage/$totalPages'),
|
||||
const Spacer(),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _currentPage < totalPages
|
||||
? () => setState(() => _currentPage = _currentPage + 1)
|
||||
: null,
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
label: const Text('Trang sau'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -4,6 +4,8 @@ import '../../../core/models/novel_model.dart';
|
||||
import '../../../core/models/chapter_model.dart';
|
||||
import '../../../core/network/providers.dart';
|
||||
|
||||
const chapterPageSize = 50;
|
||||
|
||||
// ─── Browse / Search ──────────────────────────────────────────────────────────
|
||||
|
||||
class BrowseParams {
|
||||
@@ -109,13 +111,77 @@ final novelDetailProvider =
|
||||
|
||||
// ─── Chapter List ─────────────────────────────────────────────────────────────
|
||||
|
||||
class ChapterListQuery {
|
||||
const ChapterListQuery({required this.novelId, this.page = 1});
|
||||
|
||||
final String novelId;
|
||||
final int page;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is ChapterListQuery &&
|
||||
other.novelId == novelId &&
|
||||
other.page == page;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(novelId, page);
|
||||
}
|
||||
|
||||
class ChapterListPage {
|
||||
const ChapterListPage({
|
||||
required this.chapters,
|
||||
required this.totalChapters,
|
||||
required this.totalPages,
|
||||
required this.currentPage,
|
||||
});
|
||||
|
||||
final List<ChapterListItem> chapters;
|
||||
final int totalChapters;
|
||||
final int totalPages;
|
||||
final int currentPage;
|
||||
}
|
||||
|
||||
final chapterListProvider =
|
||||
FutureProvider.family<List<ChapterListItem>, String>((ref, novelId) async {
|
||||
FutureProvider.family<ChapterListPage, ChapterListQuery>((ref, query) async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final res = await client.dio.get('/api/truyen/$novelId/chapters');
|
||||
final data = res.data as Map<String, dynamic>;
|
||||
final chapters = data['chapters'] as List? ?? [];
|
||||
return chapters
|
||||
.map((e) => ChapterListItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
Future<Map<String, dynamic>> fetchChapterPage(String idOrSlug) async {
|
||||
final res = await client.dio.get(
|
||||
'/api/truyen/$idOrSlug/chapters',
|
||||
queryParameters: {
|
||||
'page': query.page,
|
||||
'limit': chapterPageSize,
|
||||
},
|
||||
);
|
||||
return res.data as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
var data = await fetchChapterPage(query.novelId);
|
||||
var chapters = data['chapters'] as List? ?? const [];
|
||||
|
||||
// Backend stores chapters by novel id in MongoDB; if route opened by slug,
|
||||
// first request can return empty list. Resolve canonical id and retry once.
|
||||
if (chapters.isEmpty) {
|
||||
try {
|
||||
final novelRes = await client.dio.get('/api/novels/${query.novelId}');
|
||||
final novelData = novelRes.data as Map<String, dynamic>;
|
||||
final canonicalId = novelData['id'] as String?;
|
||||
if (canonicalId != null && canonicalId.isNotEmpty && canonicalId != query.novelId) {
|
||||
data = await fetchChapterPage(canonicalId);
|
||||
chapters = data['chapters'] as List? ?? const [];
|
||||
}
|
||||
} catch (_) {
|
||||
// Keep original empty list when fallback resolution fails.
|
||||
}
|
||||
}
|
||||
|
||||
return ChapterListPage(
|
||||
chapters:
|
||||
chapters.map((e) => ChapterListItem.fromJson(e as Map<String, dynamic>)).toList(),
|
||||
totalChapters: (data['totalChapters'] as num?)?.toInt() ?? 0,
|
||||
totalPages: (data['totalPages'] as num?)?.toInt() ?? 0,
|
||||
currentPage: (data['currentPage'] as num?)?.toInt() ?? query.page,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user