Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66613857e8 | |||
| 2b8fa4ee57 |
@@ -7,6 +7,7 @@ import android.app.PendingIntent
|
|||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
import android.media.AudioAttributes
|
import android.media.AudioAttributes
|
||||||
import android.media.AudioFocusRequest
|
import android.media.AudioFocusRequest
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
@@ -29,6 +30,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 +49,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 +72,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(
|
||||||
@@ -90,21 +94,27 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
language: String,
|
language: String,
|
||||||
voiceName: String?,
|
voiceName: String?,
|
||||||
backgroundModeEnabled: Boolean,
|
backgroundModeEnabled: Boolean,
|
||||||
) {
|
): Boolean {
|
||||||
ContextCompat.startForegroundService(
|
return try {
|
||||||
context,
|
ContextCompat.startForegroundService(
|
||||||
Intent(context, ReaderTtsMediaService::class.java).apply {
|
context,
|
||||||
action = ACTION_START_READING
|
Intent(context, ReaderTtsMediaService::class.java).apply {
|
||||||
putParcelableArrayListExtra(EXTRA_SEGMENTS, segments)
|
action = ACTION_START_READING
|
||||||
putExtra(EXTRA_START_INDEX, startIndex)
|
putParcelableArrayListExtra(EXTRA_SEGMENTS, segments)
|
||||||
putExtra(EXTRA_CONTENT_KEY, contentKey)
|
putExtra(EXTRA_START_INDEX, startIndex)
|
||||||
putExtra(EXTRA_TITLE, title)
|
putExtra(EXTRA_CONTENT_KEY, contentKey)
|
||||||
putExtra(EXTRA_SPEED, speed)
|
putExtra(EXTRA_TITLE, title)
|
||||||
putExtra(EXTRA_LANGUAGE, language)
|
putExtra(EXTRA_SPEED, speed)
|
||||||
putExtra(EXTRA_VOICE_NAME, voiceName)
|
putExtra(EXTRA_LANGUAGE, language)
|
||||||
putExtra(EXTRA_BACKGROUND_MODE_ENABLED, backgroundModeEnabled)
|
putExtra(EXTRA_VOICE_NAME, voiceName)
|
||||||
},
|
putExtra(EXTRA_BACKGROUND_MODE_ENABLED, backgroundModeEnabled)
|
||||||
)
|
},
|
||||||
|
)
|
||||||
|
true
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e(TAG, "startForegroundService blocked or failed", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pause(context: Context) =
|
fun pause(context: Context) =
|
||||||
@@ -121,6 +131,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 +190,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 +244,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 +258,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,7 +326,14 @@ 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()
|
||||||
recoverFromSilentPlayback("utterance_error_$errorCode")
|
// 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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -314,8 +341,12 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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()) {
|
||||||
@@ -323,7 +354,12 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
speakCurrentSegment(forceRestart = true)
|
speakCurrentSegment(forceRestart = true)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
status = "idle"
|
if (status == "playing" || pendingReplayAfterInit || segments.isNotEmpty()) {
|
||||||
|
status = "paused"
|
||||||
|
scheduleEngineRebuild("onInit_failed_$initStatus")
|
||||||
|
} else {
|
||||||
|
status = "idle"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
syncPowerState()
|
syncPowerState()
|
||||||
syncNotificationState()
|
syncNotificationState()
|
||||||
@@ -375,6 +411,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 +451,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 +462,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 +508,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 +523,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 +587,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 +656,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 +709,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 +760,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 +776,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 +903,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,
|
||||||
@@ -786,13 +912,13 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
private fun buildNotification() = NotificationCompat.Builder(this, CHANNEL_ID)
|
private fun buildNotification() = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
// Avoid adaptive launcher icon for foreground notifications on strict OEM ROMs.
|
||||||
|
.setSmallIcon(android.R.drawable.ic_media_play)
|
||||||
.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 +954,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,27 +999,24 @@ 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()
|
||||||
|
isForegroundActive = startForegroundCompat(notification)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
when (status) {
|
when (status) {
|
||||||
"playing" -> {
|
"playing", "paused" -> {
|
||||||
val notification = buildNotification()
|
val notification = buildNotification()
|
||||||
if (!isForegroundActive) {
|
if (!isForegroundActive) {
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
isForegroundActive = startForegroundCompat(notification)
|
||||||
isForegroundActive = true
|
|
||||||
} else {
|
} else {
|
||||||
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)
|
||||||
@@ -904,6 +1027,24 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startForegroundCompat(notification: android.app.Notification): Boolean {
|
||||||
|
return try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(
|
||||||
|
NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e(TAG, "startForeground failed", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun publishSnapshot() {
|
private fun publishSnapshot() {
|
||||||
val segment = currentSegment()
|
val segment = currentSegment()
|
||||||
val canExposeSegmentProgress = status == "playing" && currentUtteranceStarted
|
val canExposeSegmentProgress = status == "playing" && currentUtteranceStarted
|
||||||
@@ -964,6 +1105,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()
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -2,10 +2,26 @@ import 'package:equatable/equatable.dart';
|
|||||||
|
|
||||||
import 'novel_model.dart';
|
import 'novel_model.dart';
|
||||||
|
|
||||||
|
enum BookmarkType {
|
||||||
|
reading('reading'),
|
||||||
|
bookmarked('bookmarked');
|
||||||
|
|
||||||
|
const BookmarkType(this.value);
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
static BookmarkType fromString(String? str) {
|
||||||
|
return values.firstWhere(
|
||||||
|
(e) => e.value == str,
|
||||||
|
orElse: () => BookmarkType.bookmarked,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class BookmarkModel extends Equatable {
|
class BookmarkModel extends Equatable {
|
||||||
const BookmarkModel({
|
const BookmarkModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.novelId,
|
required this.novelId,
|
||||||
|
this.type = BookmarkType.bookmarked,
|
||||||
this.lastChapterId,
|
this.lastChapterId,
|
||||||
this.lastChapterNumber,
|
this.lastChapterNumber,
|
||||||
this.readChapters = const [],
|
this.readChapters = const [],
|
||||||
@@ -14,6 +30,7 @@ class BookmarkModel extends Equatable {
|
|||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
final String novelId;
|
final String novelId;
|
||||||
|
final BookmarkType type;
|
||||||
final String? lastChapterId;
|
final String? lastChapterId;
|
||||||
final int? lastChapterNumber;
|
final int? lastChapterNumber;
|
||||||
final List<int> readChapters;
|
final List<int> readChapters;
|
||||||
@@ -22,6 +39,7 @@ class BookmarkModel extends Equatable {
|
|||||||
factory BookmarkModel.fromJson(Map<String, dynamic> json) => BookmarkModel(
|
factory BookmarkModel.fromJson(Map<String, dynamic> json) => BookmarkModel(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
novelId: json['novelId'] as String,
|
novelId: json['novelId'] as String,
|
||||||
|
type: BookmarkType.fromString(json['type'] as String?),
|
||||||
lastChapterId: json['lastChapterId'] as String?,
|
lastChapterId: json['lastChapterId'] as String?,
|
||||||
lastChapterNumber: json['lastChapterNumber'] as int?,
|
lastChapterNumber: json['lastChapterNumber'] as int?,
|
||||||
readChapters: (json['readChapters'] as List<dynamic>?)
|
readChapters: (json['readChapters'] as List<dynamic>?)
|
||||||
@@ -34,5 +52,5 @@ class BookmarkModel extends Equatable {
|
|||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [id, novelId];
|
List<Object?> get props => [id, novelId, type];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ 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',
|
||||||
|
this.enableSentenceTapTts = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final double fontSize;
|
final double fontSize;
|
||||||
@@ -15,9 +18,12 @@ 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;
|
||||||
|
final bool enableSentenceTapTts;
|
||||||
|
|
||||||
ReadingSettings copyWith({
|
ReadingSettings copyWith({
|
||||||
double? fontSize,
|
double? fontSize,
|
||||||
@@ -25,9 +31,12 @@ 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,
|
||||||
|
bool? enableSentenceTapTts,
|
||||||
}) =>
|
}) =>
|
||||||
ReadingSettings(
|
ReadingSettings(
|
||||||
fontSize: fontSize ?? this.fontSize,
|
fontSize: fontSize ?? this.fontSize,
|
||||||
@@ -35,9 +44,12 @@ 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,
|
||||||
|
enableSentenceTapTts: enableSentenceTapTts ?? this.enableSentenceTapTts,
|
||||||
);
|
);
|
||||||
|
|
||||||
factory ReadingSettings.fromJson(Map<String, dynamic> json) => ReadingSettings(
|
factory ReadingSettings.fromJson(Map<String, dynamic> json) => ReadingSettings(
|
||||||
@@ -46,9 +58,13 @@ 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',
|
||||||
|
enableSentenceTapTts: json['enableSentenceTapTts'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
@@ -57,8 +73,11 @@ 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,
|
||||||
|
'enableSentenceTapTts': enableSentenceTapTts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,8 @@ 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 '../../novel/providers/novels_provider.dart';
|
||||||
import '../providers/bookshelf_provider.dart';
|
import '../providers/bookshelf_provider.dart';
|
||||||
import '../../auth/providers/auth_provider.dart';
|
import '../../auth/providers/auth_provider.dart';
|
||||||
|
|
||||||
@@ -17,21 +19,30 @@ 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: [
|
||||||
child: Column(
|
const MainAppHeader(title: 'Đăng truyện'),
|
||||||
mainAxisSize: MainAxisSize.min,
|
Expanded(
|
||||||
children: [
|
child: Center(
|
||||||
const Icon(Icons.lock_outline, size: 48),
|
child: Padding(
|
||||||
const SizedBox(height: 12),
|
padding: const EdgeInsets.all(24),
|
||||||
const Text('Vui lòng đăng nhập để xem tủ sách'),
|
child: Column(
|
||||||
const SizedBox(height: 16),
|
mainAxisSize: MainAxisSize.min,
|
||||||
FilledButton(
|
children: [
|
||||||
onPressed: () => ref.read(authProvider.notifier).signInWithGoogle(),
|
const Icon(Icons.lock_outline_rounded, size: 54),
|
||||||
child: const Text('Đăng nhập bằng Google'),
|
const SizedBox(height: 12),
|
||||||
|
const Text('Vui lòng đăng nhập để xem tủ sách'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => ref.read(authProvider.notifier).signInWithGoogle(),
|
||||||
|
child: const Text('Đăng nhập bằng Google'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -39,51 +50,108 @@ 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: 2,
|
||||||
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(
|
||||||
body: bookshelfAsync.when(
|
color: const Color(0xFF14B8A6),
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
borderRadius: BorderRadius.circular(0),
|
||||||
error: (e, _) => Center(
|
),
|
||||||
child: Column(
|
child: TabBar(
|
||||||
mainAxisSize: MainAxisSize.min,
|
indicatorColor: const Color(0xFFF7B500),
|
||||||
children: [
|
indicatorWeight: 3,
|
||||||
const Icon(Icons.error_outline, size: 48),
|
labelColor: Colors.white,
|
||||||
const SizedBox(height: 8),
|
unselectedLabelColor: Colors.white70,
|
||||||
Text('Lỗi: $e'),
|
dividerColor: Colors.transparent,
|
||||||
TextButton(
|
tabs: const [
|
||||||
onPressed: () => ref.read(bookshelfProvider.notifier).fetch(),
|
Tab(text: 'Đang đọc'),
|
||||||
child: const Text('Thử lại'),
|
Tab(text: 'Đánh dấu'),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
data: (bookmarks) {
|
|
||||||
if (bookmarks.isEmpty) {
|
|
||||||
return const Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.menu_book_outlined, size: 56),
|
|
||||||
SizedBox(height: 12),
|
|
||||||
Text('Chưa có truyện nào trong tủ sách'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return RefreshIndicator(
|
|
||||||
onRefresh: () => ref.read(bookshelfProvider.notifier).fetch(),
|
|
||||||
child: ListView.builder(
|
|
||||||
itemCount: bookmarks.length,
|
|
||||||
itemBuilder: (context, index) =>
|
|
||||||
_BookmarkTile(bookmark: bookmarks[index]),
|
|
||||||
),
|
),
|
||||||
|
Expanded(
|
||||||
|
child: bookshelfAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline_rounded, size: 48),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('Lỗi: $e'),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => ref.read(bookshelfProvider.notifier).fetch(),
|
||||||
|
child: const Text('Thử lại'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
data: (bookmarks) {
|
||||||
|
final readingItems = ref.watch(readingBookmarksProvider);
|
||||||
|
final bookmarkedItems = ref.watch(savedBookmarksProvider);
|
||||||
|
|
||||||
|
return TabBarView(
|
||||||
|
children: [
|
||||||
|
_BookshelfList(
|
||||||
|
bookmarks: readingItems,
|
||||||
|
emptyLabel: 'Chưa có truyện đang đọc.',
|
||||||
|
),
|
||||||
|
_BookshelfList(
|
||||||
|
bookmarks: bookmarkedItems,
|
||||||
|
emptyLabel: 'Chưa có truyện đánh dấu.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.menu_book_outlined, size: 56),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(emptyLabel),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () => ref.read(bookshelfProvider.notifier).fetch(),
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 14, 14, 24),
|
||||||
|
itemCount: bookmarks.length,
|
||||||
|
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final bookmark = bookmarks[index];
|
||||||
|
return _BookmarkTile(
|
||||||
|
bookmark: bookmark,
|
||||||
|
onRemove: () => ref
|
||||||
|
.read(bookshelfProvider.notifier)
|
||||||
|
.removeFromShelf(bookmark.novelId, bookmark.type),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -91,39 +159,145 @@ class BookshelfScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BookmarkTile extends StatelessWidget {
|
class _BookmarkTile extends ConsumerWidget {
|
||||||
final BookmarkModel bookmark;
|
final BookmarkModel bookmark;
|
||||||
const _BookmarkTile({required this.bookmark});
|
final VoidCallback onRemove;
|
||||||
|
const _BookmarkTile({
|
||||||
|
required this.bookmark,
|
||||||
|
required this.onRemove,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> _openContinueReader(BuildContext context, WidgetRef ref) async {
|
||||||
|
var targetChapterId = bookmark.lastChapterId;
|
||||||
|
if (targetChapterId == null || targetChapterId.isEmpty) {
|
||||||
|
try {
|
||||||
|
final chapters = await ref.read(
|
||||||
|
chapterListProvider(bookmark.novelId).future,
|
||||||
|
);
|
||||||
|
if (chapters.isNotEmpty) {
|
||||||
|
targetChapterId = chapters.first.id;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Fall through to novel detail when chapter lookup fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (targetChapterId != null && targetChapterId.isNotEmpty) {
|
||||||
|
context.push(RouteNames.readerChapter(targetChapterId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.push(RouteNames.novelDetail(bookmark.novelId));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final novel = bookmark.novel;
|
final novel = bookmark.novel;
|
||||||
return ListTile(
|
return GestureDetector(
|
||||||
leading: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
child: novel?.coverUrl != null
|
|
||||||
? CachedNetworkImage(
|
|
||||||
imageUrl: novel!.coverUrl!,
|
|
||||||
width: 44,
|
|
||||||
height: 60,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
width: 44,
|
|
||||||
height: 60,
|
|
||||||
color: Theme.of(context).colorScheme.primaryContainer,
|
|
||||||
child: const Icon(Icons.menu_book, size: 20),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
novel?.title ?? bookmark.novelId,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
subtitle: novel?.authorName != null
|
|
||||||
? Text(novel!.authorName, maxLines: 1, overflow: TextOverflow.ellipsis)
|
|
||||||
: null,
|
|
||||||
onTap: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
|
onTap: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
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
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: novel!.coverUrl!,
|
||||||
|
width: 92,
|
||||||
|
height: 126,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
width: 92,
|
||||||
|
height: 126,
|
||||||
|
color: Theme.of(context).colorScheme.primaryContainer,
|
||||||
|
child: const Icon(Icons.menu_book, size: 28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
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),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onRemove,
|
||||||
|
child: 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,
|
||||||
|
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: () => _openContinueReader(context, ref),
|
||||||
|
icon: const Icon(Icons.menu_book_rounded),
|
||||||
|
label: const Text('Đọc tiếp'),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF14B8A6),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,22 @@ class BookshelfNotifier extends StateNotifier<AsyncValue<List<BookmarkModel>>> {
|
|||||||
bool isBookmarked(String novelId) {
|
bool isBookmarked(String novelId) {
|
||||||
return (state.valueOrNull ?? []).any((b) => b.novelId == novelId);
|
return (state.valueOrNull ?? []).any((b) => b.novelId == novelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> removeFromShelf(String novelId, BookmarkType type) async {
|
||||||
|
try {
|
||||||
|
final client = _ref.read(apiClientProvider);
|
||||||
|
await client.dio.delete(
|
||||||
|
'/api/user/bookmarks/$novelId',
|
||||||
|
queryParameters: {'type': type.value},
|
||||||
|
);
|
||||||
|
final current = state.valueOrNull ?? [];
|
||||||
|
state = AsyncValue.data(
|
||||||
|
current.where((b) => b.novelId != novelId || b.type != type).toList(),
|
||||||
|
);
|
||||||
|
} catch (e, st) {
|
||||||
|
state = AsyncValue.error(e, st);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final bookshelfProvider =
|
final bookshelfProvider =
|
||||||
@@ -51,6 +67,16 @@ final bookshelfProvider =
|
|||||||
return BookshelfNotifier(ref);
|
return BookshelfNotifier(ref);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final readingBookmarksProvider = Provider<List<BookmarkModel>>((ref) {
|
||||||
|
final bookmarks = ref.watch(bookshelfProvider).valueOrNull ?? [];
|
||||||
|
return bookmarks.where((b) => b.type == BookmarkType.reading).toList();
|
||||||
|
});
|
||||||
|
|
||||||
|
final savedBookmarksProvider = Provider<List<BookmarkModel>>((ref) {
|
||||||
|
final bookmarks = ref.watch(bookshelfProvider).valueOrNull ?? [];
|
||||||
|
return bookmarks.where((b) => b.type == BookmarkType.bookmarked).toList();
|
||||||
|
});
|
||||||
|
|
||||||
final isBookmarkedProvider = Provider.family<bool, String>((ref, novelId) {
|
final isBookmarkedProvider = Provider.family<bool, String>((ref, novelId) {
|
||||||
final bookshelf = ref.watch(bookshelfProvider);
|
final bookshelf = ref.watch(bookshelfProvider);
|
||||||
return bookshelf.valueOrNull?.any((b) => b.novelId == novelId) ?? false;
|
return bookshelf.valueOrNull?.any((b) => b.novelId == novelId) ?? false;
|
||||||
|
|||||||
@@ -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,62 +16,67 @@ 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(
|
||||||
),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
],
|
error: (e, _) => Center(
|
||||||
),
|
child: Padding(
|
||||||
body: homeAsync.when(
|
padding: const EdgeInsets.all(24),
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
child: Column(
|
||||||
error: (e, _) => Center(
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: Column(
|
children: [
|
||||||
mainAxisSize: MainAxisSize.min,
|
const Icon(Icons.cloud_off_rounded, size: 52),
|
||||||
children: [
|
const SizedBox(height: 12),
|
||||||
const Icon(Icons.error_outline, size: 48),
|
Text('Không thể tải dữ liệu trang chủ'),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 8),
|
||||||
Text('Lỗi tải dữ liệu', style: Theme.of(context).textTheme.bodyLarge),
|
Text(
|
||||||
Padding(
|
e.toString(),
|
||||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 0),
|
maxLines: 3,
|
||||||
child: Text(
|
overflow: TextOverflow.ellipsis,
|
||||||
e.toString(),
|
textAlign: TextAlign.center,
|
||||||
textAlign: TextAlign.center,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
maxLines: 3,
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
const SizedBox(height: 12),
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
FilledButton(
|
||||||
|
onPressed: () => ref.invalidate(homeProvider),
|
||||||
|
child: const Text('Tải lại'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
data: (data) => RefreshIndicator(
|
||||||
onPressed: () => ref.invalidate(homeProvider),
|
onRefresh: () async => ref.invalidate(homeProvider),
|
||||||
child: const Text('Thử lại'),
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(0, 12, 0, 24),
|
||||||
|
children: [
|
||||||
|
_HotCarousel(novels: data.hot),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const _HomeQuickFilters(),
|
||||||
|
_SectionHeader(
|
||||||
|
title: 'Truyện mới nhất',
|
||||||
|
onMore: () => context.go(RouteNames.search),
|
||||||
|
),
|
||||||
|
_NovelHorizontalList(novels: data.latest),
|
||||||
|
_SectionHeader(
|
||||||
|
title: 'Đề cử nổi bật',
|
||||||
|
onMore: () => context.go('${RouteNames.search}?sort=rating'),
|
||||||
|
),
|
||||||
|
_FeatureGrid(novels: data.topRated.take(6).toList()),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
data: (data) => RefreshIndicator(
|
|
||||||
onRefresh: () async => ref.invalidate(homeProvider),
|
|
||||||
child: ListView(
|
|
||||||
children: [
|
|
||||||
_HotCarousel(novels: data.hot),
|
|
||||||
_SectionHeader(
|
|
||||||
title: 'Mới cập nhật',
|
|
||||||
onMore: () => context.go(RouteNames.search),
|
|
||||||
),
|
|
||||||
_NovelHorizontalList(novels: data.latest),
|
|
||||||
_SectionHeader(
|
|
||||||
title: 'Đánh giá cao',
|
|
||||||
onMore: () => context.go(RouteNames.search),
|
|
||||||
),
|
|
||||||
_NovelHorizontalList(novels: data.topRated),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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,17 +235,49 @@ 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: PageView.builder(
|
child: Column(
|
||||||
controller: _controller,
|
children: [
|
||||||
itemCount: widget.novels.length,
|
Expanded(
|
||||||
itemBuilder: (context, index) {
|
child: Padding(
|
||||||
final novel = widget.novels[index];
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
|
||||||
return GestureDetector(
|
child: ClipRRect(
|
||||||
onTap: () => context.push(RouteNames.novelDetail(novel.id)),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: _CarouselCard(novel: novel),
|
child: ClipRect(
|
||||||
);
|
child: PageView.builder(
|
||||||
},
|
controller: _controller,
|
||||||
|
itemCount: widget.novels.length,
|
||||||
|
onPageChanged: (value) => setState(() => _currentPage = value),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final novel = widget.novels[index];
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => context.push(RouteNames.novelDetail(novel.id)),
|
||||||
|
child: _CarouselCard(novel: novel),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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,52 +289,76 @@ 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),
|
fit: StackFit.expand,
|
||||||
child: ClipRRect(
|
children: [
|
||||||
borderRadius: BorderRadius.circular(12),
|
if (novel.coverUrl != null)
|
||||||
child: Stack(
|
CachedNetworkImage(
|
||||||
fit: StackFit.expand,
|
imageUrl: novel.coverUrl!,
|
||||||
children: [
|
fit: BoxFit.cover,
|
||||||
if (novel.coverUrl != null)
|
placeholder: (_, imageUrl) => Container(color: Colors.grey[200]),
|
||||||
CachedNetworkImage(
|
errorWidget: (_, imageUrl, error) => Container(color: Colors.grey[300]),
|
||||||
imageUrl: novel.coverUrl!,
|
)
|
||||||
fit: BoxFit.cover,
|
else
|
||||||
placeholder: (_, imageUrl) => Container(color: Colors.grey[200]),
|
Container(color: Theme.of(context).colorScheme.primaryContainer),
|
||||||
errorWidget: (_, imageUrl, error) =>
|
Positioned.fill(
|
||||||
Container(color: Colors.grey[300]),
|
child: DecoratedBox(
|
||||||
)
|
decoration: BoxDecoration(
|
||||||
else
|
gradient: LinearGradient(
|
||||||
Container(color: Theme.of(context).colorScheme.primaryContainer),
|
begin: Alignment.topCenter,
|
||||||
Positioned.fill(
|
end: Alignment.bottomCenter,
|
||||||
child: DecoratedBox(
|
colors: [Colors.transparent, Colors.black.withAlpha(180)],
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [Colors.transparent, Colors.black.withAlpha(180)],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
),
|
||||||
bottom: 12,
|
),
|
||||||
left: 12,
|
Positioned(
|
||||||
right: 12,
|
bottom: 12,
|
||||||
child: Text(
|
left: 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(
|
||||||
|
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),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
novel.title,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
Text(
|
Text(
|
||||||
novel.title,
|
'${novel.totalChapters} chương',
|
||||||
maxLines: 2,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,6 @@ import '../../../core/models/novel_model.dart';
|
|||||||
import '../../../core/models/chapter_model.dart';
|
import '../../../core/models/chapter_model.dart';
|
||||||
import '../../../core/network/providers.dart';
|
import '../../../core/network/providers.dart';
|
||||||
|
|
||||||
const chapterPageSize = 50;
|
|
||||||
|
|
||||||
// ─── Browse / Search ──────────────────────────────────────────────────────────
|
// ─── Browse / Search ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class BrowseParams {
|
class BrowseParams {
|
||||||
@@ -28,11 +26,11 @@ class BrowseParams {
|
|||||||
if (raw == null || raw.isEmpty) return null;
|
if (raw == null || raw.isEmpty) return null;
|
||||||
switch (raw.toLowerCase()) {
|
switch (raw.toLowerCase()) {
|
||||||
case 'ongoing':
|
case 'ongoing':
|
||||||
return 'Đang ra';
|
return 'ONGOING';
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return 'Hoàn thành';
|
return 'COMPLETED';
|
||||||
case 'hiatus':
|
case 'hiatus':
|
||||||
return 'Tạm ngưng';
|
return 'HIATUS';
|
||||||
default:
|
default:
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
@@ -189,77 +187,51 @@ final novelDetailProvider =
|
|||||||
|
|
||||||
// ─── Chapter List ─────────────────────────────────────────────────────────────
|
// ─── Chapter List ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class ChapterListQuery {
|
|
||||||
const ChapterListQuery({required this.novelId, this.page = 1});
|
|
||||||
|
|
||||||
final String novelId;
|
|
||||||
final int page;
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) {
|
|
||||||
if (identical(this, other)) return true;
|
|
||||||
return other is ChapterListQuery &&
|
|
||||||
other.novelId == novelId &&
|
|
||||||
other.page == page;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode => Object.hash(novelId, page);
|
|
||||||
}
|
|
||||||
|
|
||||||
class ChapterListPage {
|
|
||||||
const ChapterListPage({
|
|
||||||
required this.chapters,
|
|
||||||
required this.totalChapters,
|
|
||||||
required this.totalPages,
|
|
||||||
required this.currentPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
final List<ChapterListItem> chapters;
|
|
||||||
final int totalChapters;
|
|
||||||
final int totalPages;
|
|
||||||
final int currentPage;
|
|
||||||
}
|
|
||||||
|
|
||||||
final chapterListProvider =
|
final chapterListProvider =
|
||||||
FutureProvider.family<ChapterListPage, ChapterListQuery>((ref, query) async {
|
FutureProvider.family<List<ChapterListItem>, String>((ref, novelId) async {
|
||||||
final client = ref.read(apiClientProvider);
|
final client = ref.read(apiClientProvider);
|
||||||
|
|
||||||
Future<Map<String, dynamic>> fetchChapterPage(String idOrSlug) async {
|
Future<List<ChapterListItem>> fetchAllChapters(String idOrSlug) async {
|
||||||
final res = await client.dio.get(
|
const limit = 500;
|
||||||
'/api/truyen/$idOrSlug/chapters',
|
var page = 1;
|
||||||
queryParameters: {
|
var totalPages = 1;
|
||||||
'page': query.page,
|
final items = <ChapterListItem>[];
|
||||||
'limit': chapterPageSize,
|
|
||||||
},
|
while (page <= totalPages) {
|
||||||
);
|
final res = await client.dio.get(
|
||||||
return res.data as Map<String, dynamic>;
|
'/api/truyen/$idOrSlug/chapters',
|
||||||
|
queryParameters: {'page': page, 'limit': limit},
|
||||||
|
);
|
||||||
|
final data = res.data as Map<String, dynamic>;
|
||||||
|
final chapters = data['chapters'] as List? ?? const [];
|
||||||
|
|
||||||
|
items.addAll(
|
||||||
|
chapters.map((e) => ChapterListItem.fromJson(e as Map<String, dynamic>)),
|
||||||
|
);
|
||||||
|
|
||||||
|
final apiTotalPages = (data['totalPages'] as num?)?.toInt() ?? 1;
|
||||||
|
totalPages = apiTotalPages > 0 ? apiTotalPages : 1;
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
var data = await fetchChapterPage(query.novelId);
|
try {
|
||||||
var chapters = data['chapters'] as List? ?? const [];
|
return await fetchAllChapters(novelId);
|
||||||
|
} catch (_) {
|
||||||
// Backend stores chapters by novel id in MongoDB; if route opened by slug,
|
// Backend stores chapters by novel id in MongoDB; if route opened by slug,
|
||||||
// first request can return empty list. Resolve canonical id and retry once.
|
// first request can return empty list. Resolve canonical id and retry once.
|
||||||
if (chapters.isEmpty) {
|
|
||||||
try {
|
try {
|
||||||
final novelRes = await client.dio.get('/api/novels/${query.novelId}');
|
final novelRes = await client.dio.get('/api/novels/$novelId');
|
||||||
final novelData = novelRes.data as Map<String, dynamic>;
|
final novelData = novelRes.data as Map<String, dynamic>;
|
||||||
final canonicalId = novelData['id'] as String?;
|
final canonicalId = novelData['id'] as String?;
|
||||||
if (canonicalId != null && canonicalId.isNotEmpty && canonicalId != query.novelId) {
|
if (canonicalId != null && canonicalId.isNotEmpty && canonicalId != novelId) {
|
||||||
data = await fetchChapterPage(canonicalId);
|
return await fetchAllChapters(canonicalId);
|
||||||
chapters = data['chapters'] as List? ?? const [];
|
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Keep original empty list when fallback resolution fails.
|
// Keep original empty list when fallback resolution fails.
|
||||||
}
|
}
|
||||||
|
rethrow;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ChapterListPage(
|
|
||||||
chapters:
|
|
||||||
chapters.map((e) => ChapterListItem.fromJson(e as Map<String, dynamic>)).toList(),
|
|
||||||
totalChapters: (data['totalChapters'] as num?)?.toInt() ?? 0,
|
|
||||||
totalPages: (data['totalPages'] as num?)?.toInt() ?? 0,
|
|
||||||
currentPage: (data['currentPage'] as num?)?.toInt() ?? query.page,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,151 +23,180 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
: '';
|
: '';
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Tài khoản')),
|
body: Column(
|
||||||
body: switch (authState) {
|
children: [
|
||||||
AuthAuthenticated(:final user) => SingleChildScrollView(
|
const MainAppHeader(title: 'Trang cá nhân', showGenresShortcut: false),
|
||||||
padding: const EdgeInsets.all(16),
|
Expanded(
|
||||||
child: Column(
|
child: switch (authState) {
|
||||||
children: [
|
AuthAuthenticated(:final user) => SingleChildScrollView(
|
||||||
// User Avatar & Basic Info
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.primaryContainer,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
Container(
|
||||||
radius: 40,
|
padding: const EdgeInsets.all(14),
|
||||||
backgroundImage:
|
decoration: BoxDecoration(
|
||||||
user.image != null ? NetworkImage(user.image!) : null,
|
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||||
child: user.image == null
|
borderRadius: BorderRadius.circular(22),
|
||||||
? Text(
|
),
|
||||||
displayName[0].toUpperCase(),
|
child: Row(
|
||||||
style:
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Theme.of(context).textTheme.headlineMedium,
|
children: [
|
||||||
)
|
CircleAvatar(
|
||||||
: null,
|
radius: 34,
|
||||||
|
backgroundImage:
|
||||||
|
user.image != null ? NetworkImage(user.image!) : null,
|
||||||
|
child: user.image == null
|
||||||
|
? Text(
|
||||||
|
displayName.isNotEmpty ? displayName[0].toUpperCase() : 'U',
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
displayName,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
_AccountStatRow(
|
||||||
|
icon: Icons.diamond,
|
||||||
|
label: 'Linh Phiếu: 0 LP',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_AccountStatRow(
|
||||||
|
icon: Icons.local_activity,
|
||||||
|
label: 'Ngọc Phiếu: $bookmarkedCount',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () {},
|
||||||
|
icon: const Icon(Icons.workspace_premium_rounded),
|
||||||
|
label: const Text('Thêm Tiên Thạch'),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF14B8A6),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 18),
|
||||||
Text(
|
_ProfileMenuTile(
|
||||||
displayName,
|
title: 'Chỉnh sửa thông tin',
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
onTap: () => context.push(RouteNames.settings),
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
_ProfileMenuTile(
|
||||||
Text(
|
title: 'Lịch sử giao dịch',
|
||||||
user.email,
|
onTap: () {},
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
),
|
||||||
textAlign: TextAlign.center,
|
_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();
|
||||||
|
if (context.mounted) context.go(RouteNames.home);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
AuthError(:final message) => Center(child: Text(message)),
|
||||||
|
AuthUnauthenticated() => Center(
|
||||||
// Stats Cards
|
child: Padding(
|
||||||
Row(
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
child: Column(
|
||||||
Expanded(
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: _buildStatCard(
|
children: [
|
||||||
context: context,
|
FilledButton(
|
||||||
label: 'Sách Đánh Dấu',
|
onPressed: () => ref.read(authProvider.notifier).signInWithGoogle(),
|
||||||
count: bookmarkedCount,
|
child: const Text('Đăng nhập bằng Google'),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () => context.push(RouteNames.settings),
|
||||||
|
icon: const Icon(Icons.tune),
|
||||||
|
label: const Text('Mở Cài Đặt Đọc'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
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: 12),
|
_ => const Center(child: CircularProgressIndicator()),
|
||||||
|
},
|
||||||
// Logout Button
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: () async {
|
|
||||||
await ref.read(authProvider.notifier).signOut();
|
|
||||||
if (context.mounted) context.go(RouteNames.home);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.logout),
|
|
||||||
label: const Text('Đăng Xuất'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
AuthError(:final message) => Center(child: Text(message)),
|
|
||||||
AuthUnauthenticated() => Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
FilledButton(
|
|
||||||
onPressed: () => ref.read(authProvider.notifier).signInWithGoogle(),
|
|
||||||
child: const Text('Đăng nhập bằng Google'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () => context.push(RouteNames.settings),
|
|
||||||
icon: const Icon(Icons.tune),
|
|
||||||
label: const Text('Mở Cài Đặt Đọc'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
_ => 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +179,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _maybeAutoScrollToTtsParagraph(TtsState tts, int paragraphCount) {
|
void _maybeAutoScrollToTtsParagraph(TtsState tts, int paragraphCount) {
|
||||||
if (tts.status == TtsStatus.idle) return;
|
if (tts.status != TtsStatus.playing) return;
|
||||||
final index = tts.activeParagraphIndex;
|
final index = tts.activeParagraphIndex;
|
||||||
if (index < 0 || index >= paragraphCount) return;
|
if (index < 0 || index >= paragraphCount) return;
|
||||||
if (index == _lastAutoScrolledParagraph) return;
|
if (index == _lastAutoScrolledParagraph) return;
|
||||||
@@ -173,6 +189,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final ctx = _paragraphKeys[index].currentContext;
|
final ctx = _paragraphKeys[index].currentContext;
|
||||||
if (ctx == null) return;
|
if (ctx == null) return;
|
||||||
|
// Clear any active text-selection focus before programmatic scrolling.
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
Scrollable.ensureVisible(
|
Scrollable.ensureVisible(
|
||||||
ctx,
|
ctx,
|
||||||
alignment: 0.22,
|
alignment: 0.22,
|
||||||
@@ -374,23 +392,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;
|
||||||
@@ -456,11 +457,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
builder: (sheetContext) {
|
builder: (sheetContext) {
|
||||||
return Consumer(
|
return Consumer(
|
||||||
builder: (context, ref, _) {
|
builder: (context, ref, _) {
|
||||||
final tocPage = ((currentChapter.number - 1) ~/ chapterPageSize) + 1;
|
|
||||||
final chaptersAsync = ref.watch(
|
final chaptersAsync = ref.watch(
|
||||||
chapterListProvider(
|
chapterListProvider(currentChapter.novelId),
|
||||||
ChapterListQuery(novelId: currentChapter.novelId, page: tocPage),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return FractionallySizedBox(
|
return FractionallySizedBox(
|
||||||
heightFactor: 0.82,
|
heightFactor: 0.82,
|
||||||
@@ -490,12 +488,20 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
child: chaptersAsync.when(
|
child: chaptersAsync.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(child: Text('Không tải được mục lục: $e')),
|
error: (e, _) => Center(child: Text('Không tải được mục lục: $e')),
|
||||||
data: (pageData) {
|
data: (chapters) {
|
||||||
final chapters = pageData.chapters;
|
|
||||||
if (chapters.isEmpty) {
|
if (chapters.isEmpty) {
|
||||||
return const Center(child: Text('Chưa có danh sách chương.'));
|
return const Center(child: Text('Chưa có danh sách chương.'));
|
||||||
}
|
}
|
||||||
|
// Find index of current chapter for auto-scroll
|
||||||
|
final currentIndex = chapters.indexWhere((ch) => ch.id == currentChapter.id);
|
||||||
|
final scrollController = ScrollController(
|
||||||
|
initialScrollOffset: currentIndex > 0
|
||||||
|
? currentIndex * 48.0 // Approximate height per ListTile
|
||||||
|
: 0,
|
||||||
|
);
|
||||||
|
|
||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
|
controller: scrollController,
|
||||||
itemCount: chapters.length,
|
itemCount: chapters.length,
|
||||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
@@ -555,6 +561,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 +599,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,23 +621,65 @@ 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: 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(
|
Expanded(
|
||||||
@@ -656,8 +702,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 +752,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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
),
|
),
|
||||||
_PresetChip(
|
const SizedBox(height: 14),
|
||||||
label: 'Ban đêm',
|
Text(
|
||||||
value: 'night',
|
'Màu chữ',
|
||||||
selected: settings.themePreset == 'night',
|
style: Theme.of(context).textTheme.labelLarge,
|
||||||
onTap: () => update(settings.copyWith(themePreset: 'night')),
|
|
||||||
),
|
),
|
||||||
],
|
const SizedBox(height: 8),
|
||||||
),
|
Wrap(
|
||||||
),
|
spacing: 10,
|
||||||
_SettingsSection(
|
runSpacing: 10,
|
||||||
title: 'Mẫu nhanh',
|
children: _textColorChoices.map((color) {
|
||||||
child: Wrap(
|
return _ColorOptionChip(
|
||||||
spacing: 8,
|
color: color,
|
||||||
runSpacing: 8,
|
selected: settings.textColorValue == color.value,
|
||||||
children: [
|
onTap: () => update(
|
||||||
FilledButton.tonal(
|
settings.copyWith(textColorValue: color.value),
|
||||||
onPressed: () => update(const ReadingSettings()),
|
),
|
||||||
child: const Text('Mặc định'),
|
);
|
||||||
),
|
}).toList(),
|
||||||
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'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -819,6 +854,22 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
SwitchListTile.adaptive(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
value: settings.enableSentenceTapTts,
|
||||||
|
onChanged: (enabled) {
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(readingSettingsProvider.notifier)
|
||||||
|
.setSentenceTapTtsEnabled(enabled),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
title: const Text('Bật chạm câu để phát TTS'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Tắt để tránh chạm nhầm làm bắt đầu TTS.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -854,32 +905,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
|
||||||
value: tts.backgroundModeEnabled,
|
.secondaryContainer
|
||||||
onChanged: ttsNotifier.setBackgroundModeEnabled,
|
.withAlpha(90),
|
||||||
),
|
borderRadius: BorderRadius.circular(12),
|
||||||
if (tts.backgroundModeEnabled)
|
border: Border.all(
|
||||||
ListTile(
|
color: Theme.of(context).colorScheme.outlineVariant,
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: const Text('Loại trừ tối ưu pin'),
|
|
||||||
subtitle: Text(
|
|
||||||
tts.batteryOptimizationIgnored
|
|
||||||
? 'Đã bật: Android sẽ ít khả năng dừng TTS khi chạy nền.'
|
|
||||||
: 'Nên bật để Android không chặn TTS khi tắt màn hình.',
|
|
||||||
),
|
),
|
||||||
trailing: tts.batteryOptimizationIgnored
|
|
||||||
? const Icon(Icons.verified, color: Colors.green)
|
|
||||||
: OutlinedButton(
|
|
||||||
onPressed: ttsNotifier
|
|
||||||
.ensureBatteryOptimizationIgnored,
|
|
||||||
child: const Text('Bật ngay'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
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),
|
const SizedBox(height: 12),
|
||||||
if (tts.availableVietnameseVoices.isNotEmpty)
|
if (tts.availableVietnameseVoices.isNotEmpty)
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
@@ -951,24 +1054,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 +1090,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,13 +1103,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
_initializeChapterSession(chapter);
|
_initializeChapterSession(chapter);
|
||||||
});
|
});
|
||||||
|
|
||||||
return GestureDetector(
|
return ColoredBox(
|
||||||
behavior: HitTestBehavior.opaque,
|
color: readerBackground,
|
||||||
onHorizontalDragEnd: (details) =>
|
child: Column(
|
||||||
_handleHorizontalSwipeEnd(details, chapter),
|
|
||||||
child: ColoredBox(
|
|
||||||
color: readerBackground,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
children: [
|
||||||
ValueListenableBuilder<double>(
|
ValueListenableBuilder<double>(
|
||||||
valueListenable: _readingProgress,
|
valueListenable: _readingProgress,
|
||||||
@@ -1109,6 +1189,12 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
.titleLarge
|
.titleLarge
|
||||||
?.copyWith(color: readerTextColor),
|
?.copyWith(color: readerTextColor),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_NavButtons(
|
||||||
|
chapter: chapter,
|
||||||
|
onGoPrevious: () => _goToPreviousChapter(chapter),
|
||||||
|
onGoNext: () => _goToNextChapter(chapter),
|
||||||
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
if (chapter.content.trim().isEmpty)
|
if (chapter.content.trim().isEmpty)
|
||||||
Text(
|
Text(
|
||||||
@@ -1140,32 +1226,44 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 760),
|
constraints: const BoxConstraints(maxWidth: 760),
|
||||||
child: Padding(
|
child: SizedBox(
|
||||||
key: _paragraphKeys[index],
|
width: double.infinity,
|
||||||
padding: EdgeInsets.only(
|
child: Padding(
|
||||||
bottom: index == paragraphs.length - 1
|
key: _paragraphKeys[index],
|
||||||
? 0
|
padding: EdgeInsets.only(
|
||||||
: settings.paragraphSpacing,
|
bottom: index == paragraphs.length - 1
|
||||||
),
|
? 0
|
||||||
child: _buildParagraphText(
|
: settings.paragraphSpacing,
|
||||||
context: context,
|
),
|
||||||
sentenceSlices: sentenceSlices,
|
child: _buildParagraphText(
|
||||||
textAlign: textAlign,
|
context: context,
|
||||||
style: paragraphStyle,
|
sentenceSlices: sentenceSlices,
|
||||||
highlightStyle: paragraphHighlightStyle,
|
textAlign: textAlign,
|
||||||
isActiveParagraph: shouldHighlightTts &&
|
style: paragraphStyle,
|
||||||
tts.activeParagraphIndex == index,
|
highlightStyle: paragraphHighlightStyle,
|
||||||
highlightStart: tts.progressStart,
|
isActiveParagraph: shouldHighlightTts &&
|
||||||
highlightEnd: tts.progressEnd,
|
tts.activeParagraphIndex == index,
|
||||||
onSentenceTap: (charOffset) {
|
highlightStart: tts.progressStart,
|
||||||
ref.read(ttsProvider.notifier).startReading(
|
highlightEnd: tts.progressEnd,
|
||||||
chapter.content,
|
onSentenceTap: (charOffset) {
|
||||||
contentKey: chapter.id,
|
final hasActiveTtsSession =
|
||||||
title: 'Chương ${chapter.number}: ${chapter.title}',
|
tts.contentKey == chapter.id &&
|
||||||
startParagraphIndex: index,
|
(tts.status == TtsStatus.playing ||
|
||||||
startCharOffset: charOffset,
|
tts.status == TtsStatus.paused);
|
||||||
);
|
final canStartFromSentence =
|
||||||
},
|
settings.enableSentenceTapTts || hasActiveTtsSession;
|
||||||
|
if (!canStartFromSentence) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ref.read(ttsProvider.notifier).startReading(
|
||||||
|
chapter.content,
|
||||||
|
contentKey: chapter.id,
|
||||||
|
title: 'Chương ${chapter.number}: ${chapter.title}',
|
||||||
|
startParagraphIndex: index,
|
||||||
|
startCharOffset: charOffset,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1202,7 +1300,6 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -1360,25 +1457,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 +1545,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 +1560,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
|
||||||
child: Column(
|
? [
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
BoxShadow(
|
||||||
children: [
|
color: Theme.of(context).colorScheme.primary.withAlpha(60),
|
||||||
Container(
|
blurRadius: 8,
|
||||||
height: 60,
|
spreadRadius: 1,
|
||||||
decoration: BoxDecoration(
|
),
|
||||||
color: _surfaceForPreset(value),
|
]
|
||||||
borderRadius: BorderRadius.circular(10),
|
: null,
|
||||||
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 +1590,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(
|
||||||
mainAxisSize: MainAxisSize.min,
|
fontSize: compact ? 12.5 : 13.5,
|
||||||
children: [
|
fontWeight: FontWeight.w600,
|
||||||
Icon(icon, size: 16),
|
letterSpacing: -0.1,
|
||||||
const SizedBox(width: 6),
|
);
|
||||||
Text(label),
|
|
||||||
],
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1567,20 +1655,18 @@ class _NavButtons extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
if (chapter.prevChapterId != null)
|
if (chapter.prevChapterId != null)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton(
|
||||||
onPressed: onGoPrevious,
|
onPressed: onGoPrevious,
|
||||||
icon: const Icon(Icons.chevron_left),
|
child: Text('< Chương ${chapter.prevChapterNumber ?? '?'}'),
|
||||||
label: Text('Chương ${chapter.prevChapterNumber ?? '?'}'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (chapter.prevChapterId != null && chapter.nextChapterId != null)
|
if (chapter.prevChapterId != null && chapter.nextChapterId != null)
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
if (chapter.nextChapterId != null)
|
if (chapter.nextChapterId != null)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FilledButton.icon(
|
child: FilledButton(
|
||||||
onPressed: onGoNext,
|
onPressed: onGoNext,
|
||||||
icon: const Icon(Icons.chevron_right),
|
child: Text('> Chương ${chapter.nextChapterNumber ?? '?'}'),
|
||||||
label: Text('Chương ${chapter.nextChapterNumber ?? '?'}'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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) {
|
unawaited(_persistProgress(state!.chapterId, state!.chapterNumber, offset));
|
||||||
await _persistProgress(state!.chapterId, state!.chapterNumber, offset);
|
}
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +145,10 @@ class ReadingSettingsNotifier extends StateNotifier<ReadingSettings> {
|
|||||||
final localStore = _ref.read(localStoreProvider);
|
final localStore = _ref.read(localStoreProvider);
|
||||||
await localStore.saveReadingSettings(settings);
|
await localStore.saveReadingSettings(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setSentenceTapTtsEnabled(bool enabled) async {
|
||||||
|
await update(state.copyWith(enableSentenceTapTts: enabled));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final readingSettingsProvider =
|
final readingSettingsProvider =
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -155,6 +155,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
int _pendingFallbackIndex = -1;
|
int _pendingFallbackIndex = -1;
|
||||||
bool _didStartCurrentFallbackUtterance = false;
|
bool _didStartCurrentFallbackUtterance = false;
|
||||||
bool _hasPromptedNotificationSettings = false;
|
bool _hasPromptedNotificationSettings = false;
|
||||||
|
bool _androidFallbackReady = false;
|
||||||
|
|
||||||
bool get _useNativeAndroidMediaService => Platform.isAndroid;
|
bool get _useNativeAndroidMediaService => Platform.isAndroid;
|
||||||
|
|
||||||
@@ -315,6 +316,73 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _ensureAndroidFallbackReady() async {
|
||||||
|
if (_androidFallbackReady) return;
|
||||||
|
|
||||||
|
await _tts.awaitSpeakCompletion(true);
|
||||||
|
await _tts.setSharedInstance(true);
|
||||||
|
await _configureVietnameseVoiceWithFlutterTts();
|
||||||
|
await _tts.setSpeechRate(state.speed);
|
||||||
|
await _tts.setVolume(1.0);
|
||||||
|
await _tts.setPitch(1.0);
|
||||||
|
|
||||||
|
_tts.setStartHandler(() {
|
||||||
|
_didStartCurrentFallbackUtterance = true;
|
||||||
|
final index = _pendingFallbackIndex;
|
||||||
|
if (index >= 0 && index < _segments.length) {
|
||||||
|
final segment = _segments[index];
|
||||||
|
state = state.copyWith(
|
||||||
|
status: TtsStatus.playing,
|
||||||
|
paragraphIndex: index,
|
||||||
|
activeParagraphIndex: segment.paragraphIndex,
|
||||||
|
progressStart: segment.start,
|
||||||
|
progressEnd: segment.end,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
state = state.copyWith(status: TtsStatus.playing);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_tts.setCompletionHandler(() {
|
||||||
|
// Fallback playback progression is driven by _playFallbackFromGeneration.
|
||||||
|
});
|
||||||
|
|
||||||
|
_tts.setErrorHandler((_) {
|
||||||
|
if (_isInterruptingPlayback) return;
|
||||||
|
_pendingFallbackIndex = -1;
|
||||||
|
_didStartCurrentFallbackUtterance = false;
|
||||||
|
state = state.copyWith(
|
||||||
|
status: TtsStatus.idle,
|
||||||
|
activeParagraphIndex: -1,
|
||||||
|
progressStart: -1,
|
||||||
|
progressEnd: -1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
_androidFallbackReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startFallbackReading({
|
||||||
|
required int validIndex,
|
||||||
|
required _TtsSegment selectedSegment,
|
||||||
|
required String? contentKey,
|
||||||
|
}) async {
|
||||||
|
await _ensureAndroidFallbackReady();
|
||||||
|
final sessionId = await _interruptFallbackPlayback();
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
status: TtsStatus.playing,
|
||||||
|
paragraphIndex: validIndex,
|
||||||
|
totalParagraphs: _segments.length,
|
||||||
|
activeParagraphIndex: selectedSegment.paragraphIndex,
|
||||||
|
progressStart: selectedSegment.start,
|
||||||
|
progressEnd: selectedSegment.end,
|
||||||
|
contentKey: contentKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
unawaited(_playFallbackFromGeneration(validIndex, sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
void _handleAndroidMediaEvent(dynamic event) {
|
void _handleAndroidMediaEvent(dynamic event) {
|
||||||
_applyAndroidSnapshot(event);
|
_applyAndroidSnapshot(event);
|
||||||
}
|
}
|
||||||
@@ -372,6 +440,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
|
|
||||||
// Keep natural sentence flow while removing symbols that are usually read out noisily.
|
// Keep natural sentence flow while removing symbols that are usually read out noisily.
|
||||||
final cleaned = raw
|
final cleaned = raw
|
||||||
|
.replaceAll(RegExp(r'["“”]'), ' ')
|
||||||
.replaceAll(RegExp(r'[_$#^*+=~`|<>\\\[\]{}]'), ' ')
|
.replaceAll(RegExp(r'[_$#^*+=~`|<>\\\[\]{}]'), ' ')
|
||||||
.replaceAll(RegExp(r'\s+'), ' ')
|
.replaceAll(RegExp(r'\s+'), ' ')
|
||||||
.trim();
|
.trim();
|
||||||
@@ -614,33 +683,32 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
contentKey: contentKey,
|
contentKey: contentKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
await _mediaChannel.invokeMethod<void>('startReading', {
|
try {
|
||||||
'contentKey': contentKey,
|
await _mediaChannel.invokeMethod<void>('startReading', {
|
||||||
'title': title,
|
'contentKey': contentKey,
|
||||||
'startIndex': validIndex,
|
'title': title,
|
||||||
'speed': state.speed,
|
'startIndex': validIndex,
|
||||||
'language': state.language,
|
'speed': state.speed,
|
||||||
'voiceName': state.voiceName,
|
'language': state.language,
|
||||||
'backgroundModeEnabled': state.backgroundModeEnabled,
|
'voiceName': state.voiceName,
|
||||||
'segments': _segments.map((segment) => segment.toMap()).toList(),
|
'backgroundModeEnabled': state.backgroundModeEnabled,
|
||||||
});
|
'segments': _segments.map((segment) => segment.toMap()).toList(),
|
||||||
|
});
|
||||||
|
} on PlatformException {
|
||||||
|
await _startFallbackReading(
|
||||||
|
validIndex: validIndex,
|
||||||
|
selectedSegment: selectedSegment,
|
||||||
|
contentKey: contentKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final sessionId = await _interruptFallbackPlayback();
|
await _startFallbackReading(
|
||||||
|
validIndex: validIndex,
|
||||||
state = state.copyWith(
|
selectedSegment: selectedSegment,
|
||||||
status: TtsStatus.playing,
|
|
||||||
paragraphIndex: validIndex,
|
|
||||||
totalParagraphs: _segments.length,
|
|
||||||
activeParagraphIndex: -1,
|
|
||||||
progressStart: -1,
|
|
||||||
progressEnd: -1,
|
|
||||||
contentKey: contentKey,
|
contentKey: contentKey,
|
||||||
);
|
);
|
||||||
await _syncBackgroundMode();
|
|
||||||
|
|
||||||
unawaited(_playFallbackFromGeneration(validIndex, sessionId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> _interruptFallbackPlayback() async {
|
Future<int> _interruptFallbackPlayback() async {
|
||||||
|
|||||||
@@ -8,44 +8,122 @@ 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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.3+4
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user