Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b8fa4ee57 | |||
| 297fc45707 |
@@ -16,6 +16,7 @@ import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.Parcelable
|
||||
import android.os.PowerManager
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.speech.tts.UtteranceProgressListener
|
||||
import android.util.Log
|
||||
@@ -28,6 +29,7 @@ import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import com.example.reader_app.R
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlin.math.min
|
||||
import java.util.Locale
|
||||
|
||||
@Parcelize
|
||||
@@ -46,6 +48,8 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
private const val BASE_SPEED = 0.9
|
||||
private const val TAG = "ReaderTtsMediaService"
|
||||
private const val HEALTH_CHECK_INTERVAL_MS = 1500L
|
||||
private const val START_GRACE_PERIOD_MS = 5_000L
|
||||
private const val MAX_SEGMENT_RETRIES_BEFORE_SKIP = 4
|
||||
|
||||
const val ACTION_INIT = "com.example.reader_app.tts.INIT"
|
||||
const val ACTION_START_READING = "com.example.reader_app.tts.START_READING"
|
||||
@@ -67,6 +71,8 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
const val EXTRA_VOICE_NAME = "voiceName"
|
||||
const val EXTRA_BACKGROUND_MODE_ENABLED = "backgroundModeEnabled"
|
||||
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) {
|
||||
context.startService(
|
||||
@@ -118,6 +124,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
context.startService(Intent(context, ReaderTtsMediaService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
putExtra(EXTRA_CLEAR_CONTENT_KEY, clearContentKey)
|
||||
putExtra(EXTRA_STOP_REASON, STOP_REASON_USER)
|
||||
})
|
||||
|
||||
fun skipForward(context: Context) =
|
||||
@@ -154,7 +161,9 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
private lateinit var notificationManager: NotificationManagerCompat
|
||||
private lateinit var mediaSession: MediaSessionCompat
|
||||
private lateinit var audioManager: AudioManager
|
||||
private lateinit var powerManager: PowerManager
|
||||
private var audioFocusRequest: AudioFocusRequest? = null
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var tts: TextToSpeech? = null
|
||||
private var isTtsReady = false
|
||||
private var isForegroundActive = false
|
||||
@@ -174,6 +183,13 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
private var currentUtteranceId: String? = null
|
||||
private var currentUtteranceStarted = 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 consecutiveSilentHealthChecks = 0
|
||||
private var utteranceWatchdog: Runnable? = null
|
||||
@@ -210,6 +226,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
super.onCreate()
|
||||
notificationManager = NotificationManagerCompat.from(this)
|
||||
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
createNotificationChannel()
|
||||
setupMediaSession()
|
||||
setupTextToSpeech()
|
||||
@@ -220,6 +237,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.i(TAG, "onStartCommand action=${intent?.action} status=$status index=$currentIndex")
|
||||
when (intent?.action) {
|
||||
ACTION_INIT -> {
|
||||
backgroundModeEnabled = intent.getBooleanExtra(
|
||||
@@ -233,6 +251,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
ACTION_RESUME -> handleResume()
|
||||
ACTION_STOP -> handleStop(
|
||||
clearContentKey = intent.getBooleanExtra(EXTRA_CLEAR_CONTENT_KEY, true),
|
||||
reason = intent.getStringExtra(EXTRA_STOP_REASON) ?: "unknown",
|
||||
)
|
||||
ACTION_SKIP_FORWARD -> handleSkip(1)
|
||||
ACTION_SKIP_BACK -> handleSkip(-1)
|
||||
@@ -300,7 +319,14 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
if (!isActiveUtterance(utteranceId)) return@post
|
||||
if (utteranceId != currentUtteranceId) return@post
|
||||
clearUtteranceRuntimeState()
|
||||
handlePlaybackFailure()
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -308,8 +334,12 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
}
|
||||
|
||||
override fun onInit(initStatus: Int) {
|
||||
isRebuildingEngine = false
|
||||
isTtsReady = initStatus == TextToSpeech.SUCCESS
|
||||
if (isTtsReady) {
|
||||
engineRebuildAttempt = 0
|
||||
consecutivePlaybackRecoveryFailures = 0
|
||||
currentSegmentRetry = 0 // reset retry counter after successful engine reconnect
|
||||
refreshAvailableVoices()
|
||||
applyVoiceAndSpeedSettings()
|
||||
if ((pendingReplayAfterInit || status == "playing") && segments.isNotEmpty()) {
|
||||
@@ -317,8 +347,14 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
speakCurrentSegment(forceRestart = true)
|
||||
}
|
||||
} else {
|
||||
status = "idle"
|
||||
if (status == "playing" || pendingReplayAfterInit || segments.isNotEmpty()) {
|
||||
status = "paused"
|
||||
scheduleEngineRebuild("onInit_failed_$initStatus")
|
||||
} else {
|
||||
status = "idle"
|
||||
}
|
||||
}
|
||||
syncPowerState()
|
||||
syncNotificationState()
|
||||
publishSnapshot()
|
||||
}
|
||||
@@ -348,6 +384,14 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
|
||||
private fun applyVoiceAndSpeedSettings() {
|
||||
val ttsInstance = tts ?: return
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
ttsInstance.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
ttsInstance.setSpeechRate(speed.toFloat())
|
||||
val locale = language.toLocale()
|
||||
ttsInstance.setLanguage(locale)
|
||||
@@ -360,6 +404,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
}
|
||||
|
||||
private fun handleStartReading(intent: Intent) {
|
||||
cancelIdleStop()
|
||||
backgroundModeEnabled = intent.getBooleanExtra(
|
||||
EXTRA_BACKGROUND_MODE_ENABLED,
|
||||
backgroundModeEnabled,
|
||||
@@ -378,6 +423,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
pausedByAudioFocus = false
|
||||
pendingReplayAfterInit = false
|
||||
tts?.stop()
|
||||
syncPowerState()
|
||||
publishSnapshot()
|
||||
|
||||
if (!isTtsReady) return
|
||||
@@ -391,23 +437,29 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
status = "paused"
|
||||
pendingReplayAfterInit = false
|
||||
tts?.stop()
|
||||
syncPowerState()
|
||||
syncNotificationState()
|
||||
publishSnapshot()
|
||||
}
|
||||
|
||||
private fun handleResume() {
|
||||
if (segments.isEmpty()) return
|
||||
cancelIdleStop()
|
||||
status = "playing"
|
||||
sessionGeneration += 1
|
||||
clearUtteranceRuntimeState()
|
||||
pendingReplayAfterInit = false
|
||||
syncPowerState()
|
||||
publishSnapshot()
|
||||
if (!isTtsReady) return
|
||||
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
|
||||
clearScheduledRecoveries()
|
||||
cancelIdleStop()
|
||||
clearUtteranceRuntimeState()
|
||||
status = "idle"
|
||||
currentIndex = 0
|
||||
@@ -418,6 +470,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
}
|
||||
tts?.stop()
|
||||
abandonAudioFocus()
|
||||
syncPowerState()
|
||||
syncNotificationState()
|
||||
publishSnapshot()
|
||||
stopSelf()
|
||||
@@ -433,6 +486,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
status = "playing"
|
||||
pendingReplayAfterInit = false
|
||||
tts?.stop()
|
||||
syncPowerState()
|
||||
publishSnapshot()
|
||||
if (!isTtsReady) return
|
||||
speakCurrentSegment(forceRestart = true)
|
||||
@@ -447,11 +501,13 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
status = "idle"
|
||||
currentIndex = 0
|
||||
completedCount += 1
|
||||
Log.i(TAG, "chapter_completed contentKey=$contentKey completedCount=$completedCount")
|
||||
clearUtteranceRuntimeState()
|
||||
abandonAudioFocus()
|
||||
syncPowerState()
|
||||
syncNotificationState()
|
||||
publishSnapshot()
|
||||
stopSelf()
|
||||
scheduleIdleStop()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -460,21 +516,35 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
}
|
||||
|
||||
private fun handlePlaybackFailure() {
|
||||
status = "idle"
|
||||
clearUtteranceRuntimeState()
|
||||
pendingReplayAfterInit = false
|
||||
abandonAudioFocus()
|
||||
consecutivePlaybackRecoveryFailures += 1
|
||||
Log.e(
|
||||
TAG,
|
||||
"Playback failure at index=$currentIndex contentKey=$contentKey, recoveryAttempt=$consecutivePlaybackRecoveryFailures",
|
||||
)
|
||||
status = "paused"
|
||||
pendingReplayAfterInit = true
|
||||
if (consecutivePlaybackRecoveryFailures > 12) {
|
||||
// Keep trying indefinitely but avoid a tight error loop.
|
||||
consecutivePlaybackRecoveryFailures = 6
|
||||
}
|
||||
syncPowerState()
|
||||
syncNotificationState()
|
||||
publishSnapshot()
|
||||
stopSelf()
|
||||
scheduleEngineRebuild("playback_failure")
|
||||
}
|
||||
|
||||
private fun speakCurrentSegment(forceRestart: Boolean) {
|
||||
if (segments.isEmpty() || !isTtsReady) return
|
||||
if (!requestAudioFocus()) {
|
||||
handlePlaybackFailure()
|
||||
pausedByAudioFocus = true
|
||||
status = "paused"
|
||||
syncPowerState()
|
||||
syncNotificationState()
|
||||
publishSnapshot()
|
||||
scheduleAudioFocusRetry()
|
||||
return
|
||||
}
|
||||
clearAudioFocusRetry()
|
||||
|
||||
val segment = segments.getOrNull(currentIndex) ?: run {
|
||||
handlePlaybackFailure()
|
||||
@@ -487,6 +557,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
if (!forceRestart) {
|
||||
currentSegmentRetry = 0
|
||||
}
|
||||
syncPowerState()
|
||||
syncNotificationState()
|
||||
publishSnapshot()
|
||||
|
||||
@@ -496,15 +567,25 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
currentUtteranceStarted = false
|
||||
lastSpeakRequestTimeMs = System.currentTimeMillis()
|
||||
scheduleUtteranceWatchdog(utteranceId)
|
||||
val speakResult = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, Bundle(), utteranceId)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, null)
|
||||
val speakResult = try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, Bundle(), utteranceId)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "speak() failed for index=$currentIndex", e)
|
||||
null
|
||||
}
|
||||
|
||||
if (speakResult == TextToSpeech.ERROR) {
|
||||
recoverFromSilentPlayback("speak_error")
|
||||
if (speakResult == null || speakResult == TextToSpeech.ERROR) {
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,13 +633,13 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
}
|
||||
|
||||
clearUtteranceRuntimeState()
|
||||
if (currentSegmentRetry >= 2) {
|
||||
handlePlaybackFailure()
|
||||
if (currentSegmentRetry >= MAX_SEGMENT_RETRIES_BEFORE_SKIP) {
|
||||
skipCurrentSegmentAfterFailure(reason)
|
||||
return
|
||||
}
|
||||
|
||||
currentSegmentRetry += 1
|
||||
if (currentSegmentRetry >= 2) {
|
||||
if (currentSegmentRetry >= 3) {
|
||||
rebuildTtsEngineForRecovery(reason)
|
||||
return
|
||||
}
|
||||
@@ -568,14 +649,48 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
}
|
||||
|
||||
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")
|
||||
isRebuildingEngine = true
|
||||
pendingReplayAfterInit = true
|
||||
isTtsReady = false
|
||||
clearScheduledRecoveries()
|
||||
// Increment session so callbacks from the dying engine are ignored
|
||||
sessionGeneration += 1
|
||||
clearUtteranceRuntimeState()
|
||||
tts?.stop()
|
||||
tts?.shutdown()
|
||||
tts = null
|
||||
setupTextToSpeech()
|
||||
}
|
||||
|
||||
private fun skipCurrentSegmentAfterFailure(reason: String) {
|
||||
Log.e(
|
||||
TAG,
|
||||
"Skipping problematic segment after repeated recovery failures: reason=$reason index=$currentIndex total=${segments.size}",
|
||||
)
|
||||
clearUtteranceRuntimeState()
|
||||
pendingReplayAfterInit = false
|
||||
|
||||
val nextIndex = currentIndex + 1
|
||||
if (nextIndex >= segments.size) {
|
||||
handlePlaybackFailure()
|
||||
return
|
||||
}
|
||||
|
||||
currentIndex = nextIndex
|
||||
currentSegmentRetry = 0
|
||||
publishSnapshot()
|
||||
if (!isTtsReady) {
|
||||
rebuildTtsEngineForRecovery("skip_after_failure")
|
||||
return
|
||||
}
|
||||
speakCurrentSegment(forceRestart = false)
|
||||
}
|
||||
|
||||
private fun runPlaybackHealthCheck() {
|
||||
if (status != "playing") return
|
||||
if (segments.isEmpty()) return
|
||||
@@ -587,7 +702,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
}
|
||||
|
||||
if (!isTtsReady) {
|
||||
if (!pendingReplayAfterInit) {
|
||||
if (!pendingReplayAfterInit && !isRebuildingEngine) {
|
||||
rebuildTtsEngineForRecovery("tts_not_ready")
|
||||
}
|
||||
return
|
||||
@@ -602,9 +717,10 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
if (!currentUtteranceStarted) {
|
||||
if (!isSpeaking) {
|
||||
// Allow a grace period after speak() is called before flagging as silent.
|
||||
// onStart typically fires within ~100 ms; 4 s covers slow TTS initialisation.
|
||||
// Some engines on mid/low-end devices need noticeably longer before
|
||||
// firing onStart after many segments or after screen-off transitions.
|
||||
val elapsedSinceSpeak = System.currentTimeMillis() - lastSpeakRequestTimeMs
|
||||
if (elapsedSinceSpeak > 4_000L) {
|
||||
if (elapsedSinceSpeak > START_GRACE_PERIOD_MS) {
|
||||
recoverFromSilentPlayback("no_onStart_and_not_speaking")
|
||||
}
|
||||
}
|
||||
@@ -637,11 +753,12 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build(),
|
||||
)
|
||||
.setAcceptsDelayedFocusGain(false)
|
||||
.setAcceptsDelayedFocusGain(true)
|
||||
.setOnAudioFocusChangeListener(audioFocusListener)
|
||||
.build()
|
||||
.also { audioFocusRequest = it }
|
||||
audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
val result = audioManager.requestAudioFocus(request)
|
||||
result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
audioManager.requestAudioFocus(
|
||||
@@ -652,6 +769,61 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleEngineRebuild(reason: String) {
|
||||
if (isRebuildingEngine) return
|
||||
pendingEngineRebuild?.let(mainHandler::removeCallbacks)
|
||||
engineRebuildAttempt += 1
|
||||
val delayMs = min(30_000L, 1_000L * engineRebuildAttempt * engineRebuildAttempt)
|
||||
pendingEngineRebuild = Runnable {
|
||||
if (status == "idle") return@Runnable
|
||||
if (segments.isEmpty()) return@Runnable
|
||||
rebuildTtsEngineForRecovery(reason)
|
||||
}.also { mainHandler.postDelayed(it, delayMs) }
|
||||
}
|
||||
|
||||
private fun scheduleAudioFocusRetry() {
|
||||
pendingAudioFocusRetry?.let(mainHandler::removeCallbacks)
|
||||
audioFocusRetryAttempt += 1
|
||||
val delayMs = min(6_000L, 1_000L + (audioFocusRetryAttempt * 500L))
|
||||
pendingAudioFocusRetry = Runnable {
|
||||
if (status == "idle") return@Runnable
|
||||
if (!pausedByAudioFocus) return@Runnable
|
||||
if (requestAudioFocus()) {
|
||||
pausedByAudioFocus = false
|
||||
audioFocusRetryAttempt = 0
|
||||
handleResume()
|
||||
return@Runnable
|
||||
}
|
||||
scheduleAudioFocusRetry()
|
||||
}.also { mainHandler.postDelayed(it, delayMs) }
|
||||
}
|
||||
|
||||
private fun clearAudioFocusRetry() {
|
||||
pendingAudioFocusRetry?.let(mainHandler::removeCallbacks)
|
||||
pendingAudioFocusRetry = null
|
||||
audioFocusRetryAttempt = 0
|
||||
}
|
||||
|
||||
private fun clearScheduledRecoveries() {
|
||||
pendingEngineRebuild?.let(mainHandler::removeCallbacks)
|
||||
pendingEngineRebuild = null
|
||||
clearAudioFocusRetry()
|
||||
}
|
||||
|
||||
private fun scheduleIdleStop() {
|
||||
pendingIdleStop?.let(mainHandler::removeCallbacks)
|
||||
pendingIdleStop = Runnable {
|
||||
if (status != "idle") return@Runnable
|
||||
Log.i(TAG, "idle_timeout_stop")
|
||||
stopSelf()
|
||||
}.also { mainHandler.postDelayed(it, 30_000L) }
|
||||
}
|
||||
|
||||
private fun cancelIdleStop() {
|
||||
pendingIdleStop?.let(mainHandler::removeCallbacks)
|
||||
pendingIdleStop = null
|
||||
}
|
||||
|
||||
private fun abandonAudioFocus() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
audioFocusRequest?.let(audioManager::abandonAudioFocusRequest)
|
||||
@@ -661,6 +833,28 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncPowerState() {
|
||||
val shouldHoldWakeLock = backgroundModeEnabled && status == "playing"
|
||||
if (shouldHoldWakeLock) {
|
||||
if (wakeLock?.isHeld == true) return
|
||||
wakeLock = powerManager.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
"reader_app:ReaderTtsPlayback"
|
||||
).apply {
|
||||
setReferenceCounted(false)
|
||||
acquire()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
wakeLock?.let {
|
||||
if (it.isHeld) {
|
||||
it.release()
|
||||
}
|
||||
}
|
||||
wakeLock = null
|
||||
}
|
||||
|
||||
private fun isActiveUtterance(utteranceId: String): Boolean {
|
||||
val generation = utteranceId.substringBefore(':').toIntOrNull() ?: return false
|
||||
return generation == sessionGeneration
|
||||
@@ -702,6 +896,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
this.action = action
|
||||
if (action == ACTION_STOP) {
|
||||
putExtra(EXTRA_CLEAR_CONTENT_KEY, true)
|
||||
putExtra(EXTRA_STOP_REASON, STOP_REASON_USER)
|
||||
}
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
@@ -714,9 +909,8 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
.setContentTitle(title ?: appLabel())
|
||||
.setContentText(currentProgressLabel())
|
||||
.setContentIntent(buildLaunchIntent())
|
||||
.setDeleteIntent(buildServicePendingIntent(ACTION_STOP))
|
||||
.setOnlyAlertOnce(true)
|
||||
.setOngoing(status == "playing")
|
||||
.setOngoing(status == "playing" || status == "paused")
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
|
||||
.addAction(
|
||||
@@ -752,7 +946,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
object : MediaSessionCompat.Callback() {
|
||||
override fun onPlay() = handleResume()
|
||||
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 onSkipToPrevious() = handleSkip(-1)
|
||||
},
|
||||
@@ -789,6 +983,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun syncNotificationState() {
|
||||
syncPowerState()
|
||||
updateMediaSessionState()
|
||||
if (!backgroundModeEnabled) {
|
||||
if (isForegroundActive) {
|
||||
@@ -796,11 +991,18 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
isForegroundActive = false
|
||||
}
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
// Even without background mode, keep foreground service alive while playing
|
||||
// to prevent Android from killing us.
|
||||
if (status == "playing" || status == "paused") {
|
||||
val notification = buildNotification()
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
isForegroundActive = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
when (status) {
|
||||
"playing" -> {
|
||||
"playing", "paused" -> {
|
||||
val notification = buildNotification()
|
||||
if (!isForegroundActive) {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
@@ -809,14 +1011,6 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
"paused" -> {
|
||||
val notification = buildNotification()
|
||||
if (isForegroundActive) {
|
||||
stopForeground(false)
|
||||
isForegroundActive = false
|
||||
}
|
||||
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
else -> {
|
||||
if (isForegroundActive) {
|
||||
stopForeground(true)
|
||||
@@ -887,6 +1081,9 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
|
||||
override fun onDestroy() {
|
||||
mainHandler.removeCallbacks(playbackHealthRunnable)
|
||||
isRebuildingEngine = false
|
||||
clearScheduledRecoveries()
|
||||
cancelIdleStop()
|
||||
status = "idle"
|
||||
currentIndex = 0
|
||||
segments = emptyList()
|
||||
@@ -896,6 +1093,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
tts?.stop()
|
||||
tts?.shutdown()
|
||||
abandonAudioFocus()
|
||||
syncPowerState()
|
||||
if (isForegroundActive) {
|
||||
stopForeground(true)
|
||||
isForegroundActive = false
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/auth/session_expiry_notifier.dart';
|
||||
import '../core/theme/app_theme.dart';
|
||||
import '../features/auth/providers/auth_provider.dart';
|
||||
import '../features/reader/tts/tts_service.dart';
|
||||
import 'router/route_names.dart';
|
||||
import 'router/app_router.dart';
|
||||
|
||||
@@ -21,6 +23,10 @@ class _ReaderAppState extends ConsumerState<ReaderApp> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_ensureMandatoryTtsRequirements();
|
||||
});
|
||||
|
||||
_sessionExpirySub = ref.listenManual<int>(
|
||||
sessionExpiryProvider,
|
||||
(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
|
||||
void dispose() {
|
||||
_sessionExpirySub?.close();
|
||||
|
||||
@@ -5,9 +5,11 @@ class ReadingSettings {
|
||||
this.letterSpacing = 0,
|
||||
this.fontFamily = 'serif',
|
||||
this.themePreset = 'paper',
|
||||
this.backgroundColorValue = 0xFFFFFEF8,
|
||||
this.textColorValue = 0xFF111111,
|
||||
this.horizontalPadding = 20,
|
||||
this.paragraphSpacing = 24,
|
||||
this.textAlign = 'justify',
|
||||
this.textAlign = 'left',
|
||||
});
|
||||
|
||||
final double fontSize;
|
||||
@@ -15,6 +17,8 @@ class ReadingSettings {
|
||||
final double letterSpacing;
|
||||
final String fontFamily;
|
||||
final String themePreset;
|
||||
final int backgroundColorValue;
|
||||
final int textColorValue;
|
||||
final double horizontalPadding;
|
||||
final double paragraphSpacing;
|
||||
final String textAlign;
|
||||
@@ -25,6 +29,8 @@ class ReadingSettings {
|
||||
double? letterSpacing,
|
||||
String? fontFamily,
|
||||
String? themePreset,
|
||||
int? backgroundColorValue,
|
||||
int? textColorValue,
|
||||
double? horizontalPadding,
|
||||
double? paragraphSpacing,
|
||||
String? textAlign,
|
||||
@@ -35,6 +41,8 @@ class ReadingSettings {
|
||||
letterSpacing: letterSpacing ?? this.letterSpacing,
|
||||
fontFamily: fontFamily ?? this.fontFamily,
|
||||
themePreset: themePreset ?? this.themePreset,
|
||||
backgroundColorValue: backgroundColorValue ?? this.backgroundColorValue,
|
||||
textColorValue: textColorValue ?? this.textColorValue,
|
||||
horizontalPadding: horizontalPadding ?? this.horizontalPadding,
|
||||
paragraphSpacing: paragraphSpacing ?? this.paragraphSpacing,
|
||||
textAlign: textAlign ?? this.textAlign,
|
||||
@@ -46,9 +54,12 @@ class ReadingSettings {
|
||||
letterSpacing: (json['letterSpacing'] as num?)?.toDouble() ?? 0,
|
||||
fontFamily: json['fontFamily'] as String? ?? 'serif',
|
||||
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,
|
||||
paragraphSpacing: (json['paragraphSpacing'] as num?)?.toDouble() ?? 24,
|
||||
textAlign: json['textAlign'] as String? ?? 'justify',
|
||||
textAlign: json['textAlign'] as String? ?? 'left',
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@@ -57,6 +68,8 @@ class ReadingSettings {
|
||||
'letterSpacing': letterSpacing,
|
||||
'fontFamily': fontFamily,
|
||||
'themePreset': themePreset,
|
||||
'backgroundColorValue': backgroundColorValue,
|
||||
'textColorValue': textColorValue,
|
||||
'horizontalPadding': horizontalPadding,
|
||||
'paragraphSpacing': paragraphSpacing,
|
||||
'textAlign': textAlign,
|
||||
|
||||
@@ -8,6 +8,8 @@ class LocalStore {
|
||||
static const _kLetterSpacing = 'reader_letter_spacing';
|
||||
static const _kFontFamily = 'reader_font_family';
|
||||
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 _kParagraphSpacing = 'reader_paragraph_spacing';
|
||||
static const _kTextAlign = 'reader_text_align';
|
||||
@@ -24,6 +26,8 @@ class LocalStore {
|
||||
await prefs.setDouble(_kLetterSpacing, settings.letterSpacing);
|
||||
await prefs.setString(_kFontFamily, settings.fontFamily);
|
||||
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(_kParagraphSpacing, settings.paragraphSpacing);
|
||||
await prefs.setString(_kTextAlign, settings.textAlign);
|
||||
@@ -32,15 +36,29 @@ class LocalStore {
|
||||
Future<ReadingSettings?> loadReadingSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
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(
|
||||
fontSize: prefs.getDouble(_kFontSize) ?? 18,
|
||||
lineHeight: prefs.getDouble(_kLineHeight) ?? 1.8,
|
||||
letterSpacing: prefs.getDouble(_kLetterSpacing) ?? 0,
|
||||
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,
|
||||
paragraphSpacing: prefs.getDouble(_kParagraphSpacing) ?? 24,
|
||||
textAlign: prefs.getString(_kTextAlign) ?? 'justify',
|
||||
textAlign: prefs.getString(_kTextAlign) ?? 'left',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/router/route_names.dart';
|
||||
import '../../../core/models/bookmark_model.dart';
|
||||
import '../../../shared/widgets/main_app_header.dart';
|
||||
import '../providers/bookshelf_provider.dart';
|
||||
import '../../auth/providers/auth_provider.dart';
|
||||
|
||||
@@ -17,21 +18,30 @@ class BookshelfScreen extends ConsumerWidget {
|
||||
|
||||
if (!isAuth) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Tủ sách')),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.lock_outline, size: 48),
|
||||
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'),
|
||||
body: Column(
|
||||
children: [
|
||||
const MainAppHeader(title: 'Đăng truyện'),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.lock_outline_rounded, size: 54),
|
||||
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,53 +49,108 @@ class BookshelfScreen extends ConsumerWidget {
|
||||
final bookshelfAsync = ref.watch(bookshelfProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Tủ sách'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () => ref.read(bookshelfProvider.notifier).fetch(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: bookshelfAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48),
|
||||
const SizedBox(height: 8),
|
||||
Text('Lỗi: $e'),
|
||||
TextButton(
|
||||
onPressed: () => ref.read(bookshelfProvider.notifier).fetch(),
|
||||
child: const Text('Thử lại'),
|
||||
body: DefaultTabController(
|
||||
length: 3,
|
||||
child: Column(
|
||||
children: [
|
||||
MainAppHeader(
|
||||
title: 'Đăng truyện',
|
||||
bottom: Container(
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF14B8A6),
|
||||
borderRadius: BorderRadius.circular(0),
|
||||
),
|
||||
child: TabBar(
|
||||
indicatorColor: const Color(0xFFF7B500),
|
||||
indicatorWeight: 3,
|
||||
labelColor: Colors.white,
|
||||
unselectedLabelColor: Colors.white70,
|
||||
dividerColor: Colors.transparent,
|
||||
tabs: const [
|
||||
Tab(text: 'Đã đọc'),
|
||||
Tab(text: 'Đã lưu'),
|
||||
Tab(text: 'Đang mở'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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 readItems = bookmarks.where((e) => e.readChapters.isNotEmpty).toList();
|
||||
final savedItems = bookmarks;
|
||||
final openingItems = bookmarks.where((e) => e.lastChapterId != null).toList();
|
||||
|
||||
return TabBarView(
|
||||
children: [
|
||||
_BookshelfList(
|
||||
bookmarks: readItems,
|
||||
emptyLabel: 'Chưa có truyện đã đọc.',
|
||||
),
|
||||
_BookshelfList(
|
||||
bookmarks: savedItems,
|
||||
emptyLabel: 'Chưa có truyện nào trong tủ sách.',
|
||||
),
|
||||
_BookshelfList(
|
||||
bookmarks: openingItems,
|
||||
emptyLabel: 'Chưa có truyện đang mở.',
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BookshelfList extends ConsumerWidget {
|
||||
const _BookshelfList({required this.bookmarks, required this.emptyLabel});
|
||||
|
||||
final List<BookmarkModel> bookmarks;
|
||||
final String emptyLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (bookmarks.isEmpty) {
|
||||
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) => _BookmarkTile(bookmark: bookmarks[index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -98,32 +163,117 @@ class _BookmarkTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final novel = bookmark.novel;
|
||||
return ListTile(
|
||||
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),
|
||||
return 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),
|
||||
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: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
|
||||
icon: const Icon(Icons.menu_book_rounded),
|
||||
label: const Text('Đọc tiếp'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF14B8A6),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
|
||||
icon: const Icon(Icons.headphones_rounded),
|
||||
label: const Text('Nghe tiếp'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF14B8A6),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.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 '../../../app/router/route_names.dart';
|
||||
import '../../../core/models/novel_model.dart';
|
||||
import '../../../shared/widgets/main_app_header.dart';
|
||||
import '../providers/home_provider.dart';
|
||||
|
||||
class HomeScreen extends ConsumerWidget {
|
||||
@@ -13,62 +16,67 @@ class HomeScreen extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final homeAsync = ref.watch(homeProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Reader'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () => context.go(RouteNames.search),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: homeAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
Text('Lỗi tải dữ liệu', style: Theme.of(context).textTheme.bodyLarge),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 0),
|
||||
child: Text(
|
||||
e.toString(),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
backgroundColor: colorScheme.surface,
|
||||
body: Column(
|
||||
children: [
|
||||
const MainAppHeader(),
|
||||
Expanded(
|
||||
child: homeAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.cloud_off_rounded, size: 52),
|
||||
const SizedBox(height: 12),
|
||||
Text('Không thể tải dữ liệu trang chủ'),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
e.toString(),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: () => ref.invalidate(homeProvider),
|
||||
child: const Text('Tải lại'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => ref.invalidate(homeProvider),
|
||||
child: const Text('Thử lại'),
|
||||
data: (data) => RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(homeProvider),
|
||||
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
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 8, 8),
|
||||
padding: const EdgeInsets.fromLTRB(18, 18, 12, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
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 {
|
||||
final List<NovelModel> novels;
|
||||
const _HotCarousel({required this.novels});
|
||||
@@ -103,10 +175,58 @@ class _HotCarousel extends StatefulWidget {
|
||||
}
|
||||
|
||||
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
|
||||
void dispose() {
|
||||
_autoSlideTimer?.cancel();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -115,17 +235,49 @@ class _HotCarouselState extends State<_HotCarousel> {
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.novels.isEmpty) return const SizedBox.shrink();
|
||||
return SizedBox(
|
||||
height: 220,
|
||||
child: PageView.builder(
|
||||
controller: _controller,
|
||||
itemCount: widget.novels.length,
|
||||
itemBuilder: (context, index) {
|
||||
final novel = widget.novels[index];
|
||||
return GestureDetector(
|
||||
onTap: () => context.push(RouteNames.novelDetail(novel.id)),
|
||||
child: _CarouselCard(novel: novel),
|
||||
);
|
||||
},
|
||||
height: 260,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: ClipRect(
|
||||
child: PageView.builder(
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 12),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (novel.coverUrl != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: novel.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, imageUrl) => Container(color: Colors.grey[200]),
|
||||
errorWidget: (_, imageUrl, error) =>
|
||||
Container(color: Colors.grey[300]),
|
||||
)
|
||||
else
|
||||
Container(color: Theme.of(context).colorScheme.primaryContainer),
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, Colors.black.withAlpha(180)],
|
||||
),
|
||||
),
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (novel.coverUrl != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: novel.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, imageUrl) => Container(color: Colors.grey[200]),
|
||||
errorWidget: (_, imageUrl, error) => Container(color: Colors.grey[300]),
|
||||
)
|
||||
else
|
||||
Container(color: Theme.of(context).colorScheme.primaryContainer),
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, Colors.black.withAlpha(180)],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 12,
|
||||
left: 12,
|
||||
right: 12,
|
||||
child: Text(
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 12,
|
||||
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,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
fontSize: 20,
|
||||
),
|
||||
maxLines: 2,
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 200,
|
||||
height: 226,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: novels.length,
|
||||
separatorBuilder: (_, separatorIndex) => const SizedBox(width: 12),
|
||||
@@ -205,32 +381,45 @@ class _NovelHorizontalList extends StatelessWidget {
|
||||
return GestureDetector(
|
||||
onTap: () => context.push(RouteNames.novelDetail(novel.id)),
|
||||
child: SizedBox(
|
||||
width: 110,
|
||||
width: 122,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: novel.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: novel.coverUrl!,
|
||||
width: 110,
|
||||
height: 150,
|
||||
width: 122,
|
||||
height: 155,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: Container(
|
||||
width: 110,
|
||||
height: 150,
|
||||
width: 122,
|
||||
height: 155,
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
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(
|
||||
novel.title,
|
||||
maxLines: 2,
|
||||
'${novel.totalChapters} chương',
|
||||
maxLines: 1,
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/router/route_names.dart';
|
||||
import '../../../shared/widgets/main_app_header.dart';
|
||||
import '../../auth/providers/auth_provider.dart';
|
||||
import '../../bookshelf/providers/bookshelf_provider.dart';
|
||||
|
||||
@@ -22,151 +23,180 @@ class ProfileScreen extends ConsumerWidget {
|
||||
: '';
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Tài khoản')),
|
||||
body: switch (authState) {
|
||||
AuthAuthenticated(:final user) => SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// User Avatar & Basic Info
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
const MainAppHeader(title: 'Trang cá nhân', showGenresShortcut: false),
|
||||
Expanded(
|
||||
child: switch (authState) {
|
||||
AuthAuthenticated(:final user) => SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 40,
|
||||
backgroundImage:
|
||||
user.image != null ? NetworkImage(user.image!) : null,
|
||||
child: user.image == null
|
||||
? Text(
|
||||
displayName[0].toUpperCase(),
|
||||
style:
|
||||
Theme.of(context).textTheme.headlineMedium,
|
||||
)
|
||||
: null,
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
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),
|
||||
Text(
|
||||
displayName,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
const SizedBox(height: 18),
|
||||
_ProfileMenuTile(
|
||||
title: 'Chỉnh sửa thông tin',
|
||||
onTap: () => context.push(RouteNames.settings),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
user.email,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
_ProfileMenuTile(
|
||||
title: 'Lịch sử giao dịch',
|
||||
onTap: () {},
|
||||
),
|
||||
_ProfileMenuTile(
|
||||
title: 'Liên hệ, báo lỗi',
|
||||
onTap: () {},
|
||||
),
|
||||
_ProfileMenuTile(
|
||||
title: 'Điều khoản dịch vụ',
|
||||
onTap: () {},
|
||||
),
|
||||
_ProfileMenuTile(
|
||||
title: 'Xóa tài khoản',
|
||||
onTap: () {},
|
||||
),
|
||||
_ProfileMenuTile(
|
||||
title: 'Đăng xuất',
|
||||
onTap: () async {
|
||||
await ref.read(authProvider.notifier).signOut();
|
||||
if (context.mounted) context.go(RouteNames.home);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Stats Cards
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
context: context,
|
||||
label: 'Sách Đánh Dấu',
|
||||
count: bookmarkedCount,
|
||||
),
|
||||
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 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),
|
||||
|
||||
// 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,
|
||||
_ => const Center(child: CircularProgressIndicator()),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -61,6 +61,21 @@ class ReaderNotifier extends StateNotifier<ReadingProgress?> {
|
||||
scrollOffset: 0,
|
||||
);
|
||||
}
|
||||
|
||||
void resetCurrentChapterProgress() {
|
||||
if (state == null) return;
|
||||
|
||||
state = ReadingProgress(
|
||||
novelId: state!.novelId,
|
||||
chapterId: state!.chapterId,
|
||||
chapterNumber: state!.chapterNumber,
|
||||
scrollOffset: 0,
|
||||
);
|
||||
|
||||
// Persist immediately so a freshly opened chapter always resumes at top.
|
||||
unawaited(_persistProgress(state!.chapterId, state!.chapterNumber, 0));
|
||||
}
|
||||
|
||||
void updateScroll(double offset) {
|
||||
if (state == null) return;
|
||||
state = ReadingProgress(
|
||||
@@ -88,14 +103,20 @@ class ReaderNotifier extends StateNotifier<ReadingProgress?> {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
DateTime? _lastUpdate;
|
||||
Future<void> _debounceUpdate(double offset) async {
|
||||
final now = DateTime.now();
|
||||
if (_lastUpdate != null && now.difference(_lastUpdate!).inSeconds < 3) return;
|
||||
_lastUpdate = now;
|
||||
if (state != null) {
|
||||
await _persistProgress(state!.chapterId, state!.chapterNumber, offset);
|
||||
}
|
||||
Timer? _debounceTimer;
|
||||
void _debounceUpdate(double offset) {
|
||||
_debounceTimer?.cancel();
|
||||
_debounceTimer = Timer(const Duration(seconds: 3), () {
|
||||
if (state != null) {
|
||||
unawaited(_persistProgress(state!.chapterId, state!.chapterNumber, offset));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_tts/flutter_tts.dart';
|
||||
|
||||
enum TtsStatus { idle, playing, paused, stopped }
|
||||
|
||||
class TtsState {
|
||||
final TtsStatus status;
|
||||
final int currentSentenceIndex;
|
||||
final List<String> sentences;
|
||||
final double speechRate;
|
||||
final double volume;
|
||||
final double pitch;
|
||||
final String? currentLanguage;
|
||||
|
||||
const TtsState({
|
||||
this.status = TtsStatus.idle,
|
||||
this.currentSentenceIndex = 0,
|
||||
this.sentences = const [],
|
||||
this.speechRate = 0.5,
|
||||
this.volume = 1.0,
|
||||
this.pitch = 1.0,
|
||||
this.currentLanguage,
|
||||
});
|
||||
|
||||
TtsState copyWith({
|
||||
TtsStatus? status,
|
||||
int? currentSentenceIndex,
|
||||
List<String>? sentences,
|
||||
double? speechRate,
|
||||
double? volume,
|
||||
double? pitch,
|
||||
String? currentLanguage,
|
||||
}) {
|
||||
return TtsState(
|
||||
status: status ?? this.status,
|
||||
currentSentenceIndex: currentSentenceIndex ?? this.currentSentenceIndex,
|
||||
sentences: sentences ?? this.sentences,
|
||||
speechRate: speechRate ?? this.speechRate,
|
||||
volume: volume ?? this.volume,
|
||||
pitch: pitch ?? this.pitch,
|
||||
currentLanguage: currentLanguage ?? this.currentLanguage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TtsNotifier extends Notifier<TtsState> {
|
||||
late FlutterTts _tts;
|
||||
|
||||
@override
|
||||
TtsState build() {
|
||||
_tts = FlutterTts();
|
||||
_initTts();
|
||||
ref.onDispose(() async {
|
||||
await _tts.stop();
|
||||
});
|
||||
return const TtsState();
|
||||
}
|
||||
|
||||
Future<void> _initTts() async {
|
||||
await _tts.setLanguage('vi-VN');
|
||||
await _tts.setSpeechRate(state.speechRate);
|
||||
await _tts.setVolume(state.volume);
|
||||
await _tts.setPitch(state.pitch);
|
||||
// Do NOT use awaitSpeakCompletion(true) — it blocks the Dart↔native channel
|
||||
// between sentences, causing Android TTS service to disconnect.
|
||||
await _tts.awaitSpeakCompletion(false);
|
||||
|
||||
_tts.setCompletionHandler(_onSentenceComplete);
|
||||
|
||||
_tts.setCancelHandler(() {
|
||||
if (state.status == TtsStatus.playing) {
|
||||
state = state.copyWith(status: TtsStatus.stopped);
|
||||
}
|
||||
});
|
||||
|
||||
_tts.setErrorHandler((msg) {
|
||||
state = state.copyWith(status: TtsStatus.stopped);
|
||||
});
|
||||
}
|
||||
|
||||
void _onSentenceComplete() {
|
||||
if (state.status != TtsStatus.playing) return;
|
||||
final nextIndex = state.currentSentenceIndex + 1;
|
||||
if (nextIndex < state.sentences.length) {
|
||||
state = state.copyWith(currentSentenceIndex: nextIndex);
|
||||
_speakCurrent();
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
status: TtsStatus.stopped, currentSentenceIndex: 0);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _speakCurrent() async {
|
||||
if (state.sentences.isEmpty) return;
|
||||
if (state.status != TtsStatus.playing) return;
|
||||
final sentence = state.sentences[state.currentSentenceIndex];
|
||||
await _tts.speak(sentence);
|
||||
}
|
||||
|
||||
Future<void> play(List<String> sentences) async {
|
||||
await _tts.stop();
|
||||
state = state.copyWith(
|
||||
sentences: sentences,
|
||||
currentSentenceIndex: 0,
|
||||
status: TtsStatus.playing,
|
||||
);
|
||||
await _speakCurrent();
|
||||
}
|
||||
|
||||
Future<void> pause() async {
|
||||
state = state.copyWith(status: TtsStatus.paused);
|
||||
await _tts.pause();
|
||||
}
|
||||
|
||||
Future<void> resume() async {
|
||||
state = state.copyWith(status: TtsStatus.playing);
|
||||
await _speakCurrent();
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
state = state.copyWith(status: TtsStatus.stopped, currentSentenceIndex: 0);
|
||||
await _tts.stop();
|
||||
}
|
||||
|
||||
Future<void> setSpeechRate(double rate) async {
|
||||
await _tts.setSpeechRate(rate);
|
||||
state = state.copyWith(speechRate: rate);
|
||||
}
|
||||
}
|
||||
|
||||
final ttsProvider = NotifierProvider<TtsNotifier, TtsState>(TtsNotifier.new);
|
||||
@@ -8,44 +8,122 @@ class AppShell extends StatelessWidget {
|
||||
|
||||
final Widget child;
|
||||
|
||||
int _indexForLocation(String location) {
|
||||
if (location.startsWith(RouteNames.search)) return 1;
|
||||
if (location.startsWith(RouteNames.bookshelf)) return 2;
|
||||
if (location.startsWith(RouteNames.genres)) return 3;
|
||||
if (location.startsWith(RouteNames.profile)) return 4;
|
||||
return 0;
|
||||
String _tabForLocation(String location) {
|
||||
if (location.startsWith(RouteNames.bookshelf)) return RouteNames.bookshelf;
|
||||
if (location.startsWith(RouteNames.genres)) return RouteNames.genres;
|
||||
if (location.startsWith(RouteNames.profile)) return RouteNames.profile;
|
||||
return RouteNames.home;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final location = GoRouterState.of(context).uri.path;
|
||||
final selectedIndex = _indexForLocation(location);
|
||||
final selectedTab = _tabForLocation(location);
|
||||
|
||||
return Scaffold(
|
||||
body: child,
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: selectedIndex,
|
||||
onDestinationSelected: (index) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
context.go(RouteNames.home);
|
||||
case 1:
|
||||
context.go(RouteNames.search);
|
||||
case 2:
|
||||
context.go(RouteNames.bookshelf);
|
||||
case 3:
|
||||
context.go(RouteNames.genres);
|
||||
case 4:
|
||||
context.go(RouteNames.profile);
|
||||
}
|
||||
},
|
||||
destinations: const [
|
||||
NavigationDestination(icon: Icon(Icons.home_outlined), label: 'Home'),
|
||||
NavigationDestination(icon: Icon(Icons.search), label: 'Tim kiem'),
|
||||
NavigationDestination(icon: Icon(Icons.bookmark_border), label: 'Tu sach'),
|
||||
NavigationDestination(icon: Icon(Icons.category_outlined), label: 'The loai'),
|
||||
NavigationDestination(icon: Icon(Icons.person_outline), label: 'Tai khoan'),
|
||||
],
|
||||
bottomNavigationBar: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
border: Border(
|
||||
top: BorderSide(color: colorScheme.outlineVariant.withAlpha(80)),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 8, 10, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
_ShellNavItem(
|
||||
icon: Icons.home_rounded,
|
||||
label: 'Trang chủ',
|
||||
selected: selectedTab == RouteNames.home,
|
||||
onTap: () => context.go(RouteNames.home),
|
||||
),
|
||||
_ShellNavItem(
|
||||
icon: Icons.layers_rounded,
|
||||
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
|
||||
# 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.
|
||||
version: 1.0.0+1
|
||||
version: 1.0.2+3
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.3
|
||||
@@ -78,6 +78,9 @@ flutter:
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
assets:
|
||||
- assets/app_icon.png
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
|
||||
Reference in New Issue
Block a user