From 297fc45707657ff751de2991bec8cc26bed08f7d Mon Sep 17 00:00:00 2001 From: virtus Date: Thu, 16 Apr 2026 13:18:25 +0700 Subject: [PATCH] feat: Update reading progress management and enhance chapter navigation --- .../reader_app/tts/ReaderTtsMediaService.kt | 104 +++- .../reader/presentation/reader_screen.dart | 522 ++++++++++++------ .../reader/providers/reader_provider.dart | 15 + pubspec.yaml | 2 +- 4 files changed, 458 insertions(+), 185 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 4ad916b..b8d4dc9 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 @@ -16,6 +16,7 @@ import android.os.Handler import android.os.IBinder import android.os.Looper import android.os.Parcelable +import android.os.PowerManager import android.speech.tts.TextToSpeech import android.speech.tts.UtteranceProgressListener import android.util.Log @@ -46,6 +47,8 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { private const val BASE_SPEED = 0.9 private const val TAG = "ReaderTtsMediaService" private const val HEALTH_CHECK_INTERVAL_MS = 1500L + private const val START_GRACE_PERIOD_MS = 10_000L + private const val MAX_SEGMENT_RETRIES_BEFORE_SKIP = 4 const val ACTION_INIT = "com.example.reader_app.tts.INIT" const val ACTION_START_READING = "com.example.reader_app.tts.START_READING" @@ -154,7 +157,9 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { private lateinit var notificationManager: NotificationManagerCompat private lateinit var mediaSession: MediaSessionCompat private lateinit var audioManager: AudioManager + private lateinit var powerManager: PowerManager private var audioFocusRequest: AudioFocusRequest? = null + private var wakeLock: PowerManager.WakeLock? = null private var tts: TextToSpeech? = null private var isTtsReady = false private var isForegroundActive = false @@ -210,6 +215,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { super.onCreate() notificationManager = NotificationManagerCompat.from(this) audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager + powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager createNotificationChannel() setupMediaSession() setupTextToSpeech() @@ -300,7 +306,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { if (!isActiveUtterance(utteranceId)) return@post if (utteranceId != currentUtteranceId) return@post clearUtteranceRuntimeState() - handlePlaybackFailure() + recoverFromSilentPlayback("utterance_error_$errorCode") } } }, @@ -319,6 +325,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { } else { status = "idle" } + syncPowerState() syncNotificationState() publishSnapshot() } @@ -348,6 +355,14 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { private fun applyVoiceAndSpeedSettings() { val ttsInstance = tts ?: return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + ttsInstance.setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build(), + ) + } ttsInstance.setSpeechRate(speed.toFloat()) val locale = language.toLocale() ttsInstance.setLanguage(locale) @@ -378,6 +393,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { pausedByAudioFocus = false pendingReplayAfterInit = false tts?.stop() + syncPowerState() publishSnapshot() if (!isTtsReady) return @@ -391,6 +407,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { status = "paused" pendingReplayAfterInit = false tts?.stop() + syncPowerState() syncNotificationState() publishSnapshot() } @@ -401,6 +418,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { sessionGeneration += 1 clearUtteranceRuntimeState() pendingReplayAfterInit = false + syncPowerState() publishSnapshot() if (!isTtsReady) return speakCurrentSegment(forceRestart = true) @@ -418,6 +436,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { } tts?.stop() abandonAudioFocus() + syncPowerState() syncNotificationState() publishSnapshot() stopSelf() @@ -433,6 +452,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { status = "playing" pendingReplayAfterInit = false tts?.stop() + syncPowerState() publishSnapshot() if (!isTtsReady) return speakCurrentSegment(forceRestart = true) @@ -449,6 +469,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { completedCount += 1 clearUtteranceRuntimeState() abandonAudioFocus() + syncPowerState() syncNotificationState() publishSnapshot() stopSelf() @@ -460,10 +481,12 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { } private fun handlePlaybackFailure() { + Log.e(TAG, "Playback stopped after recovery failed at index=$currentIndex contentKey=$contentKey") status = "idle" clearUtteranceRuntimeState() pendingReplayAfterInit = false abandonAudioFocus() + syncPowerState() syncNotificationState() publishSnapshot() stopSelf() @@ -487,6 +510,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { if (!forceRestart) { currentSegmentRetry = 0 } + syncPowerState() syncNotificationState() publishSnapshot() @@ -496,15 +520,20 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { currentUtteranceStarted = false lastSpeakRequestTimeMs = System.currentTimeMillis() scheduleUtteranceWatchdog(utteranceId) - val speakResult = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, Bundle(), utteranceId) - } else { - @Suppress("DEPRECATION") - tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, null) + val speakResult = try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, Bundle(), utteranceId) + } else { + @Suppress("DEPRECATION") + tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, null) + } + } catch (e: Exception) { + Log.e(TAG, "speak() failed for index=$currentIndex", e) + null } - if (speakResult == TextToSpeech.ERROR) { - recoverFromSilentPlayback("speak_error") + if (speakResult == null || speakResult == TextToSpeech.ERROR) { + recoverFromSilentPlayback("speak_error_or_null") } } @@ -552,13 +581,13 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { } clearUtteranceRuntimeState() - if (currentSegmentRetry >= 2) { - handlePlaybackFailure() + if (currentSegmentRetry >= MAX_SEGMENT_RETRIES_BEFORE_SKIP) { + skipCurrentSegmentAfterFailure(reason) return } currentSegmentRetry += 1 - if (currentSegmentRetry >= 2) { + if (currentSegmentRetry >= 3) { rebuildTtsEngineForRecovery(reason) return } @@ -576,6 +605,30 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { setupTextToSpeech() } + private fun skipCurrentSegmentAfterFailure(reason: String) { + Log.e( + TAG, + "Skipping problematic segment after repeated recovery failures: reason=$reason index=$currentIndex total=${segments.size}", + ) + clearUtteranceRuntimeState() + pendingReplayAfterInit = false + + val nextIndex = currentIndex + 1 + if (nextIndex >= segments.size) { + handlePlaybackFailure() + return + } + + currentIndex = nextIndex + currentSegmentRetry = 0 + publishSnapshot() + if (!isTtsReady) { + rebuildTtsEngineForRecovery("skip_after_failure") + return + } + speakCurrentSegment(forceRestart = false) + } + private fun runPlaybackHealthCheck() { if (status != "playing") return if (segments.isEmpty()) return @@ -602,9 +655,10 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { if (!currentUtteranceStarted) { if (!isSpeaking) { // Allow a grace period after speak() is called before flagging as silent. - // onStart typically fires within ~100 ms; 4 s covers slow TTS initialisation. + // Some engines on mid/low-end devices need noticeably longer before + // firing onStart after many segments or after screen-off transitions. val elapsedSinceSpeak = System.currentTimeMillis() - lastSpeakRequestTimeMs - if (elapsedSinceSpeak > 4_000L) { + if (elapsedSinceSpeak > START_GRACE_PERIOD_MS) { recoverFromSilentPlayback("no_onStart_and_not_speaking") } } @@ -661,6 +715,28 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { } } + private fun syncPowerState() { + val shouldHoldWakeLock = backgroundModeEnabled && status == "playing" + if (shouldHoldWakeLock) { + if (wakeLock?.isHeld == true) return + wakeLock = powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "reader_app:ReaderTtsPlayback" + ).apply { + setReferenceCounted(false) + acquire() + } + return + } + + wakeLock?.let { + if (it.isHeld) { + it.release() + } + } + wakeLock = null + } + private fun isActiveUtterance(utteranceId: String): Boolean { val generation = utteranceId.substringBefore(':').toIntOrNull() ?: return false return generation == sessionGeneration @@ -789,6 +865,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { @SuppressLint("MissingPermission") private fun syncNotificationState() { + syncPowerState() updateMediaSessionState() if (!backgroundModeEnabled) { if (isForegroundActive) { @@ -896,6 +973,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { tts?.stop() tts?.shutdown() abandonAudioFocus() + syncPowerState() if (isForegroundActive) { stopForeground(true) isForegroundActive = false diff --git a/lib/features/reader/presentation/reader_screen.dart b/lib/features/reader/presentation/reader_screen.dart index 75d2ab7..5585018 100644 --- a/lib/features/reader/presentation/reader_screen.dart +++ b/lib/features/reader/presentation/reader_screen.dart @@ -28,17 +28,21 @@ class ReaderScreen extends ConsumerStatefulWidget { class _ReaderScreenState extends ConsumerState { final ScrollController _scrollCtrl = ScrollController(); Timer? _uiAutoHideTimer; - double _readingProgress = 0; + final ValueNotifier _readingProgress = ValueNotifier(0); + final ValueNotifier _showQuickActions = ValueNotifier(true); String? _activeChapterId; bool _isRestoringProgress = false; - bool _showQuickActions = true; double _lastScrollOffset = 0; double _scrollDeltaSinceToggle = 0; + double _lastReportedOffset = 0; + DateTime? _lastReportedAt; int _chapterDirection = 0; // -1: previous, 1: next int _lastAutoScrolledParagraph = -1; int _lastTtsCompletedCount = 0; String? _autoStartQueuedChapterId; final List _paragraphKeys = []; + String? _sentenceSlicesChapterId; + List> _sentenceSlicesByParagraph = const []; List _paragraphsOf(String content) => content .split(RegExp(r'\n+')) @@ -69,37 +73,30 @@ class _ReaderScreenState extends ConsumerState { Widget _buildParagraphText({ required BuildContext context, - required String paragraph, + required List<_SentenceSlice> sentenceSlices, required TextStyle style, + required TextStyle highlightStyle, required TextAlign textAlign, required bool isActiveParagraph, required int highlightStart, required int highlightEnd, required Function(int charOffset) onSentenceTap, }) { - final sentenceMatches = RegExp(r'[^.!?…]+[.!?…]*').allMatches(paragraph).toList(); - - if (sentenceMatches.isEmpty) { + if (sentenceSlices.isEmpty) { return SelectableText( - paragraph, + '', textAlign: textAlign, style: style, onTap: () => onSentenceTap(0), ); } - final highlightStyle = style.copyWith( - backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(80), - fontWeight: FontWeight.w600, - ); - return SelectableText.rich( TextSpan( style: style, - children: sentenceMatches.map((match) { - final sentence = match.group(0)!; - final start = match.start; - final end = match.end; + children: sentenceSlices.map((slice) { + final start = slice.start; + final end = slice.end; final isCurrentSpoken = isActiveParagraph && highlightStart >= 0 && @@ -108,7 +105,7 @@ class _ReaderScreenState extends ConsumerState { end <= highlightEnd; return TextSpan( - text: sentence, + text: slice.text, style: isCurrentSpoken ? highlightStyle : null, recognizer: TapGestureRecognizer()..onTap = () => onSentenceTap(start), ); @@ -118,6 +115,45 @@ class _ReaderScreenState extends ConsumerState { ); } + List> _sentenceSlicesForChapter( + ChapterModel chapter, + List paragraphs, + ) { + if (_sentenceSlicesChapterId == chapter.id && + _sentenceSlicesByParagraph.length == paragraphs.length) { + return _sentenceSlicesByParagraph; + } + + final sentencePattern = RegExp(r'[^.!?…]+[.!?…]*'); + final parsed = >[]; + + for (final paragraph in paragraphs) { + final matches = sentencePattern.allMatches(paragraph).toList(); + if (matches.isEmpty) { + parsed.add([ + _SentenceSlice(text: paragraph, start: 0, end: paragraph.length), + ]); + continue; + } + + parsed.add( + matches + .map( + (match) => _SentenceSlice( + text: match.group(0)!, + start: match.start, + end: match.end, + ), + ) + .toList(), + ); + } + + _sentenceSlicesChapterId = chapter.id; + _sentenceSlicesByParagraph = parsed; + return parsed; + } + void _ensureParagraphKeys(int count) { if (_paragraphKeys.length == count) return; _paragraphKeys @@ -183,9 +219,7 @@ class _ReaderScreenState extends ConsumerState { void initState() { super.initState(); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - WidgetsBinding.instance.addPostFrameCallback((_) { - _scrollCtrl.addListener(_onScroll); - }); + _scrollCtrl.addListener(_onScroll); } /// Handle TTS state transitions that require navigation or restarts. @@ -235,13 +269,29 @@ class _ReaderScreenState extends ConsumerState { _uiAutoHideTimer?.cancel(); _scrollCtrl.removeListener(_onScroll); _scrollCtrl.dispose(); + _readingProgress.dispose(); + _showQuickActions.dispose(); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); super.dispose(); } + bool _shouldReportScroll(double offset) { + final now = DateTime.now(); + final elapsedMs = _lastReportedAt == null + ? 1000 + : now.difference(_lastReportedAt!).inMilliseconds; + final delta = (offset - _lastReportedOffset).abs(); + return delta >= 24 || elapsedMs >= 700; + } + void _onScroll() { if (_isRestoringProgress) return; - ref.read(readerProvider.notifier).updateScroll(_scrollCtrl.offset); + final offset = _scrollCtrl.hasClients ? _scrollCtrl.offset : 0.0; + if (_shouldReportScroll(offset)) { + _lastReportedOffset = offset; + _lastReportedAt = DateTime.now(); + ref.read(readerProvider.notifier).updateScroll(offset); + } final currentOffset = _scrollCtrl.hasClients ? _scrollCtrl.offset : _lastScrollOffset; final delta = currentOffset - _lastScrollOffset; @@ -252,12 +302,12 @@ class _ReaderScreenState extends ConsumerState { _scrollDeltaSinceToggle = delta; } - if (_showQuickActions && currentOffset > 120 && _scrollDeltaSinceToggle > 56) { - setState(() => _showQuickActions = false); + if (_showQuickActions.value && currentOffset > 120 && _scrollDeltaSinceToggle > 56) { + _showQuickActions.value = false; _scrollDeltaSinceToggle = 0; - } else if (!_showQuickActions && + } else if (!_showQuickActions.value && (_scrollDeltaSinceToggle < -36 || currentOffset <= 40)) { - setState(() => _showQuickActions = true); + _showQuickActions.value = true; _scrollDeltaSinceToggle = 0; } _lastScrollOffset = currentOffset; @@ -265,15 +315,29 @@ class _ReaderScreenState extends ConsumerState { if (!_scrollCtrl.hasClients) return; final max = _scrollCtrl.position.maxScrollExtent; final next = max <= 0 ? 0.0 : (_scrollCtrl.offset / max).clamp(0.0, 1.0); - if ((next - _readingProgress).abs() > 0.01) { - setState(() => _readingProgress = next); + if ((next - _readingProgress.value).abs() > 0.02) { + _readingProgress.value = next; } } Future _initializeChapterSession(ChapterModel chapter) async { if (_activeChapterId == chapter.id) return; + final previousChapterId = _activeChapterId; + final switchedChapter = previousChapterId != null && previousChapterId != chapter.id; _activeChapterId = chapter.id; - _readingProgress = 0; + _readingProgress.value = 0; + _showQuickActions.value = true; + _lastScrollOffset = 0; + _scrollDeltaSinceToggle = 0; + _lastReportedOffset = 0; + _lastReportedAt = null; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollCtrl.hasClients) return; + _isRestoringProgress = true; + _scrollCtrl.jumpTo(0); + _isRestoringProgress = false; + }); ref.read(readerProvider.notifier).open( chapter.novelId, @@ -281,6 +345,13 @@ class _ReaderScreenState extends ConsumerState { chapter.number, ); + _consumePendingAutoStartForChapter(chapter); + + if (switchedChapter) { + ref.read(readerProvider.notifier).resetCurrentChapterProgress(); + return; + } + final localStore = ref.read(localStoreProvider); final saved = await localStore.loadProgress(chapter.novelId); if (!mounted || saved == null) return; @@ -297,6 +368,8 @@ class _ReaderScreenState extends ConsumerState { _isRestoringProgress = true; _scrollCtrl.jumpTo(target); _isRestoringProgress = false; + _lastReportedOffset = target; + _lastReportedAt = DateTime.now(); _onScroll(); }); } @@ -323,6 +396,7 @@ class _ReaderScreenState extends ConsumerState { if (prevId == null) return; setState(() => _chapterDirection = -1); HapticFeedback.selectionClick(); + _queueAutoStartIfReadingCurrentChapter(chapter.id, prevId); context.pushReplacement(RouteNames.readerChapter(prevId)); } @@ -331,9 +405,40 @@ class _ReaderScreenState extends ConsumerState { if (nextId == null) return; setState(() => _chapterDirection = 1); HapticFeedback.selectionClick(); + _queueAutoStartIfReadingCurrentChapter(chapter.id, nextId); context.pushReplacement(RouteNames.readerChapter(nextId)); } + void _queueAutoStartIfReadingCurrentChapter( + String currentChapterId, + String targetChapterId, + ) { + final tts = ref.read(ttsProvider); + final isCurrentlyReading = tts.contentKey == currentChapterId && + (tts.status == TtsStatus.playing || tts.status == TtsStatus.paused); + if (!isCurrentlyReading) return; + ref.read(ttsProvider.notifier).scheduleAutoStartForChapter(targetChapterId); + } + + void _consumePendingAutoStartForChapter(ChapterModel chapter) { + final tts = ref.read(ttsProvider); + if (tts.pendingAutoStartChapterId != chapter.id) return; + if (_autoStartQueuedChapterId == chapter.id) return; + + _autoStartQueuedChapterId = chapter.id; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final notifier = ref.read(ttsProvider.notifier); + notifier.clearPendingAutoStartChapter(); + notifier.startReading( + chapter.content, + contentKey: chapter.id, + title: 'Chương ${chapter.number}: ${chapter.title}', + ); + _autoStartQueuedChapterId = null; + }); + } + Future _scrollToTop() async { if (!_scrollCtrl.hasClients) return; await _scrollCtrl.animateTo( @@ -411,6 +516,10 @@ class _ReaderScreenState extends ConsumerState { onTap: () { Navigator.of(context).pop(); if (!isCurrent) { + _queueAutoStartIfReadingCurrentChapter( + currentChapter.id, + item.id, + ); context.pushReplacement(RouteNames.readerChapter(item.id)); } }, @@ -881,11 +990,28 @@ class _ReaderScreenState extends ConsumerState { data: (chapter) { final paragraphs = _paragraphsOf(chapter.content); _ensureParagraphKeys(paragraphs.length); + final sentenceSlicesByParagraph = + _sentenceSlicesForChapter(chapter, paragraphs); final textAlign = _textAlignFor(settings.textAlign); final novelAsync = ref.watch(novelDetailProvider(chapter.novelId)); final tts = ref.watch(ttsProvider); final shouldHighlightTts = tts.contentKey == chapter.id && (tts.status == TtsStatus.playing || tts.status == TtsStatus.paused); + final paragraphStyle = TextStyle( + color: readerTextColor, + fontSize: settings.fontSize, + height: settings.lineHeight, + letterSpacing: settings.letterSpacing, + fontFamily: settings.fontFamily == 'serif' + ? 'Georgia' + : settings.fontFamily == 'mono' + ? 'Courier' + : null, + ); + final paragraphHighlightStyle = paragraphStyle.copyWith( + backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(80), + fontWeight: FontWeight.w600, + ); _maybeAutoScrollToTtsParagraph(tts, paragraphs.length); @@ -901,16 +1027,21 @@ class _ReaderScreenState extends ConsumerState { color: readerBackground, child: Column( children: [ - _TopBar( - title: _chapterTopBarTitle(chapter), - progress: _readingProgress, - onOpenSettings: () => _openReadingSettingsSheet( - chapter.content, - chapter.id, - 'Chương ${chapter.number}: ${chapter.title}', - ), - barBackgroundColor: readerBackground, - foregroundColor: readerTextColor, + ValueListenableBuilder( + valueListenable: _readingProgress, + builder: (context, progress, _) { + return _TopBar( + title: _chapterTopBarTitle(chapter), + progress: progress, + onOpenSettings: () => _openReadingSettingsSheet( + chapter.content, + chapter.id, + 'Chương ${chapter.number}: ${chapter.title}', + ), + barBackgroundColor: readerBackground, + foregroundColor: readerTextColor, + ); + }, ), Expanded( child: AnimatedSwitcher( @@ -934,107 +1065,136 @@ class _ReaderScreenState extends ConsumerState { key: ValueKey(chapter.id), child: Scrollbar( controller: _scrollCtrl, - child: SingleChildScrollView( + child: CustomScrollView( controller: _scrollCtrl, - padding: EdgeInsets.fromLTRB( - settings.horizontalPadding, - 16, - settings.horizontalPadding, - 24, - ), - child: Align( - alignment: Alignment.topCenter, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 760), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - novelAsync.when( - loading: () => Text( - 'Đang tải tên truyện...', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: readerMutedColor, - ), - ), - error: (_, _) => const SizedBox.shrink(), - data: (novel) => Text( - novel.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: readerMutedColor, - letterSpacing: 0.2, - ), - ), - ), - const SizedBox(height: 4), - Text( - 'Chương ${chapter.number}: ${chapter.title}', - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith(color: readerTextColor), - ), - const SizedBox(height: 20), - if (chapter.content.trim().isEmpty) - Text( - 'Chương này hiện chưa có nội dung.', - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(color: readerMutedColor), - ) - else - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + slivers: [ + SliverPadding( + padding: EdgeInsets.fromLTRB( + settings.horizontalPadding, + 16, + settings.horizontalPadding, + chapter.content.trim().isEmpty ? 24 : 0, + ), + sliver: SliverToBoxAdapter( + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (var index = 0; index < paragraphs.length; index++) - Padding( - key: _paragraphKeys[index], - padding: EdgeInsets.only( - bottom: index == paragraphs.length - 1 - ? 0 - : settings.paragraphSpacing, - ), - child: _buildParagraphText( - context: context, - paragraph: paragraphs[index], - textAlign: textAlign, - style: TextStyle( - color: readerTextColor, - fontSize: settings.fontSize, - height: settings.lineHeight, - letterSpacing: settings.letterSpacing, - fontFamily: settings.fontFamily == 'serif' - ? 'Georgia' - : settings.fontFamily == 'mono' - ? 'Courier' - : null, - ), - 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, - ); - }, - ), + novelAsync.when( + loading: () => Text( + 'Đang tải tên truyện...', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: readerMutedColor, + ), + ), + error: (_, _) => const SizedBox.shrink(), + data: (novel) => Text( + novel.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: readerMutedColor, + letterSpacing: 0.2, + ), + ), + ), + const SizedBox(height: 4), + Text( + 'Chương ${chapter.number}: ${chapter.title}', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(color: readerTextColor), + ), + const SizedBox(height: 20), + if (chapter.content.trim().isEmpty) + Text( + 'Chương này hiện chưa có nội dung.', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: readerMutedColor), ), ], ), - const SizedBox(height: 40), - _NavButtons(chapter: chapter), - const SizedBox(height: 92), - ], + ), + ), ), ), - ), + if (chapter.content.trim().isNotEmpty) + SliverPadding( + padding: EdgeInsets.fromLTRB( + settings.horizontalPadding, + 0, + settings.horizontalPadding, + 0, + ), + sliver: SliverList.builder( + itemCount: paragraphs.length, + itemBuilder: (context, index) { + final sentenceSlices = sentenceSlicesByParagraph[index]; + return Align( + 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, + ); + }, + ), + ), + ), + ); + }, + ), + ), + SliverPadding( + padding: EdgeInsets.fromLTRB( + settings.horizontalPadding, + 40, + settings.horizontalPadding, + 92, + ), + sliver: SliverToBoxAdapter( + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: _NavButtons( + chapter: chapter, + onGoPrevious: () => _goToPreviousChapter(chapter), + onGoNext: () => _goToNextChapter(chapter), + ), + ), + ), + ), + ), + ], ), ), ), @@ -1047,36 +1207,41 @@ class _ReaderScreenState extends ConsumerState { }, ), floatingActionButton: chapterAsync.hasValue - ? AnimatedSlide( - duration: const Duration(milliseconds: 180), - curve: Curves.easeOut, - offset: _showQuickActions ? Offset.zero : const Offset(0, 1.4), - child: AnimatedOpacity( - duration: const Duration(milliseconds: 140), - opacity: _showQuickActions ? 1 : 0, - child: Builder( - builder: (context) { - final chapter = chapterAsync.value!; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - FloatingActionButton.small( - heroTag: 'reader-scroll-top', - onPressed: _scrollToTop, - child: const Icon(Icons.vertical_align_top_rounded, size: 20), - ), - const SizedBox(height: 10), - FloatingActionButton.small( - heroTag: 'reader-toc', - onPressed: () => _openChapterToc(chapter), - child: const Icon(Icons.list_alt_rounded, size: 20), - ), - ], - ); - }, - ), - ), + ? ValueListenableBuilder( + valueListenable: _showQuickActions, + builder: (context, showQuickActions, _) { + return AnimatedSlide( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + offset: showQuickActions ? Offset.zero : const Offset(0, 1.4), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 140), + opacity: showQuickActions ? 1 : 0, + child: Builder( + builder: (context) { + final chapter = chapterAsync.value!; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + FloatingActionButton.small( + heroTag: 'reader-scroll-top', + onPressed: _scrollToTop, + child: const Icon(Icons.vertical_align_top_rounded, size: 20), + ), + const SizedBox(height: 10), + FloatingActionButton.small( + heroTag: 'reader-toc', + onPressed: () => _openChapterToc(chapter), + child: const Icon(Icons.list_alt_rounded, size: 20), + ), + ], + ); + }, + ), + ), + ); + }, ) : null, bottomNavigationBar: chapterAsync.whenOrNull( @@ -1104,6 +1269,18 @@ class _ReaderScreenState extends ConsumerState { } } +class _SentenceSlice { + const _SentenceSlice({ + required this.text, + required this.start, + required this.end, + }); + + final String text; + final int start; + final int end; +} + class _TopBar extends StatelessWidget { final String title; final double progress; @@ -1373,20 +1550,25 @@ class _TabLabel extends StatelessWidget { } } -class _NavButtons extends ConsumerWidget { +class _NavButtons extends StatelessWidget { final ChapterModel chapter; - const _NavButtons({required this.chapter}); + final VoidCallback onGoPrevious; + final VoidCallback onGoNext; + + const _NavButtons({ + required this.chapter, + required this.onGoPrevious, + required this.onGoNext, + }); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return Row( children: [ if (chapter.prevChapterId != null) Expanded( child: OutlinedButton.icon( - onPressed: () => context.pushReplacement( - RouteNames.readerChapter(chapter.prevChapterId!), - ), + onPressed: onGoPrevious, icon: const Icon(Icons.chevron_left), label: Text('Chương ${chapter.prevChapterNumber ?? '?'}'), ), @@ -1396,9 +1578,7 @@ class _NavButtons extends ConsumerWidget { if (chapter.nextChapterId != null) Expanded( child: FilledButton.icon( - onPressed: () => context.pushReplacement( - RouteNames.readerChapter(chapter.nextChapterId!), - ), + onPressed: onGoNext, icon: const Icon(Icons.chevron_right), label: Text('Chương ${chapter.nextChapterNumber ?? '?'}'), ), diff --git a/lib/features/reader/providers/reader_provider.dart b/lib/features/reader/providers/reader_provider.dart index fc0d033..c1cb67b 100644 --- a/lib/features/reader/providers/reader_provider.dart +++ b/lib/features/reader/providers/reader_provider.dart @@ -61,6 +61,21 @@ class ReaderNotifier extends StateNotifier { scrollOffset: 0, ); } + + void resetCurrentChapterProgress() { + if (state == null) return; + + state = ReadingProgress( + novelId: state!.novelId, + chapterId: state!.chapterId, + chapterNumber: state!.chapterNumber, + scrollOffset: 0, + ); + + // Persist immediately so a freshly opened chapter always resumes at top. + unawaited(_persistProgress(state!.chapterId, state!.chapterNumber, 0)); + } + void updateScroll(double offset) { if (state == null) return; state = ReadingProgress( diff --git a/pubspec.yaml b/pubspec.yaml index ece095b..d3d93fe 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.0+1 +version: 1.0.1+2 environment: sdk: ^3.11.3