Files
reader-app/lib/features/reader/presentation/reader_screen.dart
T
virtus 6946083aee
Build Android APK / build-apk (push) Failing after 4m37s
feat: Enhance chapter list and TTS functionality
- Introduced ChapterListQuery and ChapterListPage classes for better chapter management.
- Updated chapterListProvider to handle pagination and canonical ID resolution.
- Improved ReaderScreen with enhanced TTS features, including auto-scroll to active paragraph and better handling of TTS state.
- Added TtsPlayerWidget with compact mode and improved UI for TTS controls.
- Enhanced TtsService to manage speech segments and background mode for TTS.
- Implemented battery optimization checks for TTS background mode on Android.
- Updated main.dart to ensure proper error handling in a zoned environment.
2026-04-07 18:49:29 +07:00

1376 lines
59 KiB
Dart

import 'dart:async';
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<ReaderScreen> createState() => _ReaderScreenState();
}
class _ReaderScreenState extends ConsumerState<ReaderScreen> {
final ScrollController _scrollCtrl = ScrollController();
Timer? _uiAutoHideTimer;
double _readingProgress = 0;
String? _activeChapterId;
bool _isRestoringProgress = false;
bool _showQuickActions = true;
double _lastScrollOffset = 0;
double _scrollDeltaSinceToggle = 0;
int _chapterDirection = 0; // -1: previous, 1: next
int _lastAutoScrolledParagraph = -1;
int _lastTtsCompletedCount = 0;
String? _autoStartQueuedChapterId;
final List<GlobalKey> _paragraphKeys = [];
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.justify;
}
}
Widget _buildParagraphText({
required BuildContext context,
required String paragraph,
required TextStyle style,
required TextAlign textAlign,
required bool isActiveParagraph,
required int highlightStart,
required int highlightEnd,
}) {
if (!isActiveParagraph || highlightStart < 0 || highlightEnd <= highlightStart) {
return SelectableText(
paragraph,
textAlign: textAlign,
style: style,
);
}
final safeStart = highlightStart.clamp(0, paragraph.length);
final safeEnd = highlightEnd.clamp(0, paragraph.length);
if (safeEnd <= safeStart) {
return SelectableText(
paragraph,
textAlign: textAlign,
style: style,
);
}
final highlightStyle = style.copyWith(
backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(80),
fontWeight: FontWeight.w600,
);
return RichText(
textAlign: textAlign,
text: TextSpan(
style: style,
children: [
if (safeStart > 0) TextSpan(text: paragraph.substring(0, safeStart)),
TextSpan(text: paragraph.substring(safeStart, safeEnd), style: highlightStyle),
if (safeEnd < paragraph.length) TextSpan(text: paragraph.substring(safeEnd)),
],
),
);
}
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.idle) 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;
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);
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollCtrl.addListener(_onScroll);
});
}
@override
void dispose() {
_uiAutoHideTimer?.cancel();
_scrollCtrl.removeListener(_onScroll);
_scrollCtrl.dispose();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
}
void _onScroll() {
if (_isRestoringProgress) return;
ref.read(readerProvider.notifier).updateScroll(_scrollCtrl.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 && currentOffset > 120 && _scrollDeltaSinceToggle > 56) {
setState(() => _showQuickActions = false);
_scrollDeltaSinceToggle = 0;
} else if (!_showQuickActions &&
(_scrollDeltaSinceToggle < -36 || currentOffset <= 40)) {
setState(() => _showQuickActions = 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).abs() > 0.01) {
setState(() => _readingProgress = next);
}
}
Future<void> _initializeChapterSession(ChapterModel chapter) async {
if (_activeChapterId == chapter.id) return;
_activeChapterId = chapter.id;
_readingProgress = 0;
ref.read(readerProvider.notifier).open(
chapter.novelId,
chapter.id,
chapter.number,
);
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;
_onScroll();
});
}
void _handleHorizontalSwipeEnd(DragEndDetails details, ChapterModel chapter) {
final velocity = details.primaryVelocity ?? 0;
const minVelocity = 300.0;
if (velocity.abs() < minVelocity) return;
// Swipe right -> previous chapter; swipe left -> next chapter
if (velocity > 0 && chapter.prevChapterId != null) {
_goToPreviousChapter(chapter);
return;
}
if (velocity < 0 && chapter.nextChapterId != null) {
_goToNextChapter(chapter);
}
}
void _goToPreviousChapter(ChapterModel chapter) {
final prevId = chapter.prevChapterId;
if (prevId == null) return;
setState(() => _chapterDirection = -1);
HapticFeedback.selectionClick();
context.pushReplacement(RouteNames.readerChapter(prevId));
}
void _goToNextChapter(ChapterModel chapter) {
final nextId = chapter.nextChapterId;
if (nextId == null) return;
setState(() => _chapterDirection = 1);
HapticFeedback.selectionClick();
context.pushReplacement(RouteNames.readerChapter(nextId));
}
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 tocPage = ((currentChapter.number - 1) ~/ chapterPageSize) + 1;
final chaptersAsync = ref.watch(
chapterListProvider(
ChapterListQuery(novelId: currentChapter.novelId, page: tocPage),
),
);
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: (pageData) {
final chapters = pageData.chapters;
if (chapters.isEmpty) {
return const Center(child: Text('Chưa có danh sách chương.'));
}
return ListView.separated(
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) {
context.pushReplacement(RouteNames.readerChapter(item.id));
}
},
);
},
);
},
),
),
],
),
),
);
},
);
},
);
}
Future<void> _openReadingSettingsSheet(
String previewContent,
String chapterId,
String chapterTitle,
) 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);
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),
const SizedBox(height: 4),
Text(
'Tùy chỉnh văn bản, giao diện, bố cục và TTS ngay trong chương',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
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: TabBar(
isScrollable: true,
tabAlignment: TabAlignment.start,
dividerColor: Colors.transparent,
labelPadding: const EdgeInsets.only(right: 8),
indicator: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(999),
),
labelColor: Theme.of(context).colorScheme.onSecondaryContainer,
unselectedLabelColor: Theme.of(context).colorScheme.onSurfaceVariant,
tabs: const [
Tab(child: _TabLabel(icon: Icons.text_fields, label: 'Văn bản')),
Tab(child: _TabLabel(icon: Icons.palette_outlined, label: 'Giao diện')),
Tab(child: _TabLabel(icon: Icons.view_day_outlined, label: 'Bố cục')),
Tab(child: _TabLabel(icon: Icons.record_voice_over_outlined, label: 'TTS')),
],
),
),
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: {settings.fontFamily},
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: Wrap(
spacing: 12,
runSpacing: 12,
children: [
_PresetChip(
label: 'Sáng',
value: 'paper',
selected: settings.themePreset == 'paper',
onTap: () => update(settings.copyWith(themePreset: 'paper')),
),
_PresetChip(
label: 'Sepia',
value: 'sepia',
selected: settings.themePreset == 'sepia',
onTap: () => update(settings.copyWith(themePreset: 'sepia')),
),
_PresetChip(
label: 'Ban đêm',
value: 'night',
selected: settings.themePreset == 'night',
onTap: () => update(settings.copyWith(themePreset: 'night')),
),
],
),
),
_SettingsSection(
title: 'Mẫu nhanh',
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.tonal(
onPressed: () => update(const ReadingSettings()),
child: const Text('Mặc định'),
),
FilledButton.tonal(
onPressed: () => update(
settings.copyWith(
themePreset: 'night',
fontSize: 19,
lineHeight: 1.9,
textAlign: 'justify',
),
),
child: const Text('Đọc đêm'),
),
FilledButton.tonal(
onPressed: () => update(
settings.copyWith(
themePreset: 'sepia',
fontSize: 18,
lineHeight: 1.8,
textAlign: 'justify',
),
),
child: const Text('Thư giãn'),
),
],
),
),
],
),
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: [
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.35, 0.45, 0.55, 0.65, 0.8, 1.0].map((speed) {
final selected = tts.speed == speed;
return ChoiceChip(
label: Text(formatTtsSpeedLabel(speed)),
selected: selected,
onSelected: (_) => ttsNotifier.setSpeed(speed),
);
}).toList(),
),
const SizedBox(height: 12),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('Chạy nền cho TTS'),
subtitle: const Text(
'Tiếp tục đọc khi chuyển app hoặc tắt màn hình (Android)',
),
value: tts.backgroundModeEnabled,
onChanged: ttsNotifier.setBackgroundModeEnabled,
),
if (tts.backgroundModeEnabled)
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Loại trừ tối ưu pin'),
subtitle: Text(
tts.batteryOptimizationIgnored
? 'Đã bật: Android sẽ ít khả năng dừng TTS khi chạy nền.'
: 'Nên bật để Android không chặn TTS khi tắt màn hình.',
),
trailing: tts.batteryOptimizationIgnored
? const Icon(Icons.verified, color: Colors.green)
: OutlinedButton(
onPressed: 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',
includeTitleOnStart: false,
resolveStartParagraphIndex:
_firstVisibleParagraphIndex,
onStarted: closeSettingsSheet,
),
],
),
),
],
),
],
),
),
],
),
),
),
],
),
),
),
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
final chapterAsync = ref.watch(chapterProvider(widget.chapterId));
final settings = ref.watch(readingSettingsProvider);
Color readerBackground;
Color readerTextColor;
Color readerMutedColor;
switch (settings.themePreset) {
case 'night':
readerBackground = const Color(0xFF101418);
readerTextColor = const Color(0xFFE6EAF2);
readerMutedColor = const Color(0xFFA5B0C5);
case 'sepia':
readerBackground = const Color(0xFFF6EAD7);
readerTextColor = const Color(0xFF3B2F23);
readerMutedColor = const Color(0xFF7A6753);
default:
readerBackground = const Color(0xFFFFFEF8);
readerTextColor = const Color(0xFF111111);
readerMutedColor = const Color(0xFF555555);
}
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 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);
if (tts.completedCount > _lastTtsCompletedCount) {
_lastTtsCompletedCount = tts.completedCount;
if (tts.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));
});
}
}
if (tts.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;
});
}
_maybeAutoScrollToTtsParagraph(tts, paragraphs.length);
WidgetsBinding.instance.addPostFrameCallback((_) {
_initializeChapterSession(chapter);
});
return GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragEnd: (details) =>
_handleHorizontalSwipeEnd(details, chapter),
child: ColoredBox(
color: readerBackground,
child: Column(
children: [
_TopBar(
title: _chapterTopBarTitle(chapter),
progress: _readingProgress,
onOpenSettings: () => _openReadingSettingsSheet(
chapter.content,
chapter.id,
'Chương ${chapter.number}: ${chapter.title}',
),
barBackgroundColor: readerBackground,
foregroundColor: readerTextColor,
),
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: SingleChildScrollView(
controller: _scrollCtrl,
padding: EdgeInsets.fromLTRB(
settings.horizontalPadding,
16,
settings.horizontalPadding,
24,
),
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
novelAsync.when(
loading: () => Text(
'Đang tải tên truyện...',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: readerMutedColor,
),
),
error: (_, _) => const SizedBox.shrink(),
data: (novel) => Text(
novel.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: readerMutedColor,
letterSpacing: 0.2,
),
),
),
const SizedBox(height: 4),
Text(
'Chương ${chapter.number}: ${chapter.title}',
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(color: readerTextColor),
),
const SizedBox(height: 20),
if (chapter.content.trim().isEmpty)
Text(
'Chương này hiện chưa có nội dung.',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: readerMutedColor),
)
else
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var index = 0; index < paragraphs.length; index++)
Padding(
key: _paragraphKeys[index],
padding: EdgeInsets.only(
bottom: index == paragraphs.length - 1
? 0
: settings.paragraphSpacing,
),
child: _buildParagraphText(
context: context,
paragraph: paragraphs[index],
textAlign: textAlign,
style: TextStyle(
color: readerTextColor,
fontSize: settings.fontSize,
height: settings.lineHeight,
letterSpacing: settings.letterSpacing,
fontFamily: settings.fontFamily == 'serif'
? 'Georgia'
: settings.fontFamily == 'mono'
? 'Courier'
: null,
),
isActiveParagraph: shouldHighlightTts &&
tts.activeParagraphIndex == index,
highlightStart: tts.progressStart,
highlightEnd: tts.progressEnd,
),
),
],
),
const SizedBox(height: 40),
_NavButtons(chapter: chapter),
const SizedBox(height: 92),
],
),
),
),
),
),
),
),
),
],
),
),
);
},
),
floatingActionButton: chapterAsync.hasValue
? AnimatedSlide(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
offset: _showQuickActions ? Offset.zero : const Offset(0, 1.4),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 140),
opacity: _showQuickActions ? 1 : 0,
child: Builder(
builder: (context) {
final chapter = chapterAsync.value!;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
FloatingActionButton.small(
heroTag: 'reader-scroll-top',
onPressed: _scrollToTop,
child: const Icon(Icons.vertical_align_top_rounded, size: 20),
),
const SizedBox(height: 10),
FloatingActionButton.small(
heroTag: 'reader-toc',
onPressed: () => _openChapterToc(chapter),
child: const Icon(Icons.list_alt_rounded, size: 20),
),
],
);
},
),
),
)
: 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 _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,
),
],
),
),
),
),
);
}
}
Color _surfaceForPreset(String preset) {
switch (preset) {
case 'night':
return const Color(0xFF101418);
case 'sepia':
return const Color(0xFFF6EAD7);
default:
return const Color(0xFFFFFEF8);
}
}
Color _textForPreset(String preset) {
switch (preset) {
case 'night':
return const Color(0xFFE6EAF2);
case 'sepia':
return const Color(0xFF3B2F23);
default:
return const Color(0xFF111111);
}
}
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 _PresetChip extends StatelessWidget {
const _PresetChip({
required this.label,
required this.value,
required this.selected,
required this.onTap,
});
final String label;
final String value;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Container(
width: 132,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _surfaceForPreset(value),
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: selected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outlineVariant,
width: selected ? 2 : 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 60,
decoration: BoxDecoration(
color: _surfaceForPreset(value),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: _textForPreset(value).withAlpha(40)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Aa',
style: TextStyle(
color: _textForPreset(value),
fontWeight: FontWeight.w700,
fontSize: 18,
),
),
const Spacer(),
Container(
height: 3,
width: 54,
decoration: BoxDecoration(
color: _textForPreset(value).withAlpha(110),
borderRadius: BorderRadius.circular(99),
),
),
],
),
),
),
const SizedBox(height: 8),
Text(label, style: Theme.of(context).textTheme.labelLarge),
],
),
),
);
}
}
class _TabLabel extends StatelessWidget {
const _TabLabel({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16),
const SizedBox(width: 6),
Text(label),
],
);
}
}
class _NavButtons extends ConsumerWidget {
final ChapterModel chapter;
const _NavButtons({required this.chapter});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Row(
children: [
if (chapter.prevChapterId != null)
Expanded(
child: OutlinedButton.icon(
onPressed: () => context.pushReplacement(
RouteNames.readerChapter(chapter.prevChapterId!),
),
icon: const Icon(Icons.chevron_left),
label: Text('Chương ${chapter.prevChapterNumber ?? '?'}'),
),
),
if (chapter.prevChapterId != null && chapter.nextChapterId != null)
const SizedBox(width: 12),
if (chapter.nextChapterId != null)
Expanded(
child: FilledButton.icon(
onPressed: () => context.pushReplacement(
RouteNames.readerChapter(chapter.nextChapterId!),
),
icon: const Icon(Icons.chevron_right),
label: Text('Chương ${chapter.nextChapterNumber ?? '?'}'),
),
),
],
);
}
}