1 Commits

Author SHA1 Message Date
virtus 297fc45707 feat: Update reading progress management and enhance chapter navigation
Build Android AAB / build-aab (push) Successful in 18m4s
Build Android APK / build-apk (push) Successful in 20m41s
2026-04-16 13:18:25 +07:00
4 changed files with 458 additions and 185 deletions
@@ -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) {
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
@@ -28,17 +28,21 @@ class ReaderScreen extends ConsumerStatefulWidget {
class _ReaderScreenState extends ConsumerState<ReaderScreen> {
final ScrollController _scrollCtrl = ScrollController();
Timer? _uiAutoHideTimer;
double _readingProgress = 0;
final ValueNotifier<double> _readingProgress = ValueNotifier(0);
final ValueNotifier<bool> _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<GlobalKey> _paragraphKeys = [];
String? _sentenceSlicesChapterId;
List<List<_SentenceSlice>> _sentenceSlicesByParagraph = const [];
List<String> _paragraphsOf(String content) => content
.split(RegExp(r'\n+'))
@@ -69,37 +73,30 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
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<ReaderScreen> {
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<ReaderScreen> {
);
}
List<List<_SentenceSlice>> _sentenceSlicesForChapter(
ChapterModel chapter,
List<String> paragraphs,
) {
if (_sentenceSlicesChapterId == chapter.id &&
_sentenceSlicesByParagraph.length == paragraphs.length) {
return _sentenceSlicesByParagraph;
}
final sentencePattern = RegExp(r'[^.!?…]+[.!?…]*');
final parsed = <List<_SentenceSlice>>[];
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<ReaderScreen> {
void initState() {
super.initState();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollCtrl.addListener(_onScroll);
});
}
/// Handle TTS state transitions that require navigation or restarts.
@@ -235,13 +269,29 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
_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<ReaderScreen> {
_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<ReaderScreen> {
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<void> _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<ReaderScreen> {
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<ReaderScreen> {
_isRestoringProgress = true;
_scrollCtrl.jumpTo(target);
_isRestoringProgress = false;
_lastReportedOffset = target;
_lastReportedAt = DateTime.now();
_onScroll();
});
}
@@ -323,6 +396,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
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<ReaderScreen> {
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<void> _scrollToTop() async {
if (!_scrollCtrl.hasClients) return;
await _scrollCtrl.animateTo(
@@ -411,6 +516,10 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
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<ReaderScreen> {
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,9 +1027,12 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
color: readerBackground,
child: Column(
children: [
_TopBar(
ValueListenableBuilder<double>(
valueListenable: _readingProgress,
builder: (context, progress, _) {
return _TopBar(
title: _chapterTopBarTitle(chapter),
progress: _readingProgress,
progress: progress,
onOpenSettings: () => _openReadingSettingsSheet(
chapter.content,
chapter.id,
@@ -911,6 +1040,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
),
barBackgroundColor: readerBackground,
foregroundColor: readerTextColor,
);
},
),
Expanded(
child: AnimatedSwitcher(
@@ -934,14 +1065,17 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
key: ValueKey(chapter.id),
child: Scrollbar(
controller: _scrollCtrl,
child: SingleChildScrollView(
child: CustomScrollView(
controller: _scrollCtrl,
slivers: [
SliverPadding(
padding: EdgeInsets.fromLTRB(
settings.horizontalPadding,
16,
settings.horizontalPadding,
24,
chapter.content.trim().isEmpty ? 24 : 0,
),
sliver: SliverToBoxAdapter(
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
@@ -983,13 +1117,30 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
.textTheme
.bodyMedium
?.copyWith(color: readerMutedColor),
)
else
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var index = 0; index < paragraphs.length; index++)
Padding(
),
],
),
),
),
),
),
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
@@ -998,19 +1149,10 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
),
child: _buildParagraphText(
context: context,
paragraph: paragraphs[index],
sentenceSlices: sentenceSlices,
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,
),
style: paragraphStyle,
highlightStyle: paragraphHighlightStyle,
isActiveParagraph: shouldHighlightTts &&
tts.activeParagraphIndex == index,
highlightStart: tts.progressStart,
@@ -1026,16 +1168,34 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
},
),
),
),
);
},
),
),
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),
),
),
),
),
),
],
),
const SizedBox(height: 40),
_NavButtons(chapter: chapter),
const SizedBox(height: 92),
],
),
),
),
),
),
),
),
@@ -1047,13 +1207,16 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
},
),
floatingActionButton: chapterAsync.hasValue
? AnimatedSlide(
? ValueListenableBuilder<bool>(
valueListenable: _showQuickActions,
builder: (context, showQuickActions, _) {
return AnimatedSlide(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
offset: _showQuickActions ? Offset.zero : const Offset(0, 1.4),
offset: showQuickActions ? Offset.zero : const Offset(0, 1.4),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 140),
opacity: _showQuickActions ? 1 : 0,
opacity: showQuickActions ? 1 : 0,
child: Builder(
builder: (context) {
final chapter = chapterAsync.value!;
@@ -1077,6 +1240,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
},
),
),
);
},
)
: null,
bottomNavigationBar: chapterAsync.whenOrNull(
@@ -1104,6 +1269,18 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
}
}
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 ?? '?'}'),
),
@@ -61,6 +61,21 @@ class ReaderNotifier extends StateNotifier<ReadingProgress?> {
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(
+1 -1
View File
@@ -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