feat: Update app layout with MainAppHeader and enhance user settings interface
Build Android APK / build-apk (push) Successful in 19m27s
Build Android AAB / build-aab (push) Successful in 12m5s

This commit is contained in:
2026-04-23 03:09:24 +07:00
parent 297fc45707
commit 2b8fa4ee57
13 changed files with 1627 additions and 625 deletions
@@ -26,6 +26,22 @@ class ReaderScreen extends ConsumerStatefulWidget {
}
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);
@@ -67,7 +83,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
case 'center':
return TextAlign.center;
default:
return TextAlign.justify;
return TextAlign.left;
}
}
@@ -374,23 +390,6 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
});
}
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;
@@ -555,6 +554,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
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;
@@ -590,11 +592,6 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
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,
),
],
),
),
@@ -617,23 +614,65 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
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),
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,
),
],
),
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(
@@ -656,8 +695,14 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
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)),
selected: {
{'serif', 'sans', 'mono'}.contains(settings.fontFamily)
? settings.fontFamily
: 'serif',
},
onSelectionChanged: (s) => update(
settings.copyWith(fontFamily: s.first),
),
),
const SizedBox(height: 12),
_LabeledSlider(
@@ -700,62 +745,45 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
children: [
_SettingsSection(
title: 'Giao diện đọc',
child: Wrap(
spacing: 12,
runSpacing: 12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_PresetChip(
label: 'Sáng',
value: 'paper',
selected: settings.themePreset == 'paper',
onTap: () => update(settings.copyWith(themePreset: 'paper')),
Text(
'Màu nền',
style: Theme.of(context).textTheme.labelLarge,
),
_PresetChip(
label: 'Sepia',
value: 'sepia',
selected: settings.themePreset == 'sepia',
onTap: () => update(settings.copyWith(themePreset: 'sepia')),
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(),
),
_PresetChip(
label: 'Ban đêm',
value: 'night',
selected: settings.themePreset == 'night',
onTap: () => update(settings.copyWith(themePreset: 'night')),
const SizedBox(height: 14),
Text(
'Màu chữ',
style: Theme.of(context).textTheme.labelLarge,
),
],
),
),
_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'),
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(),
),
],
),
@@ -854,32 +882,84 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
}).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.',
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,
),
trailing: tts.batteryOptimizationIgnored
? const Icon(Icons.verified, color: Colors.green)
: OutlinedButton(
onPressed: ttsNotifier
.ensureBatteryOptimizationIgnored,
child: const Text('Bật ngay'),
),
),
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>(
@@ -951,24 +1031,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
// Side-effects for TTS state changes (navigation, auto-start).
ref.listen<TtsState>(ttsProvider, _onTtsStateChanged);
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);
}
final readerBackground = Color(settings.backgroundColorValue);
final readerTextColor = Color(settings.textColorValue);
final readerMutedColor = readerTextColor.withAlpha(170);
return Scaffold(
body: chapterAsync.when(
@@ -1002,11 +1067,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
fontSize: settings.fontSize,
height: settings.lineHeight,
letterSpacing: settings.letterSpacing,
fontFamily: settings.fontFamily == 'serif'
? 'Georgia'
: settings.fontFamily == 'mono'
? 'Courier'
: null,
fontFamily: _resolveReaderFontFamily(settings.fontFamily),
);
final paragraphHighlightStyle = paragraphStyle.copyWith(
backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(80),
@@ -1019,13 +1080,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
_initializeChapterSession(chapter);
});
return GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragEnd: (details) =>
_handleHorizontalSwipeEnd(details, chapter),
child: ColoredBox(
color: readerBackground,
child: Column(
return ColoredBox(
color: readerBackground,
child: Column(
children: [
ValueListenableBuilder<double>(
valueListenable: _readingProgress,
@@ -1202,7 +1259,6 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
),
],
),
),
);
},
),
@@ -1360,25 +1416,18 @@ class _TopBar extends StatelessWidget {
}
}
Color _surfaceForPreset(String preset) {
switch (preset) {
case 'night':
return const Color(0xFF101418);
case 'sepia':
return const Color(0xFFF6EAD7);
String? _resolveReaderFontFamily(String fontFamily) {
switch (fontFamily) {
case 'serif':
case 'georgia':
return 'Georgia';
case 'mono':
return 'Courier';
case 'roboto':
return 'Roboto';
case 'sans':
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);
return null;
}
}
@@ -1455,16 +1504,14 @@ class _LabeledSlider extends StatelessWidget {
}
}
class _PresetChip extends StatelessWidget {
const _PresetChip({
required this.label,
required this.value,
class _ColorOptionChip extends StatelessWidget {
const _ColorOptionChip({
required this.color,
required this.selected,
required this.onTap,
});
final String label;
final String value;
final Color color;
final bool selected;
final VoidCallback onTap;
@@ -1472,59 +1519,29 @@ class _PresetChip extends StatelessWidget {
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Container(
width: 132,
padding: const EdgeInsets.all(12),
borderRadius: BorderRadius.circular(999),
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
width: 34,
height: 34,
decoration: BoxDecoration(
color: _surfaceForPreset(value),
borderRadius: BorderRadius.circular(18),
shape: BoxShape.circle,
color: color,
border: Border.all(
color: selected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outlineVariant,
width: selected ? 2 : 1,
width: selected ? 3 : 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),
],
boxShadow: selected
? [
BoxShadow(
color: Theme.of(context).colorScheme.primary.withAlpha(60),
blurRadius: 8,
spreadRadius: 1,
),
]
: null,
),
),
);
@@ -1532,20 +1549,50 @@ class _PresetChip extends StatelessWidget {
}
class _TabLabel extends StatelessWidget {
const _TabLabel({required this.icon, required this.label});
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) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16),
const SizedBox(width: 6),
Text(label),
],
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,
),
],
),
),
),
),
);
}
}
@@ -103,14 +103,20 @@ class ReaderNotifier extends StateNotifier<ReadingProgress?> {
} catch (_) {}
}
DateTime? _lastUpdate;
Future<void> _debounceUpdate(double offset) async {
final now = DateTime.now();
if (_lastUpdate != null && now.difference(_lastUpdate!).inSeconds < 3) return;
_lastUpdate = now;
if (state != null) {
await _persistProgress(state!.chapterId, state!.chapterNumber, offset);
}
Timer? _debounceTimer;
void _debounceUpdate(double offset) {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(seconds: 3), () {
if (state != null) {
unawaited(_persistProgress(state!.chapterId, state!.chapterNumber, offset));
}
});
}
@override
void dispose() {
_debounceTimer?.cancel();
super.dispose();
}
}
@@ -0,0 +1,133 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_tts/flutter_tts.dart';
enum TtsStatus { idle, playing, paused, stopped }
class TtsState {
final TtsStatus status;
final int currentSentenceIndex;
final List<String> sentences;
final double speechRate;
final double volume;
final double pitch;
final String? currentLanguage;
const TtsState({
this.status = TtsStatus.idle,
this.currentSentenceIndex = 0,
this.sentences = const [],
this.speechRate = 0.5,
this.volume = 1.0,
this.pitch = 1.0,
this.currentLanguage,
});
TtsState copyWith({
TtsStatus? status,
int? currentSentenceIndex,
List<String>? sentences,
double? speechRate,
double? volume,
double? pitch,
String? currentLanguage,
}) {
return TtsState(
status: status ?? this.status,
currentSentenceIndex: currentSentenceIndex ?? this.currentSentenceIndex,
sentences: sentences ?? this.sentences,
speechRate: speechRate ?? this.speechRate,
volume: volume ?? this.volume,
pitch: pitch ?? this.pitch,
currentLanguage: currentLanguage ?? this.currentLanguage,
);
}
}
class TtsNotifier extends Notifier<TtsState> {
late FlutterTts _tts;
@override
TtsState build() {
_tts = FlutterTts();
_initTts();
ref.onDispose(() async {
await _tts.stop();
});
return const TtsState();
}
Future<void> _initTts() async {
await _tts.setLanguage('vi-VN');
await _tts.setSpeechRate(state.speechRate);
await _tts.setVolume(state.volume);
await _tts.setPitch(state.pitch);
// Do NOT use awaitSpeakCompletion(true) — it blocks the Dart↔native channel
// between sentences, causing Android TTS service to disconnect.
await _tts.awaitSpeakCompletion(false);
_tts.setCompletionHandler(_onSentenceComplete);
_tts.setCancelHandler(() {
if (state.status == TtsStatus.playing) {
state = state.copyWith(status: TtsStatus.stopped);
}
});
_tts.setErrorHandler((msg) {
state = state.copyWith(status: TtsStatus.stopped);
});
}
void _onSentenceComplete() {
if (state.status != TtsStatus.playing) return;
final nextIndex = state.currentSentenceIndex + 1;
if (nextIndex < state.sentences.length) {
state = state.copyWith(currentSentenceIndex: nextIndex);
_speakCurrent();
} else {
state = state.copyWith(
status: TtsStatus.stopped, currentSentenceIndex: 0);
}
}
Future<void> _speakCurrent() async {
if (state.sentences.isEmpty) return;
if (state.status != TtsStatus.playing) return;
final sentence = state.sentences[state.currentSentenceIndex];
await _tts.speak(sentence);
}
Future<void> play(List<String> sentences) async {
await _tts.stop();
state = state.copyWith(
sentences: sentences,
currentSentenceIndex: 0,
status: TtsStatus.playing,
);
await _speakCurrent();
}
Future<void> pause() async {
state = state.copyWith(status: TtsStatus.paused);
await _tts.pause();
}
Future<void> resume() async {
state = state.copyWith(status: TtsStatus.playing);
await _speakCurrent();
}
Future<void> stop() async {
state = state.copyWith(status: TtsStatus.stopped, currentSentenceIndex: 0);
await _tts.stop();
}
Future<void> setSpeechRate(double rate) async {
await _tts.setSpeechRate(rate);
state = state.copyWith(speechRate: rate);
}
}
final ttsProvider = NotifierProvider<TtsNotifier, TtsState>(TtsNotifier.new);