import 'dart:async'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../app/router/route_names.dart'; import '../../../core/models/chapter_model.dart'; import '../../../core/models/reading_settings.dart'; import '../../../core/storage/local_store.dart'; import '../../novel/providers/novels_provider.dart'; import '../providers/reader_provider.dart'; import '../tts/tts_service.dart'; import 'tts_player_widget.dart'; class ReaderScreen extends ConsumerStatefulWidget { const ReaderScreen({super.key, required this.chapterId}); final String chapterId; @override ConsumerState createState() => _ReaderScreenState(); } class _ReaderScreenState extends ConsumerState { static const List _backgroundColorChoices = [ Color(0xFFFFFEF8), Color(0xFFF6EAD7), Color(0xFF101418), Color(0xFFF3F7FF), Color(0xFFF6FFF5), ]; static const List _textColorChoices = [ Color(0xFF111111), Color(0xFF2C1E12), Color(0xFFE6EAF2), Color(0xFF1F2A44), Color(0xFF0F5132), ]; final ScrollController _scrollCtrl = ScrollController(); Timer? _uiAutoHideTimer; final ValueNotifier _readingProgress = ValueNotifier(0); final ValueNotifier _showQuickActions = ValueNotifier(true); String? _activeChapterId; bool _isRestoringProgress = false; 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+')) .map((item) => item.trim()) .where((item) => item.isNotEmpty) .toList(); String _chapterTopBarTitle(ChapterModel chapter) { final title = chapter.title.trim(); if (title.isNotEmpty) return title; final volumeTitle = chapter.volumeTitle?.trim(); if (volumeTitle != null && volumeTitle.isNotEmpty) return volumeTitle; return 'Chương ${chapter.number}'; } TextAlign _textAlignFor(String value) { switch (value) { case 'left': return TextAlign.left; case 'center': return TextAlign.center; default: return TextAlign.left; } } Widget _buildParagraphText({ required BuildContext context, 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, }) { if (sentenceSlices.isEmpty) { return SelectableText( '', textAlign: textAlign, style: style, onTap: () => onSentenceTap(0), ); } return SelectableText.rich( TextSpan( style: style, children: sentenceSlices.map((slice) { final start = slice.start; final end = slice.end; final isCurrentSpoken = isActiveParagraph && highlightStart >= 0 && highlightEnd > highlightStart && start >= highlightStart && end <= highlightEnd; return TextSpan( text: slice.text, style: isCurrentSpoken ? highlightStyle : null, recognizer: TapGestureRecognizer()..onTap = () => onSentenceTap(start), ); }).toList(), ), textAlign: textAlign, ); } 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 ..clear() ..addAll(List.generate(count, (_) => GlobalKey())); _lastAutoScrolledParagraph = -1; } void _maybeAutoScrollToTtsParagraph(TtsState tts, int paragraphCount) { if (tts.status != TtsStatus.playing) return; final index = tts.activeParagraphIndex; if (index < 0 || index >= paragraphCount) return; if (index == _lastAutoScrolledParagraph) return; _lastAutoScrolledParagraph = index; WidgetsBinding.instance.addPostFrameCallback((_) { 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, duration: const Duration(milliseconds: 280), curve: Curves.easeOutCubic, ); }); } int _firstVisibleParagraphIndex() { if (!_scrollCtrl.hasClients || _paragraphKeys.isEmpty) return 0; final viewportTop = _scrollCtrl.offset + 8; final viewportBottom = _scrollCtrl.offset + _scrollCtrl.position.viewportDimension - 8; int? partiallyVisibleIndex; for (var i = 0; i < _paragraphKeys.length; i++) { final ctx = _paragraphKeys[i].currentContext; if (ctx == null) continue; final renderObject = ctx.findRenderObject(); if (renderObject == null || !renderObject.attached) continue; final viewport = RenderAbstractViewport.of(renderObject); final top = viewport.getOffsetToReveal(renderObject, 0).offset; final bottom = viewport.getOffsetToReveal(renderObject, 1).offset; final fullyVisible = top >= viewportTop && bottom <= viewportBottom; if (fullyVisible) return i; final partiallyVisible = bottom > viewportTop && top < viewportBottom; if (partiallyVisible && partiallyVisibleIndex == null) { partiallyVisibleIndex = i; } } return partiallyVisibleIndex ?? 0; } @override void initState() { super.initState(); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); _scrollCtrl.addListener(_onScroll); } /// Handle TTS state transitions that require navigation or restarts. /// Called once from [build] via [ref.listen] — safe to run side effects here. void _onTtsStateChanged(TtsState? previous, TtsState next) { // Guard: only act when something meaningful changed. if (previous == null) return; final chapterAsync = ref.read(chapterProvider(widget.chapterId)); final chapter = chapterAsync.valueOrNull; if (chapter == null) return; // Chapter-completion → auto-advance to next chapter. if (next.completedCount > _lastTtsCompletedCount) { _lastTtsCompletedCount = next.completedCount; if (next.contentKey == chapter.id && chapter.nextChapterId != null) { final nextChapterId = chapter.nextChapterId!; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; ref.read(ttsProvider.notifier).scheduleAutoStartForChapter(nextChapterId); context.pushReplacement(RouteNames.readerChapter(nextChapterId)); }); } return; } // Pending auto-start for this chapter (set by previous chapter's completion). if (next.pendingAutoStartChapterId == chapter.id && _autoStartQueuedChapterId != chapter.id) { _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; }); } } @override void dispose() { _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; 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; if (_scrollDeltaSinceToggle == 0 || (_scrollDeltaSinceToggle.isNegative == delta.isNegative)) { _scrollDeltaSinceToggle += delta; } else { _scrollDeltaSinceToggle = delta; } if (_showQuickActions.value && currentOffset > 120 && _scrollDeltaSinceToggle > 56) { _showQuickActions.value = false; _scrollDeltaSinceToggle = 0; } else if (!_showQuickActions.value && (_scrollDeltaSinceToggle < -36 || currentOffset <= 40)) { _showQuickActions.value = true; _scrollDeltaSinceToggle = 0; } _lastScrollOffset = currentOffset; 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.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.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, chapter.id, 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; if (saved['chapterId'] != chapter.id) return; final savedOffset = (saved['scrollOffset'] as num?)?.toDouble() ?? 0; if (savedOffset <= 0) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !_scrollCtrl.hasClients) return; final max = _scrollCtrl.position.maxScrollExtent; final target = savedOffset.clamp(0.0, max); _isRestoringProgress = true; _scrollCtrl.jumpTo(target); _isRestoringProgress = false; _lastReportedOffset = target; _lastReportedAt = DateTime.now(); _onScroll(); }); } void _goToPreviousChapter(ChapterModel chapter) { final prevId = chapter.prevChapterId; if (prevId == null) return; setState(() => _chapterDirection = -1); HapticFeedback.selectionClick(); _queueAutoStartIfReadingCurrentChapter(chapter.id, prevId); context.pushReplacement(RouteNames.readerChapter(prevId)); } void _goToNextChapter(ChapterModel chapter) { final nextId = chapter.nextChapterId; 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( 0, duration: const Duration(milliseconds: 320), curve: Curves.easeOutCubic, ); } Future _openChapterToc(ChapterModel currentChapter) async { await showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, builder: (sheetContext) { return Consumer( builder: (context, ref, _) { final chaptersAsync = ref.watch( chapterListProvider(currentChapter.novelId), ); return FractionallySizedBox( heightFactor: 0.82, child: SafeArea( top: false, child: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 4, 12, 8), child: Row( children: [ Expanded( child: Text( 'Mục lục chương', style: Theme.of(context).textTheme.titleLarge, ), ), IconButton( onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.close), ), ], ), ), const Divider(height: 1), Expanded( child: chaptersAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('Không tải được mục lục: $e')), 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) { final item = chapters[index]; final isCurrent = item.id == currentChapter.id; return ListTile( dense: true, selected: isCurrent, selectedTileColor: Theme.of(context).colorScheme.primaryContainer.withAlpha(90), title: Text( 'Chương ${item.number}: ${item.title}', maxLines: 1, overflow: TextOverflow.ellipsis, ), trailing: isCurrent ? const Icon(Icons.menu_book_rounded, size: 18) : null, onTap: () { Navigator.of(context).pop(); if (!isCurrent) { _queueAutoStartIfReadingCurrentChapter( currentChapter.id, item.id, ); context.pushReplacement(RouteNames.readerChapter(item.id)); } }, ); }, ); }, ), ), ], ), ), ); }, ); }, ); } Future _openReadingSettingsSheet( String previewContent, String chapterId, String chapterTitle, ) async { await showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, backgroundColor: Colors.transparent, builder: (sheetContext) { return Consumer( builder: (context, ref, _) { final settings = ref.watch(readingSettingsProvider); final tts = ref.watch(ttsProvider); final ttsNotifier = ref.read(ttsProvider.notifier); final theme = Theme.of(context); final colorScheme = theme.colorScheme; final isCompactTabs = MediaQuery.sizeOf(context).width < 380; void closeSettingsSheet() { if (!sheetContext.mounted) return; final route = ModalRoute.of(sheetContext); if (route != null) { Navigator.of(sheetContext).removeRoute(route); return; } Navigator.of(sheetContext).maybePop(); } Future update(dynamic next) async { await ref.read(readingSettingsProvider.notifier).update(next); } return FractionallySizedBox( heightFactor: 0.92, child: DecoratedBox( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), ), child: SafeArea( top: false, child: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 8, 12, 8), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Tùy chỉnh đọc', style: Theme.of(context).textTheme.headlineSmall), ], ), ), TextButton( onPressed: () => update(const ReadingSettings()), child: const Text('Mặc định'), ), IconButton( onPressed: closeSettingsSheet, icon: const Icon(Icons.close), ), ], ), ), const Divider(height: 1), Expanded( child: DefaultTabController( length: 4, child: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Container( height: 52, padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest.withAlpha(180), borderRadius: BorderRadius.circular(24), border: Border.all( color: colorScheme.outlineVariant.withAlpha(160), ), ), child: TabBar( isScrollable: false, dividerColor: Colors.transparent, padding: EdgeInsets.zero, labelPadding: EdgeInsets.zero, indicatorSize: TabBarIndicatorSize.tab, splashBorderRadius: BorderRadius.circular(18), overlayColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.pressed)) { return colorScheme.primary.withAlpha(18); } return null; }), indicator: BoxDecoration( color: colorScheme.surface, borderRadius: BorderRadius.circular(18), boxShadow: [ BoxShadow( color: Colors.black.withAlpha(16), blurRadius: 10, offset: const Offset(0, 2), ), ], ), labelColor: colorScheme.onSurface, unselectedLabelColor: colorScheme.onSurfaceVariant, tabs: [ _TabLabel( icon: Icons.text_fields_rounded, label: 'Văn bản', compact: isCompactTabs, ), _TabLabel( icon: Icons.palette_outlined, label: 'Giao diện', compact: isCompactTabs, ), _TabLabel( icon: Icons.tune_rounded, label: 'Bố cục', compact: isCompactTabs, ), _TabLabel( icon: Icons.record_voice_over_outlined, label: 'TTS', compact: isCompactTabs, ), ], ), ), ), Expanded( child: TabBarView( children: [ ListView( physics: const BouncingScrollPhysics( parent: AlwaysScrollableScrollPhysics(), ), padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), children: [ _SettingsSection( title: 'Kiểu chữ', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SegmentedButton( segments: const [ ButtonSegment(value: 'serif', label: Text('Có chân')), ButtonSegment(value: 'sans', label: Text('Không chân')), ButtonSegment(value: 'mono', label: Text('Đơn cách')), ], selected: { {'serif', 'sans', 'mono'}.contains(settings.fontFamily) ? settings.fontFamily : 'serif', }, onSelectionChanged: (s) => update( settings.copyWith(fontFamily: s.first), ), ), const SizedBox(height: 12), _LabeledSlider( label: 'Cỡ chữ', valueLabel: settings.fontSize.toStringAsFixed(0), min: 12, max: 32, divisions: 10, value: settings.fontSize, onChanged: (v) => update(settings.copyWith(fontSize: v)), ), _LabeledSlider( label: 'Giãn dòng', valueLabel: settings.lineHeight.toStringAsFixed(1), min: 1.2, max: 3.0, divisions: 9, value: settings.lineHeight, onChanged: (v) => update(settings.copyWith(lineHeight: v)), ), _LabeledSlider( label: 'Khoảng cách chữ', valueLabel: settings.letterSpacing.toStringAsFixed(1), min: 0, max: 4, divisions: 8, value: settings.letterSpacing, onChanged: (v) => update(settings.copyWith(letterSpacing: v)), ), ], ), ), ], ), ListView( physics: const BouncingScrollPhysics( parent: AlwaysScrollableScrollPhysics(), ), padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), children: [ _SettingsSection( title: 'Giao diện đọc', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Màu nền', style: Theme.of(context).textTheme.labelLarge, ), const SizedBox(height: 8), Wrap( spacing: 10, runSpacing: 10, children: _backgroundColorChoices.map((color) { return _ColorOptionChip( color: color, selected: settings.backgroundColorValue == color.value, onTap: () => update( settings.copyWith(backgroundColorValue: color.value), ), ); }).toList(), ), const SizedBox(height: 14), Text( 'Màu chữ', style: Theme.of(context).textTheme.labelLarge, ), const SizedBox(height: 8), Wrap( spacing: 10, runSpacing: 10, children: _textColorChoices.map((color) { return _ColorOptionChip( color: color, selected: settings.textColorValue == color.value, onTap: () => update( settings.copyWith(textColorValue: color.value), ), ); }).toList(), ), ], ), ), ], ), ListView( physics: const BouncingScrollPhysics( parent: AlwaysScrollableScrollPhysics(), ), padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), children: [ _SettingsSection( title: 'Bố cục trang', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Canh chữ', style: Theme.of(context).textTheme.labelLarge), const SizedBox(height: 8), SegmentedButton( segments: const [ ButtonSegment(value: 'left', label: Text('Trái')), ButtonSegment(value: 'justify', label: Text('Đều')), ButtonSegment(value: 'center', label: Text('Giữa')), ], selected: {settings.textAlign}, onSelectionChanged: (s) => update(settings.copyWith(textAlign: s.first)), ), const SizedBox(height: 12), _LabeledSlider( label: 'Lề ngang', valueLabel: settings.horizontalPadding.toStringAsFixed(0), min: 12, max: 36, divisions: 8, value: settings.horizontalPadding, onChanged: (v) => update(settings.copyWith(horizontalPadding: v)), ), _LabeledSlider( label: 'Khoảng cách đoạn', valueLabel: settings.paragraphSpacing.toStringAsFixed(0), min: 8, max: 36, divisions: 7, value: settings.paragraphSpacing, onChanged: (v) => update(settings.copyWith(paragraphSpacing: v)), ), ], ), ), ], ), ListView( physics: const BouncingScrollPhysics( parent: AlwaysScrollableScrollPhysics(), ), padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), children: [ _SettingsSection( title: 'TTS tiếng Việt', 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( child: Text( tts.voiceName ?? tts.language, style: Theme.of(context).textTheme.titleSmall, ), ), Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: Theme.of(context).colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(999), ), child: Text( formatTtsSpeedLabel(tts.speed), style: Theme.of(context).textTheme.labelLarge, ), ), ], ), const SizedBox(height: 12), Wrap( spacing: 8, runSpacing: 8, children: [0.45, 0.675, 0.9, 1.125, 1.35, 1.8].map((speed) { final selected = tts.speed == speed; return ChoiceChip( label: Text(formatTtsSpeedLabel(speed)), selected: selected, onSelected: (_) => ttsNotifier.setSpeed(speed), ); }).toList(), ), const SizedBox(height: 12), Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Theme.of(context) .colorScheme .secondaryContainer .withAlpha(90), borderRadius: BorderRadius.circular(12), border: Border.all( color: Theme.of(context).colorScheme.outlineVariant, ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Điều kiện bắt buộc để TTS chạy ổn định', style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 8), Row( children: [ Icon( tts.backgroundModeEnabled ? Icons.check_circle : Icons.radio_button_unchecked, size: 18, color: tts.backgroundModeEnabled ? Colors.green : Theme.of(context) .colorScheme .onSurfaceVariant, ), const SizedBox(width: 8), const Expanded( child: Text( 'Bật chạy nền cho TTS', ), ), ], ), const SizedBox(height: 6), Row( children: [ Icon( tts.batteryOptimizationIgnored ? Icons.check_circle : Icons.radio_button_unchecked, size: 18, color: tts.batteryOptimizationIgnored ? Colors.green : Theme.of(context) .colorScheme .onSurfaceVariant, ), const SizedBox(width: 8), const Expanded( child: Text( 'Loại trừ tối ưu pin', ), ), ], ), const SizedBox(height: 10), Align( alignment: Alignment.centerRight, child: OutlinedButton( onPressed: () async { await ttsNotifier.setBackgroundModeEnabled(true); await ttsNotifier.ensureBatteryOptimizationIgnored(); }, child: const Text('Bật ngay'), ), ), ], ), ), const SizedBox(height: 12), if (tts.availableVietnameseVoices.isNotEmpty) DropdownButtonFormField( initialValue: tts.voiceName, isExpanded: true, decoration: const InputDecoration( labelText: 'Giọng đọc tiếng Việt', border: OutlineInputBorder(), ), items: tts.availableVietnameseVoices .map( (v) => DropdownMenuItem( value: v.name, child: Text( v.displayName, overflow: TextOverflow.ellipsis, ), ), ) .toList(), onChanged: (value) { if (value != null) { ttsNotifier.setVoiceByName(value); } }, ) else Text( 'Thiết bị không cung cấp nhiều giọng tiếng Việt. Đang dùng ${tts.voiceName ?? tts.language}.', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 12), TtsPlayerWidget( content: previewContent, contentKey: chapterId, title: 'Chương $chapterTitle', includeTitleOnStart: false, resolveStartParagraphIndex: _firstVisibleParagraphIndex, onStarted: closeSettingsSheet, ), ], ), ), ], ), ], ), ), ], ), ), ), ], ), ), ), ); }, ); }, ); } @override Widget build(BuildContext context) { final chapterAsync = ref.watch(chapterProvider(widget.chapterId)); final settings = ref.watch(readingSettingsProvider); // Side-effects for TTS state changes (navigation, auto-start). ref.listen(ttsProvider, _onTtsStateChanged); final readerBackground = Color(settings.backgroundColorValue); final readerTextColor = Color(settings.textColorValue); final readerMutedColor = readerTextColor.withAlpha(170); return Scaffold( body: chapterAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.error_outline, size: 48), const SizedBox(height: 8), Text('Lỗi tải chương: $e'), FilledButton( onPressed: () => ref.invalidate(chapterProvider(widget.chapterId)), child: const Text('Thử lại'), ), ], ), ), 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: _resolveReaderFontFamily(settings.fontFamily), ); final paragraphHighlightStyle = paragraphStyle.copyWith( backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(80), fontWeight: FontWeight.w600, ); _maybeAutoScrollToTtsParagraph(tts, paragraphs.length); WidgetsBinding.instance.addPostFrameCallback((_) { _initializeChapterSession(chapter); }); return ColoredBox( color: readerBackground, child: Column( children: [ 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( duration: const Duration(milliseconds: 220), switchInCurve: Curves.easeOutCubic, switchOutCurve: Curves.easeInCubic, transitionBuilder: (child, animation) { final beginOffset = _chapterDirection < 0 ? const Offset(-0.08, 0) : const Offset(0.08, 0); final fade = CurvedAnimation(parent: animation, curve: Curves.easeOut); final slide = Tween( begin: beginOffset, end: Offset.zero, ).animate(fade); return FadeTransition( opacity: fade, child: SlideTransition(position: slide, child: child), ); }, child: KeyedSubtree( key: ValueKey(chapter.id), child: Scrollbar( controller: _scrollCtrl, child: CustomScrollView( controller: _scrollCtrl, 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: [ 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: 12), _NavButtons( chapter: chapter, onGoPrevious: () => _goToPreviousChapter(chapter), onGoNext: () => _goToNextChapter(chapter), ), 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), ), ], ), ), ), ), ), 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: 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, ); }, ), ), ), ), ); }, ), ), 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), ), ), ), ), ), ], ), ), ), ), ), ], ), ); }, ), floatingActionButton: chapterAsync.hasValue ? 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( data: (chapter) { final tts = ref.watch(ttsProvider); final showMini = tts.contentKey == chapter.id && (tts.status == TtsStatus.playing || tts.status == TtsStatus.paused); if (!showMini) return const SizedBox.shrink(); return SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(12, 4, 12, 10), child: TtsPlayerWidget( compact: true, content: chapter.content, contentKey: chapter.id, title: 'Chương ${chapter.number}: ${chapter.title}', ), ), ); }, ), ); } } 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; final VoidCallback onOpenSettings; final Color barBackgroundColor; final Color foregroundColor; const _TopBar({ required this.title, required this.progress, required this.onOpenSettings, required this.barBackgroundColor, required this.foregroundColor, }); @override Widget build(BuildContext context) { final progressText = '${(progress * 100).round()}%'; return Container( padding: const EdgeInsets.only(bottom: 6), decoration: BoxDecoration( color: barBackgroundColor, border: Border( bottom: BorderSide(color: Colors.black.withAlpha(20)), ), ), child: SafeArea( bottom: false, child: SizedBox( height: 52, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 6), child: Row( children: [ IconButton( tooltip: 'Quay lại', icon: const Icon(Icons.arrow_back_ios_new, size: 18), onPressed: () => Navigator.maybePop(context), ), Expanded( child: Text( title, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleSmall?.copyWith( color: foregroundColor, fontWeight: FontWeight.w600, ), ), ), Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: foregroundColor.withAlpha(18), borderRadius: BorderRadius.circular(999), ), child: Text( progressText, style: Theme.of(context).textTheme.labelLarge?.copyWith( color: foregroundColor, fontWeight: FontWeight.w700, ), ), ), const SizedBox(width: 4), IconButton( tooltip: 'Tùy chỉnh đọc', icon: const Icon(Icons.tune, size: 20), onPressed: onOpenSettings, ), ], ), ), ), ), ); } } String? _resolveReaderFontFamily(String fontFamily) { switch (fontFamily) { case 'serif': case 'georgia': return 'Georgia'; case 'mono': return 'Courier'; case 'roboto': return 'Roboto'; case 'sans': default: return null; } } class _SettingsSection extends StatelessWidget { const _SettingsSection({required this.title, required this.child}); final String title; final Widget child; @override Widget build(BuildContext context) { return Container( margin: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerLowest, borderRadius: BorderRadius.circular(20), border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 12), child, ], ), ); } } class _LabeledSlider extends StatelessWidget { const _LabeledSlider({ required this.label, required this.valueLabel, required this.min, required this.max, required this.divisions, required this.value, required this.onChanged, }); final String label; final String valueLabel; final double min; final double max; final int divisions; final double value; final ValueChanged onChanged; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: 10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded(child: Text(label, style: Theme.of(context).textTheme.labelLarge)), Text(valueLabel, style: Theme.of(context).textTheme.labelLarge), ], ), Slider( min: min, max: max, divisions: divisions, value: value, onChanged: onChanged, ), ], ), ); } } class _ColorOptionChip extends StatelessWidget { const _ColorOptionChip({ required this.color, required this.selected, required this.onTap, }); final Color color; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(999), child: AnimatedContainer( duration: const Duration(milliseconds: 160), width: 34, height: 34, decoration: BoxDecoration( shape: BoxShape.circle, color: color, border: Border.all( color: selected ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.outlineVariant, width: selected ? 3 : 1, ), boxShadow: selected ? [ BoxShadow( color: Theme.of(context).colorScheme.primary.withAlpha(60), blurRadius: 8, spreadRadius: 1, ), ] : null, ), ), ); } } class _TabLabel extends StatelessWidget { const _TabLabel({ required this.icon, required this.label, this.compact = false, }); final IconData icon; final String label; final bool compact; @override Widget build(BuildContext context) { final textStyle = Theme.of(context).textTheme.labelLarge?.copyWith( fontSize: compact ? 12.5 : 13.5, fontWeight: FontWeight.w600, letterSpacing: -0.1, ); return SizedBox( height: double.infinity, child: Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 6), child: FittedBox( fit: BoxFit.scaleDown, child: Row( mainAxisSize: MainAxisSize.min, children: [ if (!compact) ...[ Icon(icon, size: 16), const SizedBox(width: 6), ], Text( label, maxLines: 1, overflow: TextOverflow.fade, softWrap: false, style: textStyle, ), ], ), ), ), ), ); } } class _NavButtons extends StatelessWidget { final ChapterModel chapter; final VoidCallback onGoPrevious; final VoidCallback onGoNext; const _NavButtons({ required this.chapter, required this.onGoPrevious, required this.onGoNext, }); @override Widget build(BuildContext context) { return Row( children: [ if (chapter.prevChapterId != null) Expanded( child: OutlinedButton( onPressed: onGoPrevious, child: Text('< Chương ${chapter.prevChapterNumber ?? '?'}'), ), ), if (chapter.prevChapterId != null && chapter.nextChapterId != null) const SizedBox(width: 12), if (chapter.nextChapterId != null) Expanded( child: FilledButton( onPressed: onGoNext, child: Text('> Chương ${chapter.nextChapterNumber ?? '?'}'), ), ), ], ); } }