From 66613857e854d224c4af03e778ba0a9be9ed8f5c Mon Sep 17 00:00:00 2001 From: virtus Date: Fri, 24 Apr 2026 03:03:32 +0700 Subject: [PATCH] Refactor chapter list provider and improve TTS functionality - Removed the constant chapterPageSize and refactored ChapterListQuery to use a simpler approach for fetching chapters. - Updated the chapter list provider to handle fetching all chapters in a single request with pagination. - Enhanced error handling for fetching chapters by resolving canonical IDs when necessary. - Modified TTS functionality to ensure proper handling of Android fallback reading and improved error management. - Added a new setting to enable/disable TTS on sentence tap. - Updated UI components in the reader screen for better user experience and added navigation buttons for chapters. - Bumped version to 1.0.3+4 in pubspec.yaml. --- .../reader_app/tts/ReaderTtsMediaService.kt | 64 +- lib/core/models/bookmark_model.dart | 20 +- lib/core/models/reading_settings.dart | 6 + .../presentation/bookshelf_screen.dart | 94 +- .../providers/bookshelf_provider.dart | 26 + .../presentation/novel_detail_screen.dart | 1012 ++++++++++++----- .../novel/providers/novels_provider.dart | 102 +- .../reader/presentation/reader_screen.dart | 117 +- .../reader/providers/reader_provider.dart | 4 + lib/features/reader/tts/tts_service.dart | 112 +- pubspec.yaml | 2 +- 11 files changed, 1112 insertions(+), 447 deletions(-) diff --git a/android/app/src/main/kotlin/com/example/reader_app/tts/ReaderTtsMediaService.kt b/android/app/src/main/kotlin/com/example/reader_app/tts/ReaderTtsMediaService.kt index aca0ffe..38db6c0 100644 --- a/android/app/src/main/kotlin/com/example/reader_app/tts/ReaderTtsMediaService.kt +++ b/android/app/src/main/kotlin/com/example/reader_app/tts/ReaderTtsMediaService.kt @@ -7,6 +7,7 @@ import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent +import android.content.pm.ServiceInfo import android.media.AudioAttributes import android.media.AudioFocusRequest import android.media.AudioManager @@ -93,21 +94,27 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { language: String, voiceName: String?, backgroundModeEnabled: Boolean, - ) { - ContextCompat.startForegroundService( - context, - Intent(context, ReaderTtsMediaService::class.java).apply { - action = ACTION_START_READING - putParcelableArrayListExtra(EXTRA_SEGMENTS, segments) - putExtra(EXTRA_START_INDEX, startIndex) - putExtra(EXTRA_CONTENT_KEY, contentKey) - putExtra(EXTRA_TITLE, title) - putExtra(EXTRA_SPEED, speed) - putExtra(EXTRA_LANGUAGE, language) - putExtra(EXTRA_VOICE_NAME, voiceName) - putExtra(EXTRA_BACKGROUND_MODE_ENABLED, backgroundModeEnabled) - }, - ) + ): Boolean { + return try { + ContextCompat.startForegroundService( + context, + Intent(context, ReaderTtsMediaService::class.java).apply { + action = ACTION_START_READING + putParcelableArrayListExtra(EXTRA_SEGMENTS, segments) + putExtra(EXTRA_START_INDEX, startIndex) + putExtra(EXTRA_CONTENT_KEY, contentKey) + putExtra(EXTRA_TITLE, title) + putExtra(EXTRA_SPEED, speed) + putExtra(EXTRA_LANGUAGE, language) + putExtra(EXTRA_VOICE_NAME, voiceName) + putExtra(EXTRA_BACKGROUND_MODE_ENABLED, backgroundModeEnabled) + }, + ) + true + } catch (e: Throwable) { + Log.e(TAG, "startForegroundService blocked or failed", e) + false + } } fun pause(context: Context) = @@ -905,7 +912,8 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { @SuppressLint("MissingPermission") private fun buildNotification() = NotificationCompat.Builder(this, CHANNEL_ID) - .setSmallIcon(R.mipmap.ic_launcher) + // Avoid adaptive launcher icon for foreground notifications on strict OEM ROMs. + .setSmallIcon(android.R.drawable.ic_media_play) .setContentTitle(title ?: appLabel()) .setContentText(currentProgressLabel()) .setContentIntent(buildLaunchIntent()) @@ -995,8 +1003,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { // to prevent Android from killing us. if (status == "playing" || status == "paused") { val notification = buildNotification() - startForeground(NOTIFICATION_ID, notification) - isForegroundActive = true + isForegroundActive = startForegroundCompat(notification) } return } @@ -1005,8 +1012,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { "playing", "paused" -> { val notification = buildNotification() if (!isForegroundActive) { - startForeground(NOTIFICATION_ID, notification) - isForegroundActive = true + isForegroundActive = startForegroundCompat(notification) } else { notificationManager.notify(NOTIFICATION_ID, notification) } @@ -1021,6 +1027,24 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { } } + private fun startForegroundCompat(notification: android.app.Notification): Boolean { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK, + ) + } else { + startForeground(NOTIFICATION_ID, notification) + } + true + } catch (e: Throwable) { + Log.e(TAG, "startForeground failed", e) + false + } + } + private fun publishSnapshot() { val segment = currentSegment() val canExposeSegmentProgress = status == "playing" && currentUtteranceStarted diff --git a/lib/core/models/bookmark_model.dart b/lib/core/models/bookmark_model.dart index a83f747..564f90e 100644 --- a/lib/core/models/bookmark_model.dart +++ b/lib/core/models/bookmark_model.dart @@ -2,10 +2,26 @@ import 'package:equatable/equatable.dart'; import 'novel_model.dart'; +enum BookmarkType { + reading('reading'), + bookmarked('bookmarked'); + + const BookmarkType(this.value); + final String value; + + static BookmarkType fromString(String? str) { + return values.firstWhere( + (e) => e.value == str, + orElse: () => BookmarkType.bookmarked, + ); + } +} + class BookmarkModel extends Equatable { const BookmarkModel({ required this.id, required this.novelId, + this.type = BookmarkType.bookmarked, this.lastChapterId, this.lastChapterNumber, this.readChapters = const [], @@ -14,6 +30,7 @@ class BookmarkModel extends Equatable { final String id; final String novelId; + final BookmarkType type; final String? lastChapterId; final int? lastChapterNumber; final List readChapters; @@ -22,6 +39,7 @@ class BookmarkModel extends Equatable { factory BookmarkModel.fromJson(Map json) => BookmarkModel( id: json['id'] as String, novelId: json['novelId'] as String, + type: BookmarkType.fromString(json['type'] as String?), lastChapterId: json['lastChapterId'] as String?, lastChapterNumber: json['lastChapterNumber'] as int?, readChapters: (json['readChapters'] as List?) @@ -34,5 +52,5 @@ class BookmarkModel extends Equatable { ); @override - List get props => [id, novelId]; + List get props => [id, novelId, type]; } diff --git a/lib/core/models/reading_settings.dart b/lib/core/models/reading_settings.dart index c57b93c..443ebc0 100644 --- a/lib/core/models/reading_settings.dart +++ b/lib/core/models/reading_settings.dart @@ -10,6 +10,7 @@ class ReadingSettings { this.horizontalPadding = 20, this.paragraphSpacing = 24, this.textAlign = 'left', + this.enableSentenceTapTts = false, }); final double fontSize; @@ -22,6 +23,7 @@ class ReadingSettings { final double horizontalPadding; final double paragraphSpacing; final String textAlign; + final bool enableSentenceTapTts; ReadingSettings copyWith({ double? fontSize, @@ -34,6 +36,7 @@ class ReadingSettings { double? horizontalPadding, double? paragraphSpacing, String? textAlign, + bool? enableSentenceTapTts, }) => ReadingSettings( fontSize: fontSize ?? this.fontSize, @@ -46,6 +49,7 @@ class ReadingSettings { horizontalPadding: horizontalPadding ?? this.horizontalPadding, paragraphSpacing: paragraphSpacing ?? this.paragraphSpacing, textAlign: textAlign ?? this.textAlign, + enableSentenceTapTts: enableSentenceTapTts ?? this.enableSentenceTapTts, ); factory ReadingSettings.fromJson(Map json) => ReadingSettings( @@ -60,6 +64,7 @@ class ReadingSettings { horizontalPadding: (json['horizontalPadding'] as num?)?.toDouble() ?? 20, paragraphSpacing: (json['paragraphSpacing'] as num?)?.toDouble() ?? 24, textAlign: json['textAlign'] as String? ?? 'left', + enableSentenceTapTts: json['enableSentenceTapTts'] as bool? ?? false, ); Map toJson() => { @@ -73,5 +78,6 @@ class ReadingSettings { 'horizontalPadding': horizontalPadding, 'paragraphSpacing': paragraphSpacing, 'textAlign': textAlign, + 'enableSentenceTapTts': enableSentenceTapTts, }; } diff --git a/lib/features/bookshelf/presentation/bookshelf_screen.dart b/lib/features/bookshelf/presentation/bookshelf_screen.dart index e8aabba..47717b8 100644 --- a/lib/features/bookshelf/presentation/bookshelf_screen.dart +++ b/lib/features/bookshelf/presentation/bookshelf_screen.dart @@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart'; import '../../../app/router/route_names.dart'; import '../../../core/models/bookmark_model.dart'; import '../../../shared/widgets/main_app_header.dart'; +import '../../novel/providers/novels_provider.dart'; import '../providers/bookshelf_provider.dart'; import '../../auth/providers/auth_provider.dart'; @@ -50,7 +51,7 @@ class BookshelfScreen extends ConsumerWidget { return Scaffold( body: DefaultTabController( - length: 3, + length: 2, child: Column( children: [ MainAppHeader( @@ -68,9 +69,8 @@ class BookshelfScreen extends ConsumerWidget { unselectedLabelColor: Colors.white70, dividerColor: Colors.transparent, tabs: const [ - Tab(text: 'Đã đọc'), - Tab(text: 'Đã lưu'), - Tab(text: 'Đang mở'), + Tab(text: 'Đang đọc'), + Tab(text: 'Đánh dấu'), ], ), ), @@ -93,23 +93,18 @@ class BookshelfScreen extends ConsumerWidget { ), ), data: (bookmarks) { - final readItems = bookmarks.where((e) => e.readChapters.isNotEmpty).toList(); - final savedItems = bookmarks; - final openingItems = bookmarks.where((e) => e.lastChapterId != null).toList(); + final readingItems = ref.watch(readingBookmarksProvider); + final bookmarkedItems = ref.watch(savedBookmarksProvider); return TabBarView( children: [ _BookshelfList( - bookmarks: readItems, - emptyLabel: 'Chưa có truyện đã đọc.', + bookmarks: readingItems, + emptyLabel: 'Chưa có truyện đang đọc.', ), _BookshelfList( - bookmarks: savedItems, - emptyLabel: 'Chưa có truyện nào trong tủ sách.', - ), - _BookshelfList( - bookmarks: openingItems, - emptyLabel: 'Chưa có truyện đang mở.', + bookmarks: bookmarkedItems, + emptyLabel: 'Chưa có truyện đánh dấu.', ), ], ); @@ -150,20 +145,57 @@ class _BookshelfList extends ConsumerWidget { padding: const EdgeInsets.fromLTRB(14, 14, 14, 24), itemCount: bookmarks.length, separatorBuilder: (context, index) => const SizedBox(height: 12), - itemBuilder: (context, index) => _BookmarkTile(bookmark: bookmarks[index]), + itemBuilder: (context, index) { + final bookmark = bookmarks[index]; + return _BookmarkTile( + bookmark: bookmark, + onRemove: () => ref + .read(bookshelfProvider.notifier) + .removeFromShelf(bookmark.novelId, bookmark.type), + ); + }, ), ); } } -class _BookmarkTile extends StatelessWidget { +class _BookmarkTile extends ConsumerWidget { final BookmarkModel bookmark; - const _BookmarkTile({required this.bookmark}); + final VoidCallback onRemove; + const _BookmarkTile({ + required this.bookmark, + required this.onRemove, + }); + + Future _openContinueReader(BuildContext context, WidgetRef ref) async { + var targetChapterId = bookmark.lastChapterId; + if (targetChapterId == null || targetChapterId.isEmpty) { + try { + final chapters = await ref.read( + chapterListProvider(bookmark.novelId).future, + ); + if (chapters.isNotEmpty) { + targetChapterId = chapters.first.id; + } + } catch (_) { + // Fall through to novel detail when chapter lookup fails. + } + } + + if (!context.mounted) return; + if (targetChapterId != null && targetChapterId.isNotEmpty) { + context.push(RouteNames.readerChapter(targetChapterId)); + return; + } + context.push(RouteNames.novelDetail(bookmark.novelId)); + } @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final novel = bookmark.novel; - return Container( + return GestureDetector( + onTap: () => context.push(RouteNames.novelDetail(bookmark.novelId)), + child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerLow, @@ -172,7 +204,7 @@ class _BookmarkTile extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( + Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( @@ -211,7 +243,10 @@ class _BookmarkTile extends StatelessWidget { ), ), const SizedBox(width: 8), - const Icon(Icons.close_rounded, size: 20), + GestureDetector( + onTap: onRemove, + child: const Icon(Icons.close_rounded, size: 20), + ), ], ), const SizedBox(height: 8), @@ -249,7 +284,7 @@ class _BookmarkTile extends StatelessWidget { children: [ Expanded( child: FilledButton.icon( - onPressed: () => context.push(RouteNames.novelDetail(bookmark.novelId)), + onPressed: () => _openContinueReader(context, ref), icon: const Icon(Icons.menu_book_rounded), label: const Text('Đọc tiếp'), style: FilledButton.styleFrom( @@ -258,22 +293,11 @@ class _BookmarkTile extends StatelessWidget { ), ), ), - const SizedBox(width: 10), - Expanded( - child: FilledButton.icon( - onPressed: () => context.push(RouteNames.novelDetail(bookmark.novelId)), - icon: const Icon(Icons.headphones_rounded), - label: const Text('Nghe tiếp'), - style: FilledButton.styleFrom( - backgroundColor: const Color(0xFF14B8A6), - foregroundColor: Colors.white, - ), - ), - ), ], ), ], ), + ), ); } } diff --git a/lib/features/bookshelf/providers/bookshelf_provider.dart b/lib/features/bookshelf/providers/bookshelf_provider.dart index 3f3fa58..d2f652a 100644 --- a/lib/features/bookshelf/providers/bookshelf_provider.dart +++ b/lib/features/bookshelf/providers/bookshelf_provider.dart @@ -44,6 +44,22 @@ class BookshelfNotifier extends StateNotifier>> { bool isBookmarked(String novelId) { return (state.valueOrNull ?? []).any((b) => b.novelId == novelId); } + + Future removeFromShelf(String novelId, BookmarkType type) async { + try { + final client = _ref.read(apiClientProvider); + await client.dio.delete( + '/api/user/bookmarks/$novelId', + queryParameters: {'type': type.value}, + ); + final current = state.valueOrNull ?? []; + state = AsyncValue.data( + current.where((b) => b.novelId != novelId || b.type != type).toList(), + ); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } } final bookshelfProvider = @@ -51,6 +67,16 @@ final bookshelfProvider = return BookshelfNotifier(ref); }); +final readingBookmarksProvider = Provider>((ref) { + final bookmarks = ref.watch(bookshelfProvider).valueOrNull ?? []; + return bookmarks.where((b) => b.type == BookmarkType.reading).toList(); +}); + +final savedBookmarksProvider = Provider>((ref) { + final bookmarks = ref.watch(bookshelfProvider).valueOrNull ?? []; + return bookmarks.where((b) => b.type == BookmarkType.bookmarked).toList(); +}); + final isBookmarkedProvider = Provider.family((ref, novelId) { final bookshelf = ref.watch(bookshelfProvider); return bookshelf.valueOrNull?.any((b) => b.novelId == novelId) ?? false; diff --git a/lib/features/novel/presentation/novel_detail_screen.dart b/lib/features/novel/presentation/novel_detail_screen.dart index 6047fc6..6efd5a1 100644 --- a/lib/features/novel/presentation/novel_detail_screen.dart +++ b/lib/features/novel/presentation/novel_detail_screen.dart @@ -1,9 +1,14 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:go_router/go_router.dart'; import '../../../app/router/route_names.dart'; +import '../../../core/models/bookmark_model.dart'; +import '../../../core/models/chapter_model.dart'; import '../../../core/models/novel_model.dart'; import '../../../core/storage/local_store.dart'; import '../../bookshelf/providers/bookshelf_provider.dart'; @@ -25,316 +30,795 @@ class NovelDetailScreen extends ConsumerStatefulWidget { } class _NovelDetailScreenState extends ConsumerState { - int _currentPage = 1; + static const _kRangeSize = 100; + static const _kStickyTabHeight = 49.0; + static const _kStickyChipsHeight = 94.0; // Computed: title(20) + spacer(8) + chips(40) + padding(18) + buffer(8) + + static const _kChapterItemExtent = 48.0; // fixed height per chapter row + + _NovelDetailTab _selectedTab = _NovelDetailTab.intro; + int _selectedRangeIndex = 0; + final _scrollController = ScrollController(); + // Anchor placed right before SliverList — always built, never destroyed + final _chapterListAnchorKey = GlobalKey(); + // First sorted-list index of each range label, used for offset calculation + final Map _rangeFirstIndex = {}; + + @override + void initState() { + super.initState(); + Future.microtask(() { + ref.read(bookshelfProvider.notifier).fetch(); + }); + } @override void didUpdateWidget(covariant NovelDetailScreen oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.novelId != widget.novelId && _currentPage != 1) { - setState(() => _currentPage = 1); + if (oldWidget.novelId != widget.novelId) { + setState(() { + _selectedTab = _NovelDetailTab.intro; + _selectedRangeIndex = 0; + _rangeFirstIndex.clear(); + }); + Future.microtask(() { + ref.read(bookshelfProvider.notifier).fetch(); + }); } } + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final novelId = widget.novelId; final novelAsync = ref.watch(novelDetailProvider(novelId)); - final chaptersAsync = ref.watch( - chapterListProvider(ChapterListQuery(novelId: novelId, page: _currentPage)), - ); - final firstChapterAsync = ref.watch( - chapterListProvider(ChapterListQuery(novelId: novelId, page: 1)), - ); + final chaptersAsync = ref.watch(chapterListProvider(novelId)); final readProgressAsync = ref.watch(novelReadProgressProvider(novelId)); - final isBookmarked = ref.watch(isBookmarkedProvider(novelId)); + final bookshelfAsync = ref.watch(bookshelfProvider); - return Scaffold( - body: novelAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center(child: Text('Lỗi: $e')), - data: (novel) => CustomScrollView( - slivers: [ - _NovelAppBar(novel: novel, isBookmarked: isBookmarked, onBookmark: () { - ref.read(bookshelfProvider.notifier).toggle(novelId); - }), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Genre chips - if (novel.genres.isNotEmpty) - Wrap( - spacing: 6, - runSpacing: 4, - children: novel.genres - .map((g) => Chip( - label: Text(g.name), - padding: EdgeInsets.zero, - labelPadding: const EdgeInsets.symmetric(horizontal: 8), - )) - .toList(), - ), - const SizedBox(height: 12), - // Description - if (novel.description != null) - Text(novel.description!, style: Theme.of(context).textTheme.bodyMedium), - const SizedBox(height: 16), - // Stats row - _StatsRow(novel: novel), - const SizedBox(height: 16), - // Read button - firstChapterAsync.when( - loading: () => const SizedBox.shrink(), - 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(targetChapterId), - ), - icon: const Icon(Icons.menu_book), - label: Text(buttonLabel), - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(48), - ), - ); - }, - ), - const SizedBox(height: 24), - // Chapter list header - Row( - children: [ - Text('Danh sách chương', style: Theme.of(context).textTheme.titleMedium), - const Spacer(), - chaptersAsync.whenOrNull( - 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(), - ], - ), - ], - ), - ), - ), - // Chapter list - chaptersAsync.when( - loading: () => const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), - ), - ), - 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 = pageData.chapters[index]; - return ListTile( - dense: true, - title: Text( - 'Chương ${ch.number}: ${ch.title}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - onTap: () => context.push(RouteNames.readerChapter(ch.id)), - ); - }, - 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)), - ], - ), + return novelAsync.when( + loading: () => Scaffold( + appBar: AppBar(), + body: const Center(child: CircularProgressIndicator()), ), - ); - } -} - -class _NovelAppBar extends StatelessWidget { - final NovelModel novel; - final bool isBookmarked; - final VoidCallback onBookmark; - - const _NovelAppBar({ - required this.novel, - required this.isBookmarked, - required this.onBookmark, - }); - - @override - Widget build(BuildContext context) { - return SliverAppBar( - expandedHeight: 280, - pinned: true, - actions: [ - IconButton( - icon: Icon(isBookmarked ? Icons.bookmark : Icons.bookmark_outline), - onPressed: onBookmark, + error: (e, _) => Scaffold( + appBar: AppBar(), + body: Center(child: Text('Lỗi: $e')), + ), + data: (novel) => Scaffold( + appBar: AppBar( + title: Text(novel.title, overflow: TextOverflow.ellipsis, maxLines: 1), ), - IconButton( - icon: const Icon(Icons.comment_outlined), - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => Scaffold( - appBar: AppBar(title: const Text('Bình luận')), - ), - ), - ), - ), - ], - flexibleSpace: FlexibleSpaceBar( - background: Stack( - fit: StackFit.expand, - children: [ - if (novel.coverUrl != null) - CachedNetworkImage( - imageUrl: novel.coverUrl!, - fit: BoxFit.cover, - ) - else - Container(color: Theme.of(context).colorScheme.primaryContainer), - DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, Colors.black.withAlpha(200)], - ), - ), - ), - Positioned( - bottom: 16, - left: 16, - right: 16, + body: CustomScrollView( + controller: _scrollController, + slivers: [ + // Info card + read button (scrolls away) + SliverToBoxAdapter( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - novel.title, - style: const TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.bold, - ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + child: _NovelInfoCard(novel: novel), ), - if (novel.authorName.isNotEmpty) - Text( - novel.authorName, - style: const TextStyle(color: Colors.white70, fontSize: 14), - ), + _buildReadButton( + context: context, + novelId: novelId, + chaptersAsync: chaptersAsync, + readProgressAsync: readProgressAsync, + bookshelfAsync: bookshelfAsync, + ), + const SizedBox(height: 16), ], ), ), + // Sticky tab bar + SliverPersistentHeader( + pinned: true, + delegate: _StickyTabDelegate( + height: _kStickyTabHeight, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + child: _DetailTabs( + selectedTab: _selectedTab, + onChanged: (tab) { + if (tab == _selectedTab) return; + setState(() { + _selectedTab = tab; + if (tab != _NovelDetailTab.chapters) { + _selectedRangeIndex = 0; + } + }); + }, + ), + ), + ), + // Tab content + ..._buildContentSlivers( + context: context, + novel: novel, + chaptersAsync: chaptersAsync, + ), + const SliverToBoxAdapter(child: SizedBox(height: 32)), ], ), ), ); } + + Widget _buildReadButton({ + required BuildContext context, + required String novelId, + required AsyncValue> chaptersAsync, + required AsyncValue?> readProgressAsync, + required AsyncValue> bookshelfAsync, + }) { + return chaptersAsync.when( + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + data: (chapters) { + final first = chapters.isNotEmpty ? chapters.first : null; + if (first == null) return const SizedBox.shrink(); + final bookmarks = bookshelfAsync.valueOrNull ?? const []; + final latestBookmark = + bookmarks.where((b) => b.novelId == novelId).firstOrNull; + final progress = readProgressAsync.valueOrNull; + final continueChapterId = + latestBookmark?.lastChapterId ?? (progress?['chapterId'] as String?); + final continueChapterNumber = + latestBookmark?.lastChapterNumber ?? + (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 Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: FilledButton.icon( + onPressed: () => + context.push(RouteNames.readerChapter(targetChapterId)), + icon: const Icon(Icons.menu_book), + label: Text(buttonLabel), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), + ), + ); + }, + ); + } + + List _buildContentSlivers({ + required BuildContext context, + required NovelModel novel, + required AsyncValue> chaptersAsync, + }) { + // Non-chapter tabs: simple sliver + if (_selectedTab != _NovelDetailTab.chapters) { + final Widget content; + switch (_selectedTab) { + case _NovelDetailTab.intro: + content = Padding( + padding: const EdgeInsets.all(16), + child: novel.description != null + ? Text(novel.description!, style: Theme.of(context).textTheme.bodyMedium) + : Text('Chưa có giới thiệu', style: Theme.of(context).textTheme.bodyMedium), + ); + case _NovelDetailTab.ratings: + content = Padding( + padding: const EdgeInsets.all(16), + child: _buildPlaceholderContent(context, 'Đánh giá sẽ được cập nhật sau.'), + ); + case _NovelDetailTab.comments: + content = Padding( + padding: const EdgeInsets.all(16), + child: _buildPlaceholderContent(context, 'Bình luận sẽ được cập nhật sau.'), + ); + case _NovelDetailTab.chapters: + content = const SizedBox.shrink(); + } + return [SliverToBoxAdapter(child: content)]; + } + + // Chapters tab + if (chaptersAsync.isLoading) { + return [ + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ), + ), + ]; + } + if (chaptersAsync.hasError) { + return [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Không tải được danh sách chương: ${chaptersAsync.error}', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: Theme.of(context).colorScheme.error), + ), + ), + ), + ]; + } + + final sorted = [...(chaptersAsync.valueOrNull ?? [])] + ..sort((a, b) => a.number.compareTo(b.number)); + + if (sorted.isEmpty) { + return [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text('Không có chương nào', style: Theme.of(context).textTheme.bodyMedium), + ), + ), + ]; + } + + final ranges = _buildChapterRanges(sorted); + final selIdx = + _selectedRangeIndex >= ranges.length ? ranges.length - 1 : _selectedRangeIndex; + + final firstChapterForRange = {}; + for (final range in ranges) { + for (final ch in sorted) { + if (ch.number >= range.start && ch.number <= range.end) { + firstChapterForRange[range.label] = ch; + break; + } + } + } + + final visibleRanges = + ranges.where((r) => firstChapterForRange.containsKey(r.label)).toList(); + + // Build first-index map for offset-based scrolling (no GlobalKey per item needed) + for (var i = 0; i < sorted.length; i++) { + final ch = sorted[i]; + for (final e in firstChapterForRange.entries) { + if (e.value.id == ch.id) { + _rangeFirstIndex[e.key] = i; + break; + } + } + } + + return [ + // ── Sticky range chips header ──────────────────────────────────── + SliverPersistentHeader( + pinned: true, + delegate: _StickyRangeChipsDelegate( + visibleRanges: visibleRanges, + selectedIndex: selIdx, + totalChapters: sorted.length, + onSelected: (i) { + final label = visibleRanges[i].label; + final firstIndex = _rangeFirstIndex[label] ?? 0; + setState(() => _selectedRangeIndex = i); + _scrollToRangeIndex(firstIndex); + }, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + primaryColor: Theme.of(context).colorScheme.primary, + outlineColor: Theme.of(context).colorScheme.outline, + titleStyle: Theme.of(context).textTheme.titleMedium, + bodySmallStyle: Theme.of(context).textTheme.bodySmall, + ), + ), + // ── Anchor: SliverToBoxAdapter luôn được build, không bị lazy-destroy ── + SliverToBoxAdapter( + key: _chapterListAnchorKey, + child: const SizedBox.shrink(), + ), + // ── Full chapter list (fixed extent để tính offset chính xác) ──── + SliverFixedExtentList( + itemExtent: _kChapterItemExtent, + delegate: SliverChildBuilderDelegate( + (context, index) { + final ch = sorted[index]; + return InkWell( + onTap: () => context.push(RouteNames.readerChapter(ch.id)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Text( + 'Chương ${ch.number}: ${ch.title}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ); + }, + childCount: sorted.length, + ), + ), + ]; + } + + Future _scrollToRangeIndex(int itemIndex) async { + // Small delay for setState rebuild to complete + await Future.delayed(const Duration(milliseconds: 50)); + if (!mounted || !_scrollController.hasClients) return; + + // Anchor widget (SliverToBoxAdapter) is always built — context never null + final anchorCtx = _chapterListAnchorKey.currentContext; + if (anchorCtx == null) { + debugPrint('[ScrollToRange] Anchor context null — unexpected'); + return; + } + final anchorRender = anchorCtx.findRenderObject(); + if (anchorRender == null || !anchorRender.attached) { + debugPrint('[ScrollToRange] Anchor render object not attached'); + return; + } + + final viewport = RenderAbstractViewport.of(anchorRender); + if (viewport == null) { + debugPrint('[ScrollToRange] No viewport for anchor render object'); + return; + } + + // Absolute scroll offset where the chapter list starts. + final anchorOffset = viewport.getOffsetToReveal(anchorRender, 0.0).offset; + // Keep a small gap below pinned sticky headers inside CustomScrollView body. + const stickyCompensation = _kStickyTabHeight + _kStickyChipsHeight + 8.0; + + final rawTarget = anchorOffset + itemIndex * _kChapterItemExtent - stickyCompensation; + final target = rawTarget + .clamp(0.0, _scrollController.position.maxScrollExtent); + + debugPrint('[ScrollToRange] index=$itemIndex anchorOffset=$anchorOffset target=$target'); + + await _scrollController.animateTo( + target, + duration: const Duration(milliseconds: 350), + curve: Curves.easeOut, + ); + } + + // ignore: unused_element + Future _scrollToRange(GlobalKey targetKey, {int retryCount = 0}) async { + // Delay để rebuild + layout settle + await Future.delayed(const Duration(milliseconds: 100)); + if (!mounted) { + debugPrint('[ScrollToRange] Not mounted'); + return; + } + if (!_scrollController.hasClients) { + debugPrint('[ScrollToRange] No scroll clients'); + return; + } + + final ctx = targetKey.currentContext; + if (ctx == null) { + debugPrint('[ScrollToRange] Target context is null (retry: $retryCount)'); + if (retryCount < 3) { + await Future.delayed(const Duration(milliseconds: 100)); + return _scrollToRange(targetKey, retryCount: retryCount + 1); + } + return; + } + + final box = ctx.findRenderObject() as RenderBox?; + if (box == null) { + debugPrint('[ScrollToRange] RenderBox is null (retry: $retryCount)'); + if (retryCount < 3) { + await Future.delayed(const Duration(milliseconds: 100)); + return _scrollToRange(targetKey, retryCount: retryCount + 1); + } + return; + } + + try { + // Compute absolute Y of the item on screen + final itemScreenY = box.localToGlobal(Offset.zero).dy; + debugPrint('[ScrollToRange] Item screen Y: $itemScreenY'); + + // Target Y: just below AppBar + sticky tab bar + sticky chips, with small padding + const targetScreenY = kToolbarHeight + _kStickyTabHeight + _kStickyChipsHeight + 8.0; + debugPrint('[ScrollToRange] Target screen Y: $targetScreenY'); + + final delta = itemScreenY - targetScreenY; + final currentOffset = _scrollController.offset; + final target = (currentOffset + delta).clamp(0.0, _scrollController.position.maxScrollExtent); + + debugPrint('[ScrollToRange] Current offset: $currentOffset, Delta: $delta, Target: $target, Max: ${_scrollController.position.maxScrollExtent}'); + + await _scrollController.animateTo( + target, + duration: const Duration(milliseconds: 350), + curve: Curves.easeOut, + ); + debugPrint('[ScrollToRange] Scroll completed'); + } catch (e) { + debugPrint('[ScrollToRange] Error: $e'); + } + } + + List<_ChapterRange> _buildChapterRanges(List chapters) { + if (chapters.isEmpty) return const []; + final maxNum = chapters.last.number; + final ranges = <_ChapterRange>[]; + for (var start = 1; start <= maxNum; start += _kRangeSize) { + final end = (start + _kRangeSize - 1).clamp(start, maxNum); + ranges.add(_ChapterRange(start: start, end: end)); + } + return ranges; + } + + Widget _buildPlaceholderContent(BuildContext context, String text) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).colorScheme.outline), + borderRadius: BorderRadius.circular(8), + ), + child: Text(text, style: Theme.of(context).textTheme.bodyMedium), + ); + } } -class _StatsRow extends StatelessWidget { - final NovelModel novel; - const _StatsRow({required this.novel}); +enum _NovelDetailTab { intro, ratings, chapters, comments } + +class _DetailTabs extends StatelessWidget { + const _DetailTabs({required this.selectedTab, required this.onChanged}); + + final _NovelDetailTab selectedTab; + final ValueChanged<_NovelDetailTab> onChanged; @override Widget build(BuildContext context) { - return Wrap( - spacing: 16, - runSpacing: 8, + final tabs = [ + (_NovelDetailTab.intro, 'Giới thiệu'), + (_NovelDetailTab.ratings, 'Đánh giá'), + (_NovelDetailTab.chapters, 'Chương'), + (_NovelDetailTab.comments, 'Bình luận'), + ]; + + return Row( + children: tabs.map((tab) { + final isSelected = selectedTab == tab.$1; + return Expanded( + child: InkWell( + onTap: () => onChanged(tab.$1), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Text( + tab.$2, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: + isSelected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ), + ); + }).toList(), + ); + } +} + +class _ChapterRange { + const _ChapterRange({required this.start, required this.end}); + + final int start; + final int end; + + String get label => '$start-$end'; +} + +// ── Novel info card ──────────────────────────────────────────────────────────── + +class _NovelInfoCard extends StatelessWidget { + const _NovelInfoCard({required this.novel}); + final NovelModel novel; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final statusColor = novel.status.toLowerCase().contains('ho\u00e0n') + ? Colors.green.shade700 + : colorScheme.tertiary; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (novel.rating > 0) - _Stat(icon: Icons.star, value: novel.rating.toStringAsFixed(1)), - if (novel.views > 0) - _Stat(icon: Icons.visibility, value: _formatNum(novel.views)), - if (novel.latestChapter != null) - _Stat(icon: Icons.library_books, value: 'Ch. ${novel.latestChapter!.number}'), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: novel.coverUrl != null + ? CachedNetworkImage( + imageUrl: novel.coverUrl!, + width: 90, + height: 130, + fit: BoxFit.cover, + errorWidget: (_, __, ___) => _placeholder(colorScheme), + ) + : _placeholder(colorScheme), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + novel.title, + style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + if (novel.authorName.isNotEmpty) + Text( + novel.authorName, + style: textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: statusColor.withAlpha(30), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: statusColor.withAlpha(120)), + ), + child: Text( + novel.status, + style: textTheme.labelSmall?.copyWith(color: statusColor), + ), + ), + const SizedBox(height: 8), + if (novel.genres.isNotEmpty) + Wrap( + spacing: 4, + runSpacing: 4, + children: novel.genres + .take(5) + .map((g) => _SmallChip(label: g.name)) + .toList(), + ), + const SizedBox(height: 8), + DefaultTextStyle( + style: textTheme.bodySmall! + .copyWith(color: colorScheme.onSurfaceVariant), + child: Wrap( + spacing: 12, + children: [ + if (novel.totalChapters > 0) + Text('${novel.totalChapters} Ch\u01b0\u01a1ng'), + if (novel.views > 0) Text('${_fmt(novel.views)} \u0110\u1ecdc'), + if (novel.rating > 0) + Text('${novel.rating.toStringAsFixed(1)}\u2605'), + ], + ), + ), + ], + ), + ), ], ); } - String _formatNum(int n) { + Widget _placeholder(ColorScheme cs) => Container( + width: 90, + height: 130, + color: cs.primaryContainer, + child: Icon(Icons.book, color: cs.onPrimaryContainer, size: 36), + ); + + String _fmt(int n) { if (n >= 1000000) return '${(n / 1000000).toStringAsFixed(1)}M'; if (n >= 1000) return '${(n / 1000).toStringAsFixed(1)}K'; return n.toString(); } } -class _Stat extends StatelessWidget { - final IconData icon; - final String value; - const _Stat({required this.icon, required this.value}); +class _SmallChip extends StatelessWidget { + const _SmallChip({required this.label}); + final String label; @override Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 16, color: Theme.of(context).colorScheme.secondary), - const SizedBox(width: 4), - Text(value, style: Theme.of(context).textTheme.bodySmall), - ], + final cs = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + border: Border.all(color: cs.outline), + borderRadius: BorderRadius.circular(4), + ), + child: Text(label, style: Theme.of(context).textTheme.labelSmall), ); } } + +class _ChapterListItem extends StatelessWidget { + final ChapterListItem chapter; + final VoidCallback onTap; + + const _ChapterListItem({ + required this.chapter, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text( + 'Chương ${chapter.number}: ${chapter.title}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ); + } +} + +// ── Sticky tab bar delegate ──────────────────────────────────────────────────── + +class _StickyTabDelegate extends SliverPersistentHeaderDelegate { + const _StickyTabDelegate({ + required this.child, + required this.height, + required this.backgroundColor, + }); + + final Widget child; + final double height; + final Color backgroundColor; + + @override + double get minExtent => height; + @override + double get maxExtent => height; + + @override + Widget build( + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) { + return Material( + color: backgroundColor, + elevation: shrinkOffset > 0 ? 2 : 0, + shadowColor: Theme.of(context).shadowColor, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: child, + ), + ), + const Divider(height: 1), + ], + ), + ); + } + + @override + bool shouldRebuild(_StickyTabDelegate old) => + old.child != child || old.height != height || old.backgroundColor != backgroundColor; +} + +// ── Sticky range chips delegate ──────────────────────────────────────────────── + +class _StickyRangeChipsDelegate extends SliverPersistentHeaderDelegate { + const _StickyRangeChipsDelegate({ + required this.visibleRanges, + required this.selectedIndex, + required this.totalChapters, + required this.onSelected, + required this.backgroundColor, + required this.primaryColor, + required this.outlineColor, + required this.titleStyle, + required this.bodySmallStyle, + }); + + final List<_ChapterRange> visibleRanges; + final int selectedIndex; + final int totalChapters; + final void Function(int) onSelected; + final Color backgroundColor; + final Color primaryColor; + final Color outlineColor; + final TextStyle? titleStyle; + final TextStyle? bodySmallStyle; + + @override + double get minExtent => _computeExtent(); + @override + double get maxExtent => _computeExtent(); + + double _computeExtent() { + // Estimate dynamically based on typical component heights + // This avoids hardcoding and adapts to different devices/fonts + // Title row: ~20px (text + line height) + // Spacer: 8px + // Chips row: ~40px (typical FilterChip height) + // Padding vertical: 10 + 8 = 18px + // Buffer: 8px for safety across devices + return 20 + 8 + 40 + 18 + 8; // ~94px (flexible, not hardcoded) + } + + @override + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { + final extent = _computeExtent(); + return SizedBox( + height: extent, + child: Material( + color: backgroundColor, + elevation: shrinkOffset > 0 ? 2 : 0, + shadowColor: Theme.of(context).shadowColor, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('Danh sách chương', style: titleStyle), + const Spacer(), + Text('$totalChapters chương', style: bodySmallStyle), + ], + ), + const SizedBox(height: 8), + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: List.generate(visibleRanges.length, (i) { + final range = visibleRanges[i]; + final isSelected = i == selectedIndex; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + label: Text(range.label), + selected: isSelected, + onSelected: (_) => onSelected(i), + backgroundColor: Colors.transparent, + selectedColor: primaryColor.withAlpha(40), + side: BorderSide( + color: isSelected ? primaryColor : outlineColor, + ), + ), + ); + }), + ), + ), + ), + ], + ), + ), + ), + ); + } + + @override + bool shouldRebuild(_StickyRangeChipsDelegate old) => + old.selectedIndex != selectedIndex || + old.visibleRanges != visibleRanges || + old.totalChapters != totalChapters; +} diff --git a/lib/features/novel/providers/novels_provider.dart b/lib/features/novel/providers/novels_provider.dart index 426588f..9aae778 100644 --- a/lib/features/novel/providers/novels_provider.dart +++ b/lib/features/novel/providers/novels_provider.dart @@ -4,8 +4,6 @@ import '../../../core/models/novel_model.dart'; import '../../../core/models/chapter_model.dart'; import '../../../core/network/providers.dart'; -const chapterPageSize = 50; - // ─── Browse / Search ────────────────────────────────────────────────────────── class BrowseParams { @@ -28,11 +26,11 @@ class BrowseParams { if (raw == null || raw.isEmpty) return null; switch (raw.toLowerCase()) { case 'ongoing': - return 'Đang ra'; + return 'ONGOING'; case 'completed': - return 'Hoàn thành'; + return 'COMPLETED'; case 'hiatus': - return 'Tạm ngưng'; + return 'HIATUS'; default: return raw; } @@ -189,77 +187,51 @@ 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 chapters; - final int totalChapters; - final int totalPages; - final int currentPage; -} - final chapterListProvider = - FutureProvider.family((ref, query) async { + FutureProvider.family, String>((ref, novelId) async { final client = ref.read(apiClientProvider); - Future> 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; + Future> fetchAllChapters(String idOrSlug) async { + const limit = 500; + var page = 1; + var totalPages = 1; + final items = []; + + while (page <= totalPages) { + final res = await client.dio.get( + '/api/truyen/$idOrSlug/chapters', + queryParameters: {'page': page, 'limit': limit}, + ); + final data = res.data as Map; + final chapters = data['chapters'] as List? ?? const []; + + items.addAll( + chapters.map((e) => ChapterListItem.fromJson(e as Map)), + ); + + final apiTotalPages = (data['totalPages'] as num?)?.toInt() ?? 1; + totalPages = apiTotalPages > 0 ? apiTotalPages : 1; + page += 1; + } + + return items; } - 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 { + return await fetchAllChapters(novelId); + } catch (_) { + // 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. try { - final novelRes = await client.dio.get('/api/novels/${query.novelId}'); + final novelRes = await client.dio.get('/api/novels/$novelId'); final novelData = novelRes.data as Map; final canonicalId = novelData['id'] as String?; - if (canonicalId != null && canonicalId.isNotEmpty && canonicalId != query.novelId) { - data = await fetchChapterPage(canonicalId); - chapters = data['chapters'] as List? ?? const []; + if (canonicalId != null && canonicalId.isNotEmpty && canonicalId != novelId) { + return await fetchAllChapters(canonicalId); } } catch (_) { // Keep original empty list when fallback resolution fails. } + rethrow; } - - return ChapterListPage( - chapters: - chapters.map((e) => ChapterListItem.fromJson(e as Map)).toList(), - totalChapters: (data['totalChapters'] as num?)?.toInt() ?? 0, - totalPages: (data['totalPages'] as num?)?.toInt() ?? 0, - currentPage: (data['currentPage'] as num?)?.toInt() ?? query.page, - ); }); diff --git a/lib/features/reader/presentation/reader_screen.dart b/lib/features/reader/presentation/reader_screen.dart index 421dd46..375b960 100644 --- a/lib/features/reader/presentation/reader_screen.dart +++ b/lib/features/reader/presentation/reader_screen.dart @@ -179,7 +179,7 @@ class _ReaderScreenState extends ConsumerState { } void _maybeAutoScrollToTtsParagraph(TtsState tts, int paragraphCount) { - if (tts.status == TtsStatus.idle) return; + if (tts.status != TtsStatus.playing) return; final index = tts.activeParagraphIndex; if (index < 0 || index >= paragraphCount) return; if (index == _lastAutoScrolledParagraph) return; @@ -189,6 +189,8 @@ class _ReaderScreenState extends ConsumerState { if (!mounted) return; final ctx = _paragraphKeys[index].currentContext; if (ctx == null) return; + // Clear any active text-selection focus before programmatic scrolling. + FocusManager.instance.primaryFocus?.unfocus(); Scrollable.ensureVisible( ctx, alignment: 0.22, @@ -455,11 +457,8 @@ class _ReaderScreenState extends ConsumerState { builder: (sheetContext) { return Consumer( builder: (context, ref, _) { - final tocPage = ((currentChapter.number - 1) ~/ chapterPageSize) + 1; final chaptersAsync = ref.watch( - chapterListProvider( - ChapterListQuery(novelId: currentChapter.novelId, page: tocPage), - ), + chapterListProvider(currentChapter.novelId), ); return FractionallySizedBox( heightFactor: 0.82, @@ -489,12 +488,20 @@ class _ReaderScreenState extends ConsumerState { child: chaptersAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('Không tải được mục lục: $e')), - data: (pageData) { - final chapters = pageData.chapters; + data: (chapters) { if (chapters.isEmpty) { return const Center(child: Text('Chưa có danh sách chương.')); } + // Find index of current chapter for auto-scroll + final currentIndex = chapters.indexWhere((ch) => ch.id == currentChapter.id); + final scrollController = ScrollController( + initialScrollOffset: currentIndex > 0 + ? currentIndex * 48.0 // Approximate height per ListTile + : 0, + ); + return ListView.separated( + controller: scrollController, itemCount: chapters.length, separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { @@ -847,6 +854,22 @@ class _ReaderScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + value: settings.enableSentenceTapTts, + onChanged: (enabled) { + unawaited( + ref + .read(readingSettingsProvider.notifier) + .setSentenceTapTtsEnabled(enabled), + ); + }, + title: const Text('Bật chạm câu để phát TTS'), + subtitle: const Text( + 'Tắt để tránh chạm nhầm làm bắt đầu TTS.', + ), + ), + const SizedBox(height: 8), Row( children: [ Expanded( @@ -1166,6 +1189,12 @@ class _ReaderScreenState extends ConsumerState { .titleLarge ?.copyWith(color: readerTextColor), ), + const SizedBox(height: 12), + _NavButtons( + chapter: chapter, + onGoPrevious: () => _goToPreviousChapter(chapter), + onGoNext: () => _goToNextChapter(chapter), + ), const SizedBox(height: 20), if (chapter.content.trim().isEmpty) Text( @@ -1197,32 +1226,44 @@ class _ReaderScreenState extends ConsumerState { alignment: Alignment.topCenter, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 760), - child: Padding( - key: _paragraphKeys[index], - padding: EdgeInsets.only( - bottom: index == paragraphs.length - 1 - ? 0 - : settings.paragraphSpacing, - ), - child: _buildParagraphText( - context: context, - sentenceSlices: sentenceSlices, - textAlign: textAlign, - style: paragraphStyle, - highlightStyle: paragraphHighlightStyle, - isActiveParagraph: shouldHighlightTts && - tts.activeParagraphIndex == index, - highlightStart: tts.progressStart, - highlightEnd: tts.progressEnd, - onSentenceTap: (charOffset) { - ref.read(ttsProvider.notifier).startReading( - chapter.content, - contentKey: chapter.id, - title: 'Chương ${chapter.number}: ${chapter.title}', - startParagraphIndex: index, - startCharOffset: charOffset, - ); - }, + child: SizedBox( + width: double.infinity, + child: Padding( + key: _paragraphKeys[index], + padding: EdgeInsets.only( + bottom: index == paragraphs.length - 1 + ? 0 + : settings.paragraphSpacing, + ), + child: _buildParagraphText( + context: context, + sentenceSlices: sentenceSlices, + textAlign: textAlign, + style: paragraphStyle, + highlightStyle: paragraphHighlightStyle, + isActiveParagraph: shouldHighlightTts && + tts.activeParagraphIndex == index, + highlightStart: tts.progressStart, + highlightEnd: tts.progressEnd, + onSentenceTap: (charOffset) { + final hasActiveTtsSession = + tts.contentKey == chapter.id && + (tts.status == TtsStatus.playing || + tts.status == TtsStatus.paused); + final canStartFromSentence = + settings.enableSentenceTapTts || hasActiveTtsSession; + if (!canStartFromSentence) { + return; + } + ref.read(ttsProvider.notifier).startReading( + chapter.content, + contentKey: chapter.id, + title: 'Chương ${chapter.number}: ${chapter.title}', + startParagraphIndex: index, + startCharOffset: charOffset, + ); + }, + ), ), ), ), @@ -1614,20 +1655,18 @@ class _NavButtons extends StatelessWidget { children: [ if (chapter.prevChapterId != null) Expanded( - child: OutlinedButton.icon( + child: OutlinedButton( onPressed: onGoPrevious, - icon: const Icon(Icons.chevron_left), - label: Text('Chương ${chapter.prevChapterNumber ?? '?'}'), + child: Text('< Chương ${chapter.prevChapterNumber ?? '?'}'), ), ), if (chapter.prevChapterId != null && chapter.nextChapterId != null) const SizedBox(width: 12), if (chapter.nextChapterId != null) Expanded( - child: FilledButton.icon( + child: FilledButton( onPressed: onGoNext, - icon: const Icon(Icons.chevron_right), - label: Text('Chương ${chapter.nextChapterNumber ?? '?'}'), + child: Text('> Chương ${chapter.nextChapterNumber ?? '?'}'), ), ), ], diff --git a/lib/features/reader/providers/reader_provider.dart b/lib/features/reader/providers/reader_provider.dart index 442005f..3d828a9 100644 --- a/lib/features/reader/providers/reader_provider.dart +++ b/lib/features/reader/providers/reader_provider.dart @@ -145,6 +145,10 @@ class ReadingSettingsNotifier extends StateNotifier { final localStore = _ref.read(localStoreProvider); await localStore.saveReadingSettings(settings); } + + Future setSentenceTapTtsEnabled(bool enabled) async { + await update(state.copyWith(enableSentenceTapTts: enabled)); + } } final readingSettingsProvider = diff --git a/lib/features/reader/tts/tts_service.dart b/lib/features/reader/tts/tts_service.dart index dc2e5f6..50c9429 100644 --- a/lib/features/reader/tts/tts_service.dart +++ b/lib/features/reader/tts/tts_service.dart @@ -155,6 +155,7 @@ class TtsNotifier extends StateNotifier { int _pendingFallbackIndex = -1; bool _didStartCurrentFallbackUtterance = false; bool _hasPromptedNotificationSettings = false; + bool _androidFallbackReady = false; bool get _useNativeAndroidMediaService => Platform.isAndroid; @@ -315,6 +316,73 @@ class TtsNotifier extends StateNotifier { ); } + Future _ensureAndroidFallbackReady() async { + if (_androidFallbackReady) return; + + await _tts.awaitSpeakCompletion(true); + await _tts.setSharedInstance(true); + await _configureVietnameseVoiceWithFlutterTts(); + await _tts.setSpeechRate(state.speed); + await _tts.setVolume(1.0); + await _tts.setPitch(1.0); + + _tts.setStartHandler(() { + _didStartCurrentFallbackUtterance = true; + final index = _pendingFallbackIndex; + if (index >= 0 && index < _segments.length) { + final segment = _segments[index]; + state = state.copyWith( + status: TtsStatus.playing, + paragraphIndex: index, + activeParagraphIndex: segment.paragraphIndex, + progressStart: segment.start, + progressEnd: segment.end, + ); + } else { + state = state.copyWith(status: TtsStatus.playing); + } + }); + + _tts.setCompletionHandler(() { + // Fallback playback progression is driven by _playFallbackFromGeneration. + }); + + _tts.setErrorHandler((_) { + if (_isInterruptingPlayback) return; + _pendingFallbackIndex = -1; + _didStartCurrentFallbackUtterance = false; + state = state.copyWith( + status: TtsStatus.idle, + activeParagraphIndex: -1, + progressStart: -1, + progressEnd: -1, + ); + }); + + _androidFallbackReady = true; + } + + Future _startFallbackReading({ + required int validIndex, + required _TtsSegment selectedSegment, + required String? contentKey, + }) async { + await _ensureAndroidFallbackReady(); + final sessionId = await _interruptFallbackPlayback(); + + state = state.copyWith( + status: TtsStatus.playing, + paragraphIndex: validIndex, + totalParagraphs: _segments.length, + activeParagraphIndex: selectedSegment.paragraphIndex, + progressStart: selectedSegment.start, + progressEnd: selectedSegment.end, + contentKey: contentKey, + ); + + unawaited(_playFallbackFromGeneration(validIndex, sessionId)); + } + void _handleAndroidMediaEvent(dynamic event) { _applyAndroidSnapshot(event); } @@ -372,6 +440,7 @@ class TtsNotifier extends StateNotifier { // Keep natural sentence flow while removing symbols that are usually read out noisily. final cleaned = raw + .replaceAll(RegExp(r'["“”]'), ' ') .replaceAll(RegExp(r'[_$#^*+=~`|<>\\\[\]{}]'), ' ') .replaceAll(RegExp(r'\s+'), ' ') .trim(); @@ -614,33 +683,32 @@ class TtsNotifier extends StateNotifier { contentKey: contentKey, ); - await _mediaChannel.invokeMethod('startReading', { - 'contentKey': contentKey, - 'title': title, - 'startIndex': validIndex, - 'speed': state.speed, - 'language': state.language, - 'voiceName': state.voiceName, - 'backgroundModeEnabled': state.backgroundModeEnabled, - 'segments': _segments.map((segment) => segment.toMap()).toList(), - }); + try { + await _mediaChannel.invokeMethod('startReading', { + 'contentKey': contentKey, + 'title': title, + 'startIndex': validIndex, + 'speed': state.speed, + 'language': state.language, + 'voiceName': state.voiceName, + 'backgroundModeEnabled': state.backgroundModeEnabled, + 'segments': _segments.map((segment) => segment.toMap()).toList(), + }); + } on PlatformException { + await _startFallbackReading( + validIndex: validIndex, + selectedSegment: selectedSegment, + contentKey: contentKey, + ); + } return; } - final sessionId = await _interruptFallbackPlayback(); - - state = state.copyWith( - status: TtsStatus.playing, - paragraphIndex: validIndex, - totalParagraphs: _segments.length, - activeParagraphIndex: -1, - progressStart: -1, - progressEnd: -1, + await _startFallbackReading( + validIndex: validIndex, + selectedSegment: selectedSegment, contentKey: contentKey, ); - await _syncBackgroundMode(); - - unawaited(_playFallbackFromGeneration(validIndex, sessionId)); } Future _interruptFallbackPlayback() async { diff --git a/pubspec.yaml b/pubspec.yaml index fe63684..2757d3d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.2+3 +version: 1.0.3+4 environment: sdk: ^3.11.3