c3e6d66f43
- Added ReaderTtsPlaybackStore to manage TTS start requests with a maximum of 4 pending requests. - Updated app configuration to use a production API URL. - Enhanced BookmarkModel to infer type when not provided by the API for backward compatibility. - Introduced methods in LocalStore for saving, loading, and clearing the last route path. - Implemented syncProgress method in BookshelfNotifier to update reading progress and bookmarks from the server. - Modified ReaderScreen to handle chapter navigation and TTS playback more effectively, including auto-start logic. - Updated TtsPlayerWidget to accept additional parameters for chapter navigation. - Enhanced TtsNotifier to handle new parameters for TTS requests and manage playback state. - Improved SplashScreen to restore the last visited route after splash screen display.
1766 lines
78 KiB
Dart
1766 lines
78 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:flutter/foundation.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/config/app_config.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<ReaderScreen> createState() => _ReaderScreenState();
|
||
}
|
||
|
||
class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||
static const List<Color> _backgroundColorChoices = [
|
||
Color(0xFFFFFEF8),
|
||
Color(0xFFF6EAD7),
|
||
Color(0xFF101418),
|
||
Color(0xFFF3F7FF),
|
||
Color(0xFFF6FFF5),
|
||
];
|
||
|
||
static const List<Color> _textColorChoices = [
|
||
Color(0xFF111111),
|
||
Color(0xFF2C1E12),
|
||
Color(0xFFE6EAF2),
|
||
Color(0xFF1F2A44),
|
||
Color(0xFF0F5132),
|
||
];
|
||
|
||
final ScrollController _scrollCtrl = ScrollController();
|
||
Timer? _uiAutoHideTimer;
|
||
final ValueNotifier<double> _readingProgress = ValueNotifier(0);
|
||
final ValueNotifier<bool> _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<GlobalKey> _paragraphKeys = [];
|
||
String? _sentenceSlicesChapterId;
|
||
List<List<_SentenceSlice>> _sentenceSlicesByParagraph = const [];
|
||
|
||
List<String> _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,
|
||
// When true, renders Text.rich instead of SelectableText.rich.
|
||
// This avoids the "selection.isValid" assertion that fires when a
|
||
// TapGestureRecognizer on a span triggers TTS/scroll while SelectableText
|
||
// still holds a stale internal selection.
|
||
bool useTapRecognizer = false,
|
||
}) {
|
||
final spans = sentenceSlices.map((slice) {
|
||
final start = slice.start;
|
||
final end = slice.end;
|
||
|
||
final isCurrentSpoken = isActiveParagraph &&
|
||
highlightStart >= 0 &&
|
||
highlightEnd > highlightStart &&
|
||
start >= highlightStart &&
|
||
end <= highlightEnd;
|
||
|
||
if (!useTapRecognizer) {
|
||
return TextSpan(
|
||
text: slice.text,
|
||
style: isCurrentSpoken ? highlightStyle : null,
|
||
);
|
||
}
|
||
|
||
// Use WidgetSpan + GestureDetector to avoid lifecycle issues from
|
||
// creating/discarding many TapGestureRecognizer instances across rebuilds.
|
||
return WidgetSpan(
|
||
alignment: PlaceholderAlignment.baseline,
|
||
baseline: TextBaseline.alphabetic,
|
||
child: GestureDetector(
|
||
behavior: HitTestBehavior.translucent,
|
||
onTap: () {
|
||
// Unfocus immediately so SelectableText drops its selection state
|
||
// before any scroll notification can call getBoxesForSelection.
|
||
FocusManager.instance.primaryFocus?.unfocus();
|
||
onSentenceTap(start);
|
||
},
|
||
child: Text(
|
||
slice.text,
|
||
style: isCurrentSpoken ? highlightStyle : style,
|
||
),
|
||
),
|
||
);
|
||
}).toList();
|
||
|
||
final textSpan = TextSpan(style: style, children: spans);
|
||
|
||
if (useTapRecognizer || sentenceSlices.isEmpty) {
|
||
// Use plain Text.rich when sentence-tap TTS is active.
|
||
// SelectableText keeps an internal TextEditingController/selection that
|
||
// becomes invalid after a programmatic rebuild, causing a Flutter
|
||
// assertion failure in getBoxesForSelection during scroll.
|
||
return GestureDetector(
|
||
onTap: sentenceSlices.isEmpty ? () => onSentenceTap(0) : null,
|
||
child: RichText(text: textSpan, textAlign: textAlign),
|
||
);
|
||
}
|
||
|
||
return SelectableText.rich(textSpan, textAlign: textAlign);
|
||
}
|
||
|
||
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
|
||
..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;
|
||
|
||
if (previous.contentKey == chapter.id &&
|
||
next.contentKey != null &&
|
||
next.contentKey != chapter.id &&
|
||
next.contentKey != previous.contentKey &&
|
||
(next.status == TtsStatus.playing || next.status == TtsStatus.paused)) {
|
||
final targetChapterId = next.contentKey!;
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!mounted) return;
|
||
context.pushReplacement(RouteNames.readerChapter(targetChapterId));
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Chapter-completion → auto-advance to next chapter.
|
||
// On Android, native service already fetches and starts next chapter.
|
||
// Re-queueing auto-start from UI causes duplicate START_READING races.
|
||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||
if (next.completedCount > _lastTtsCompletedCount) {
|
||
_lastTtsCompletedCount = next.completedCount;
|
||
}
|
||
return;
|
||
}
|
||
|
||
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}',
|
||
nextChapterId: chapter.nextChapterId,
|
||
chapterNumber: chapter.number,
|
||
apiBaseUrl: AppConfig.baseUrl,
|
||
);
|
||
_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<void> _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);
|
||
// Only auto-start on the target chapter when TTS is actively PLAYING.
|
||
// If paused, the user intentionally stopped – do not resume on navigation.
|
||
final isActivelyPlaying = tts.contentKey == currentChapterId &&
|
||
tts.status == TtsStatus.playing;
|
||
if (!isActivelyPlaying) 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;
|
||
|
||
// If native TTS service already moved to this chapter and is actively
|
||
// controlling playback, do not issue another manual START_READING.
|
||
final isAlreadyPlayingThisChapter =
|
||
tts.contentKey == chapter.id &&
|
||
(tts.status == TtsStatus.playing || tts.status == TtsStatus.paused);
|
||
if (isAlreadyPlayingThisChapter) {
|
||
ref.read(ttsProvider.notifier).clearPendingAutoStartChapter();
|
||
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}',
|
||
nextChapterId: chapter.nextChapterId,
|
||
chapterNumber: chapter.number,
|
||
apiBaseUrl: AppConfig.baseUrl,
|
||
);
|
||
_autoStartQueuedChapterId = null;
|
||
});
|
||
}
|
||
|
||
Future<void> _scrollToTop() async {
|
||
if (!_scrollCtrl.hasClients) return;
|
||
await _scrollCtrl.animateTo(
|
||
0,
|
||
duration: const Duration(milliseconds: 320),
|
||
curve: Curves.easeOutCubic,
|
||
);
|
||
}
|
||
|
||
Future<void> _openChapterToc(ChapterModel currentChapter) async {
|
||
await showModalBottomSheet<void>(
|
||
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<void> _openReadingSettingsSheet(
|
||
String previewContent,
|
||
String chapterId,
|
||
String chapterTitle,
|
||
String? nextChapterId,
|
||
int? chapterNumber,
|
||
) async {
|
||
await showModalBottomSheet<void>(
|
||
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<void> 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<String>(
|
||
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<String>(
|
||
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<String>(
|
||
initialValue: tts.voiceName,
|
||
isExpanded: true,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Giọng đọc tiếng Việt',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
items: tts.availableVietnameseVoices
|
||
.map(
|
||
(v) => DropdownMenuItem<String>(
|
||
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',
|
||
nextChapterId: nextChapterId,
|
||
chapterNumber: chapterNumber,
|
||
apiBaseUrl: AppConfig.baseUrl,
|
||
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<TtsState>(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<double>(
|
||
valueListenable: _readingProgress,
|
||
builder: (context, progress, _) {
|
||
return _TopBar(
|
||
title: _chapterTopBarTitle(chapter),
|
||
progress: progress,
|
||
onOpenSettings: () => _openReadingSettingsSheet(
|
||
chapter.content,
|
||
chapter.id,
|
||
'Chương ${chapter.number}: ${chapter.title}',
|
||
chapter.nextChapterId,
|
||
chapter.number,
|
||
),
|
||
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<Offset>(
|
||
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;
|
||
}
|
||
// Synchronous unfocus clears stale SelectableText selection
|
||
// before startReading triggers a widget rebuild + scroll.
|
||
FocusManager.instance.primaryFocus?.unfocus();
|
||
ref
|
||
.read(ttsProvider.notifier)
|
||
.clearPendingAutoStartChapter();
|
||
ref.read(ttsProvider.notifier).startReading(
|
||
chapter.content,
|
||
contentKey: chapter.id,
|
||
title: 'Chương ${chapter.number}: ${chapter.title}',
|
||
nextChapterId: chapter.nextChapterId,
|
||
chapterNumber: chapter.number,
|
||
apiBaseUrl: AppConfig.baseUrl,
|
||
startParagraphIndex: index,
|
||
startCharOffset: charOffset,
|
||
);
|
||
},
|
||
useTapRecognizer: settings.enableSentenceTapTts ||
|
||
(tts.contentKey == chapter.id &&
|
||
(tts.status == TtsStatus.playing ||
|
||
tts.status == TtsStatus.paused)),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
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<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),
|
||
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}',
|
||
nextChapterId: chapter.nextChapterId,
|
||
chapterNumber: chapter.number,
|
||
apiBaseUrl: AppConfig.baseUrl,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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<double> 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 ?? '?'}'),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|