1 Commits

Author SHA1 Message Date
virtus 2b8fa4ee57 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
2026-04-23 03:09:24 +07:00
13 changed files with 1627 additions and 625 deletions
@@ -29,6 +29,7 @@ import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat
import com.example.reader_app.R import com.example.reader_app.R
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import kotlin.math.min
import java.util.Locale import java.util.Locale
@Parcelize @Parcelize
@@ -47,7 +48,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
private const val BASE_SPEED = 0.9 private const val BASE_SPEED = 0.9
private const val TAG = "ReaderTtsMediaService" private const val TAG = "ReaderTtsMediaService"
private const val HEALTH_CHECK_INTERVAL_MS = 1500L private const val HEALTH_CHECK_INTERVAL_MS = 1500L
private const val START_GRACE_PERIOD_MS = 10_000L private const val START_GRACE_PERIOD_MS = 5_000L
private const val MAX_SEGMENT_RETRIES_BEFORE_SKIP = 4 private const val MAX_SEGMENT_RETRIES_BEFORE_SKIP = 4
const val ACTION_INIT = "com.example.reader_app.tts.INIT" const val ACTION_INIT = "com.example.reader_app.tts.INIT"
@@ -70,6 +71,8 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
const val EXTRA_VOICE_NAME = "voiceName" const val EXTRA_VOICE_NAME = "voiceName"
const val EXTRA_BACKGROUND_MODE_ENABLED = "backgroundModeEnabled" const val EXTRA_BACKGROUND_MODE_ENABLED = "backgroundModeEnabled"
const val EXTRA_CLEAR_CONTENT_KEY = "clearContentKey" const val EXTRA_CLEAR_CONTENT_KEY = "clearContentKey"
const val EXTRA_STOP_REASON = "stopReason"
private const val STOP_REASON_USER = "user"
fun initialize(context: Context, backgroundModeEnabled: Boolean) { fun initialize(context: Context, backgroundModeEnabled: Boolean) {
context.startService( context.startService(
@@ -121,6 +124,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
context.startService(Intent(context, ReaderTtsMediaService::class.java).apply { context.startService(Intent(context, ReaderTtsMediaService::class.java).apply {
action = ACTION_STOP action = ACTION_STOP
putExtra(EXTRA_CLEAR_CONTENT_KEY, clearContentKey) putExtra(EXTRA_CLEAR_CONTENT_KEY, clearContentKey)
putExtra(EXTRA_STOP_REASON, STOP_REASON_USER)
}) })
fun skipForward(context: Context) = fun skipForward(context: Context) =
@@ -179,6 +183,13 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
private var currentUtteranceId: String? = null private var currentUtteranceId: String? = null
private var currentUtteranceStarted = false private var currentUtteranceStarted = false
private var pendingReplayAfterInit = false private var pendingReplayAfterInit = false
private var isRebuildingEngine = false
private var engineRebuildAttempt = 0
private var audioFocusRetryAttempt = 0
private var consecutivePlaybackRecoveryFailures = 0
private var pendingEngineRebuild: Runnable? = null
private var pendingAudioFocusRetry: Runnable? = null
private var pendingIdleStop: Runnable? = null
private var currentSegmentRetry = 0 private var currentSegmentRetry = 0
private var consecutiveSilentHealthChecks = 0 private var consecutiveSilentHealthChecks = 0
private var utteranceWatchdog: Runnable? = null private var utteranceWatchdog: Runnable? = null
@@ -226,6 +237,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.i(TAG, "onStartCommand action=${intent?.action} status=$status index=$currentIndex")
when (intent?.action) { when (intent?.action) {
ACTION_INIT -> { ACTION_INIT -> {
backgroundModeEnabled = intent.getBooleanExtra( backgroundModeEnabled = intent.getBooleanExtra(
@@ -239,6 +251,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
ACTION_RESUME -> handleResume() ACTION_RESUME -> handleResume()
ACTION_STOP -> handleStop( ACTION_STOP -> handleStop(
clearContentKey = intent.getBooleanExtra(EXTRA_CLEAR_CONTENT_KEY, true), clearContentKey = intent.getBooleanExtra(EXTRA_CLEAR_CONTENT_KEY, true),
reason = intent.getStringExtra(EXTRA_STOP_REASON) ?: "unknown",
) )
ACTION_SKIP_FORWARD -> handleSkip(1) ACTION_SKIP_FORWARD -> handleSkip(1)
ACTION_SKIP_BACK -> handleSkip(-1) ACTION_SKIP_BACK -> handleSkip(-1)
@@ -306,25 +319,41 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
if (!isActiveUtterance(utteranceId)) return@post if (!isActiveUtterance(utteranceId)) return@post
if (utteranceId != currentUtteranceId) return@post if (utteranceId != currentUtteranceId) return@post
clearUtteranceRuntimeState() clearUtteranceRuntimeState()
// ERROR_SERVICE (-6) means the TTS engine process disconnected.
// Rebuild the engine immediately rather than retrying on a dead instance.
if (errorCode == TextToSpeech.ERROR_SERVICE ||
errorCode == TextToSpeech.ERROR_NOT_INSTALLED_YET) {
rebuildTtsEngineForRecovery("utterance_error_$errorCode")
} else {
recoverFromSilentPlayback("utterance_error_$errorCode") recoverFromSilentPlayback("utterance_error_$errorCode")
} }
} }
}
}, },
) )
} }
override fun onInit(initStatus: Int) { override fun onInit(initStatus: Int) {
isRebuildingEngine = false
isTtsReady = initStatus == TextToSpeech.SUCCESS isTtsReady = initStatus == TextToSpeech.SUCCESS
if (isTtsReady) { if (isTtsReady) {
engineRebuildAttempt = 0
consecutivePlaybackRecoveryFailures = 0
currentSegmentRetry = 0 // reset retry counter after successful engine reconnect
refreshAvailableVoices() refreshAvailableVoices()
applyVoiceAndSpeedSettings() applyVoiceAndSpeedSettings()
if ((pendingReplayAfterInit || status == "playing") && segments.isNotEmpty()) { if ((pendingReplayAfterInit || status == "playing") && segments.isNotEmpty()) {
pendingReplayAfterInit = false pendingReplayAfterInit = false
speakCurrentSegment(forceRestart = true) speakCurrentSegment(forceRestart = true)
} }
} else {
if (status == "playing" || pendingReplayAfterInit || segments.isNotEmpty()) {
status = "paused"
scheduleEngineRebuild("onInit_failed_$initStatus")
} else { } else {
status = "idle" status = "idle"
} }
}
syncPowerState() syncPowerState()
syncNotificationState() syncNotificationState()
publishSnapshot() publishSnapshot()
@@ -375,6 +404,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
} }
private fun handleStartReading(intent: Intent) { private fun handleStartReading(intent: Intent) {
cancelIdleStop()
backgroundModeEnabled = intent.getBooleanExtra( backgroundModeEnabled = intent.getBooleanExtra(
EXTRA_BACKGROUND_MODE_ENABLED, EXTRA_BACKGROUND_MODE_ENABLED,
backgroundModeEnabled, backgroundModeEnabled,
@@ -414,6 +444,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
private fun handleResume() { private fun handleResume() {
if (segments.isEmpty()) return if (segments.isEmpty()) return
cancelIdleStop()
status = "playing" status = "playing"
sessionGeneration += 1 sessionGeneration += 1
clearUtteranceRuntimeState() clearUtteranceRuntimeState()
@@ -424,8 +455,11 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
speakCurrentSegment(forceRestart = true) speakCurrentSegment(forceRestart = true)
} }
private fun handleStop(clearContentKey: Boolean) { private fun handleStop(clearContentKey: Boolean, reason: String) {
Log.i(TAG, "handleStop reason=$reason clearContentKey=$clearContentKey")
sessionGeneration += 1 sessionGeneration += 1
clearScheduledRecoveries()
cancelIdleStop()
clearUtteranceRuntimeState() clearUtteranceRuntimeState()
status = "idle" status = "idle"
currentIndex = 0 currentIndex = 0
@@ -467,12 +501,13 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
status = "idle" status = "idle"
currentIndex = 0 currentIndex = 0
completedCount += 1 completedCount += 1
Log.i(TAG, "chapter_completed contentKey=$contentKey completedCount=$completedCount")
clearUtteranceRuntimeState() clearUtteranceRuntimeState()
abandonAudioFocus() abandonAudioFocus()
syncPowerState() syncPowerState()
syncNotificationState() syncNotificationState()
publishSnapshot() publishSnapshot()
stopSelf() scheduleIdleStop()
return return
} }
@@ -481,23 +516,35 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
} }
private fun handlePlaybackFailure() { private fun handlePlaybackFailure() {
Log.e(TAG, "Playback stopped after recovery failed at index=$currentIndex contentKey=$contentKey") consecutivePlaybackRecoveryFailures += 1
status = "idle" Log.e(
clearUtteranceRuntimeState() TAG,
pendingReplayAfterInit = false "Playback failure at index=$currentIndex contentKey=$contentKey, recoveryAttempt=$consecutivePlaybackRecoveryFailures",
abandonAudioFocus() )
status = "paused"
pendingReplayAfterInit = true
if (consecutivePlaybackRecoveryFailures > 12) {
// Keep trying indefinitely but avoid a tight error loop.
consecutivePlaybackRecoveryFailures = 6
}
syncPowerState() syncPowerState()
syncNotificationState() syncNotificationState()
publishSnapshot() publishSnapshot()
stopSelf() scheduleEngineRebuild("playback_failure")
} }
private fun speakCurrentSegment(forceRestart: Boolean) { private fun speakCurrentSegment(forceRestart: Boolean) {
if (segments.isEmpty() || !isTtsReady) return if (segments.isEmpty() || !isTtsReady) return
if (!requestAudioFocus()) { if (!requestAudioFocus()) {
handlePlaybackFailure() pausedByAudioFocus = true
status = "paused"
syncPowerState()
syncNotificationState()
publishSnapshot()
scheduleAudioFocusRetry()
return return
} }
clearAudioFocusRetry()
val segment = segments.getOrNull(currentIndex) ?: run { val segment = segments.getOrNull(currentIndex) ?: run {
handlePlaybackFailure() handlePlaybackFailure()
@@ -533,7 +580,12 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
} }
if (speakResult == null || speakResult == TextToSpeech.ERROR) { if (speakResult == null || speakResult == TextToSpeech.ERROR) {
recoverFromSilentPlayback("speak_error_or_null") // speak() returning ERROR/null almost always means the TTS engine process died
// (visible in logcat as "Disconnected from TTS engine").
// Rebuild immediately instead of burning 3 retries on a dead engine.
if (!isRebuildingEngine) {
rebuildTtsEngineForRecovery("speak_error_or_null")
}
} }
} }
@@ -597,11 +649,21 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
} }
private fun rebuildTtsEngineForRecovery(reason: String) { private fun rebuildTtsEngineForRecovery(reason: String) {
if (isRebuildingEngine) {
Log.w(TAG, "Rebuild already in progress, skipping: $reason")
return
}
Log.w(TAG, "Rebuilding TextToSpeech engine for recovery: $reason") Log.w(TAG, "Rebuilding TextToSpeech engine for recovery: $reason")
isRebuildingEngine = true
pendingReplayAfterInit = true pendingReplayAfterInit = true
isTtsReady = false isTtsReady = false
clearScheduledRecoveries()
// Increment session so callbacks from the dying engine are ignored
sessionGeneration += 1
clearUtteranceRuntimeState()
tts?.stop() tts?.stop()
tts?.shutdown() tts?.shutdown()
tts = null
setupTextToSpeech() setupTextToSpeech()
} }
@@ -640,7 +702,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
} }
if (!isTtsReady) { if (!isTtsReady) {
if (!pendingReplayAfterInit) { if (!pendingReplayAfterInit && !isRebuildingEngine) {
rebuildTtsEngineForRecovery("tts_not_ready") rebuildTtsEngineForRecovery("tts_not_ready")
} }
return return
@@ -691,11 +753,12 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build(), .build(),
) )
.setAcceptsDelayedFocusGain(false) .setAcceptsDelayedFocusGain(true)
.setOnAudioFocusChangeListener(audioFocusListener) .setOnAudioFocusChangeListener(audioFocusListener)
.build() .build()
.also { audioFocusRequest = it } .also { audioFocusRequest = it }
audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED val result = audioManager.requestAudioFocus(request)
result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
audioManager.requestAudioFocus( audioManager.requestAudioFocus(
@@ -706,6 +769,61 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
} }
} }
private fun scheduleEngineRebuild(reason: String) {
if (isRebuildingEngine) return
pendingEngineRebuild?.let(mainHandler::removeCallbacks)
engineRebuildAttempt += 1
val delayMs = min(30_000L, 1_000L * engineRebuildAttempt * engineRebuildAttempt)
pendingEngineRebuild = Runnable {
if (status == "idle") return@Runnable
if (segments.isEmpty()) return@Runnable
rebuildTtsEngineForRecovery(reason)
}.also { mainHandler.postDelayed(it, delayMs) }
}
private fun scheduleAudioFocusRetry() {
pendingAudioFocusRetry?.let(mainHandler::removeCallbacks)
audioFocusRetryAttempt += 1
val delayMs = min(6_000L, 1_000L + (audioFocusRetryAttempt * 500L))
pendingAudioFocusRetry = Runnable {
if (status == "idle") return@Runnable
if (!pausedByAudioFocus) return@Runnable
if (requestAudioFocus()) {
pausedByAudioFocus = false
audioFocusRetryAttempt = 0
handleResume()
return@Runnable
}
scheduleAudioFocusRetry()
}.also { mainHandler.postDelayed(it, delayMs) }
}
private fun clearAudioFocusRetry() {
pendingAudioFocusRetry?.let(mainHandler::removeCallbacks)
pendingAudioFocusRetry = null
audioFocusRetryAttempt = 0
}
private fun clearScheduledRecoveries() {
pendingEngineRebuild?.let(mainHandler::removeCallbacks)
pendingEngineRebuild = null
clearAudioFocusRetry()
}
private fun scheduleIdleStop() {
pendingIdleStop?.let(mainHandler::removeCallbacks)
pendingIdleStop = Runnable {
if (status != "idle") return@Runnable
Log.i(TAG, "idle_timeout_stop")
stopSelf()
}.also { mainHandler.postDelayed(it, 30_000L) }
}
private fun cancelIdleStop() {
pendingIdleStop?.let(mainHandler::removeCallbacks)
pendingIdleStop = null
}
private fun abandonAudioFocus() { private fun abandonAudioFocus() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let(audioManager::abandonAudioFocusRequest) audioFocusRequest?.let(audioManager::abandonAudioFocusRequest)
@@ -778,6 +896,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
this.action = action this.action = action
if (action == ACTION_STOP) { if (action == ACTION_STOP) {
putExtra(EXTRA_CLEAR_CONTENT_KEY, true) putExtra(EXTRA_CLEAR_CONTENT_KEY, true)
putExtra(EXTRA_STOP_REASON, STOP_REASON_USER)
} }
}, },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
@@ -790,9 +909,8 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
.setContentTitle(title ?: appLabel()) .setContentTitle(title ?: appLabel())
.setContentText(currentProgressLabel()) .setContentText(currentProgressLabel())
.setContentIntent(buildLaunchIntent()) .setContentIntent(buildLaunchIntent())
.setDeleteIntent(buildServicePendingIntent(ACTION_STOP))
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setOngoing(status == "playing") .setOngoing(status == "playing" || status == "paused")
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setCategory(NotificationCompat.CATEGORY_TRANSPORT) .setCategory(NotificationCompat.CATEGORY_TRANSPORT)
.addAction( .addAction(
@@ -828,7 +946,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
object : MediaSessionCompat.Callback() { object : MediaSessionCompat.Callback() {
override fun onPlay() = handleResume() override fun onPlay() = handleResume()
override fun onPause() = handlePause() override fun onPause() = handlePause()
override fun onStop() = handleStop(clearContentKey = true) override fun onStop() = handleStop(clearContentKey = true, reason = STOP_REASON_USER)
override fun onSkipToNext() = handleSkip(1) override fun onSkipToNext() = handleSkip(1)
override fun onSkipToPrevious() = handleSkip(-1) override fun onSkipToPrevious() = handleSkip(-1)
}, },
@@ -873,11 +991,18 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
isForegroundActive = false isForegroundActive = false
} }
notificationManager.cancel(NOTIFICATION_ID) notificationManager.cancel(NOTIFICATION_ID)
// Even without background mode, keep foreground service alive while playing
// to prevent Android from killing us.
if (status == "playing" || status == "paused") {
val notification = buildNotification()
startForeground(NOTIFICATION_ID, notification)
isForegroundActive = true
}
return return
} }
when (status) { when (status) {
"playing" -> { "playing", "paused" -> {
val notification = buildNotification() val notification = buildNotification()
if (!isForegroundActive) { if (!isForegroundActive) {
startForeground(NOTIFICATION_ID, notification) startForeground(NOTIFICATION_ID, notification)
@@ -886,14 +1011,6 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
notificationManager.notify(NOTIFICATION_ID, notification) notificationManager.notify(NOTIFICATION_ID, notification)
} }
} }
"paused" -> {
val notification = buildNotification()
if (isForegroundActive) {
stopForeground(false)
isForegroundActive = false
}
notificationManager.notify(NOTIFICATION_ID, notification)
}
else -> { else -> {
if (isForegroundActive) { if (isForegroundActive) {
stopForeground(true) stopForeground(true)
@@ -964,6 +1081,9 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
override fun onDestroy() { override fun onDestroy() {
mainHandler.removeCallbacks(playbackHealthRunnable) mainHandler.removeCallbacks(playbackHealthRunnable)
isRebuildingEngine = false
clearScheduledRecoveries()
cancelIdleStop()
status = "idle" status = "idle"
currentIndex = 0 currentIndex = 0
segments = emptyList() segments = emptyList()
+45
View File
@@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/auth/session_expiry_notifier.dart'; import '../core/auth/session_expiry_notifier.dart';
import '../core/theme/app_theme.dart'; import '../core/theme/app_theme.dart';
import '../features/auth/providers/auth_provider.dart'; import '../features/auth/providers/auth_provider.dart';
import '../features/reader/tts/tts_service.dart';
import 'router/route_names.dart'; import 'router/route_names.dart';
import 'router/app_router.dart'; import 'router/app_router.dart';
@@ -21,6 +23,10 @@ class _ReaderAppState extends ConsumerState<ReaderApp> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_ensureMandatoryTtsRequirements();
});
_sessionExpirySub = ref.listenManual<int>( _sessionExpirySub = ref.listenManual<int>(
sessionExpiryProvider, sessionExpiryProvider,
(previous, next) async { (previous, next) async {
@@ -45,6 +51,45 @@ class _ReaderAppState extends ConsumerState<ReaderApp> {
); );
} }
Future<void> _ensureMandatoryTtsRequirements() async {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android || !mounted) {
return;
}
final notifier = ref.read(ttsProvider.notifier);
await notifier.setBackgroundModeEnabled(true);
await notifier.ensureBatteryOptimizationIgnored();
if (!mounted) return;
while (mounted && !ref.read(ttsProvider).batteryOptimizationIgnored) {
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) {
return AlertDialog(
title: const Text('Yeu cau bat buoc cho TTS'),
content: const Text(
'Can bat Chay nen va Loai tru toi uu pin de TTS khong bi ngat dot ngot.',
),
actions: [
FilledButton(
onPressed: () async {
await notifier.setBackgroundModeEnabled(true);
await notifier.ensureBatteryOptimizationIgnored();
if (!context.mounted) return;
if (ref.read(ttsProvider).batteryOptimizationIgnored) {
Navigator.of(context).pop();
}
},
child: const Text('Bat ngay'),
),
],
);
},
);
}
}
@override @override
void dispose() { void dispose() {
_sessionExpirySub?.close(); _sessionExpirySub?.close();
+15 -2
View File
@@ -5,9 +5,11 @@ class ReadingSettings {
this.letterSpacing = 0, this.letterSpacing = 0,
this.fontFamily = 'serif', this.fontFamily = 'serif',
this.themePreset = 'paper', this.themePreset = 'paper',
this.backgroundColorValue = 0xFFFFFEF8,
this.textColorValue = 0xFF111111,
this.horizontalPadding = 20, this.horizontalPadding = 20,
this.paragraphSpacing = 24, this.paragraphSpacing = 24,
this.textAlign = 'justify', this.textAlign = 'left',
}); });
final double fontSize; final double fontSize;
@@ -15,6 +17,8 @@ class ReadingSettings {
final double letterSpacing; final double letterSpacing;
final String fontFamily; final String fontFamily;
final String themePreset; final String themePreset;
final int backgroundColorValue;
final int textColorValue;
final double horizontalPadding; final double horizontalPadding;
final double paragraphSpacing; final double paragraphSpacing;
final String textAlign; final String textAlign;
@@ -25,6 +29,8 @@ class ReadingSettings {
double? letterSpacing, double? letterSpacing,
String? fontFamily, String? fontFamily,
String? themePreset, String? themePreset,
int? backgroundColorValue,
int? textColorValue,
double? horizontalPadding, double? horizontalPadding,
double? paragraphSpacing, double? paragraphSpacing,
String? textAlign, String? textAlign,
@@ -35,6 +41,8 @@ class ReadingSettings {
letterSpacing: letterSpacing ?? this.letterSpacing, letterSpacing: letterSpacing ?? this.letterSpacing,
fontFamily: fontFamily ?? this.fontFamily, fontFamily: fontFamily ?? this.fontFamily,
themePreset: themePreset ?? this.themePreset, themePreset: themePreset ?? this.themePreset,
backgroundColorValue: backgroundColorValue ?? this.backgroundColorValue,
textColorValue: textColorValue ?? this.textColorValue,
horizontalPadding: horizontalPadding ?? this.horizontalPadding, horizontalPadding: horizontalPadding ?? this.horizontalPadding,
paragraphSpacing: paragraphSpacing ?? this.paragraphSpacing, paragraphSpacing: paragraphSpacing ?? this.paragraphSpacing,
textAlign: textAlign ?? this.textAlign, textAlign: textAlign ?? this.textAlign,
@@ -46,9 +54,12 @@ class ReadingSettings {
letterSpacing: (json['letterSpacing'] as num?)?.toDouble() ?? 0, letterSpacing: (json['letterSpacing'] as num?)?.toDouble() ?? 0,
fontFamily: json['fontFamily'] as String? ?? 'serif', fontFamily: json['fontFamily'] as String? ?? 'serif',
themePreset: json['themePreset'] as String? ?? 'paper', themePreset: json['themePreset'] as String? ?? 'paper',
backgroundColorValue:
(json['backgroundColorValue'] as num?)?.toInt() ?? 0xFFFFFEF8,
textColorValue: (json['textColorValue'] as num?)?.toInt() ?? 0xFF111111,
horizontalPadding: (json['horizontalPadding'] as num?)?.toDouble() ?? 20, horizontalPadding: (json['horizontalPadding'] as num?)?.toDouble() ?? 20,
paragraphSpacing: (json['paragraphSpacing'] as num?)?.toDouble() ?? 24, paragraphSpacing: (json['paragraphSpacing'] as num?)?.toDouble() ?? 24,
textAlign: json['textAlign'] as String? ?? 'justify', textAlign: json['textAlign'] as String? ?? 'left',
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
@@ -57,6 +68,8 @@ class ReadingSettings {
'letterSpacing': letterSpacing, 'letterSpacing': letterSpacing,
'fontFamily': fontFamily, 'fontFamily': fontFamily,
'themePreset': themePreset, 'themePreset': themePreset,
'backgroundColorValue': backgroundColorValue,
'textColorValue': textColorValue,
'horizontalPadding': horizontalPadding, 'horizontalPadding': horizontalPadding,
'paragraphSpacing': paragraphSpacing, 'paragraphSpacing': paragraphSpacing,
'textAlign': textAlign, 'textAlign': textAlign,
+20 -2
View File
@@ -8,6 +8,8 @@ class LocalStore {
static const _kLetterSpacing = 'reader_letter_spacing'; static const _kLetterSpacing = 'reader_letter_spacing';
static const _kFontFamily = 'reader_font_family'; static const _kFontFamily = 'reader_font_family';
static const _kThemePreset = 'reader_theme_preset'; static const _kThemePreset = 'reader_theme_preset';
static const _kBackgroundColor = 'reader_background_color';
static const _kTextColor = 'reader_text_color';
static const _kHorizontalPadding = 'reader_horizontal_padding'; static const _kHorizontalPadding = 'reader_horizontal_padding';
static const _kParagraphSpacing = 'reader_paragraph_spacing'; static const _kParagraphSpacing = 'reader_paragraph_spacing';
static const _kTextAlign = 'reader_text_align'; static const _kTextAlign = 'reader_text_align';
@@ -24,6 +26,8 @@ class LocalStore {
await prefs.setDouble(_kLetterSpacing, settings.letterSpacing); await prefs.setDouble(_kLetterSpacing, settings.letterSpacing);
await prefs.setString(_kFontFamily, settings.fontFamily); await prefs.setString(_kFontFamily, settings.fontFamily);
await prefs.setString(_kThemePreset, settings.themePreset); await prefs.setString(_kThemePreset, settings.themePreset);
await prefs.setInt(_kBackgroundColor, settings.backgroundColorValue);
await prefs.setInt(_kTextColor, settings.textColorValue);
await prefs.setDouble(_kHorizontalPadding, settings.horizontalPadding); await prefs.setDouble(_kHorizontalPadding, settings.horizontalPadding);
await prefs.setDouble(_kParagraphSpacing, settings.paragraphSpacing); await prefs.setDouble(_kParagraphSpacing, settings.paragraphSpacing);
await prefs.setString(_kTextAlign, settings.textAlign); await prefs.setString(_kTextAlign, settings.textAlign);
@@ -32,15 +36,29 @@ class LocalStore {
Future<ReadingSettings?> loadReadingSettings() async { Future<ReadingSettings?> loadReadingSettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
if (!prefs.containsKey(_kFontSize)) return null; if (!prefs.containsKey(_kFontSize)) return null;
final themePreset = prefs.getString(_kThemePreset) ?? 'paper';
final fallbackBackground = switch (themePreset) {
'night' => 0xFF101418,
'sepia' => 0xFFF6EAD7,
_ => 0xFFFFFEF8,
};
final fallbackText = switch (themePreset) {
'night' => 0xFFE6EAF2,
'sepia' => 0xFF3B2F23,
_ => 0xFF111111,
};
return ReadingSettings( return ReadingSettings(
fontSize: prefs.getDouble(_kFontSize) ?? 18, fontSize: prefs.getDouble(_kFontSize) ?? 18,
lineHeight: prefs.getDouble(_kLineHeight) ?? 1.8, lineHeight: prefs.getDouble(_kLineHeight) ?? 1.8,
letterSpacing: prefs.getDouble(_kLetterSpacing) ?? 0, letterSpacing: prefs.getDouble(_kLetterSpacing) ?? 0,
fontFamily: prefs.getString(_kFontFamily) ?? 'serif', fontFamily: prefs.getString(_kFontFamily) ?? 'serif',
themePreset: prefs.getString(_kThemePreset) ?? 'paper', themePreset: themePreset,
backgroundColorValue: prefs.getInt(_kBackgroundColor) ?? fallbackBackground,
textColorValue: prefs.getInt(_kTextColor) ?? fallbackText,
horizontalPadding: prefs.getDouble(_kHorizontalPadding) ?? 20, horizontalPadding: prefs.getDouble(_kHorizontalPadding) ?? 20,
paragraphSpacing: prefs.getDouble(_kParagraphSpacing) ?? 24, paragraphSpacing: prefs.getDouble(_kParagraphSpacing) ?? 24,
textAlign: prefs.getString(_kTextAlign) ?? 'justify', textAlign: prefs.getString(_kTextAlign) ?? 'left',
); );
} }
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart'; import '../../../app/router/route_names.dart';
import '../../../core/models/bookmark_model.dart'; import '../../../core/models/bookmark_model.dart';
import '../../../shared/widgets/main_app_header.dart';
import '../providers/bookshelf_provider.dart'; import '../providers/bookshelf_provider.dart';
import '../../auth/providers/auth_provider.dart'; import '../../auth/providers/auth_provider.dart';
@@ -17,12 +18,17 @@ class BookshelfScreen extends ConsumerWidget {
if (!isAuth) { if (!isAuth) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Tủ sách')), body: Column(
body: Center( children: [
const MainAppHeader(title: 'Đăng truyện'),
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.lock_outline, size: 48), const Icon(Icons.lock_outline_rounded, size: 54),
const SizedBox(height: 12), const SizedBox(height: 12),
const Text('Vui lòng đăng nhập để xem tủ sách'), const Text('Vui lòng đăng nhập để xem tủ sách'),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -33,28 +39,50 @@ class BookshelfScreen extends ConsumerWidget {
], ],
), ),
), ),
),
),
],
),
); );
} }
final bookshelfAsync = ref.watch(bookshelfProvider); final bookshelfAsync = ref.watch(bookshelfProvider);
return Scaffold( return Scaffold(
appBar: AppBar( body: DefaultTabController(
title: const Text('Tủ sách'), length: 3,
actions: [ child: Column(
IconButton( children: [
icon: const Icon(Icons.refresh), MainAppHeader(
onPressed: () => ref.read(bookshelfProvider.notifier).fetch(), title: 'Đăng truyện',
bottom: Container(
height: 42,
decoration: BoxDecoration(
color: const Color(0xFF14B8A6),
borderRadius: BorderRadius.circular(0),
), ),
child: TabBar(
indicatorColor: const Color(0xFFF7B500),
indicatorWeight: 3,
labelColor: Colors.white,
unselectedLabelColor: Colors.white70,
dividerColor: Colors.transparent,
tabs: const [
Tab(text: 'Đã đọc'),
Tab(text: 'Đã lưu'),
Tab(text: 'Đang mở'),
], ],
), ),
body: bookshelfAsync.when( ),
),
Expanded(
child: bookshelfAsync.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center( error: (e, _) => Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.error_outline, size: 48), const Icon(Icons.error_outline_rounded, size: 48),
const SizedBox(height: 8), const SizedBox(height: 8),
Text('Lỗi: $e'), Text('Lỗi: $e'),
TextButton( TextButton(
@@ -65,27 +93,64 @@ class BookshelfScreen extends ConsumerWidget {
), ),
), ),
data: (bookmarks) { data: (bookmarks) {
final readItems = bookmarks.where((e) => e.readChapters.isNotEmpty).toList();
final savedItems = bookmarks;
final openingItems = bookmarks.where((e) => e.lastChapterId != null).toList();
return TabBarView(
children: [
_BookshelfList(
bookmarks: readItems,
emptyLabel: 'Chưa có truyện đã đọc.',
),
_BookshelfList(
bookmarks: savedItems,
emptyLabel: 'Chưa có truyện nào trong tủ sách.',
),
_BookshelfList(
bookmarks: openingItems,
emptyLabel: 'Chưa có truyện đang mở.',
),
],
);
},
),
),
],
),
),
);
}
}
class _BookshelfList extends ConsumerWidget {
const _BookshelfList({required this.bookmarks, required this.emptyLabel});
final List<BookmarkModel> bookmarks;
final String emptyLabel;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (bookmarks.isEmpty) { if (bookmarks.isEmpty) {
return const Center( return Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.menu_book_outlined, size: 56), const Icon(Icons.menu_book_outlined, size: 56),
SizedBox(height: 12), const SizedBox(height: 12),
Text('Chưa có truyện nào trong tủ sách'), Text(emptyLabel),
], ],
), ),
); );
} }
return RefreshIndicator( return RefreshIndicator(
onRefresh: () => ref.read(bookshelfProvider.notifier).fetch(), onRefresh: () => ref.read(bookshelfProvider.notifier).fetch(),
child: ListView.builder( child: ListView.separated(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 24),
itemCount: bookmarks.length, itemCount: bookmarks.length,
itemBuilder: (context, index) => separatorBuilder: (context, index) => const SizedBox(height: 12),
_BookmarkTile(bookmark: bookmarks[index]), itemBuilder: (context, index) => _BookmarkTile(bookmark: bookmarks[index]),
),
);
},
), ),
); );
} }
@@ -98,32 +163,117 @@ class _BookmarkTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final novel = bookmark.novel; final novel = bookmark.novel;
return ListTile( return Container(
leading: ClipRRect( padding: const EdgeInsets.all(12),
borderRadius: BorderRadius.circular(6), decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: novel?.coverUrl != null child: novel?.coverUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: novel!.coverUrl!, imageUrl: novel!.coverUrl!,
width: 44, width: 92,
height: 60, height: 126,
fit: BoxFit.cover, fit: BoxFit.cover,
) )
: Container( : Container(
width: 44, width: 92,
height: 60, height: 126,
color: Theme.of(context).colorScheme.primaryContainer, color: Theme.of(context).colorScheme.primaryContainer,
child: const Icon(Icons.menu_book, size: 20), child: const Icon(Icons.menu_book, size: 28),
), ),
), ),
title: Text( const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
novel?.title ?? bookmark.novelId, novel?.title ?? bookmark.novelId,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 8),
const Icon(Icons.close_rounded, size: 20),
],
),
const SizedBox(height: 8),
Text(
'Số chương: ${novel?.totalChapters ?? '--'}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
if (bookmark.lastChapterNumber != null) ...[
const SizedBox(height: 6),
Text(
'Đang đọc đến: ${bookmark.lastChapterNumber} / ${novel?.totalChapters ?? '--'}',
style: Theme.of(context).textTheme.bodyMedium,
),
],
if (novel?.authorName != null) ...[
const SizedBox(height: 10),
Text(
novel!.authorName,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
),
),
],
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
icon: const Icon(Icons.menu_book_rounded),
label: const Text('Đọc tiếp'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF14B8A6),
foregroundColor: Colors.white,
),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton.icon(
onPressed: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
icon: const Icon(Icons.headphones_rounded),
label: const Text('Nghe tiếp'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF14B8A6),
foregroundColor: Colors.white,
),
),
),
],
),
],
), ),
subtitle: novel?.authorName != null
? Text(novel!.authorName, maxLines: 1, overflow: TextOverflow.ellipsis)
: null,
onTap: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
); );
} }
} }
+312 -49
View File
@@ -1,10 +1,13 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart'; import '../../../app/router/route_names.dart';
import '../../../core/models/novel_model.dart'; import '../../../core/models/novel_model.dart';
import '../../../shared/widgets/main_app_header.dart';
import '../providers/home_provider.dart'; import '../providers/home_provider.dart';
class HomeScreen extends ConsumerWidget { class HomeScreen extends ConsumerWidget {
@@ -13,63 +16,68 @@ class HomeScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final homeAsync = ref.watch(homeProvider); final homeAsync = ref.watch(homeProvider);
final colorScheme = Theme.of(context).colorScheme;
return Scaffold( return Scaffold(
appBar: AppBar( backgroundColor: colorScheme.surface,
title: const Text('Reader'), body: Column(
actions: [ children: [
IconButton( const MainAppHeader(),
icon: const Icon(Icons.search), Expanded(
onPressed: () => context.go(RouteNames.search), child: homeAsync.when(
),
],
),
body: homeAsync.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center( error: (e, _) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.error_outline, size: 48), const Icon(Icons.cloud_off_rounded, size: 52),
const SizedBox(height: 12), const SizedBox(height: 12),
Text('Lỗi tải dữ liệu', style: Theme.of(context).textTheme.bodyLarge), Text('Không thể tải dữ liệu trang chủ'),
Padding( const SizedBox(height: 8),
padding: const EdgeInsets.fromLTRB(24, 8, 24, 0), Text(
child: Text(
e.toString(), e.toString(),
textAlign: TextAlign.center,
maxLines: 3, maxLines: 3,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
), const SizedBox(height: 12),
TextButton( FilledButton(
onPressed: () => ref.invalidate(homeProvider), onPressed: () => ref.invalidate(homeProvider),
child: const Text('Thử lại'), child: const Text('Tải lại'),
), ),
], ],
), ),
), ),
),
data: (data) => RefreshIndicator( data: (data) => RefreshIndicator(
onRefresh: () async => ref.invalidate(homeProvider), onRefresh: () async => ref.invalidate(homeProvider),
child: ListView( child: ListView(
padding: const EdgeInsets.fromLTRB(0, 12, 0, 24),
children: [ children: [
_HotCarousel(novels: data.hot), _HotCarousel(novels: data.hot),
const SizedBox(height: 12),
const _HomeQuickFilters(),
_SectionHeader( _SectionHeader(
title: 'Mới cập nht', title: 'Truyện mới nht',
onMore: () => context.go(RouteNames.search), onMore: () => context.go(RouteNames.search),
), ),
_NovelHorizontalList(novels: data.latest), _NovelHorizontalList(novels: data.latest),
_SectionHeader( _SectionHeader(
title: 'Đánh giá cao', title: 'Đề cử nổi bật',
onMore: () => context.go(RouteNames.search), onMore: () => context.go('${RouteNames.search}?sort=rating'),
), ),
_NovelHorizontalList(novels: data.topRated), _FeatureGrid(novels: data.topRated.take(6).toList()),
const SizedBox(height: 16), const SizedBox(height: 12),
], ],
), ),
), ),
), ),
),
],
),
); );
} }
} }
@@ -82,18 +90,82 @@ class _SectionHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) => Padding( Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 8, 8), padding: const EdgeInsets.fromLTRB(18, 18, 12, 6),
child: Row( child: Row(
children: [ children: [
Text(title, style: Theme.of(context).textTheme.titleMedium), Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
const Spacer(), const Spacer(),
if (onMore != null) if (onMore != null)
TextButton(onPressed: onMore, child: const Text('Xem thêm')), InkWell(
onTap: onMore,
borderRadius: BorderRadius.circular(999),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Row(
children: [
Text(
'Xem thêm',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: const Color(0xFF14B8A6),
),
),
const SizedBox(width: 4),
const Icon(Icons.chevron_right_rounded, color: Color(0xFF14B8A6)),
],
),
),
),
], ],
), ),
); );
} }
class _HomeQuickFilters extends StatelessWidget {
const _HomeQuickFilters();
@override
Widget build(BuildContext context) {
const items = [
(Icons.dashboard_customize_rounded, 'Thể loại'),
(Icons.verified_rounded, 'Hoàn thành'),
(Icons.sell_rounded, 'Miễn phí'),
(Icons.local_fire_department_rounded, 'Truyện hot'),
];
return Padding(
padding: const EdgeInsets.fromLTRB(10, 0, 10, 8),
child: Row(
children: items
.map(
(item) => Expanded(
child: Column(
children: [
Icon(item.$1, color: const Color(0xFF14B8A6), size: 26),
const SizedBox(height: 6),
Text(
item.$2,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: const Color(0xFF14B8A6),
),
),
],
),
),
)
.toList(),
),
);
}
}
class _HotCarousel extends StatefulWidget { class _HotCarousel extends StatefulWidget {
final List<NovelModel> novels; final List<NovelModel> novels;
const _HotCarousel({required this.novels}); const _HotCarousel({required this.novels});
@@ -103,10 +175,58 @@ class _HotCarousel extends StatefulWidget {
} }
class _HotCarouselState extends State<_HotCarousel> { class _HotCarouselState extends State<_HotCarousel> {
final PageController _controller = PageController(viewportFraction: 0.85); late PageController _controller;
Timer? _autoSlideTimer;
int _currentPage = 0;
@override
void initState() {
super.initState();
_controller = PageController(viewportFraction: 1);
_startAutoSlide();
}
@override
void reassemble() {
super.reassemble();
_recreateController();
}
void _recreateController() {
final oldController = _controller;
final page = oldController.hasClients
? (oldController.page?.round() ?? _currentPage)
: _currentPage;
_controller = PageController(initialPage: page, viewportFraction: 1);
oldController.dispose();
}
@override
void didUpdateWidget(covariant _HotCarousel oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.novels.length != widget.novels.length) {
_startAutoSlide();
}
}
void _startAutoSlide() {
_autoSlideTimer?.cancel();
if (widget.novels.length <= 1) return;
_autoSlideTimer = Timer.periodic(const Duration(seconds: 4), (_) {
if (!mounted || !_controller.hasClients) return;
final nextPage = (_currentPage + 1) % widget.novels.length;
_controller.animateToPage(
nextPage,
duration: const Duration(milliseconds: 360),
curve: Curves.easeInOut,
);
});
}
@override @override
void dispose() { void dispose() {
_autoSlideTimer?.cancel();
_controller.dispose(); _controller.dispose();
super.dispose(); super.dispose();
} }
@@ -115,10 +235,19 @@ class _HotCarouselState extends State<_HotCarousel> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (widget.novels.isEmpty) return const SizedBox.shrink(); if (widget.novels.isEmpty) return const SizedBox.shrink();
return SizedBox( return SizedBox(
height: 220, height: 260,
child: Column(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: ClipRect(
child: PageView.builder( child: PageView.builder(
controller: _controller, controller: _controller,
itemCount: widget.novels.length, itemCount: widget.novels.length,
onPageChanged: (value) => setState(() => _currentPage = value),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final novel = widget.novels[index]; final novel = widget.novels[index];
return GestureDetector( return GestureDetector(
@@ -127,6 +256,29 @@ class _HotCarouselState extends State<_HotCarousel> {
); );
}, },
), ),
),
),
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(widget.novels.length.clamp(0, 5), (index) {
final active = index == _currentPage.clamp(0, 4);
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: active ? 16 : 7,
height: 7,
decoration: BoxDecoration(
color: active ? const Color(0xFF14B8A6) : Colors.white54,
borderRadius: BorderRadius.circular(99),
),
);
}),
),
],
),
); );
} }
} }
@@ -137,11 +289,7 @@ class _CarouselCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Stack(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 12),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
if (novel.coverUrl != null) if (novel.coverUrl != null)
@@ -149,8 +297,7 @@ class _CarouselCard extends StatelessWidget {
imageUrl: novel.coverUrl!, imageUrl: novel.coverUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (_, imageUrl) => Container(color: Colors.grey[200]), placeholder: (_, imageUrl) => Container(color: Colors.grey[200]),
errorWidget: (_, imageUrl, error) => errorWidget: (_, imageUrl, error) => Container(color: Colors.grey[300]),
Container(color: Colors.grey[300]),
) )
else else
Container(color: Theme.of(context).colorScheme.primaryContainer), Container(color: Theme.of(context).colorScheme.primaryContainer),
@@ -169,20 +316,49 @@ class _CarouselCard extends StatelessWidget {
bottom: 12, bottom: 12,
left: 12, left: 12,
right: 12, right: 12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (novel.status.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF22C55E),
borderRadius: BorderRadius.circular(999),
),
child: Text( child: Text(
novel.status,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(height: 10),
Text(
novel.title, novel.title,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16, fontSize: 20,
), ),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 4),
Text(
novel.description?.trim().isNotEmpty == true
? novel.description!.trim()
: novel.authorName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white70, fontStyle: FontStyle.italic),
), ),
], ],
), ),
), ),
],
); );
} }
} }
@@ -194,9 +370,9 @@ class _NovelHorizontalList extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( return SizedBox(
height: 200, height: 226,
child: ListView.separated( child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 18),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: novels.length, itemCount: novels.length,
separatorBuilder: (_, separatorIndex) => const SizedBox(width: 12), separatorBuilder: (_, separatorIndex) => const SizedBox(width: 12),
@@ -205,32 +381,45 @@ class _NovelHorizontalList extends StatelessWidget {
return GestureDetector( return GestureDetector(
onTap: () => context.push(RouteNames.novelDetail(novel.id)), onTap: () => context.push(RouteNames.novelDetail(novel.id)),
child: SizedBox( child: SizedBox(
width: 110, width: 122,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(10),
child: novel.coverUrl != null child: novel.coverUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: novel.coverUrl!, imageUrl: novel.coverUrl!,
width: 110, width: 122,
height: 150, height: 155,
fit: BoxFit.cover, fit: BoxFit.cover,
) )
: Container( : Container(
width: 110, width: 122,
height: 150, height: 155,
color: Theme.of(context).colorScheme.primaryContainer, color: Theme.of(context).colorScheme.primaryContainer,
child: const Icon(Icons.menu_book), child: const Icon(Icons.menu_book),
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 6),
Text( Flexible(
child: Text(
novel.title, novel.title,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 3),
Text(
'${novel.totalChapters} chương',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: const Color(0xFF58D68D),
),
), ),
], ],
), ),
@@ -241,3 +430,77 @@ class _NovelHorizontalList extends StatelessWidget {
); );
} }
} }
class _FeatureGrid extends StatelessWidget {
const _FeatureGrid({required this.novels});
final List<NovelModel> novels;
@override
Widget build(BuildContext context) {
if (novels.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.fromLTRB(18, 4, 18, 0),
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero,
itemCount: novels.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 18,
crossAxisSpacing: 14,
childAspectRatio: 0.74,
),
itemBuilder: (context, index) {
final novel = novels[index];
return GestureDetector(
onTap: () => context.push(RouteNames.novelDetail(novel.id)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: novel.coverUrl != null
? CachedNetworkImage(
imageUrl: novel.coverUrl!,
width: double.infinity,
fit: BoxFit.cover,
)
: Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: const Center(child: Icon(Icons.menu_book_rounded)),
),
),
),
const SizedBox(height: 8),
Text(
novel.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 2),
Text(
'${novel.totalChapters} Chương',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: const Color(0xFF58D68D),
),
),
Text(
'${novel.bookmarkCount > 0 ? novel.bookmarkCount : novel.views} ${novel.bookmarkCount > 0 ? 'Đề cử/tuần' : 'Lượt xem'}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: const Color(0xFF1677FF),
),
),
],
),
);
},
),
);
}
}
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart'; import '../../../app/router/route_names.dart';
import '../../../shared/widgets/main_app_header.dart';
import '../../auth/providers/auth_provider.dart'; import '../../auth/providers/auth_provider.dart';
import '../../bookshelf/providers/bookshelf_provider.dart'; import '../../bookshelf/providers/bookshelf_provider.dart';
@@ -22,94 +23,109 @@ class ProfileScreen extends ConsumerWidget {
: ''; : '';
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Tài khoản')), body: Column(
body: switch (authState) {
AuthAuthenticated(:final user) => SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [ children: [
// User Avatar & Basic Info const MainAppHeader(title: 'Trang cá nhân', showGenresShortcut: false),
Container( Expanded(
padding: const EdgeInsets.all(20), child: switch (authState) {
decoration: BoxDecoration( AuthAuthenticated(:final user) => SingleChildScrollView(
color: Theme.of(context).colorScheme.primaryContainer, padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
borderRadius: BorderRadius.circular(12),
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(22),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
CircleAvatar( CircleAvatar(
radius: 40, radius: 34,
backgroundImage: backgroundImage:
user.image != null ? NetworkImage(user.image!) : null, user.image != null ? NetworkImage(user.image!) : null,
child: user.image == null child: user.image == null
? Text( ? Text(
displayName[0].toUpperCase(), displayName.isNotEmpty ? displayName[0].toUpperCase() : 'U',
style: style: Theme.of(context).textTheme.headlineMedium,
Theme.of(context).textTheme.headlineMedium,
) )
: null, : null,
), ),
const SizedBox(height: 12), const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text( Text(
displayName, displayName,
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center, ),
Text(
user.role.toLowerCase(),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 10),
_AccountStatRow(
icon: Icons.auto_awesome,
label: 'Tiên Thạch: 0.00 TT',
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( _AccountStatRow(
user.email, icon: Icons.diamond,
style: Theme.of(context).textTheme.bodyMedium, label: 'Linh Phiếu: 0 LP',
textAlign: TextAlign.center,
),
],
),
),
const SizedBox(height: 24),
// Stats Cards
Row(
children: [
Expanded(
child: _buildStatCard(
context: context,
label: 'Sách Đánh Dấu',
count: bookmarkedCount,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatCard(
context: context,
label: 'Đang Đọc',
count: bookmarkedCount,
),
),
],
),
const SizedBox(height: 24),
// Settings Button
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => context.push(RouteNames.settings),
icon: const Icon(Icons.tune),
label: const Text('Cài Đặt Đọc'),
), ),
const SizedBox(height: 4),
_AccountStatRow(
icon: Icons.local_activity,
label: 'Ngọc Phiếu: $bookmarkedCount',
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
FilledButton.icon(
// Logout Button onPressed: () {},
SizedBox( icon: const Icon(Icons.workspace_premium_rounded),
width: double.infinity, label: const Text('Thêm Tiên Thạch'),
child: OutlinedButton.icon( style: FilledButton.styleFrom(
onPressed: () async { backgroundColor: const Color(0xFF14B8A6),
foregroundColor: Colors.white,
),
),
],
),
),
],
),
),
const SizedBox(height: 18),
_ProfileMenuTile(
title: 'Chỉnh sửa thông tin',
onTap: () => context.push(RouteNames.settings),
),
_ProfileMenuTile(
title: 'Lịch sử giao dịch',
onTap: () {},
),
_ProfileMenuTile(
title: 'Liên hệ, báo lỗi',
onTap: () {},
),
_ProfileMenuTile(
title: 'Điều khoản dịch vụ',
onTap: () {},
),
_ProfileMenuTile(
title: 'Xóa tài khoản',
onTap: () {},
),
_ProfileMenuTile(
title: 'Đăng xuất',
onTap: () async {
await ref.read(authProvider.notifier).signOut(); await ref.read(authProvider.notifier).signOut();
if (context.mounted) context.go(RouteNames.home); if (context.mounted) context.go(RouteNames.home);
}, },
icon: const Icon(Icons.logout),
label: const Text('Đăng Xuất'),
),
), ),
], ],
), ),
@@ -137,36 +153,50 @@ class ProfileScreen extends ConsumerWidget {
), ),
_ => const Center(child: CircularProgressIndicator()), _ => const Center(child: CircularProgressIndicator()),
}, },
);
}
Widget _buildStatCard({
required BuildContext context,
required String label,
required int count,
}) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Text(
count.toString(),
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 4),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
), ),
], ],
), ),
); );
} }
} }
class _AccountStatRow extends StatelessWidget {
const _AccountStatRow({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, size: 18, color: const Color(0xFF58D68D)),
const SizedBox(width: 8),
Expanded(child: Text(label, style: Theme.of(context).textTheme.titleMedium)),
],
);
}
}
class _ProfileMenuTile extends StatelessWidget {
const _ProfileMenuTile({required this.title, required this.onTap});
final String title;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(14),
),
child: ListTile(
title: Text(title),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: onTap,
),
);
}
}
@@ -26,6 +26,22 @@ class ReaderScreen extends ConsumerStatefulWidget {
} }
class _ReaderScreenState extends ConsumerState<ReaderScreen> { 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(); final ScrollController _scrollCtrl = ScrollController();
Timer? _uiAutoHideTimer; Timer? _uiAutoHideTimer;
final ValueNotifier<double> _readingProgress = ValueNotifier(0); final ValueNotifier<double> _readingProgress = ValueNotifier(0);
@@ -67,7 +83,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
case 'center': case 'center':
return TextAlign.center; return TextAlign.center;
default: 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) { void _goToPreviousChapter(ChapterModel chapter) {
final prevId = chapter.prevChapterId; final prevId = chapter.prevChapterId;
if (prevId == null) return; if (prevId == null) return;
@@ -555,6 +554,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
final settings = ref.watch(readingSettingsProvider); final settings = ref.watch(readingSettingsProvider);
final tts = ref.watch(ttsProvider); final tts = ref.watch(ttsProvider);
final ttsNotifier = ref.read(ttsProvider.notifier); final ttsNotifier = ref.read(ttsProvider.notifier);
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isCompactTabs = MediaQuery.sizeOf(context).width < 380;
void closeSettingsSheet() { void closeSettingsSheet() {
if (!sheetContext.mounted) return; if (!sheetContext.mounted) return;
@@ -590,11 +592,6 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Tùy chỉnh đọc', style: Theme.of(context).textTheme.headlineSmall), 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,24 +614,66 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: TabBar( child: Container(
isScrollable: true, height: 52,
tabAlignment: TabAlignment.start, padding: const EdgeInsets.all(4),
dividerColor: Colors.transparent, decoration: BoxDecoration(
labelPadding: const EdgeInsets.only(right: 8), color: colorScheme.surfaceContainerHighest.withAlpha(180),
indicator: BoxDecoration( borderRadius: BorderRadius.circular(24),
color: Theme.of(context).colorScheme.secondaryContainer, border: Border.all(
borderRadius: BorderRadius.circular(999), 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: 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')),
], ],
), ),
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( Expanded(
child: TabBarView( child: TabBarView(
@@ -656,8 +695,14 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
ButtonSegment(value: 'sans', label: Text('Không chân')), ButtonSegment(value: 'sans', label: Text('Không chân')),
ButtonSegment(value: 'mono', label: Text('Đơn cách')), ButtonSegment(value: 'mono', label: Text('Đơn cách')),
], ],
selected: {settings.fontFamily}, selected: {
onSelectionChanged: (s) => update(settings.copyWith(fontFamily: s.first)), {'serif', 'sans', 'mono'}.contains(settings.fontFamily)
? settings.fontFamily
: 'serif',
},
onSelectionChanged: (s) => update(
settings.copyWith(fontFamily: s.first),
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_LabeledSlider( _LabeledSlider(
@@ -700,62 +745,45 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
children: [ children: [
_SettingsSection( _SettingsSection(
title: 'Giao diện đọc', title: 'Giao diện đọc',
child: Wrap( child: Column(
spacing: 12, crossAxisAlignment: CrossAxisAlignment.start,
runSpacing: 12,
children: [ children: [
_PresetChip( Text(
label: 'Sáng', 'Màu nền',
value: 'paper', style: Theme.of(context).textTheme.labelLarge,
selected: settings.themePreset == 'paper',
onTap: () => update(settings.copyWith(themePreset: 'paper')),
), ),
_PresetChip( const SizedBox(height: 8),
label: 'Sepia', Wrap(
value: 'sepia', spacing: 10,
selected: settings.themePreset == 'sepia', runSpacing: 10,
onTap: () => update(settings.copyWith(themePreset: 'sepia')), children: _backgroundColorChoices.map((color) {
return _ColorOptionChip(
color: color,
selected: settings.backgroundColorValue == color.value,
onTap: () => update(
settings.copyWith(backgroundColorValue: color.value),
), ),
_PresetChip( );
label: 'Ban đêm', }).toList(),
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,
), ),
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),
), ),
_SettingsSection( );
title: 'Mẫu nhanh', }).toList(),
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'),
), ),
], ],
), ),
@@ -854,32 +882,84 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
}).toList(), }).toList(),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
SwitchListTile.adaptive( Container(
contentPadding: EdgeInsets.zero, width: double.infinity,
title: const Text('Chạy nền cho TTS'), padding: const EdgeInsets.all(12),
subtitle: const Text( decoration: BoxDecoration(
'Tiếp tục đọc khi chuyển app hoặc tắt màn hình (Android)', color: Theme.of(context)
.colorScheme
.secondaryContainer
.withAlpha(90),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
), ),
value: tts.backgroundModeEnabled,
onChanged: ttsNotifier.setBackgroundModeEnabled,
), ),
if (tts.backgroundModeEnabled) child: Column(
ListTile( crossAxisAlignment: CrossAxisAlignment.start,
contentPadding: EdgeInsets.zero, children: [
title: const Text('Loại trừ tối ưu pin'), Text(
subtitle: 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 tts.batteryOptimizationIgnored
? 'Đã bật: Android sẽ ít khả năng dừng TTS khi chạy nền.' ? Icons.check_circle
: 'Nên bật để Android không chặn TTS khi tắt màn hình.', : Icons.radio_button_unchecked,
size: 18,
color: tts.batteryOptimizationIgnored
? Colors.green
: Theme.of(context)
.colorScheme
.onSurfaceVariant,
), ),
trailing: tts.batteryOptimizationIgnored const SizedBox(width: 8),
? const Icon(Icons.verified, color: Colors.green) const Expanded(
: OutlinedButton( child: Text(
onPressed: ttsNotifier 'Loại trừ tối ưu pin',
.ensureBatteryOptimizationIgnored, ),
),
],
),
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'), child: const Text('Bật ngay'),
), ),
), ),
],
),
),
const SizedBox(height: 12), const SizedBox(height: 12),
if (tts.availableVietnameseVoices.isNotEmpty) if (tts.availableVietnameseVoices.isNotEmpty)
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
@@ -951,24 +1031,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
// Side-effects for TTS state changes (navigation, auto-start). // Side-effects for TTS state changes (navigation, auto-start).
ref.listen<TtsState>(ttsProvider, _onTtsStateChanged); ref.listen<TtsState>(ttsProvider, _onTtsStateChanged);
Color readerBackground; final readerBackground = Color(settings.backgroundColorValue);
Color readerTextColor; final readerTextColor = Color(settings.textColorValue);
Color readerMutedColor; final readerMutedColor = readerTextColor.withAlpha(170);
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( return Scaffold(
body: chapterAsync.when( body: chapterAsync.when(
@@ -1002,11 +1067,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
fontSize: settings.fontSize, fontSize: settings.fontSize,
height: settings.lineHeight, height: settings.lineHeight,
letterSpacing: settings.letterSpacing, letterSpacing: settings.letterSpacing,
fontFamily: settings.fontFamily == 'serif' fontFamily: _resolveReaderFontFamily(settings.fontFamily),
? 'Georgia'
: settings.fontFamily == 'mono'
? 'Courier'
: null,
); );
final paragraphHighlightStyle = paragraphStyle.copyWith( final paragraphHighlightStyle = paragraphStyle.copyWith(
backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(80), backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(80),
@@ -1019,11 +1080,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
_initializeChapterSession(chapter); _initializeChapterSession(chapter);
}); });
return GestureDetector( return ColoredBox(
behavior: HitTestBehavior.opaque,
onHorizontalDragEnd: (details) =>
_handleHorizontalSwipeEnd(details, chapter),
child: ColoredBox(
color: readerBackground, color: readerBackground,
child: Column( child: Column(
children: [ children: [
@@ -1202,7 +1259,6 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
), ),
], ],
), ),
),
); );
}, },
), ),
@@ -1360,25 +1416,18 @@ class _TopBar extends StatelessWidget {
} }
} }
Color _surfaceForPreset(String preset) { String? _resolveReaderFontFamily(String fontFamily) {
switch (preset) { switch (fontFamily) {
case 'night': case 'serif':
return const Color(0xFF101418); case 'georgia':
case 'sepia': return 'Georgia';
return const Color(0xFFF6EAD7); case 'mono':
return 'Courier';
case 'roboto':
return 'Roboto';
case 'sans':
default: default:
return const Color(0xFFFFFEF8); return null;
}
}
Color _textForPreset(String preset) {
switch (preset) {
case 'night':
return const Color(0xFFE6EAF2);
case 'sepia':
return const Color(0xFF3B2F23);
default:
return const Color(0xFF111111);
} }
} }
@@ -1455,16 +1504,14 @@ class _LabeledSlider extends StatelessWidget {
} }
} }
class _PresetChip extends StatelessWidget { class _ColorOptionChip extends StatelessWidget {
const _PresetChip({ const _ColorOptionChip({
required this.label, required this.color,
required this.value,
required this.selected, required this.selected,
required this.onTap, required this.onTap,
}); });
final String label; final Color color;
final String value;
final bool selected; final bool selected;
final VoidCallback onTap; final VoidCallback onTap;
@@ -1472,59 +1519,29 @@ class _PresetChip extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return InkWell( return InkWell(
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(999),
child: Container( child: AnimatedContainer(
width: 132, duration: const Duration(milliseconds: 160),
padding: const EdgeInsets.all(12), width: 34,
height: 34,
decoration: BoxDecoration( decoration: BoxDecoration(
color: _surfaceForPreset(value), shape: BoxShape.circle,
borderRadius: BorderRadius.circular(18), color: color,
border: Border.all( border: Border.all(
color: selected color: selected
? Theme.of(context).colorScheme.primary ? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outlineVariant, : Theme.of(context).colorScheme.outlineVariant,
width: selected ? 2 : 1, width: selected ? 3 : 1,
), ),
boxShadow: selected
? [
BoxShadow(
color: Theme.of(context).colorScheme.primary.withAlpha(60),
blurRadius: 8,
spreadRadius: 1,
), ),
child: Column( ]
crossAxisAlignment: CrossAxisAlignment.start, : null,
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),
],
), ),
), ),
); );
@@ -1532,20 +1549,50 @@ class _PresetChip extends StatelessWidget {
} }
class _TabLabel 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 IconData icon;
final String label; final String label;
final bool compact;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( 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, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (!compact) ...[
Icon(icon, size: 16), Icon(icon, size: 16),
const SizedBox(width: 6), const SizedBox(width: 6),
Text(label),
], ],
Text(
label,
maxLines: 1,
overflow: TextOverflow.fade,
softWrap: false,
style: textStyle,
),
],
),
),
),
),
); );
} }
} }
@@ -103,14 +103,20 @@ class ReaderNotifier extends StateNotifier<ReadingProgress?> {
} catch (_) {} } catch (_) {}
} }
DateTime? _lastUpdate; Timer? _debounceTimer;
Future<void> _debounceUpdate(double offset) async { void _debounceUpdate(double offset) {
final now = DateTime.now(); _debounceTimer?.cancel();
if (_lastUpdate != null && now.difference(_lastUpdate!).inSeconds < 3) return; _debounceTimer = Timer(const Duration(seconds: 3), () {
_lastUpdate = now;
if (state != null) { if (state != null) {
await _persistProgress(state!.chapterId, state!.chapterNumber, offset); 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);
+107 -29
View File
@@ -8,45 +8,123 @@ class AppShell extends StatelessWidget {
final Widget child; final Widget child;
int _indexForLocation(String location) { String _tabForLocation(String location) {
if (location.startsWith(RouteNames.search)) return 1; if (location.startsWith(RouteNames.bookshelf)) return RouteNames.bookshelf;
if (location.startsWith(RouteNames.bookshelf)) return 2; if (location.startsWith(RouteNames.genres)) return RouteNames.genres;
if (location.startsWith(RouteNames.genres)) return 3; if (location.startsWith(RouteNames.profile)) return RouteNames.profile;
if (location.startsWith(RouteNames.profile)) return 4; return RouteNames.home;
return 0;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final location = GoRouterState.of(context).uri.path; final location = GoRouterState.of(context).uri.path;
final selectedIndex = _indexForLocation(location); final selectedTab = _tabForLocation(location);
return Scaffold( return Scaffold(
body: child, body: child,
bottomNavigationBar: NavigationBar( bottomNavigationBar: Container(
selectedIndex: selectedIndex, decoration: BoxDecoration(
onDestinationSelected: (index) { color: colorScheme.surface,
switch (index) { border: Border(
case 0: top: BorderSide(color: colorScheme.outlineVariant.withAlpha(80)),
context.go(RouteNames.home); ),
case 1: ),
context.go(RouteNames.search); child: SafeArea(
case 2: top: false,
context.go(RouteNames.bookshelf); child: Padding(
case 3: padding: const EdgeInsets.fromLTRB(10, 8, 10, 6),
context.go(RouteNames.genres); child: Row(
case 4: children: [
context.go(RouteNames.profile); _ShellNavItem(
} icon: Icons.home_rounded,
}, label: 'Trang chủ',
destinations: const [ selected: selectedTab == RouteNames.home,
NavigationDestination(icon: Icon(Icons.home_outlined), label: 'Home'), onTap: () => context.go(RouteNames.home),
NavigationDestination(icon: Icon(Icons.search), label: 'Tim kiem'), ),
NavigationDestination(icon: Icon(Icons.bookmark_border), label: 'Tu sach'), _ShellNavItem(
NavigationDestination(icon: Icon(Icons.category_outlined), label: 'The loai'), icon: Icons.layers_rounded,
NavigationDestination(icon: Icon(Icons.person_outline), label: 'Tai khoan'), label: 'Tủ sách',
selected: selectedTab == RouteNames.bookshelf,
onTap: () => context.go(RouteNames.bookshelf),
),
_ShellNavItem(
icon: Icons.category_rounded,
label: 'Thể loại',
selected: selectedTab == RouteNames.genres,
onTap: () => context.go(RouteNames.genres),
),
_ShellNavItem(
icon: Icons.person_rounded,
label: 'Tài khoản',
selected: selectedTab == RouteNames.profile,
onTap: () => context.go(RouteNames.profile),
),
], ],
), ),
),
),
),
);
}
}
class _ShellNavItem extends StatelessWidget {
const _ShellNavItem({
required this.icon,
required this.label,
required this.selected,
required this.onTap,
});
final IconData icon;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final activeColor = const Color(0xFF14B8A6);
final inactiveColor = colorScheme.onSurfaceVariant;
return Expanded(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: selected ? activeColor.withAlpha(28) : Colors.transparent,
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 22,
color: selected ? activeColor : inactiveColor,
),
),
const SizedBox(height: 4),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: selected ? const Color(0xFFF7B500) : inactiveColor,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
),
],
),
),
),
); );
} }
} }
+96
View File
@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../app/router/route_names.dart';
class MainAppHeader extends StatelessWidget {
const MainAppHeader({
super.key,
this.title = 'Đăng truyện',
this.showSearch = true,
this.showGenresShortcut = true,
this.bottom,
});
final String title;
final bool showSearch;
final bool showGenresShortcut;
final Widget? bottom;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 12),
decoration: BoxDecoration(
color: colorScheme.surface.withAlpha(245),
border: Border(
bottom: BorderSide(color: colorScheme.outlineVariant.withAlpha(90)),
),
),
child: SafeArea(
bottom: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
GestureDetector(
onTap: () => context.go(RouteNames.home),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SizedBox(
width: 34,
height: 34,
child: Image.asset(
'assets/app_icon.png',
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) => Icon(
Icons.menu_book_rounded,
color: theme.colorScheme.primary,
),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
"Virtus's Reader",
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
color: const Color(0xFF15B8A6),
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 12),
if (showSearch)
IconButton(
tooltip: 'Tìm kiếm',
visualDensity: VisualDensity.compact,
onPressed: () => context.go(RouteNames.search),
icon: const Icon(Icons.search_rounded),
color: const Color(0xFF15B8A6),
),
],
),
if (bottom != null) ...[
const SizedBox(height: 12),
bottom!,
],
],
),
),
);
}
}
+4 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.1+2 version: 1.0.2+3
environment: environment:
sdk: ^3.11.3 sdk: ^3.11.3
@@ -78,6 +78,9 @@ flutter:
# the material Icons class. # the material Icons class.
uses-material-design: true uses-material-design: true
assets:
- assets/app_icon.png
# To add assets to your application, add an assets section, like this: # To add assets to your application, add an assets section, like this:
# assets: # assets:
# - images/a_dot_burr.jpeg # - images/a_dot_burr.jpeg