feat: Enhance chapter list and TTS functionality
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:
2026-04-07 18:49:29 +07:00
parent 1afff18f4d
commit 6946083aee
27 changed files with 1590 additions and 157 deletions
@@ -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,
);
});