Просмотр исходного кода

[优化] 重写书籍详情页、本地书籍页和优化一些界面,为Miuix增加自定义取色功能,优化颜色选择器

HapeLee 7 месяцев назад
Родитель
Сommit
1bd61c719a
48 измененных файлов с 4268 добавлено и 2058 удалено
  1. 2 1
      app/src/main/AndroidManifest.xml
  2. 1 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  3. 6 0
      app/src/main/java/io/legado/app/di/appModule.kt
  4. 14 2
      app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverViewModel.kt
  5. 65 0
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt
  6. 23 5
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt
  7. 32 0
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceConfig.kt
  8. 71 0
      app/src/main/java/io/legado/app/ui/book/changesource/ObservableSourceConfig.kt
  9. 96 1075
      app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt
  10. 154 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt
  11. 1013 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt
  12. 510 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt
  13. 877 318
      app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt
  14. 5 1
      app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt
  15. 5 9
      app/src/main/java/io/legado/app/ui/config/themeConfig/LauncherIconPickerSheet.kt
  16. 4 0
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt
  17. 74 37
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt
  18. 17 31
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeListDialog.kt
  19. 19 0
      app/src/main/java/io/legado/app/ui/theme/AppContentColor.kt
  20. 121 129
      app/src/main/java/io/legado/app/ui/theme/AppTheme.kt
  21. 1 1
      app/src/main/java/io/legado/app/ui/theme/AppThemeMode.kt
  22. 137 0
      app/src/main/java/io/legado/app/ui/theme/ImageSeedColorExtractor.kt
  23. 2 2
      app/src/main/java/io/legado/app/ui/theme/OpaqueColorScheme.kt
  24. 242 0
      app/src/main/java/io/legado/app/ui/theme/ThemeColorSchemeOverride.kt
  25. 142 0
      app/src/main/java/io/legado/app/ui/theme/ThemeEngine.kt
  26. 0 97
      app/src/main/java/io/legado/app/ui/theme/ThemeManager.kt
  27. 100 0
      app/src/main/java/io/legado/app/ui/theme/ThemeOverride.kt
  28. 101 42
      app/src/main/java/io/legado/app/ui/theme/ThemeResolver.kt
  29. 8 1
      app/src/main/java/io/legado/app/ui/widget/components/AppFloatingActionButton.kt
  30. 17 3
      app/src/main/java/io/legado/app/ui/widget/components/alert/AppAlertDialog.kt
  31. 50 1
      app/src/main/java/io/legado/app/ui/widget/components/button/ConfirmDismissButtonsRow.kt
  32. 6 2
      app/src/main/java/io/legado/app/ui/widget/components/button/MediumIconButton.kt
  33. 2 3
      app/src/main/java/io/legado/app/ui/widget/components/button/TopBarButton.kt
  34. 6 7
      app/src/main/java/io/legado/app/ui/widget/components/card/GlassCard.kt
  35. 28 18
      app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt
  36. 1 1
      app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt
  37. 99 123
      app/src/main/java/io/legado/app/ui/widget/components/dialog/ColorPickerSheet.kt
  38. 15 6
      app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenu.kt
  39. 56 19
      app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenuItem.kt
  40. 44 14
      app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt
  41. 25 48
      app/src/main/java/io/legado/app/ui/widget/components/settingItem/InputSettingItem.kt
  42. 55 60
      app/src/main/java/io/legado/app/ui/widget/components/settingItem/SliderSettingItem.kt
  43. 1 1
      app/src/main/java/io/legado/app/ui/widget/components/topbar/MiuixScrollBehavior.kt
  44. 1 1
      app/src/main/res/layout/dialog_content_edit.xml
  45. 5 0
      app/src/main/res/values-zh-rCN/strings.xml
  46. 5 0
      app/src/main/res/values-zh-rHK/strings.xml
  47. 5 0
      app/src/main/res/values-zh-rTW/strings.xml
  48. 5 0
      app/src/main/res/values/strings.xml

+ 2 - 1
app/src/main/AndroidManifest.xml

@@ -176,7 +176,8 @@
         <activity
             android:name=".ui.book.info.edit.BookInfoEditActivity"
             android:enableOnBackInvokedCallback="true"
-            android:launchMode="singleTask" />
+            android:launchMode="singleTask"
+            android:windowSoftInputMode="adjustResize|stateHidden" />
         <!-- 音频播放界面 -->
         <activity
             android:name="io.legado.app.ui.book.audio.AudioPlayActivity"

+ 1 - 0
app/src/main/java/io/legado/app/constant/PreferKey.kt

@@ -185,6 +185,7 @@ object PreferKey {
     const val paletteStyle = "paletteStyle"
     const val materialVersion = "materialVersion"
     const val composeEngine = "composeEngine"
+    const val useMiuixMonet = "useMiuixMonet"
     const val containerOpacity = "containerOpacity"
     const val topBarOpacity = "topBarOpacity"
     const val bottomBarOpacity = "bottomBarOpacity"

+ 6 - 0
app/src/main/java/io/legado/app/di/appModule.kt

@@ -20,6 +20,9 @@ import io.legado.app.help.coil.CoverInterceptor
 import io.legado.app.help.http.okHttpClient
 import io.legado.app.help.http.okHttpClientManga
 import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
+import io.legado.app.ui.book.changecover.ChangeCoverViewModel
+import io.legado.app.ui.book.changesource.ChangeBookSourceComposeViewModel
+import io.legado.app.ui.book.changesource.ChangeBookSourceViewModel
 import io.legado.app.ui.book.explore.ExploreShowViewModel
 import io.legado.app.ui.book.group.GroupViewModel
 import io.legado.app.ui.book.import.local.ImportBookViewModel
@@ -101,6 +104,9 @@ val appModule = module {
     viewModelOf(::ImportBookViewModel)
     viewModelOf(::RemoteBookViewModel)
     viewModelOf(::BookInfoViewModel)
+    viewModelOf(::ChangeCoverViewModel)
+    viewModelOf(::ChangeBookSourceComposeViewModel)
+    viewModelOf(::ChangeBookSourceViewModel)
     viewModelOf(::ExploreViewModel)
     viewModelOf(::RssViewModel)
 

+ 14 - 2
app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverViewModel.kt

@@ -25,8 +25,10 @@ import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.collect
 import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.onCompletion
 import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withTimeout
 import java.util.Collections
@@ -51,6 +53,8 @@ class ChangeCoverViewModel(application: Application) : BaseViewModel(application
     }
     private var task: Job? = null
     val searchStateData = MutableLiveData<Boolean>()
+    private val _isSearching = MutableStateFlow(false)
+    val isSearching = _isSearching.asStateFlow()
     var name: String = ""
     var author: String = ""
     val searchBooks: MutableList<SearchBook> = Collections.synchronizedList(arrayListOf())
@@ -94,6 +98,11 @@ class ChangeCoverViewModel(application: Application) : BaseViewModel(application
         }
     }
 
+    fun initData(name: String, author: String) {
+        this.name = name
+        this.author = author.replace(AppPattern.authorRegex, "")
+    }
+
     private fun initSearchPool() {
         searchPool = Executors
             .newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
@@ -121,12 +130,14 @@ class ChangeCoverViewModel(application: Application) : BaseViewModel(application
                 }
             }.onStart {
                 searchStateData.postValue(true)
+                _isSearching.value = true
             }.mapParallelSafe(threadCount) {
                 withTimeout(60000L) {
                     search(it)
                 }
             }.onCompletion {
                 searchStateData.postValue(false)
+                _isSearching.value = false
             }.catch {
                 AppLog.put("封面换源搜索出错\n${it.localizedMessage}", it)
             }.collect()
@@ -156,10 +167,11 @@ class ChangeCoverViewModel(application: Application) : BaseViewModel(application
         }
     }
 
-    private fun stopSearch() {
+    fun stopSearch() {
         task?.cancel()
         searchPool?.close()
         searchStateData.postValue(false)
+        _isSearching.value = false
     }
 
     override fun onCleared() {
@@ -167,4 +179,4 @@ class ChangeCoverViewModel(application: Application) : BaseViewModel(application
         searchPool?.close()
     }
 
-}
+}

+ 65 - 0
app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt

@@ -0,0 +1,65 @@
+package io.legado.app.ui.book.changesource
+
+import android.app.Application
+import io.legado.app.data.entities.SearchBook
+import kotlinx.coroutines.flow.StateFlow
+
+class ChangeBookSourceComposeViewModel(application: Application) :
+    ChangeBookSourceViewModel(application) {
+
+    val searchGroup: String
+        get() = ChangeSourceConfig.searchGroup
+
+    val checkAuthor: Boolean
+        get() = ChangeSourceConfig.checkAuthor
+
+    val loadInfo: Boolean
+        get() = ChangeSourceConfig.loadInfo
+
+    val loadToc: Boolean
+        get() = ChangeSourceConfig.loadToc
+
+    val loadWordCount: Boolean
+        get() = ChangeSourceConfig.loadWordCount
+
+    fun onSearchGroupSelected(group: String) {
+        if (ChangeSourceConfig.searchGroup == group) return
+        ChangeSourceConfig.searchGroup = group
+        if (refresh()) startSearch()
+    }
+
+    fun onCheckAuthorChange(enabled: Boolean) {
+        if (ChangeSourceConfig.checkAuthor == enabled) return
+        ChangeSourceConfig.checkAuthor = enabled
+        refresh()
+    }
+
+    fun onLoadInfoChange(enabled: Boolean) {
+        if (ChangeSourceConfig.loadInfo == enabled) return
+        ChangeSourceConfig.loadInfo = enabled
+    }
+
+    fun onLoadTocChange(enabled: Boolean) {
+        if (ChangeSourceConfig.loadToc == enabled) return
+        ChangeSourceConfig.loadToc = enabled
+    }
+
+    fun onLoadWordCountChange(enabled: Boolean) {
+        if (ChangeSourceConfig.loadWordCount == enabled) return
+        ChangeSourceConfig.loadWordCount = enabled
+        if (enabled) {
+            onLoadWordCountChecked(true)
+        } else {
+            refresh()
+        }
+    }
+
+    fun bookScoreFlow(searchBook: SearchBook): StateFlow<Int> {
+        return ObservableSourceConfig.bookScoreFlow(searchBook)
+    }
+
+    fun onBookScoreClick(searchBook: SearchBook) {
+        val currentScore = ObservableSourceConfig.getBookScore(searchBook)
+        setBookScore(searchBook, if (currentScore > 0) 0 else 1)
+    }
+}

+ 23 - 5
app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt

@@ -62,6 +62,8 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
     private val threadCount = AppConfig.threadCount
     private var searchPool: ExecutorCoroutineDispatcher? = null
     val searchStateData = MutableLiveData<Boolean>()
+    private val _isSearching = MutableStateFlow(false)
+    val isSearching = _isSearching.asStateFlow()
     var searchFinishCallback: ((isEmpty: Boolean) -> Unit)? = null
     var name: String = ""
     var author: String = ""
@@ -163,6 +165,19 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
         }
     }
 
+    @CallSuper
+    open fun initData(
+        name: String,
+        author: String,
+        book: Book?,
+        fromReadBookActivity: Boolean
+    ) {
+        this.name = name
+        this.author = author.replace(AppPattern.authorRegex, "")
+        this.fromReadBookActivity = fromReadBookActivity
+        oldBook = book
+    }
+
     private fun initSearchPool() {
         searchPool = Executors
             .newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
@@ -234,6 +249,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
                 }
             }.onStart {
                 searchStateData.postValue(true)
+                _isSearching.value = true
             }.mapParallel(threadCount) {
                 try {
                     withTimeout(60000L) {
@@ -250,6 +266,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
             }.onCompletion {
                 ensureActive()
                 searchStateData.postValue(false)
+                _isSearching.value = false
                 searchFinishCallback?.invoke(searchBooks.isEmpty())
             }.catch {
                 AppLog.put("换源搜索出错\n${it.localizedMessage}", it)
@@ -383,6 +400,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
                 }
             }.onStart {
                 searchStateData.postValue(true)
+                _isSearching.value = true
             }.mapParallelSafe(threadCount) {
                 val source = appDb.bookSourceDao.getBookSource(it.origin)!!
                 withTimeout(60000L) {
@@ -390,6 +408,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
                 }
             }.onCompletion {
                 searchStateData.postValue(false)
+                _isSearching.value = false
             }.catch {
                 AppLog.put("换源刷新列表出错\n${it.localizedMessage}", it)
             }.collect()
@@ -446,6 +465,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
         task?.cancel()
         searchPool?.close()
         searchStateData.postValue(false)
+        _isSearching.value = false
     }
 
     fun getToc(
@@ -554,10 +574,8 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
     }
 
     fun setBookScore(searchBook: SearchBook, score: Int) {
-        execute {
-            SourceConfig.setBookScore(searchBook.origin, searchBook.name, searchBook.author, score)
-            searchCallback?.upAdapter()
-        }
+        SourceConfig.setBookScore(searchBook.origin, searchBook.name, searchBook.author, score)
+        searchCallback?.upAdapter()
     }
 
     fun getBookScore(searchBook: SearchBook): Int {
@@ -577,4 +595,4 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
 
     }
 
-}
+}

+ 32 - 0
app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceConfig.kt

@@ -0,0 +1,32 @@
+package io.legado.app.ui.book.changesource
+
+import io.legado.app.constant.PreferKey
+import io.legado.app.ui.config.prefDelegate
+
+object ChangeSourceConfig {
+
+    var searchGroup by prefDelegate(
+        key = "searchGroup",
+        defaultValue = ""
+    )
+
+    var checkAuthor by prefDelegate(
+        key = PreferKey.changeSourceCheckAuthor,
+        defaultValue = false
+    )
+
+    var loadInfo by prefDelegate(
+        key = PreferKey.changeSourceLoadInfo,
+        defaultValue = false
+    )
+
+    var loadToc by prefDelegate(
+        key = PreferKey.changeSourceLoadToc,
+        defaultValue = false
+    )
+
+    var loadWordCount by prefDelegate(
+        key = PreferKey.changeSourceLoadWordCount,
+        defaultValue = false
+    )
+}

+ 71 - 0
app/src/main/java/io/legado/app/ui/book/changesource/ObservableSourceConfig.kt

@@ -0,0 +1,71 @@
+package io.legado.app.ui.book.changesource
+
+import android.content.Context.MODE_PRIVATE
+import android.content.SharedPreferences
+import io.legado.app.data.entities.SearchBook
+import io.legado.app.help.config.SourceConfig
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import splitties.init.appCtx
+import java.util.concurrent.ConcurrentHashMap
+
+object ObservableSourceConfig : SharedPreferences.OnSharedPreferenceChangeListener {
+
+    private val sp = appCtx.getSharedPreferences("SourceConfig", MODE_PRIVATE)
+    private val bookScoreFlows = ConcurrentHashMap<String, MutableStateFlow<Int>>()
+    private val sourceScoreFlows = ConcurrentHashMap<String, MutableStateFlow<Int>>()
+
+    init {
+        sp.registerOnSharedPreferenceChangeListener(this)
+    }
+
+    fun bookScoreFlow(searchBook: SearchBook): StateFlow<Int> {
+        return bookScoreFlow(searchBook.origin, searchBook.name, searchBook.author)
+    }
+
+    fun bookScoreFlow(origin: String, name: String, author: String): StateFlow<Int> {
+        val key = bookScoreKey(origin, name, author)
+        return bookScoreFlows.getOrPut(key) {
+            MutableStateFlow(sp.getInt(key, 0))
+        }.asStateFlow()
+    }
+
+    fun sourceScoreFlow(origin: String): StateFlow<Int> {
+        return sourceScoreFlows.getOrPut(origin) {
+            MutableStateFlow(sp.getInt(origin, 0))
+        }.asStateFlow()
+    }
+
+    fun getBookScore(searchBook: SearchBook): Int {
+        return bookScoreFlow(searchBook).value
+    }
+
+    fun setBookScore(searchBook: SearchBook, score: Int) {
+        setBookScore(searchBook.origin, searchBook.name, searchBook.author, score)
+    }
+
+    fun setBookScore(origin: String, name: String, author: String, score: Int) {
+        SourceConfig.setBookScore(origin, name, author, score)
+        syncBookScore(bookScoreKey(origin, name, author))
+        syncSourceScore(origin)
+    }
+
+    override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
+        key ?: return
+        syncBookScore(key)
+        syncSourceScore(key)
+    }
+
+    private fun syncBookScore(key: String) {
+        bookScoreFlows[key]?.value = sp.getInt(key, 0)
+    }
+
+    private fun syncSourceScore(origin: String) {
+        sourceScoreFlows[origin]?.value = sp.getInt(origin, 0)
+    }
+
+    private fun bookScoreKey(origin: String, name: String, author: String): String {
+        return "${origin}_${name}_${author}"
+    }
+}

+ 96 - 1075
app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt

@@ -1,131 +1,43 @@
 package io.legado.app.ui.book.info
 
-import android.animation.ValueAnimator
-import android.annotation.SuppressLint
-import android.content.Context
 import android.content.Intent
-import android.content.res.ColorStateList
-import android.content.res.Configuration
-import android.graphics.Color
-import android.graphics.RenderEffect
-import android.graphics.Shader
-import android.graphics.drawable.BitmapDrawable
-import android.graphics.drawable.Drawable
-import android.graphics.drawable.GradientDrawable
-import android.graphics.drawable.TransitionDrawable
-import android.net.Uri
-import android.os.Build
 import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import android.view.View
-import android.widget.CheckBox
-import android.widget.LinearLayout
-import androidx.activity.addCallback
-import androidx.core.app.ActivityCompat
-import androidx.core.app.ActivityOptionsCompat
-import androidx.core.content.ContextCompat
-import androidx.core.graphics.scale
-import androidx.core.view.doOnPreDraw
-import androidx.lifecycle.lifecycleScope
-import androidx.transition.TransitionManager
-import com.google.android.material.color.DynamicColors
-import com.google.android.material.color.DynamicColorsOptions
-import com.google.android.material.color.MaterialColors
-import com.google.android.material.transition.platform.MaterialContainerTransform
-import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
-import io.legado.app.base.VMBaseActivity
-import io.legado.app.constant.BookType
-import io.legado.app.data.appDb
-import io.legado.app.data.entities.Book
-import io.legado.app.data.entities.BookChapter
-import io.legado.app.data.entities.BookSource
-import io.legado.app.databinding.ActivityBookInfoBinding
-import io.legado.app.databinding.DialogEditTextBinding
-import io.legado.app.help.book.addType
+import io.legado.app.base.BaseComposeActivity
+import io.legado.app.help.config.AppConfig
 import io.legado.app.help.book.isAudio
 import io.legado.app.help.book.isImage
 import io.legado.app.help.book.isLocal
-import io.legado.app.help.book.isLocalTxt
-import io.legado.app.help.book.isWebFile
-import io.legado.app.help.book.removeType
-import io.legado.app.help.config.AppConfig
-import io.legado.app.help.config.LocalConfig
-import io.legado.app.lib.dialogs.alert
-import io.legado.app.lib.dialogs.selector
-import io.legado.app.model.BookCover
 import io.legado.app.model.SourceCallBack
-import io.legado.app.ui.about.AppLogDialog
 import io.legado.app.ui.book.audio.AudioPlayActivity
-import io.legado.app.ui.book.changecover.ChangeCoverDialog
-import io.legado.app.ui.book.changesource.ChangeBookSourceDialog
-import io.legado.app.ui.book.group.GroupSelectDialog
 import io.legado.app.ui.book.info.edit.BookInfoEditActivity
 import io.legado.app.ui.book.manga.ReadMangaActivity
 import io.legado.app.ui.book.read.ReadBookActivity
-import io.legado.app.ui.book.read.ReadBookActivity.Companion.RESULT_DELETED
 import io.legado.app.ui.book.search.SearchActivity
 import io.legado.app.ui.book.source.edit.BookSourceEditActivity
 import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.file.HandleFileContract
 import io.legado.app.ui.login.SourceLoginActivity
-import io.legado.app.ui.widget.dialog.PhotoDialog
 import io.legado.app.ui.widget.dialog.VariableDialog
-import io.legado.app.ui.widget.dialog.WaitDialog
-import io.legado.app.utils.ConvertUtils
-import io.legado.app.utils.FileDoc
-import io.legado.app.utils.GSON
 import io.legado.app.utils.StartActivityContract
-import io.legado.app.utils.ToolbarUtils.setAllIconsColor
-import io.legado.app.utils.applyNavigationBarMargin
-import io.legado.app.utils.dpToPx
-import io.legado.app.utils.gone
-import io.legado.app.utils.longToastOnUi
 import io.legado.app.utils.openFileUri
 import io.legado.app.utils.sendToClip
 import io.legado.app.utils.showDialogFragment
 import io.legado.app.utils.startActivity
-import io.legado.app.utils.toastOnUi
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import io.legado.app.utils.visible
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
+import kotlinx.coroutines.flow.collectLatest
 import org.koin.androidx.viewmodel.ext.android.viewModel
 
-class BookInfoActivity :
-    VMBaseActivity<ActivityBookInfoBinding, BookInfoViewModel>(),
-    GroupSelectDialog.CallBack,
-    ChangeBookSourceDialog.CallBack,
-    ChangeCoverDialog.CallBack,
-    VariableDialog.Callback {
+class BookInfoActivity : BaseComposeActivity(), VariableDialog.Callback {
 
-    companion object {
-        private const val READ_BOOK_REQUEST_CODE = 1001
-    }
+    private val viewModel: BookInfoViewModel by viewModel()
 
     private val tocActivityResult = registerForActivityResult(TocActivityResult()) {
-        it?.let {
-            viewModel.getBook(false)?.let { book ->
-                lifecycleScope.launch {
-                    withContext(IO) {
-                        book.durChapterIndex = it.first
-                        book.durChapterPos = it.second
-                        chapterChanged = it.third
-                        appDb.bookDao.update(book)
-                    }
-                    startReadActivity(book, binding.btnRead)
-                }
-            }
-        } ?: let {
-            if (!viewModel.inBookshelf) {
-                viewModel.delBook()
-            }
-        }
+        viewModel.onTocResult(it)
     }
     private val localBookTreeSelect = registerForActivityResult(HandleFileContract()) {
         it.uri?.let { treeUri ->
@@ -136,1036 +48,145 @@ class BookInfoActivity :
         StartActivityContract(BookInfoEditActivity::class.java)
     ) {
         if (it.resultCode == RESULT_OK) {
-            viewModel.upEditBook()
+            viewModel.onInfoEdited()
         }
     }
     private val editSourceResult = registerForActivityResult(
         StartActivityContract(BookSourceEditActivity::class.java)
     ) {
-        if (it.resultCode == RESULT_CANCELED) {
-            return@registerForActivityResult
-        }
-        book?.let { book ->
-            viewModel.bookSource = appDb.bookSourceDao.getBookSource(book.origin)
-            viewModel.refreshBook(book)
+        if (it.resultCode != RESULT_CANCELED) {
+            viewModel.onSourceEdited()
         }
     }
-    private var surfaceFinalColor: Int = 0
-    private var surfaceContainerFinalColor: Int = 0
-    private var secondaryFinalColor: Int = 0
-    private var onSurfaceFinalColor: Int = 0
-    private var secondaryContainerFinalColor: Int = 0
-    private var primaryFinalColor: Int = 0
-    private var tertiaryFinalColor: Int = 0
-    private var currentJob: Job? = null
-    private var wrappedContext: Context? = null
-    private var chapterChanged = false
-    private val waitDialog by lazy { WaitDialog(this) }
-    private var editMenuItem: MenuItem? = null
-    private val book get() = viewModel.getBook(false)
-
-    override val binding by viewBinding(ActivityBookInfoBinding::inflate)
-    override val viewModel: BookInfoViewModel by viewModel()
+    private val readBookResult = registerForActivityResult(
+        ActivityResultContracts.StartActivityForResult()
+    ) {
+        viewModel.onReaderResult(it.resultCode)
+    }
 
-    @SuppressLint("PrivateResource")
     override fun onCreate(savedInstanceState: Bundle?) {
-        setEnterSharedElementCallback(MaterialContainerTransformSharedElementCallback())
-        setExitSharedElementCallback(MaterialContainerTransformSharedElementCallback())
-        val transform = MaterialContainerTransform().apply {
-            addTarget(binding.cdCov)
-            scrimColor = Color.TRANSPARENT
-        }
-        window.sharedElementEnterTransition = transform
-        window.sharedElementReturnTransition = transform
         super.onCreate(savedInstanceState)
-        setupBackCallback()
-        surfaceFinalColor =
-            MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface, -1)
-        secondaryFinalColor =
-            MaterialColors.getColor(this, com.google.android.material.R.attr.colorSecondary, -1)
-        onSurfaceFinalColor =
-            MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnSurface, -1)
-        surfaceContainerFinalColor = MaterialColors.getColor(
-            this,
-            com.google.android.material.R.attr.colorSurfaceContainer,
-            -1
-        )
-        secondaryContainerFinalColor = MaterialColors.getColor(
-            this,
-            com.google.android.material.R.attr.colorSecondaryContainer,
-            -1
-        )
-        primaryFinalColor = MaterialColors.getColor(
-            this,
-            androidx.appcompat.R.attr.colorPrimary,
-            -1
-        )
-        tertiaryFinalColor = MaterialColors.getColor(
-            this,
-            com.google.android.material.R.attr.colorTertiary,
-            -1
-        )
-        binding.cdCov.transitionName = intent.getStringExtra("transitionName")
-        binding.cdCov.doOnPreDraw {
-            startPostponedEnterTransition()
-        }
-        setSupportActionBar(binding.topBar)
-        
-        binding.scrollView.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
-            if (scrollY > oldScrollY) binding.btnRead.shrink()
-            else if (scrollY < oldScrollY) binding.btnRead.extend()
-        }
-
-        binding.btnRead.applyNavigationBarMargin(true)
-        binding.btnShelf.text = getString(R.string.remove_from_bookshelf)
-        binding.tvToc.text = getString(R.string.toc_s, getString(R.string.loading))
-
-        binding.tvDetail.revealOnFocusHint = false
-
-        viewModel.bookData.observe(this) { showBook(it) }
-        viewModel.chapterListData.observe(this) { upLoading(false, it) }
-        viewModel.waitDialogData.observe(this) { upWaitDialogStatus(it) }
         viewModel.initData(intent)
-        initViewEvent()
     }
 
-    override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
-        menuInflater.inflate(R.menu.book_info, menu)
-        editMenuItem = menu.findItem(R.id.menu_edit)
-        return super.onCompatCreateOptionsMenu(menu)
-    }
-
-    override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
-        menu.findItem(R.id.menu_can_update)?.isChecked =
-            viewModel.bookData.value?.canUpdate ?: true
-        menu.findItem(R.id.menu_split_long_chapter)?.isChecked =
-            viewModel.bookData.value?.getSplitLongChapter() ?: true
-        menu.findItem(R.id.menu_login)?.isVisible =
-            !viewModel.bookSource?.loginUrl.isNullOrBlank()
-        menu.findItem(R.id.menu_set_source_variable)?.isVisible =
-            viewModel.bookSource != null
-        menu.findItem(R.id.menu_set_book_variable)?.isVisible =
-            viewModel.bookSource != null
-        menu.findItem(R.id.menu_can_update)?.isVisible =
-            viewModel.bookSource != null
-        menu.findItem(R.id.menu_split_long_chapter)?.isVisible =
-            viewModel.bookData.value?.isLocalTxt ?: false
-        menu.findItem(R.id.menu_upload)?.isVisible =
-            viewModel.bookData.value?.isLocal ?: false
-        menu.findItem(R.id.menu_delete_alert)?.isChecked =
-            LocalConfig.bookInfoDeleteAlert
-        return super.onMenuOpened(featureId, menu)
-    }
+    @Composable
+    override fun Content() {
+        val state = viewModel.uiState.collectAsStateWithLifecycle().value
 
-    override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.menu_edit -> {
-                viewModel.getBook()?.let {
-                    infoEditResult.launch {
-                        putExtra("bookUrl", it.bookUrl)
+        LaunchedEffect(Unit) {
+            viewModel.effects.collectLatest { effect ->
+                when (effect) {
+                    is BookInfoEffect.Finish -> {
+                        effect.resultCode?.let { setResult(it) }
+                        if (effect.afterTransition) finishAfterTransition() else finish()
                     }
-                }
-            }
 
-            R.id.menu_share_it -> {
-                viewModel.getBook()?.let {
-                    SourceCallBack.callBackBtn(
-                        this,
-                        SourceCallBack.CLICK_SHARE_BOOK,
-                        viewModel.bookSource,
-                        it,
-                        null
-                    ) {
-                        val bookJson = GSON.toJson(it)
-                        val shareStr = "${it.bookUrl}#$bookJson"
-                        val intent = Intent(Intent.ACTION_SEND)
-                        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
-                        intent.putExtra(Intent.EXTRA_TEXT, shareStr)
-                        intent.type = "text/plain"
-                        startActivity(Intent.createChooser(intent, it.name))
-                    }
-                }
-            }
-
-            R.id.menu_refresh -> {
-                refreshBook()
-            }
-
-            R.id.menu_sync_remote -> {
-                viewModel.syncFromRemote()
-            }
-
-            R.id.menu_login -> viewModel.bookSource?.let {
-                startActivity<SourceLoginActivity> {
-                    putExtra("type", "bookSource")
-                    putExtra("key", it.bookSourceUrl)
-                }
-            }
-
-            R.id.menu_top -> viewModel.topBook()
-            R.id.menu_set_source_variable -> setSourceVariable()
-            R.id.menu_set_book_variable -> setBookVariable()
-            R.id.menu_copy_book_url -> viewModel.getBook()?.let {
-                SourceCallBack.callBackBtn(
-                    this,
-                    SourceCallBack.CLICK_COPY_BOOK_URL,
-                    viewModel.bookSource,
-                    it,
-                    null
-                ) {
-                    sendToClip(it.bookUrl)
-                }
-            }
-
-            R.id.menu_copy_toc_url -> viewModel.getBook()?.let {
-                SourceCallBack.callBackBtn(
-                    this,
-                    SourceCallBack.CLICK_COPY_TOC_URL,
-                    viewModel.bookSource,
-                    it,
-                    null
-                ) {
-                    sendToClip(it.tocUrl)
-                }
-            }
-
-            R.id.menu_can_update -> {
-                viewModel.getBook()?.let {
-                    it.canUpdate = !it.canUpdate
-                    if (viewModel.inBookshelf) {
-                        if (!it.canUpdate) {
-                            it.removeType(BookType.updateError)
+                    is BookInfoEffect.OpenBookInfoEdit -> {
+                        infoEditResult.launch {
+                            putExtra("bookUrl", effect.bookUrl)
                         }
-                        viewModel.saveBook(it)
                     }
-                }
-            }
 
-            R.id.menu_clear_cache -> viewModel.getBook()?.let {
-                SourceCallBack.callBackBtn(
-                    this,
-                    SourceCallBack.CLICK_CLEAR_CACHE,
-                    viewModel.bookSource,
-                    it,
-                    null
-                ) {
-                    viewModel.clearCache(it)
-                }
-            }
-
-            R.id.menu_log -> showDialogFragment<AppLogDialog>()
-            R.id.menu_split_long_chapter -> {
-                upLoading(true)
-                viewModel.getBook()?.let {
-                    it.setSplitLongChapter(!item.isChecked)
-                    viewModel.loadBookInfo(it, false)
-                }
-                item.isChecked = !item.isChecked
-                if (!item.isChecked) longToastOnUi(R.string.need_more_time_load_content)
-            }
-
-            R.id.menu_delete_alert -> LocalConfig.bookInfoDeleteAlert = !item.isChecked
-            R.id.menu_upload -> {
-                viewModel.getBook()?.let { book ->
-                    viewModel.uploadBook(book) {
-                        toastOnUi("上传成功")
+                    is BookInfoEffect.OpenReader -> startReadActivity(
+                        book = effect.book,
+                        inBookshelf = effect.inBookshelf,
+                        chapterChanged = effect.chapterChanged,
+                    )
+                    is BookInfoEffect.OpenToc -> tocActivityResult.launch(effect.bookUrl)
+                    is BookInfoEffect.OpenBookSourceEdit -> {
+                        editSourceResult.launch {
+                            putExtra("sourceUrl", effect.sourceUrl)
+                        }
                     }
-                }
-            }
-        }
-        return super.onCompatOptionsItemSelected(item)
-    }
-
-    override fun observeLiveBus() {
-        viewModel.actionLive.observe(this) {
-            when (it) {
-                "selectBooksDir" -> localBookTreeSelect.launch {
-                    title = getString(R.string.select_book_folder)
-                }
-            }
-        }
-    }
-
-    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
-        super.onActivityResult(requestCode, resultCode, data)
-        if (requestCode == READ_BOOK_REQUEST_CODE) {
-            when (resultCode) {
-                RESULT_OK -> {
-                    viewModel.inBookshelf = true
-                    upTvBookshelf()
-                }
 
-                RESULT_DELETED -> {
-                    setResult(RESULT_OK)
-                    finish()
-                }
-            }
-        }
-    }
-
-    private fun setupBackCallback() {
-        onBackPressedDispatcher.addCallback(this) {
-            if (!viewModel.inBookshelf && AppConfig.showAddToShelfAlert) {
-                viewModel.getBook()?.let { book ->
-                    alert(title = getString(R.string.add_to_bookshelf)) {
-                        setMessage(getString(R.string.check_add_bookshelf, book.name))
-                        okButton {
-                            book.removeType(BookType.notShelf)
-                            book.save()
-                            viewModel.inBookshelf = true
-                            super.finishAfterTransition()
+                    is BookInfoEffect.OpenSourceLogin -> {
+                        startActivity<SourceLoginActivity> {
+                            putExtra("type", "bookSource")
+                            putExtra("key", effect.sourceUrl)
                         }
-                        noButton { super.finishAfterTransition() }
                     }
-                }
-            } else {
-                finishAfterTransition()
-            }
-        }
-    }
-
-    private fun refreshBook() {
-        upLoading(true)
-        viewModel.getBook()?.let {
-            viewModel.refreshBook(it)
-        }
-    }
 
-    private fun showBook(book: Book) = binding.run {
-        showCover(book)
-        addColorScheme(binding.ivCover.drawable)
-        tvName.text = book.name
-        tvRemark.text = book.remark
-        if (book.remark.isNullOrEmpty())
-            cdRemark.gone()
-        else
-            cdRemark.visible()
-        tvAuthor.text = getString(R.string.author_show, book.getRealAuthor())
-        tvOrigin.text = getString(R.string.origin_show, book.originName)
-        tvLasted.text = getString(R.string.lasted_show, book.latestChapterTitle)
-        tvChapter.text = getString(R.string.read_chapter_total, book.totalChapterNum)
-        if (book.durChapterIndex + 1 == book.totalChapterNum)
-            tvChapterIndex.text = "已读完"
-        else
-            tvChapterIndex.text = getString(R.string.read_chapter_index, book.durChapterIndex + 1)
-        tvDetail.text = book.getDisplayIntro()
-        tvToc.visible(!book.isWebFile)
-        upTvBookshelf()
-        upKinds(book)
-        upGroup(book.group)
-    }
-
-    private fun upKinds(book: Book) = binding.run {
-        lifecycleScope.launch {
-            var kinds = book.getKindList()
-            if (book.isLocal) {
-                withContext(IO) {
-                    val size = FileDoc.fromFile(book.bookUrl).size
-                    if (size > 0) {
-                        kinds = kinds.toMutableList()
-                        kinds.add(ConvertUtils.formatFileSize(size))
+                    BookInfoEffect.OpenSelectBooksDir -> localBookTreeSelect.launch {
+                        title = getString(R.string.select_book_folder)
                     }
-                }
-            }
-            if (kinds.isEmpty()) {
-                lbKind.gone()
-            } else {
-                lbKind.visible()
-                lbKind.setLabels(kinds) {}
-            }
-        }
-    }
 
-    private fun showCover(book: Book) {
-        binding.ivCover.load(
-            book.getDisplayCover(),
-            book.name,
-            book.author,
-            false,
-            book.origin,
-            onLoadFinish = {
-                binding.ivCover.post {
-                    val drawable = binding.ivCover.drawable
-                    if (drawable != null) {
-                        addColorScheme(drawable)
+                    is BookInfoEffect.OpenFile -> openFileUri(effect.uri, effect.mimeType)
+                    is BookInfoEffect.RunSourceCallback -> runSourceCallback(effect)
+                    is BookInfoEffect.ShowVariableDialog -> {
+                        showDialogFragment(
+                            VariableDialog(
+                                effect.title,
+                                effect.key,
+                                effect.variable,
+                                effect.comment,
+                            )
+                        )
                     }
                 }
             }
-        )
-            if (!AppConfig.isEInkMode) {
-                //高版本使用RenderEffect
-                BookCover.load(this, book.getDisplayCover(), false, book.origin)
-                    .into(binding.bgBook)
-                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
-                    BookCover.load(this, book.getDisplayCover(), false, book.origin)
-                        .into(binding.bgBook)
-                    val blurEffect = RenderEffect.createBlurEffect(60f, 60f, Shader.TileMode.CLAMP)
-                    binding.bgBook.setRenderEffect(blurEffect)
-                }else{
-                    BookCover.loadBlur(this, book.getDisplayCover(), false, book.origin)
-                        .into(binding.bgBook)
-                }
-            }
-        addColorScheme(binding.ivCover.drawable)
-
-    }
-
-    private fun addColorScheme(drawable: Drawable?) {
-        currentJob?.cancel()
-        currentJob = CoroutineScope(Dispatchers.Default).launch {
-            val bitmap = when (drawable) {
-                is BitmapDrawable -> drawable.bitmap
-                is TransitionDrawable -> (drawable.getDrawable(1) as? BitmapDrawable)?.bitmap
-                else -> null
-            } ?: return@launch
-
-            val colorAccuracy = true
-            val targetWidth = if (colorAccuracy) (bitmap.width / 4).coerceAtMost(256) else 16
-            val targetHeight = if (colorAccuracy) (bitmap.height / 4).coerceAtMost(256) else 16
-            val scaledBitmap = bitmap.scale(targetWidth, targetHeight, false)
-
-            val options = DynamicColorsOptions.Builder()
-                .setContentBasedSource(scaledBitmap)
-                .build()
-
-            wrappedContext = DynamicColors.wrapContextIfAvailable(
-                this@BookInfoActivity,
-                options
-            ).apply {
-                resources.configuration.uiMode =
-                    this@BookInfoActivity.resources.configuration.uiMode
-            }
-
-            withContext(Dispatchers.Main) {
-                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
-                    applyColorScheme()
-                }
-            }
         }
-    }
-
-    private suspend fun applyColorScheme() {
-        val ctx = wrappedContext ?: this
-
-        val colorPrimary =
-            MaterialColors.getColor(ctx, androidx.appcompat.R.attr.colorPrimary, -1)
-        val colorSecondary =
-            MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSecondary, -1)
-        val colorTertiary =
-            MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorTertiary, -1)
-        val colorOnSurface =
-            MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, -1)
-        val colorSurface =
-            MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSurface, -1)
-        val colorSurfaceContainer =
-            MaterialColors.getColor(
-                ctx,
-                com.google.android.material.R.attr.colorSurfaceContainerHighest,
-                -1
-            )
-        val colorSecondaryContainer =
-            MaterialColors.getColor(
-                ctx,
-                com.google.android.material.R.attr.colorSecondaryContainer,
-                -1
-            )
-        val colorOnTertiary =
-            MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnTertiary, -1)
-
-        val surfaceTransition = ValueAnimator.ofArgb(surfaceFinalColor, colorSurface).apply {
-            duration = 400L
-            addUpdateListener { animation ->
-                val nightModeFlags =
-                    resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
-                val isDarkMode = nightModeFlags == Configuration.UI_MODE_NIGHT_YES
-                var color = animation.animatedValue as Int
-                if (AppConfig.pureBlack && isDarkMode) {
-                    color = Color.BLACK
-                }
-                binding.cdInfo?.setCardBackgroundColor(color)
-                if (!AppConfig.isTransparent)
-                {
-                    binding.llInfo.setBackgroundColor(color)
-                    (binding.llCover.background as? GradientDrawable)?.colors =
-                        intArrayOf(Color.TRANSPARENT, color)
-                }
-            }
-        }
-
-        val surfaceContainerTransition =
-            ValueAnimator.ofArgb(surfaceContainerFinalColor, colorSurfaceContainer).apply {
-                duration = 400L
-                addUpdateListener { animation ->
-                    val color = animation.animatedValue as Int
-                    binding.lbKind.applyColorScheme(color, colorOnSurface)
-                    binding.collTopBar.setContentScrimColor(color)
-                    binding.cdRemark.strokeColor = color
-                }
-            }
-
-        val buttonTransition = ValueAnimator.ofArgb(secondaryFinalColor, colorSecondary).apply {
-            duration = 400L
-            addUpdateListener { animation ->
-                val color = animation.animatedValue as Int
-                val tint = ColorStateList.valueOf(color)
-                listOf(
-                    binding.btnShelf,
-                    binding.tvTocView,
-                    binding.tvChangeGroup,
-                    binding.btnChangeSource
-                ).forEach { btn ->
-                    btn.setTextColor(color)
-                    btn.iconTint = tint
-                }
-                binding.div.setTextColor(color)
-                binding.tvChapterIndex.setTextColor(color)
-                binding.tvRemark.setTextColor(color)
-            }
-        }
-
-        val backTransition =
-            ValueAnimator.ofArgb(secondaryContainerFinalColor, colorSecondaryContainer).apply {
-                duration = 400L
-                addUpdateListener { animation ->
-                    val color = animation.animatedValue as Int
-                    if (!AppConfig.isTransparent)
-                        binding.bgBookMask.setBackgroundColor(color)
-                }
-            }
-
-        val textTransition = ValueAnimator.ofArgb(onSurfaceFinalColor, colorOnSurface).apply {
-            duration = 400L
-            addUpdateListener { animation ->
-                val color = animation.animatedValue as Int
-                binding.tvName.setTextColor(color)
-                binding.tvAuthor.setTextColor(color)
-                binding.tvOrigin.setTextColor(color)
-                binding.tvDetail.setTextColor(color)
-                binding.tvToc.setTextColor(color)
-                binding.tvLasted.setTextColor(color)
-                binding.ivName.imageTintList = ColorStateList.valueOf(color)
-                binding.ivWeb.imageTintList = ColorStateList.valueOf(color)
-            }
-        }
-
-        val primaryTransition = ValueAnimator.ofArgb(primaryFinalColor, colorPrimary).apply {
-            duration = 400L
-            addUpdateListener { animation ->
-                val color = animation.animatedValue as Int
-                binding.tvChapter.setTextColor(color)
-            }
-        }
-
-        val tertiaryTransition = ValueAnimator.ofArgb(tertiaryFinalColor, colorTertiary).apply {
-            duration = 400L
-            addUpdateListener { animation ->
-                val color = animation.animatedValue as Int
-                binding.btnRead.backgroundTintList = ColorStateList.valueOf(color)
-            }
-        }
-
-        withContext(Dispatchers.Main) {
-            listOf(
-                surfaceTransition,
-                surfaceContainerTransition,
-                buttonTransition,
-                backTransition,
-                textTransition,
-                primaryTransition,
-                tertiaryTransition
-            ).forEach { it.start() }
-        }
-
-        surfaceFinalColor = colorSurface
-        secondaryFinalColor = colorSecondary
-        onSurfaceFinalColor = colorOnSurface
-        surfaceContainerFinalColor = colorSurfaceContainer
-        primaryFinalColor = colorPrimary
-        tertiaryFinalColor = colorTertiary
-        secondaryContainerFinalColor = colorSecondaryContainer
-
-        binding.topBar.setAllIconsColor(colorOnSurface)
-        binding.btnRead.setTextColor(colorOnTertiary)
-        binding.btnRead.iconTint = ColorStateList.valueOf(colorOnTertiary)
-    }
-
-    private fun upLoading(isLoading: Boolean, chapterList: List<BookChapter>? = null) {
-        when {
-            isLoading -> {
-                binding.tvToc.text = getString(R.string.toc_s, getString(R.string.loading))
-                binding.tvTocView.text = getString(R.string.loading)
-                binding.tvTocView.isEnabled = false
-            }
 
-            chapterList.isNullOrEmpty() -> {
-                binding.tvToc.text = getString(
-                    R.string.toc_s,
-                    getString(R.string.error_load_toc)
-                )
-                binding.tvTocView.text = getString(R.string.error_load_toc)
-                binding.tvTocView.isEnabled = false
-            }
-
-            else -> {
-                book?.let {
-                    binding.tvToc.text = getString(R.string.toc_s, it.durChapterTitle)
-                    binding.tvLasted.text = getString(R.string.lasted_show, it.latestChapterTitle)
-                    binding.tvTocView.text = getString(R.string.view_toc)
-                    binding.tvTocView.isEnabled = true
-                }
-            }
-        }
-    }
-
-    private fun upTvBookshelf() {
-        binding.btnShelf.apply {
-            if (viewModel.inBookshelf) {
-                text = getString(R.string.remove_from_bookshelf)
-                icon = ContextCompat.getDrawable(context, R.drawable.ic_star_fill)
-            } else {
-                text = getString(R.string.add_to_bookshelf)
-                icon = ContextCompat.getDrawable(context, R.drawable.ic_star)
-            }
-        }
-        editMenuItem?.isVisible = viewModel.inBookshelf
+        BookInfoScreen(
+            state = state,
+            onIntent = viewModel::onIntent,
+            onBack = { finishAfterTransition() },
+        )
     }
 
-    private fun upGroup(groupId: Long) {
-        viewModel.loadGroup(groupId) {
-            if (it.isNullOrEmpty()) {
-                binding.tvChangeGroup.text = if (book?.isLocal == true) {
-                    getString(R.string.local_no_group)
-                } else {
-                    getString(R.string.no_group)
-                }
-                binding.tvChangeGroup.setIconResource(R.drawable.ic_groups)
-            } else {
-                binding.tvChangeGroup.text = getString(R.string.group_s, it)
-                binding.tvChangeGroup.setIconResource(R.drawable.ic_groups_fill)
-            }
-        }
+    override fun setVariable(key: String, variable: String?) {
+        viewModel.setVariable(key, variable)
     }
 
-    private fun initViewEvent() = binding.run {
-        ivCover.setOnClickListener {
-            viewModel.getBook()?.let {
-                showDialogFragment(
-                    ChangeCoverDialog(it.name, it.author)
-                )
-            }
-        }
-        ivCover.setOnLongClickListener {
-            viewModel.getBook()?.getDisplayCover()?.let { path ->
-                showDialogFragment(PhotoDialog(path))
-            }
-            true
-        }
-        btnRead.setOnClickListener {
-            viewModel.getBook()?.let { book ->
-                if (book.isWebFile) {
-                    showWebFileDownloadAlert {
-                        readBook(it)
-                    }
-                } else {
-                    readBook(book)
-                }
-            }
-        }
-        btnShelf.setOnClickListener {
-            viewModel.getBook()?.let { book ->
-                if (viewModel.inBookshelf) {
-                    deleteBook()
-                } else {
-                    if (book.isWebFile) {
-                        showWebFileDownloadAlert()
-                    } else {
-                        viewModel.addToBookshelf {
-                            upTvBookshelf()
-                        }
-                    }
-                }
-            }
-        }
-        tvOrigin.setOnClickListener {
-            viewModel.getBook()?.let { book ->
-                if (book.isLocal) return@let
-                if (!appDb.bookSourceDao.has(book.origin)) {
-                    toastOnUi(R.string.error_no_source)
-                    return@let
-                }
-                editSourceResult.launch {
-                    putExtra("sourceUrl", book.origin)
-                }
-            }
-        }
-        btnChangeSource.setOnClickListener {
-            viewModel.getBook()?.let { book ->
-                showDialogFragment(ChangeBookSourceDialog(book.name, book.author))
-            }
-        }
-        tvTocView.setOnClickListener {
-            if (viewModel.chapterListData.value.isNullOrEmpty()) {
-                toastOnUi(R.string.chapter_list_empty)
-                return@setOnClickListener
-            }
-            viewModel.getBook()?.let { book ->
-                if (!viewModel.inBookshelf) {
-                    viewModel.saveBook(book) {
-                        viewModel.saveChapterList {
-                            openChapterList()
-                        }
-                    }
-                } else {
-                    openChapterList()
-                }
-            }
-        }
-        tvChangeGroup.setOnClickListener {
-            viewModel.getBook()?.let {
-                showDialogFragment(
-                    GroupSelectDialog(it.group)
-                )
-            }
-        }
-        tvAuthor.setOnClickListener {
-            viewModel.getBook(false)?.let { book ->
-                SourceCallBack.callBackBtn(
-                    this@BookInfoActivity,
-                    SourceCallBack.CLICK_AUTHOR,
-                    viewModel.bookSource,
-                    book,
-                    null
-                ) {
-                    startActivity<SearchActivity> {
-                        putExtra("key", book.author)
-                    }
-                }
-            }
-        }
-        tvAuthor.setOnLongClickListener {
-            viewModel.getBook(false)?.let { book ->
-                SourceCallBack.callBackBtn(
-                    this@BookInfoActivity,
-                    SourceCallBack.LONG_CLICK_AUTHOR,
-                    viewModel.bookSource,
-                    book,
-                    null
-                ) {
-                    startActivity<SearchActivity> {
-                        putExtra("key", book.author)
-                    }
-                }
-            }
-            true
-        }
-        tvName.setOnClickListener {
-            viewModel.getBook(false)?.let { book ->
-                SourceCallBack.callBackBtn(
-                    this@BookInfoActivity,
-                    SourceCallBack.CLICK_BOOK_NAME,
-                    viewModel.bookSource,
-                    book,
-                    null
-                ) {
+    private fun runSourceCallback(effect: BookInfoEffect.RunSourceCallback) {
+        SourceCallBack.callBackBtn(
+            this,
+            effect.event,
+            effect.source,
+            effect.book,
+            null,
+        ) {
+            when (val action = effect.action) {
+                is BookInfoCallbackAction.Search -> {
                     startActivity<SearchActivity> {
-                        putExtra("key", book.name)
+                        putExtra("key", action.keyword)
                     }
                 }
-            }
-        }
-        tvName.setOnLongClickListener {
-            TransitionManager.beginDelayedTransition(scrollView)
-            tvName.maxLines = if (tvName.maxLines == 3) 10 else 3
-            true
-        }
-        cdRemark.setOnClickListener {
-            alert(R.string.edit_remark) {
-                val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
-                    editLayout.hint = "编辑备注"
-                    editView.setText(book?.remark)
-                }
-                customView { alertBinding.root }
-                okButton {
-                    viewModel.saveRemark(alertBinding.editView.text.toString())
-                }
-                cancelButton()
-            }
-        }
-        refreshLayout.setOnRefreshListener {
-            refreshLayout.isRefreshing = false
-            refreshBook()
-        }
-    }
-
-    private fun setSourceVariable() {
-        lifecycleScope.launch {
-            val source = viewModel.bookSource
-            if (source == null) {
-                toastOnUi("书源不存在")
-                return@launch
-            }
-            val comment =
-                source.getDisplayVariableComment("源变量可在js中通过source.getVariable()获取")
-            val variable = withContext(IO) { source.getVariable() }
-            showDialogFragment(
-                VariableDialog(
-                    getString(R.string.set_source_variable),
-                    source.getKey(),
-                    variable,
-                    comment
-                )
-            )
-        }
-    }
 
-    private fun setBookVariable() {
-        lifecycleScope.launch {
-            val source = viewModel.bookSource
-            if (source == null) {
-                toastOnUi("书源不存在")
-                return@launch
-            }
-            val book = viewModel.getBook() ?: return@launch
-            val variable = withContext(IO) { book.getCustomVariable() }
-            val comment = source.getDisplayVariableComment(
-                """书籍变量可在js中通过book.getVariable("custom")获取"""
-            )
-            showDialogFragment(
-                VariableDialog(
-                    getString(R.string.set_book_variable),
-                    book.bookUrl,
-                    variable,
-                    comment
-                )
-            )
-        }
-    }
-
-    override fun setVariable(key: String, variable: String?) {
-        when (key) {
-            viewModel.bookSource?.getKey() -> viewModel.bookSource?.setVariable(variable)
-            viewModel.bookData.value?.bookUrl -> viewModel.bookData.value?.let {
-                it.putCustomVariable(variable)
-                if (viewModel.inBookshelf) {
-                    viewModel.saveBook(it)
-                }
-            }
-        }
-    }
-
-    @SuppressLint("InflateParams")
-    private fun deleteBook() {
-        viewModel.getBook()?.let { book ->
-            if (LocalConfig.bookInfoDeleteAlert) {
-                alert(
-                    titleResource = R.string.draw,
-                    messageResource = R.string.sure_del
-                ) {
-                    var checkBox: CheckBox? = null
-                    if (book.isLocal) {
-                        checkBox = CheckBox(this@BookInfoActivity).apply {
-                            setText(R.string.delete_book_file)
-                            isChecked = LocalConfig.deleteBookOriginal
-                        }
-                        val view = LinearLayout(this@BookInfoActivity).apply {
-                            setPadding(16.dpToPx(), 0, 16.dpToPx(), 0)
-                            addView(checkBox)
-                        }
-                        customView { view }
-                    }
-                    yesButton {
-                        if (checkBox != null) {
-                            LocalConfig.deleteBookOriginal = checkBox.isChecked
-                        }
-                        SourceCallBack.callBackBook(
-                            SourceCallBack.DEL_BOOK_SHELF,
-                            viewModel.bookSource,
-                            book
-                        ) //确认后删除书架
-                        viewModel.delBook(LocalConfig.deleteBookOriginal) {
-                            setResult(RESULT_OK)
-                            finish()
-                        }
+                is BookInfoCallbackAction.ShareText -> {
+                    val intent = Intent(Intent.ACTION_SEND).apply {
+                        putExtra(Intent.EXTRA_TEXT, action.text)
+                        type = "text/plain"
                     }
-                    noButton()
+                    startActivity(Intent.createChooser(intent, action.chooserTitle))
                 }
-            } else {
-                SourceCallBack.callBackBook(
-                    SourceCallBack.DEL_BOOK_SHELF,
-                    viewModel.bookSource,
-                    book
-                ) //点按钮直接删除书架
-                viewModel.delBook(LocalConfig.deleteBookOriginal) {
-                    setResult(RESULT_OK)
-                    finish()
-                }
-            }
-        }
-    }
 
-    private fun openChapterList() {
-        viewModel.getBook()?.let {
-            tocActivityResult.launch(it.bookUrl)
-        }
-    }
-
-    private fun showWebFileDownloadAlert(
-        onClick: ((Book) -> Unit)? = null,
-    ) {
-        val webFiles = viewModel.webFiles
-        if (webFiles.isEmpty()) {
-            toastOnUi("Unexpected webFileData")
-            return
-        }
-        selector(
-            R.string.download_and_import_file,
-            webFiles
-        ) { _, webFile, _ ->
-            if (webFile.isSupported) {
-                /* import */
-                viewModel.importOrDownloadWebFile<Book>(webFile) {
-                    onClick?.invoke(it)
-                }
-            } else if (webFile.isSupportDecompress) {
-                /* 解压筛选后再选择导入项 */
-                viewModel.importOrDownloadWebFile<Uri>(webFile) { uri ->
-                    viewModel.getArchiveFilesName(uri) { fileNames ->
-                        if (fileNames.size == 1) {
-                            viewModel.importArchiveBook(uri, fileNames[0]) {
-                                onClick?.invoke(it)
-                            }
-                        } else {
-                            showDecompressFileImportAlert(uri, fileNames, onClick)
-                        }
-                    }
+                is BookInfoCallbackAction.CopyText -> {
+                    sendToClip(action.text)
                 }
-            } else {
-                alert(
-                    title = getString(R.string.draw),
-                    message = getString(R.string.file_not_supported, webFile.name)
-                ) {
-                    neutralButton(R.string.open_fun) {
-                        /* download only */
-                        viewModel.importOrDownloadWebFile<Uri>(webFile) {
-                            openFileUri(it, "*/*")
-                        }
-                    }
-                    noButton()
-                }
-            }
-        }
-    }
-
-    private fun showDecompressFileImportAlert(
-        archiveFileUri: Uri,
-        fileNames: List<String>,
-        success: ((Book) -> Unit)? = null,
-    ) {
-        if (fileNames.isEmpty()) {
-            toastOnUi(R.string.unsupport_archivefile_entry)
-            return
-        }
-        selector(
-            R.string.import_select_book,
-            fileNames
-        ) { _, name, _ ->
-            viewModel.importArchiveBook(archiveFileUri, name) {
-                success?.invoke(it)
-            }
-        }
-    }
 
-    private fun readBook(book: Book) {
-        if (!viewModel.inBookshelf) {
-            book.addType(BookType.notShelf)
-            viewModel.saveBook(book) {
-                viewModel.saveChapterList {
-                    startReadActivity(book, binding.btnRead)
+                BookInfoCallbackAction.ClearCache -> {
+                    viewModel.clearCache()
                 }
             }
-        } else {
-            viewModel.saveBook(book) {
-                startReadActivity(book, binding.btnRead)
-            }
         }
     }
 
-    private fun startReadActivity(book: Book, sharedView: View) {
-        val transitionName = "book_${book.bookUrl}_${System.currentTimeMillis()}"
-        sharedView.transitionName = transitionName
-
+    private fun startReadActivity(
+        book: io.legado.app.data.entities.Book,
+        inBookshelf: Boolean,
+        chapterChanged: Boolean,
+    ) {
         val cls = when {
             book.isAudio -> AudioPlayActivity::class.java
             !book.isLocal && book.isImage && AppConfig.showMangaUi -> ReadMangaActivity::class.java
             else -> ReadBookActivity::class.java
         }
-
-        val intent = Intent(this, cls).apply {
-            putExtra("bookUrl", book.bookUrl)
-            putExtra("inBookshelf", viewModel.inBookshelf)
-            putExtra("chapterChanged", chapterChanged)
-            putExtra("transitionName", transitionName)
-        }
-
-        val options = ActivityOptionsCompat.makeSceneTransitionAnimation(
-            this,
-            sharedView,
-            transitionName
-        )
-        ActivityCompat.startActivityForResult(
-            this,
-            intent,
-            READ_BOOK_REQUEST_CODE,
-            options.toBundle()
-        )
-    }
-
-    override val oldBook: Book?
-        get() = viewModel.bookData.value
-
-    override fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>) {
-        viewModel.changeTo(source, book, toc)
-    }
-
-    override fun coverChangeTo(coverUrl: String) {
-        viewModel.bookData.value?.let { book ->
-            book.customCoverUrl = coverUrl
-            showCover(book)
-            if (viewModel.inBookshelf) {
-                viewModel.saveBook(book)
-            }
-        }
-    }
-
-    override fun upGroup(requestCode: Int, groupId: Long) {
-        upGroup(groupId)
-        viewModel.getBook()?.let { book ->
-            book.group = groupId
-            if (viewModel.inBookshelf) {
-                viewModel.saveBook(book)
-            } else if (groupId > 0) {
-                viewModel.addToBookshelf {
-                    upTvBookshelf()
-                }
-            }
-        }
-    }
-
-    private fun upWaitDialogStatus(isShow: Boolean) {
-        val showText = "Loading....."
-        if (isShow) {
-            waitDialog.run {
-                setText(showText)
-                show()
+        readBookResult.launch(
+            Intent(this, cls).apply {
+                putExtra("bookUrl", book.bookUrl)
+                putExtra("inBookshelf", inBookshelf)
+                putExtra("chapterChanged", chapterChanged)
             }
-        } else {
-            waitDialog.dismiss()
-        }
-    }
-
-    override fun addToBookshelf(book: Book, toc: List<BookChapter>) {
-        viewModel.addToBookshelf(book, toc) {
-            toastOnUi("已添加到书架")
-        }
+        )
     }
 }

+ 154 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt

@@ -0,0 +1,154 @@
+package io.legado.app.ui.book.info
+
+import android.net.Uri
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.BookSource
+
+data class BookInfoUiState(
+    val book: Book? = null,
+    val chapterList: List<BookChapter> = emptyList(),
+    val webFiles: List<BookInfoWebFile> = emptyList(),
+    val kindLabels: List<String> = emptyList(),
+    val groupNames: String? = null,
+    val inBookshelf: Boolean = false,
+    val bookSource: BookSource? = null,
+    val isTocLoading: Boolean = true,
+    val isBusy: Boolean = false,
+    val showAppLogSheet: Boolean = false,
+    val sheet: BookInfoSheet = BookInfoSheet.None,
+    val dialog: BookInfoDialog? = null,
+)
+
+sealed interface BookInfoSheet {
+    data object None : BookInfoSheet
+    data object CoverPicker : BookInfoSheet
+    data object GroupPicker : BookInfoSheet
+    data object SourcePicker : BookInfoSheet
+    data class WebFiles(val openAfterImport: Boolean) : BookInfoSheet
+    data class ArchiveEntries(
+        val archiveUri: Uri,
+        val entries: List<String>,
+        val openAfterImport: Boolean,
+    ) : BookInfoSheet
+}
+
+sealed interface BookInfoDialog {
+    data object AddToShelfOnBack : BookInfoDialog
+    data class DeleteBook(val isLocal: Boolean) : BookInfoDialog
+    data class EditRemark(val remark: String?) : BookInfoDialog
+    data class PhotoPreview(val path: String) : BookInfoDialog
+    data class UnsupportedWebFile(
+        val webFile: BookInfoWebFile,
+        val openAfterImport: Boolean,
+    ) : BookInfoDialog
+}
+
+data class BookInfoWebFile(
+    val url: String,
+    val name: String,
+) {
+    override fun toString(): String = name
+}
+
+sealed interface BookInfoIntent {
+    data object BackPressed : BookInfoIntent
+    data object DismissSheet : BookInfoIntent
+    data object DismissDialog : BookInfoIntent
+    data object DismissAppLogSheet : BookInfoIntent
+    data class MenuAction(val action: BookInfoMenuAction) : BookInfoIntent
+    data class AuthorClick(val longClick: Boolean) : BookInfoIntent
+    data class BookNameClick(val longClick: Boolean) : BookInfoIntent
+    data object OriginClick : BookInfoIntent
+    data object ReadClick : BookInfoIntent
+    data object ShelfClick : BookInfoIntent
+    data object TocClick : BookInfoIntent
+    data object CoverClick : BookInfoIntent
+    data object CoverLongClick : BookInfoIntent
+    data object GroupClick : BookInfoIntent
+    data object ChangeSourceClick : BookInfoIntent
+    data object RemarkClick : BookInfoIntent
+    data object ConfirmBackAddToShelf : BookInfoIntent
+    data class ConfirmDelete(val deleteOriginal: Boolean) : BookInfoIntent
+    data class UpdateRemark(val remark: String) : BookInfoIntent
+    data class SelectGroup(val groupId: Long) : BookInfoIntent
+    data class SelectCover(val coverUrl: String) : BookInfoIntent
+    data class ReplaceWithSource(
+        val source: BookSource,
+        val book: Book,
+        val toc: List<BookChapter>,
+    ) : BookInfoIntent
+    data class AddSourceAsNewBook(
+        val book: Book,
+        val toc: List<BookChapter>,
+    ) : BookInfoIntent
+    data class SelectWebFile(
+        val webFile: BookInfoWebFile,
+        val openAfterImport: Boolean,
+    ) : BookInfoIntent
+    data class OpenUnsupportedWebFile(
+        val webFile: BookInfoWebFile,
+    ) : BookInfoIntent
+    data class SelectArchiveEntry(
+        val archiveUri: Uri,
+        val entryName: String,
+        val openAfterImport: Boolean,
+    ) : BookInfoIntent
+}
+
+sealed interface BookInfoEffect {
+    data class Finish(
+        val resultCode: Int? = null,
+        val afterTransition: Boolean = false,
+    ) : BookInfoEffect
+
+    data class OpenBookInfoEdit(val bookUrl: String) : BookInfoEffect
+    data class OpenToc(val bookUrl: String) : BookInfoEffect
+    data class OpenReader(
+        val book: Book,
+        val inBookshelf: Boolean,
+        val chapterChanged: Boolean,
+    ) : BookInfoEffect
+    data class OpenBookSourceEdit(val sourceUrl: String) : BookInfoEffect
+    data class OpenSourceLogin(val sourceUrl: String) : BookInfoEffect
+    data object OpenSelectBooksDir : BookInfoEffect
+    data class OpenFile(val uri: Uri, val mimeType: String) : BookInfoEffect
+    data class RunSourceCallback(
+        val event: String,
+        val source: BookSource?,
+        val book: Book,
+        val action: BookInfoCallbackAction,
+    ) : BookInfoEffect
+    data class ShowVariableDialog(
+        val title: String,
+        val key: String,
+        val variable: String?,
+        val comment: String,
+    ) : BookInfoEffect
+}
+
+sealed interface BookInfoCallbackAction {
+    data class Search(val keyword: String) : BookInfoCallbackAction
+    data class ShareText(val chooserTitle: String, val text: String) : BookInfoCallbackAction
+    data class CopyText(val text: String) : BookInfoCallbackAction
+    data object ClearCache : BookInfoCallbackAction
+}
+
+enum class BookInfoMenuAction {
+    Edit,
+    Share,
+    Upload,
+    SyncRemote,
+    Refresh,
+    Login,
+    Top,
+    SetSourceVariable,
+    SetBookVariable,
+    CopyBookUrl,
+    CopyTocUrl,
+    ToggleCanUpdate,
+    ToggleSplitLongChapter,
+    ToggleDeleteAlert,
+    ClearCache,
+    ShowLog,
+}

+ 1013 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt

@@ -0,0 +1,1013 @@
+package io.legado.app.ui.book.info
+
+import android.net.Uri
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Book
+import androidx.compose.material.icons.filled.Bookmark
+import androidx.compose.material.icons.filled.BookmarkAdd
+import androidx.compose.material.icons.filled.Code
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material.icons.filled.Star
+import androidx.compose.material.icons.outlined.Book
+import androidx.compose.material.icons.outlined.CollectionsBookmark
+import androidx.compose.material.icons.outlined.FolderZip
+import androidx.compose.material.icons.outlined.Image
+import androidx.compose.material.icons.outlined.Link
+import androidx.compose.material.icons.outlined.Settings
+import androidx.compose.material.icons.outlined.SwapHoriz
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
+import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
+import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.blur
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.lerp
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import coil.ImageLoader
+import coil.compose.AsyncImage
+import io.legado.app.R
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.BookGroup
+import io.legado.app.data.entities.BookSource
+import io.legado.app.data.entities.SearchBook
+import io.legado.app.help.book.isLocal
+import io.legado.app.help.config.AppConfig
+import io.legado.app.ui.about.AppLogSheet
+import io.legado.app.ui.book.changecover.ChangeCoverViewModel
+import io.legado.app.ui.book.changesource.ChangeBookSourceViewModel
+import io.legado.app.ui.book.group.GroupEditSheet
+import io.legado.app.ui.book.source.edit.BookSourceEditActivity
+import io.legado.app.ui.book.source.manage.BookSourceActivity
+import io.legado.app.ui.config.coverConfig.CoverConfig
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.ProvideThemeOverride
+import io.legado.app.ui.theme.ThemeResolver
+import io.legado.app.ui.theme.ThemeOverrideState
+import io.legado.app.ui.theme.rememberImageSeedColor
+import io.legado.app.ui.theme.rememberThemeOverride
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.button.TopBarActionButton
+import io.legado.app.ui.widget.components.button.TopBarButtonVariant
+import io.legado.app.ui.widget.components.button.TopBarNavigationButton
+import io.legado.app.ui.widget.components.card.GlassCard
+import io.legado.app.ui.widget.components.card.TextCard
+import io.legado.app.ui.widget.components.cover.BookCover
+import io.legado.app.ui.widget.components.icon.AppIcon
+import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
+import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.text.AnimatedTextLine
+import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
+import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
+import io.legado.app.ui.widget.components.topbar.MiuixGlassScrollBehavior
+import io.legado.app.utils.StartActivityContract
+import io.legado.app.utils.startActivity
+import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.delay
+import org.koin.compose.koinInject
+import org.koin.androidx.compose.koinViewModel
+import top.yukonga.miuix.kmp.basic.TopAppBar as MiuixTopAppBar
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BookInfoScreen(
+    state: BookInfoUiState,
+    onIntent: (BookInfoIntent) -> Unit,
+    onBack: () -> Unit,
+) {
+    val bookColorTheme = rememberBookInfoColorTheme(state.book)
+
+    BookInfoColorTheme(theme = bookColorTheme) {
+        BookInfoScreenContent(
+            state = state,
+            onIntent = onIntent,
+            onBack = onBack,
+        )
+    }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun BookInfoScreenContent(
+    state: BookInfoUiState,
+    onIntent: (BookInfoIntent) -> Unit,
+    onBack: () -> Unit,
+) {
+    val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
+    val miuixScrollBehavior = if (isMiuix) {
+        GlassTopAppBarDefaults.defaultScrollBehavior() as? MiuixGlassScrollBehavior
+    } else {
+        null
+    }
+    val miuixCollapsedFraction = miuixScrollBehavior?.collapsedFraction ?: 0f
+    val listState = rememberLazyListState()
+    val colorThresholdPx = with(LocalDensity.current) { 6.dp.roundToPx() }
+    val showAppBarColor by remember(listState, isMiuix, colorThresholdPx, miuixCollapsedFraction) {
+        derivedStateOf {
+            if (isMiuix) {
+                miuixCollapsedFraction > 0.01f
+            } else {
+                listState.firstVisibleItemIndex > 0 ||
+                    listState.firstVisibleItemScrollOffset > colorThresholdPx
+            }
+        }
+    }
+    val appBarColor by animateColorAsState(
+        targetValue = if (showAppBarColor) {
+            LegadoTheme.colorScheme.surfaceContainer
+        } else {
+            LegadoTheme.colorScheme.surfaceContainer.copy(alpha = 0f)
+        },
+        label = "book-info-top-bar-color",
+    )
+    val pullState = rememberPullToRefreshState()
+    var showMenu by rememberSaveable { mutableStateOf(false) }
+
+    BackHandler { onIntent(BookInfoIntent.BackPressed) }
+
+    AppScaffold(
+        modifier = Modifier
+            .fillMaxSize()
+            .then(
+                if (miuixScrollBehavior != null) {
+                    Modifier.nestedScroll(miuixScrollBehavior.nestedScrollConnection)
+                } else {
+                    Modifier
+                }
+            ),
+        topBar = {
+            if (isMiuix) {
+                MiuixTopAppBar(
+                    title = "",
+                    color = appBarColor,
+                    navigationIcon = {
+                        TopBarNavigationButton(
+                            onClick = { onIntent(BookInfoIntent.BackPressed) }
+                        )
+                    },
+                    actions = {
+                        BookInfoTopBarActions(
+                            state = state,
+                            showMenu = showMenu,
+                            onShowMenuChange = { showMenu = it },
+                            onMenuAction = { onIntent(BookInfoIntent.MenuAction(it)) },
+                        )
+                    },
+                    scrollBehavior = miuixScrollBehavior?.miuixBehavior
+                )
+            } else {
+                TopAppBar(
+                    title = {
+                        Text(
+                            text = "",
+                            maxLines = 1,
+                        )
+                    },
+                    navigationIcon = {
+                        TopBarNavigationButton(
+                            onClick = { onIntent(BookInfoIntent.BackPressed) }
+                        )
+                    },
+                    actions = {
+                        BookInfoTopBarActions(
+                            state = state,
+                            showMenu = showMenu,
+                            onShowMenuChange = { showMenu = it },
+                            onMenuAction = { onIntent(BookInfoIntent.MenuAction(it)) },
+                        )
+                    },
+                    colors = TopAppBarDefaults.topAppBarColors(
+                        containerColor = appBarColor,
+                        scrolledContainerColor = appBarColor,
+                        navigationIconContentColor = LegadoTheme.colorScheme.onSurface,
+                        titleContentColor = LegadoTheme.colorScheme.onSurface,
+                        actionIconContentColor = LegadoTheme.colorScheme.onSurface,
+                    ),
+                )
+            }
+        },
+        floatingActionButton = {
+            ExtendedFloatingActionButton(
+                onClick = { onIntent(BookInfoIntent.ReadClick) },
+                containerColor = LegadoTheme.colorScheme.primaryContainer,
+                contentColor = LegadoTheme.colorScheme.onPrimaryContainer,
+                icon = { Icon(Icons.Default.Book, null) },
+                text = { Text(stringResource(R.string.reading)) },
+            )
+        },
+    ) { paddingValues ->
+        val book = state.book
+        if (book == null) {
+            Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+                CircularProgressIndicator(
+                    color = LegadoTheme.colorScheme.primary,
+                    trackColor = LegadoTheme.colorScheme.surfaceContainerHighest,
+                )
+            }
+        } else {
+            Box(modifier = Modifier.fillMaxSize()) {
+                BookInfoBackdrop(book)
+                PullToRefreshBox(
+                    modifier = Modifier.fillMaxSize(),
+                    state = pullState,
+                    isRefreshing = state.isTocLoading,
+                    onRefresh = { onIntent(BookInfoIntent.MenuAction(BookInfoMenuAction.Refresh)) },
+                    indicator = {
+                        PullToRefreshDefaults.LoadingIndicator(
+                            state = pullState,
+                            isRefreshing = state.isTocLoading,
+                            containerColor = LegadoTheme.colorScheme.surfaceContainerHigh,
+                            color = LegadoTheme.colorScheme.primary,
+                            modifier = Modifier
+                                .align(Alignment.TopCenter)
+                                .padding(top = paddingValues.calculateTopPadding())
+                        )
+                    }
+                ) {
+                    LazyColumn(
+                        state = listState,
+                        modifier = Modifier.fillMaxSize(),
+                        contentPadding = PaddingValues(
+                            top = paddingValues.calculateTopPadding() + 8.dp,
+                            bottom = paddingValues.calculateBottomPadding() + 88.dp,
+                        ),
+                    ) {
+                        item {
+                            BookInfoHeader(
+                                book = book,
+                                kindLabels = state.kindLabels,
+                                groupNames = state.groupNames,
+                                onCoverClick = { onIntent(BookInfoIntent.CoverClick) },
+                                onCoverLongClick = { onIntent(BookInfoIntent.CoverLongClick) },
+                                onAuthorClick = { onIntent(BookInfoIntent.AuthorClick(it)) },
+                                onBookNameClick = { onIntent(BookInfoIntent.BookNameClick(it)) },
+                                onOriginClick = { onIntent(BookInfoIntent.OriginClick) },
+                            )
+                        }
+                        item {
+                            Column(
+                                modifier = Modifier
+                                    .fillMaxWidth()
+                                    .background(
+                                        color = LegadoTheme.colorScheme.surface
+                                    )
+                                    .padding(bottom = 24.dp)
+                            ) {
+                                BookInfoActions(
+                                    inBookshelf = state.inBookshelf,
+                                    onShelfClick = { onIntent(BookInfoIntent.ShelfClick) },
+                                    onTocClick = { onIntent(BookInfoIntent.TocClick) },
+                                    onGroupClick = { onIntent(BookInfoIntent.GroupClick) },
+                                    onSourceClick = { onIntent(BookInfoIntent.ChangeSourceClick) },
+                                )
+                                BookInfoSummary(
+                                    book = book,
+                                    chapterList = state.chapterList,
+                                    onRemarkClick = { onIntent(BookInfoIntent.RemarkClick) },
+                                )
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    val currentSheet = state.sheet
+    var renderedSheet by remember { mutableStateOf<BookInfoSheet>(BookInfoSheet.None) }
+
+    LaunchedEffect(currentSheet) {
+        if (currentSheet == BookInfoSheet.None) {
+            delay(300)
+            renderedSheet = BookInfoSheet.None
+        } else {
+            renderedSheet = currentSheet
+        }
+    }
+
+    when (val sheet = renderedSheet) {
+        BookInfoSheet.None -> Unit
+        BookInfoSheet.CoverPicker -> ChangeCoverSheet(
+            show = currentSheet == BookInfoSheet.CoverPicker,
+            name = state.book?.name.orEmpty(),
+            author = state.book?.author.orEmpty(),
+            onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) },
+            onSelect = { onIntent(BookInfoIntent.SelectCover(it)) },
+        )
+        BookInfoSheet.GroupPicker -> GroupSelectSheet(
+            show = currentSheet == BookInfoSheet.GroupPicker,
+            currentGroupId = state.book?.group ?: 0L,
+            onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) },
+            onConfirm = { onIntent(BookInfoIntent.SelectGroup(it)) },
+        )
+        BookInfoSheet.SourcePicker -> state.book?.let { book ->
+            ChangeSourceSheet(
+                show = currentSheet == BookInfoSheet.SourcePicker,
+                oldBook = book,
+                onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) },
+                onReplace = { source, newBook, toc ->
+                    onIntent(BookInfoIntent.ReplaceWithSource(source, newBook, toc))
+                },
+                onAddAsNew = { newBook, toc ->
+                    onIntent(BookInfoIntent.AddSourceAsNewBook(newBook, toc))
+                },
+            )
+        }
+        is BookInfoSheet.WebFiles -> WebFileSheet(
+            show = currentSheet is BookInfoSheet.WebFiles,
+            files = state.webFiles,
+            title = stringResource(R.string.download_and_import_file),
+            onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) },
+            onSelect = { onIntent(BookInfoIntent.SelectWebFile(it, sheet.openAfterImport)) },
+        )
+        is BookInfoSheet.ArchiveEntries -> WebFileSheet(
+            show = currentSheet is BookInfoSheet.ArchiveEntries,
+            files = sheet.entries.map { BookInfoWebFile(it, it) },
+            title = stringResource(R.string.import_select_book),
+            onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) },
+            onSelect = {
+                onIntent(
+                    BookInfoIntent.SelectArchiveEntry(
+                        archiveUri = sheet.archiveUri,
+                        entryName = it.name,
+                        openAfterImport = sheet.openAfterImport,
+                    )
+                )
+            },
+        )
+    }
+
+    BookInfoDialogs(state = state, onIntent = onIntent, onBack = onBack)
+}
+
+@Composable
+private fun BookInfoColorTheme(
+    theme: ThemeOverrideState?,
+    content: @Composable () -> Unit,
+) {
+    ProvideThemeOverride(theme = theme, content = content)
+}
+
+@Composable
+private fun rememberBookInfoColorTheme(book: Book?): ThemeOverrideState? {
+    val imageLoader = koinInject<ImageLoader>()
+    val coverPath = book?.getDisplayCover()
+    val sourceOrigin = book?.origin
+    val loadOnlyWifi = CoverConfig.loadCoverOnlyWifi
+
+    val seedColor = rememberImageSeedColor(
+        imageLoader = imageLoader,
+        data = coverPath,
+        requestKey = listOf(coverPath, sourceOrigin, loadOnlyWifi),
+    ) {
+        setParameter("sourceOrigin", sourceOrigin)
+        setParameter("loadOnlyWifi", loadOnlyWifi)
+    }
+
+    return rememberThemeOverride(seedColor)
+}
+
+@Composable
+private fun BookInfoTopBarActions(
+    state: BookInfoUiState,
+    showMenu: Boolean,
+    onShowMenuChange: (Boolean) -> Unit,
+    onMenuAction: (BookInfoMenuAction) -> Unit,
+) {
+    if (state.inBookshelf) {
+        TopBarActionButton(
+            onClick = { onMenuAction(BookInfoMenuAction.Edit) },
+            imageVector = Icons.Default.Edit,
+        )
+    }
+    TopBarActionButton(
+        onClick = { onMenuAction(BookInfoMenuAction.Share) },
+        imageVector = Icons.Default.Share,
+    )
+    TopBarActionButton(
+        onClick = { onShowMenuChange(true) },
+        imageVector = Icons.Default.MoreVert,
+    )
+    BookInfoOverflowMenu(
+        expanded = showMenu,
+        onDismissRequest = { onShowMenuChange(false) },
+        state = state,
+        onMenuAction = {
+            onShowMenuChange(false)
+            onMenuAction(it)
+        }
+    )
+}
+
+@Composable
+private fun BookInfoBackdrop(book: Book) {
+    val cover = book.getDisplayCover()
+    val seedOverlay = lerp(
+        LegadoTheme.colorScheme.secondaryContainer,
+        LegadoTheme.seedColor,
+        0.42f
+    )
+    Box(modifier = Modifier.fillMaxSize()) {
+        if (!cover.isNullOrBlank()) {
+            AsyncImage(
+                model = cover,
+                contentDescription = null,
+                modifier = Modifier
+                    .fillMaxSize()
+                    .blur(32.dp),
+                contentScale = ContentScale.Crop,
+            )
+        }
+        Box(
+            modifier = Modifier
+                .fillMaxSize()
+                .background(seedOverlay.copy(alpha = 0.34f))
+        )
+        Box(
+            modifier = Modifier
+                .fillMaxSize()
+                .background(
+                    Brush.verticalGradient(
+                        colorStops = arrayOf(
+                            0f to Color.Transparent,
+                            0.18f to seedOverlay.copy(alpha = 0.10f),
+                            0.34f to seedOverlay.copy(alpha = 0.18f),
+                            0.52f to LegadoTheme.colorScheme.surface.copy(alpha = 0.82f),
+                            0.72f to LegadoTheme.colorScheme.surface,
+                            1f to LegadoTheme.colorScheme.surface,
+                        )
+                    )
+                )
+        )
+    }
+}
+
+@Composable
+private fun BookInfoOverflowMenu(
+    expanded: Boolean,
+    onDismissRequest: () -> Unit,
+    state: BookInfoUiState,
+    onMenuAction: (BookInfoMenuAction) -> Unit,
+) {
+    val book = state.book
+    RoundDropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
+        if (state.inBookshelf) {
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.edit),
+                onClick = { onMenuAction(BookInfoMenuAction.Edit) }
+            )
+        }
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.refresh),
+            onClick = { onMenuAction(BookInfoMenuAction.Refresh) }
+        )
+        if (book?.isLocal == true) {
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.re_sync_webdav),
+                onClick = { onMenuAction(BookInfoMenuAction.SyncRemote) }
+            )
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.upload_to_remote),
+                onClick = { onMenuAction(BookInfoMenuAction.Upload) }
+            )
+        }
+        if (!state.bookSource?.loginUrl.isNullOrBlank()) {
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.login),
+                onClick = { onMenuAction(BookInfoMenuAction.Login) }
+            )
+        }
+        if (state.bookSource != null) {
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.set_source_variable),
+                onClick = { onMenuAction(BookInfoMenuAction.SetSourceVariable) }
+            )
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.set_book_variable),
+                onClick = { onMenuAction(BookInfoMenuAction.SetBookVariable) }
+            )
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.copy_book_url),
+                onClick = { onMenuAction(BookInfoMenuAction.CopyBookUrl) }
+            )
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.copy_toc_url),
+                onClick = { onMenuAction(BookInfoMenuAction.CopyTocUrl) }
+            )
+        }
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.to_top),
+            onClick = { onMenuAction(BookInfoMenuAction.Top) }
+        )
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.allow_update),
+            onClick = { onMenuAction(BookInfoMenuAction.ToggleCanUpdate) },
+            trailingIcon = { if (book?.canUpdate == true) Icon(Icons.Default.Star, null) }
+        )
+        if (book?.isLocal == true && book.type and io.legado.app.constant.BookType.text > 0) {
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.split_long_chapter),
+                onClick = { onMenuAction(BookInfoMenuAction.ToggleSplitLongChapter) },
+                trailingIcon = { if (book.getSplitLongChapter()) Icon(Icons.Default.Star, null) }
+            )
+        }
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.delete_alert),
+            onClick = { onMenuAction(BookInfoMenuAction.ToggleDeleteAlert) },
+            trailingIcon = { if (io.legado.app.help.config.LocalConfig.bookInfoDeleteAlert) Icon(Icons.Default.Star, null) }
+        )
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.clear_cache),
+            onClick = { onMenuAction(BookInfoMenuAction.ClearCache) }
+        )
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.log),
+            onClick = { onMenuAction(BookInfoMenuAction.ShowLog) }
+        )
+    }
+}
+
+@Composable
+private fun BookInfoHeader(
+    book: Book,
+    kindLabels: List<String>,
+    groupNames: String?,
+    onCoverClick: () -> Unit,
+    onCoverLongClick: () -> Unit,
+    onAuthorClick: (Boolean) -> Unit,
+    onBookNameClick: (Boolean) -> Unit,
+    onOriginClick: () -> Unit,
+) {
+    val labelBackground = lerp(
+        LegadoTheme.colorScheme.surface,
+        LegadoTheme.seedColor,
+        0.1f
+    ).copy(alpha = 0.82f)
+    Column(
+        modifier = Modifier
+            .fillMaxWidth()
+            .background(
+                Brush.verticalGradient(
+                    colors = listOf(
+                        Color.Transparent,
+                        lerp(LegadoTheme.colorScheme.surface, LegadoTheme.seedColor, 0.08f)
+                            .copy(alpha = 0.5f),
+                        LegadoTheme.colorScheme.surface,
+                    )
+                )
+            )
+            .padding(top = 16.dp, bottom = 8.dp),
+        verticalArrangement = Arrangement.spacedBy(12.dp)
+    ) {
+        Column(
+            modifier = Modifier.padding(horizontal = 16.dp),
+            verticalArrangement = Arrangement.spacedBy(12.dp)
+        ) {
+            Row(
+                modifier = Modifier.fillMaxWidth(),
+                horizontalArrangement = Arrangement.spacedBy(16.dp),
+                verticalAlignment = Alignment.Top,
+            ) {
+                Box(
+                    modifier = Modifier
+                        .width(112.dp)
+                        .combinedClickable(onClick = onCoverClick, onLongClick = onCoverLongClick)
+                ) {
+                    BookCover(
+                        name = book.name,
+                        author = book.author,
+                        path = book.getDisplayCover(),
+                        sourceOrigin = book.origin,
+                        modifier = Modifier.width(112.dp)
+                    )
+                }
+                Column(
+                    modifier = Modifier
+                        .weight(1f)
+                        .align(Alignment.CenterVertically)
+                        .padding(top = 8.dp, bottom = 8.dp),
+                    verticalArrangement = Arrangement.spacedBy(4.dp)
+                ) {
+                    AppText(
+                        text = book.name,
+                        style = LegadoTheme.typography.headlineSmall,
+                        fontWeight = FontWeight.Bold,
+                        maxLines = 3,
+                        modifier = Modifier.combinedClickable(
+                            onClick = { onBookNameClick(false) },
+                            onLongClick = { onBookNameClick(true) }
+                        )
+                    )
+                    AppText(
+                        text = stringResource(R.string.author_show, book.getRealAuthor()),
+                        style = LegadoTheme.typography.bodyLarge,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                        modifier = Modifier.combinedClickable(
+                            onClick = { onAuthorClick(false) },
+                            onLongClick = { onAuthorClick(true) }
+                        )
+                    )
+                    AppText(
+                        text = stringResource(R.string.origin_show, book.originName),
+                        style = LegadoTheme.typography.labelMedium,
+                        color = LegadoTheme.colorScheme.primary,
+                        modifier = Modifier.clickable(onClick = onOriginClick)
+                    )
+                }
+            }
+            if (kindLabels.isNotEmpty() || !groupNames.isNullOrBlank()) {
+                LazyRow(
+                    modifier = Modifier.fillMaxWidth(),
+                    horizontalArrangement = Arrangement.spacedBy(8.dp),
+                ) {
+                    itemsIndexed(
+                        items = kindLabels,
+                        key = { index, label -> "kind-$index-$label" }
+                    ) { _, label ->
+                        TextCard(
+                            text = label,
+                            textStyle = LegadoTheme.typography.labelLargeEmphasized,
+                            backgroundColor = labelBackground,
+                            contentColor = LegadoTheme.colorScheme.onSurface,
+                        )
+                    }
+                    groupNames?.takeIf { it.isNotBlank() }?.let {
+                        item(key = "group-$it") {
+                            TextCard(
+                                text = stringResource(R.string.group_s, it),
+                                textStyle = LegadoTheme.typography.labelLargeEmphasized,
+                                backgroundColor = labelBackground,
+                                contentColor = LegadoTheme.colorScheme.onSurface,
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun BookInfoActions(
+    inBookshelf: Boolean,
+    onShelfClick: () -> Unit,
+    onTocClick: () -> Unit,
+    onGroupClick: () -> Unit,
+    onSourceClick: () -> Unit,
+) {
+    var awaitingShelfAddition by rememberSaveable { mutableStateOf(false) }
+    var showShelfRemoveHint by rememberSaveable { mutableStateOf(false) }
+
+    LaunchedEffect(inBookshelf, awaitingShelfAddition) {
+        if (awaitingShelfAddition && inBookshelf) {
+            awaitingShelfAddition = false
+            showShelfRemoveHint = true
+            delay(1000)
+            showShelfRemoveHint = false
+        } else if (!inBookshelf) {
+            showShelfRemoveHint = false
+        }
+    }
+
+    val shelfLabel = when {
+        !inBookshelf -> stringResource(R.string.add_to_bookshelf)
+        showShelfRemoveHint -> stringResource(R.string.click_to_remove)
+        else -> stringResource(R.string.remove_from_bookshelf)
+    }
+
+    Row(
+        modifier = Modifier
+            .fillMaxWidth()
+            .background(LegadoTheme.colorScheme.surface)
+            .padding(horizontal = 16.dp, vertical = 4.dp),
+        horizontalArrangement = Arrangement.spacedBy(8.dp),
+    ) {
+        BookInfoActionCard(
+            modifier = Modifier.weight(1f),
+            icon = if (inBookshelf) Icons.Outlined.Book else Icons.Default.BookmarkAdd,
+            label = shelfLabel,
+            onClick = {
+                if (!inBookshelf) {
+                    awaitingShelfAddition = true
+                } else {
+                    awaitingShelfAddition = false
+                    showShelfRemoveHint = false
+                }
+                onShelfClick()
+            },
+        )
+        BookInfoActionCard(
+            modifier = Modifier.weight(1f),
+            icon = Icons.AutoMirrored.Outlined.FormatListBulleted,
+            label = stringResource(R.string.view_toc),
+            onClick = onTocClick
+        )
+        BookInfoActionCard(
+            modifier = Modifier.weight(1f),
+            icon = Icons.Default.Bookmark,
+            label = stringResource(R.string.change_group),
+            onClick = onGroupClick
+        )
+        BookInfoActionCard(
+            modifier = Modifier.weight(1f),
+            icon = Icons.Default.Code,
+            label = stringResource(R.string.book_source),
+            onClick = onSourceClick
+        )
+    }
+}
+
+@Composable
+private fun BookInfoActionCard(
+    modifier: Modifier = Modifier,
+    icon: androidx.compose.ui.graphics.vector.ImageVector,
+    label: String,
+    onClick: () -> Unit
+) {
+    GlassCard(
+        modifier = modifier,
+        onClick = onClick,
+        containerColor = LegadoTheme.colorScheme.surfaceContainer.copy(alpha = 0.5f),
+        contentColor = LegadoTheme.colorScheme.onSurface,
+    ) {
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(horizontal = 8.dp, vertical = 12.dp),
+            verticalArrangement = Arrangement.spacedBy(4.dp),
+            horizontalAlignment = Alignment.CenterHorizontally,
+        ) {
+            AppIcon(icon, null)
+            AnimatedTextLine(
+                text = label,
+                style = LegadoTheme.typography.bodySmall,
+                maxLines = 2,
+            )
+        }
+    }
+}
+
+@Composable
+private fun BookInfoSummary(
+    book: Book,
+    chapterList: List<BookChapter>,
+    onRemarkClick: () -> Unit,
+) {
+    Column(
+        modifier = Modifier
+            .fillMaxWidth()
+            .background(LegadoTheme.colorScheme.surface)
+            .padding(horizontal = 16.dp, vertical = 8.dp),
+        verticalArrangement = Arrangement.spacedBy(4.dp)
+    ) {
+        AppText(
+            text = stringResource(R.string.toc_s, book.durChapterTitle ?: stringResource(R.string.loading)),
+            style = LegadoTheme.typography.titleMedium,
+            fontWeight = FontWeight.Bold,
+        )
+        AppText(
+            text = stringResource(R.string.lasted_show, book.latestChapterTitle ?: ""),
+            style = LegadoTheme.typography.bodyMedium,
+            color = LegadoTheme.colorScheme.onSurfaceVariant,
+        )
+        Row(
+            modifier = Modifier.fillMaxWidth(),
+            horizontalArrangement = Arrangement.spacedBy(8.dp),
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            AppText(
+                text = stringResource(R.string.read_chapter_total, book.totalChapterNum),
+                style = LegadoTheme.typography.bodyMedium,
+                color = LegadoTheme.colorScheme.primary,
+                fontWeight = FontWeight.Bold,
+            )
+            AppText(text = "|", color = LegadoTheme.colorScheme.secondary)
+            AppText(
+                text = if (book.durChapterIndex + 1 == book.totalChapterNum && book.totalChapterNum > 0) "已读完" else stringResource(R.string.read_chapter_index, book.durChapterIndex + 1),
+                style = LegadoTheme.typography.labelMedium,
+                color = LegadoTheme.colorScheme.secondary,
+            )
+        }
+        if (chapterList.isEmpty()) {
+            AppText(
+                text = stringResource(R.string.error_load_toc),
+                style = LegadoTheme.typography.bodySmall,
+                color = LegadoTheme.colorScheme.error
+            )
+        }
+        Spacer(modifier = Modifier.height(4.dp))
+        book.remark?.takeIf { it.isNotBlank() }?.let { remark ->
+            ElevatedCard(
+                modifier = Modifier.fillMaxWidth(),
+                onClick = onRemarkClick,
+                colors = CardDefaults.elevatedCardColors(
+                    containerColor = LegadoTheme.colorScheme.surfaceContainerHigh,
+                    contentColor = LegadoTheme.colorScheme.onSurface,
+                )
+            ) {
+                Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+                    AppText(text = stringResource(R.string.book_remark), style = LegadoTheme.typography.titleSmall)
+                    AppText(text = remark, style = LegadoTheme.typography.labelMediumEmphasized)
+                }
+            }
+        }
+        Spacer(modifier = Modifier.height(4.dp))
+        AppText(
+            text = book.getDisplayIntro().orEmpty().ifBlank { stringResource(R.string.intro_show_null) },
+            style = LegadoTheme.typography.bodyMedium,
+        )
+    }
+}
+@Composable
+private fun BookInfoDialogs(
+    state: BookInfoUiState,
+    onIntent: (BookInfoIntent) -> Unit,
+    onBack: () -> Unit,
+) {
+    val dialog = state.dialog
+    var deleteOriginal by remember(dialog) { mutableStateOf(io.legado.app.help.config.LocalConfig.deleteBookOriginal) }
+    var remarkText by remember(dialog) { mutableStateOf((dialog as? BookInfoDialog.EditRemark)?.remark.orEmpty()) }
+
+    if (dialog is BookInfoDialog.AddToShelfOnBack) {
+        AppAlertDialog(
+            show = true,
+            onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) },
+            title = stringResource(R.string.add_to_bookshelf),
+            text = stringResource(R.string.check_add_bookshelf, state.book?.name.orEmpty()),
+            confirmText = stringResource(android.R.string.ok),
+            onConfirm = { onIntent(BookInfoIntent.ConfirmBackAddToShelf) },
+            dismissText = stringResource(android.R.string.cancel),
+            onDismiss = onBack,
+        )
+    }
+
+    if (dialog is BookInfoDialog.DeleteBook) {
+        AppAlertDialog(
+            show = true,
+            onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) },
+            title = stringResource(R.string.draw),
+            text = stringResource(R.string.sure_del),
+            confirmText = stringResource(android.R.string.ok),
+            onConfirm = {
+                io.legado.app.help.config.LocalConfig.deleteBookOriginal = deleteOriginal
+                onIntent(BookInfoIntent.ConfirmDelete(deleteOriginal))
+            },
+            dismissText = stringResource(android.R.string.cancel),
+            onDismiss = { onIntent(BookInfoIntent.DismissDialog) },
+            content = {
+                if (dialog.isLocal) {
+                    Row(verticalAlignment = Alignment.CenterVertically) {
+                        androidx.compose.material3.Checkbox(
+                            checked = deleteOriginal,
+                            onCheckedChange = { deleteOriginal = it },
+                            colors = androidx.compose.material3.CheckboxDefaults.colors(
+                                checkedColor = LegadoTheme.colorScheme.primary,
+                                checkmarkColor = LegadoTheme.colorScheme.onPrimary,
+                                uncheckedColor = LegadoTheme.colorScheme.onSurfaceVariant,
+                            )
+                        )
+                        Text(text = stringResource(R.string.delete_book_file))
+                    }
+                }
+            }
+        )
+    }
+
+    if (dialog is BookInfoDialog.EditRemark) {
+        AppAlertDialog(
+            show = true,
+            onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) },
+            title = stringResource(R.string.edit_remark),
+            confirmText = stringResource(android.R.string.ok),
+            onConfirm = { onIntent(BookInfoIntent.UpdateRemark(remarkText)) },
+            dismissText = stringResource(android.R.string.cancel),
+            onDismiss = { onIntent(BookInfoIntent.DismissDialog) },
+            content = {
+                AppTextField(
+                    value = remarkText,
+                    onValueChange = { remarkText = it },
+                    label = "备注",
+                    modifier = Modifier.fillMaxWidth(),
+                )
+            }
+        )
+    }
+
+    if (dialog is BookInfoDialog.UnsupportedWebFile) {
+        AppAlertDialog(
+            show = true,
+            onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) },
+            title = stringResource(R.string.draw),
+            text = stringResource(R.string.file_not_supported, dialog.webFile.name),
+            confirmText = stringResource(R.string.open_fun),
+            onConfirm = { onIntent(BookInfoIntent.OpenUnsupportedWebFile(dialog.webFile)) },
+            dismissText = stringResource(android.R.string.cancel),
+            onDismiss = { onIntent(BookInfoIntent.DismissDialog) },
+        )
+    }
+
+    if (dialog is BookInfoDialog.PhotoPreview) {
+        AppAlertDialog(
+            show = true,
+            onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) },
+            title = stringResource(R.string.img_cover),
+            confirmText = stringResource(android.R.string.ok),
+            onConfirm = { onIntent(BookInfoIntent.DismissDialog) },
+            content = {
+                AsyncImage(
+                    model = dialog.path,
+                    contentDescription = null,
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .heightIn(max = 420.dp),
+                    contentScale = ContentScale.Fit,
+                )
+            }
+        )
+    }
+
+    if (state.isBusy) {
+        AppAlertDialog(
+            show = true,
+            onDismissRequest = {},
+            content = {
+                Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
+                    CircularProgressIndicator(
+                        color = LegadoTheme.colorScheme.primary,
+                        trackColor = LegadoTheme.colorScheme.surfaceContainerHighest,
+                    )
+                }
+            }
+        )
+    }
+
+    AppLogSheet(show = state.showAppLogSheet, onDismissRequest = { onIntent(BookInfoIntent.DismissAppLogSheet) })
+}

+ 510 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt

@@ -0,0 +1,510 @@
+package io.legado.app.ui.book.info
+
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.FilterList
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.PauseCircleOutline
+import androidx.compose.material.icons.filled.PushPin
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Star
+import androidx.compose.material.icons.outlined.FolderZip
+import androidx.compose.material.icons.outlined.Image
+import androidx.compose.material.icons.outlined.Settings
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.R
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.BookGroup
+import io.legado.app.data.entities.BookSource
+import io.legado.app.data.entities.SearchBook
+import io.legado.app.ui.book.changecover.ChangeCoverViewModel
+import io.legado.app.ui.book.changesource.ChangeBookSourceComposeViewModel
+import io.legado.app.ui.book.group.GroupEditSheet
+import io.legado.app.ui.book.source.edit.BookSourceEditActivity
+import io.legado.app.ui.book.source.manage.BookSourceActivity
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
+import io.legado.app.ui.widget.components.button.MediumIconButton
+import io.legado.app.ui.widget.components.button.SmallIconButton
+import io.legado.app.ui.widget.components.button.TopBarButton
+import io.legado.app.ui.widget.components.card.GlassCard
+import io.legado.app.ui.widget.components.card.SelectionItemCard
+import io.legado.app.ui.widget.components.checkBox.AppCheckbox
+import io.legado.app.ui.widget.components.cover.BookCover
+import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
+import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.utils.StartActivityContract
+import io.legado.app.utils.startActivity
+import io.legado.app.utils.toastOnUi
+import org.koin.androidx.compose.koinViewModel
+
+@Composable
+fun WebFileSheet(
+    show: Boolean,
+    files: List<BookInfoWebFile>,
+    title: String,
+    onDismissRequest: () -> Unit,
+    onSelect: (BookInfoWebFile) -> Unit,
+) {
+    AppModalBottomSheet(show = show, onDismissRequest = onDismissRequest, title = title) {
+        if (files.isEmpty()) {
+            Box(modifier = Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) {
+                Text(text = stringResource(R.string.empty))
+            }
+        } else {
+            LazyColumn(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+                items(files, key = { it.name }) { file ->
+                    GlassCard(onClick = { onSelect(file) }) {
+                        Row(
+                            modifier = Modifier.fillMaxWidth().padding(16.dp),
+                            horizontalArrangement = Arrangement.spacedBy(12.dp),
+                            verticalAlignment = Alignment.CenterVertically,
+                        ) {
+                            Icon(if (file.name.endsWith("zip") || file.name.endsWith("rar") || file.name.endsWith("7z")) Icons.Outlined.FolderZip else Icons.Outlined.Image, null)
+                            Text(text = file.name, modifier = Modifier.weight(1f), style = LegadoTheme.typography.bodyMedium)
+                        }
+                    }
+                }
+            }
+        }
+        Spacer(modifier = Modifier.height(16.dp))
+    }
+}
+
+@Composable
+fun GroupSelectSheet(
+    show: Boolean,
+    currentGroupId: Long,
+    onDismissRequest: () -> Unit,
+    onConfirm: (Long) -> Unit,
+) {
+    val groups by appDb.bookGroupDao.flowSelect().collectAsStateWithLifecycle(initialValue = emptyList())
+    var selectedGroupId by remember(currentGroupId) { mutableLongStateOf(currentGroupId) }
+    var editingGroup by remember { mutableStateOf<BookGroup?>(null) }
+
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.group_select),
+        endAction = { IconButton(onClick = { editingGroup = BookGroup() }) { Icon(Icons.Default.Add, null) } }
+    ) {
+        LazyColumn(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+            items(groups, key = { it.groupId }) { group ->
+                val isSelected = selectedGroupId and group.groupId > 0
+                SelectionItemCard(
+                    title = group.groupName,
+                    isSelected = isSelected,
+                    onToggleSelection = {
+                        selectedGroupId = if (isSelected) {
+                            selectedGroupId - group.groupId
+                        } else {
+                            selectedGroupId + group.groupId
+                        }
+                    },
+                    leadingContent = {
+                        AppCheckbox(
+                            checked = isSelected,
+                            onCheckedChange = {
+                                selectedGroupId = if (it) {
+                                    selectedGroupId + group.groupId
+                                } else {
+                                    selectedGroupId - group.groupId
+                                }
+                            }
+                        )
+                    },
+                    trailingAction = {
+                        SmallIconButton(
+                            onClick = { editingGroup = group },
+                            imageVector = Icons.Default.Edit
+                        )
+                    },
+                    containerColor = LegadoTheme.colorScheme.surfaceContainerLow
+                )
+            }
+        }
+        Spacer(modifier = Modifier.height(12.dp))
+        ConfirmDismissButtonsRow(
+            onDismiss = onDismissRequest,
+            onConfirm = { onConfirm(selectedGroupId) },
+            dismissText = stringResource(R.string.cancel),
+            confirmText = stringResource(R.string.ok),
+        )
+        Spacer(modifier = Modifier.height(12.dp))
+    }
+
+    GroupEditSheet(show = editingGroup != null, group = editingGroup, onDismissRequest = { editingGroup = null })
+}
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun ChangeCoverSheet(
+    show: Boolean,
+    name: String,
+    author: String,
+    onDismissRequest: () -> Unit,
+    onSelect: (String) -> Unit,
+    viewModel: ChangeCoverViewModel = koinViewModel(key = "cover-$name-$author"),
+) {
+    val items by viewModel.dataFlow.collectAsStateWithLifecycle(initialValue = emptyList())
+    val isSearching by viewModel.isSearching.collectAsStateWithLifecycle()
+
+    LaunchedEffect(name, author) {
+        viewModel.initData(name, author)
+    }
+    DisposableEffect(show) {
+        onDispose {
+            viewModel.stopSearch()
+        }
+    }
+
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.change_cover_source),
+        endAction = {
+            IconButton(onClick = { viewModel.startOrStopSearch() }) {
+                Icon(if (isSearching) Icons.Default.MoreVert else Icons.Default.Refresh, null)
+            }
+        }
+    ) {
+        if (isSearching) {
+            LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
+            Spacer(modifier = Modifier.height(12.dp))
+        }
+        LazyVerticalGrid(columns = GridCells.Fixed(3), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+            items(items, key = { it.bookUrl + it.originName }) { item ->
+                GlassCard(onClick = { onSelect(item.coverUrl.orEmpty()) }) {
+                    Column(modifier = Modifier.fillMaxWidth().padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+                        BookCover(name = item.name, author = item.author, path = item.coverUrl, sourceOrigin = item.origin, modifier = Modifier.fillMaxWidth())
+                        AppText(text = item.originName, style = LegadoTheme.typography.bodySmall, maxLines = 2)
+                    }
+                }
+            }
+        }
+        Spacer(modifier = Modifier.height(16.dp))
+    }
+}
+
+@Composable
+fun ChangeSourceSheet(
+    show: Boolean,
+    oldBook: Book,
+    onDismissRequest: () -> Unit,
+    onReplace: (BookSource, Book, List<BookChapter>) -> Unit,
+    onAddAsNew: (Book, List<BookChapter>) -> Unit,
+    viewModel: ChangeBookSourceComposeViewModel = koinViewModel(key = "source-${oldBook.bookUrl}"),
+) {
+    val context = LocalContext.current
+    val items by viewModel.searchDataFlow.collectAsStateWithLifecycle(initialValue = emptyList())
+    val isSearching by viewModel.isSearching.collectAsStateWithLifecycle()
+    val progress by viewModel.changeSourceProgress.collectAsStateWithLifecycle()
+    val groups by appDb.bookSourceDao.flowEnabledGroups().collectAsStateWithLifecycle(initialValue = emptyList())
+    var searchQuery by rememberSaveable { mutableStateOf("") }
+    val selectedGroup = viewModel.searchGroup
+    val checkAuthor = viewModel.checkAuthor
+    val loadInfo = viewModel.loadInfo
+    val loadToc = viewModel.loadToc
+    val loadWordCount = viewModel.loadWordCount
+    var actionBook by remember { mutableStateOf<SearchBook?>(null) }
+    var mismatchBook by remember { mutableStateOf<SearchBook?>(null) }
+    var loadingAction by remember { mutableStateOf(false) }
+    var showOptionsMenu by rememberSaveable { mutableStateOf(false) }
+    var showFilterMenu by rememberSaveable { mutableStateOf(false) }
+
+    val editSourceResult = rememberLauncherForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) {
+        val origin = it.data?.getStringExtra("origin") ?: return@rememberLauncherForActivityResult
+        viewModel.startSearch(origin)
+    }
+
+    LaunchedEffect(oldBook.bookUrl) {
+        viewModel.initData(oldBook.name, oldBook.author, oldBook, false)
+    }
+    DisposableEffect(oldBook.bookUrl) {
+        onDispose {
+            viewModel.stopSearch()
+        }
+    }
+
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.book_source),
+        startAction = {
+            Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+                Box {
+                    MediumIconButton(
+                        onClick = { showOptionsMenu = true },
+                        imageVector = Icons.Default.MoreVert
+                    )
+                    RoundDropdownMenu(
+                        expanded = showOptionsMenu,
+                        onDismissRequest = { showOptionsMenu = false }
+                    ) { dismiss ->
+                        RoundDropdownMenuItem(
+                            text = "校验作者",
+                            isSelected = checkAuthor,
+                            onClick = {
+                                viewModel.onCheckAuthorChange(!checkAuthor)
+                                dismiss()
+                            }
+                        )
+                        RoundDropdownMenuItem(
+                            text = "加载详情",
+                            isSelected = loadInfo,
+                            onClick = {
+                                viewModel.onLoadInfoChange(!loadInfo)
+                                dismiss()
+                            }
+                        )
+                        RoundDropdownMenuItem(
+                            text = "加载目录",
+                            isSelected = loadToc,
+                            onClick = {
+                                viewModel.onLoadTocChange(!loadToc)
+                                dismiss()
+                            }
+                        )
+                        RoundDropdownMenuItem(
+                            text = "字数对比",
+                            isSelected = loadWordCount,
+                            onClick = {
+                                viewModel.onLoadWordCountChange(!loadWordCount)
+                                dismiss()
+                            }
+                        )
+                    }
+                }
+                MediumIconButton(
+                    onClick = { context.startActivity<BookSourceActivity>() },
+                    imageVector = Icons.Outlined.Settings
+                )
+            }
+        },
+        endAction = {
+            Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+                MediumIconButton(
+                    onClick = { viewModel.startOrStopSearch() },
+                    imageVector = if (isSearching) Icons.Default.PauseCircleOutline else Icons.Default.Refresh,
+                )
+                Box {
+                    MediumIconButton(
+                        onClick = { showFilterMenu = true },
+                        imageVector = Icons.Default.FilterList
+                    )
+                    RoundDropdownMenu(
+                        expanded = showFilterMenu,
+                        onDismissRequest = { showFilterMenu = false }
+                    ) { dismiss ->
+                        RoundDropdownMenuItem(
+                            text = stringResource(R.string.all_source),
+                            isSelected = selectedGroup.isBlank(),
+                            onClick = {
+                                viewModel.onSearchGroupSelected("")
+                                dismiss()
+                            }
+                        )
+                        groups.forEach { group ->
+                            RoundDropdownMenuItem(
+                                text = group,
+                                isSelected = selectedGroup == group,
+                                onClick = {
+                                    viewModel.onSearchGroupSelected(group)
+                                    dismiss()
+                                }
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    ) {
+        AppTextField(
+            value = searchQuery,
+            backgroundColor = LegadoTheme.colorScheme.surface,
+            onValueChange = {
+                searchQuery = it
+                viewModel.screen(it)
+            },
+            label = stringResource(R.string.screen),
+            modifier = Modifier.fillMaxWidth()
+        )
+        Spacer(modifier = Modifier.height(12.dp))
+        if (isSearching) {
+            LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
+            Spacer(modifier = Modifier.height(8.dp))
+            AppText(
+                text = "${progress.first} / ${viewModel.totalSourceCount} · ${items.size}",
+                style = LegadoTheme.typography.bodySmall
+            )
+            Spacer(modifier = Modifier.height(12.dp))
+        }
+        LazyColumn(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+            items(items, key = { it.bookUrl + it.origin }) { item ->
+                val bookScore by remember(item.origin, item.name, item.author) {
+                    viewModel.bookScoreFlow(item)
+                }.collectAsStateWithLifecycle()
+                SelectionItemCard(
+                    title = item.originName,
+                    containerColor = LegadoTheme.colorScheme.surfaceContainer,
+                    selectedContainerColor = LegadoTheme.colorScheme.primaryContainer.copy(alpha = 0.2f),
+                    leadingContent = {
+                        MediumIconButton(
+                            onClick = {
+                                viewModel.onBookScoreClick(item)
+                            },
+                            imageVector = Icons.Default.PushPin,
+                            tint = if (bookScore > 0) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.outline,
+                            contentDescription = null
+                        )
+                    },
+                    supportingContent = {
+                        Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+                            AppText(
+                                text = item.author,
+                                style = LegadoTheme.typography.labelLargeEmphasized
+                            )
+                            AppText(
+                                text = item.getDisplayLastChapterTitle(),
+                                style = LegadoTheme.typography.labelMediumEmphasized
+                            )
+                            item.chapterWordCountText?.takeIf { loadWordCount }?.let {
+                                AppText(
+                                    text = it,
+                                    style = LegadoTheme.typography.labelSmallEmphasized,
+                                    color = LegadoTheme.colorScheme.primary
+                                )
+                            }
+                        }
+                    },
+                    isSelected = item.bookUrl == oldBook.bookUrl,
+                    onToggleSelection = {
+                        if (item.bookUrl != oldBook.bookUrl) {
+                            if (!item.sameBookTypeLocal(oldBook.type)) mismatchBook = item else actionBook = item
+                        }
+                    },
+                    dropdownContent = { onDismiss: () -> Unit ->
+                        RoundDropdownMenuItem(
+                            text = stringResource(R.string.to_top),
+                            onClick = {
+                                viewModel.topSource(item)
+                                onDismiss()
+                            }
+                        )
+                        RoundDropdownMenuItem(
+                            text = "置底",
+                            onClick = {
+                                viewModel.bottomSource(item)
+                                onDismiss()
+                            }
+                        )
+                        RoundDropdownMenuItem(
+                            text = stringResource(R.string.edit),
+                            onClick = {
+                                onDismiss()
+                                editSourceResult.launch { putExtra("sourceUrl", item.origin) }
+                            }
+                        )
+                        RoundDropdownMenuItem(
+                            text = "禁用",
+                            onClick = {
+                                viewModel.disableSource(item)
+                                onDismiss()
+                            }
+                        )
+                        RoundDropdownMenuItem(
+                            text = stringResource(R.string.delete),
+                            color = LegadoTheme.colorScheme.error,
+                            onClick = {
+                                viewModel.del(item)
+                                if (oldBook.bookUrl == item.bookUrl) {
+                                    viewModel.autoChangeSource(oldBook.type) { book, toc, source ->
+                                        onReplace(source, book, toc)
+                                    }
+                                }
+                                onDismiss()
+                            }
+                        )
+                    }
+                )
+            }
+        }
+        Spacer(modifier = Modifier.height(16.dp))
+    }
+
+    val performAction: (SearchBook, Boolean) -> Unit = { searchBook, replace ->
+        loadingAction = true
+        val book = viewModel.bookMap[searchBook.primaryStr()] ?: searchBook.toBook()
+        viewModel.getToc(book, { toc, source ->
+            loadingAction = false
+            if (replace) {
+                onReplace(source, book, toc)
+                onDismissRequest()
+            } else {
+                onAddAsNew(book, toc)
+                context.toastOnUi(context.getString(R.string.book_added_to_shelf))
+            }
+            actionBook = null
+        }, {
+            loadingAction = false
+            context.toastOnUi(if (replace) "换源失败" else "添加书籍失败")
+        })
+    }
+
+    if (mismatchBook != null) {
+        AppAlertDialog(show = true, onDismissRequest = { mismatchBook = null }, title = stringResource(R.string.book_type_different), text = stringResource(R.string.soure_change_source), confirmText = stringResource(android.R.string.ok), onConfirm = { actionBook = mismatchBook; mismatchBook = null }, dismissText = stringResource(android.R.string.cancel), onDismiss = { mismatchBook = null })
+    }
+    actionBook?.let { searchBook ->
+        AppAlertDialog(show = true, onDismissRequest = { actionBook = null }, title = stringResource(R.string.change_source_option_title), dismissText = stringResource(R.string.add_as_new_book), onDismiss = { performAction(searchBook, false) }, confirmText = stringResource(R.string.replace_current_book), onConfirm = { performAction(searchBook, true) })
+    }
+    if (loadingAction) {
+        AppAlertDialog(show = true, onDismissRequest = {}, content = {
+            Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
+        })
+    }
+}

+ 877 - 318
app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt

@@ -1,9 +1,9 @@
 package io.legado.app.ui.book.info
 
+import android.app.Activity.RESULT_OK
 import android.app.Application
 import android.content.Intent
 import android.net.Uri
-import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.viewModelScope
 import io.legado.app.R
 import io.legado.app.base.BaseViewModel
@@ -19,6 +19,7 @@ import io.legado.app.data.repository.RemoteBookRepository
 import io.legado.app.exception.NoBooksDirException
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.book.BookHelp
+import io.legado.app.help.book.addType
 import io.legado.app.help.book.getExportFileName
 import io.legado.app.help.book.isLocal
 import io.legado.app.help.book.isNotShelf
@@ -26,6 +27,8 @@ import io.legado.app.help.book.isSameNameAuthor
 import io.legado.app.help.book.isWebFile
 import io.legado.app.help.book.removeType
 import io.legado.app.help.book.updateTo
+import io.legado.app.help.config.AppConfig
+import io.legado.app.help.config.LocalConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.lib.webdav.ObjectNotFoundException
 import io.legado.app.model.AudioPlay
@@ -37,535 +40,1091 @@ import io.legado.app.model.analyzeRule.AnalyzeUrl
 import io.legado.app.model.localBook.LocalBook
 import io.legado.app.model.webBook.WebBook
 import io.legado.app.utils.ArchiveUtils
+import io.legado.app.utils.ConvertUtils
+import io.legado.app.utils.FileDoc
+import io.legado.app.utils.GSON
 import io.legado.app.utils.UrlUtil
 import io.legado.app.utils.postEvent
 import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
 
 class BookInfoViewModel(
     application: Application,
     private val remoteBookRepository: RemoteBookRepository
 ) : BaseViewModel(application) {
-    val bookData = MutableLiveData<Book>()
-    val chapterListData = MutableLiveData<List<BookChapter>>()
-    val webFiles = mutableListOf<WebFile>()
+
+    private val _uiState = MutableStateFlow(BookInfoUiState())
+    val uiState = _uiState.asStateFlow()
+
+    private val _effects = MutableSharedFlow<BookInfoEffect>(extraBufferCapacity = 8)
+    val effects = _effects.asSharedFlow()
+
+    private var currentBook: Book? = null
+    private var currentChapterList: List<BookChapter> = emptyList()
+    private var currentWebFiles: List<BookInfoWebFile> = emptyList()
+    private var currentKindLabels: List<String> = emptyList()
+    private var currentGroupNames: String? = null
+    private var chapterChanged = false
+
     var inBookshelf = false
+        private set
     var bookSource: BookSource? = null
+        private set
+
     private var changeSourceCoroutine: Coroutine<*>? = null
-    val waitDialogData = MutableLiveData<Boolean>()
-    val actionLive = MutableLiveData<String>()
 
     fun initData(intent: Intent) {
+        if (currentBook != null) return
         execute {
             val bookUrl = intent.getStringExtra("bookUrl") ?: ""
             appDb.bookDao.getBook(bookUrl)?.let {
                 inBookshelf = !it.isNotShelf
-                upBook(it)
-                return@execute
+                return@execute it
             }
             appDb.searchBookDao.getSearchBook(bookUrl)?.toBook()?.let {
-                upBook(it)
-                return@execute
+                inBookshelf = false
+                return@execute it
             }
             throw NoStackTraceException("未找到书籍")
+        }.onSuccess {
+            upBook(it)
+        }.onError {
+            context.toastOnUi(it.localizedMessage ?: "未找到书籍")
+            emitEffect(BookInfoEffect.Finish(afterTransition = true))
         }
     }
 
-    fun upBook(intent: Intent) {
-        execute {
-            val name = intent.getStringExtra("name") ?: ""
-            val author = intent.getStringExtra("author") ?: ""
-            val bookUrl = intent.getStringExtra("bookUrl") ?: ""
-            val book = when {
-                bookUrl.isNotBlank() -> appDb.bookDao.getBook(bookUrl)
-                else -> appDb.bookDao.getBook(name, author)
+    fun onIntent(intent: BookInfoIntent) {
+        when (intent) {
+            BookInfoIntent.BackPressed -> onBackPressed()
+            BookInfoIntent.DismissSheet -> dismissSheet()
+            BookInfoIntent.DismissDialog -> dismissDialog()
+            is BookInfoIntent.MenuAction -> handleMenuAction(intent.action)
+            is BookInfoIntent.AuthorClick -> onAuthorClick(intent.longClick)
+            is BookInfoIntent.BookNameClick -> onBookNameClick(intent.longClick)
+            BookInfoIntent.OriginClick -> onOriginClick()
+            BookInfoIntent.DismissAppLogSheet -> {
+                _uiState.update { it.copy(showAppLogSheet = false) }
+            }
+
+            BookInfoIntent.ReadClick -> onReadClick()
+            BookInfoIntent.ShelfClick -> onShelfClick()
+            BookInfoIntent.TocClick -> onTocClick()
+            BookInfoIntent.CoverClick -> setSheet(BookInfoSheet.CoverPicker)
+            BookInfoIntent.CoverLongClick -> currentBook?.getDisplayCover()?.takeIf { it.isNotBlank() }
+                ?.let { showDialog(BookInfoDialog.PhotoPreview(it)) }
+
+            BookInfoIntent.GroupClick -> setSheet(BookInfoSheet.GroupPicker)
+            BookInfoIntent.ChangeSourceClick -> setSheet(BookInfoSheet.SourcePicker)
+            BookInfoIntent.RemarkClick -> showDialog(BookInfoDialog.EditRemark(currentBook?.remark))
+            BookInfoIntent.ConfirmBackAddToShelf -> {
+                dismissDialog()
+                addToBookshelf {
+                    emitEffect(BookInfoEffect.Finish(afterTransition = true))
+                }
+            }
+
+            is BookInfoIntent.ConfirmDelete -> {
+                dismissDialog()
+                deleteBook(intent.deleteOriginal)
+            }
+
+            is BookInfoIntent.UpdateRemark -> {
+                dismissDialog()
+                saveRemark(intent.remark)
+            }
+
+            is BookInfoIntent.SelectGroup -> {
+                dismissSheet()
+                updateGroup(intent.groupId)
+            }
+
+            is BookInfoIntent.SelectCover -> {
+                dismissSheet()
+                updateCover(intent.coverUrl)
+            }
+
+            is BookInfoIntent.ReplaceWithSource -> {
+                dismissSheet()
+                changeTo(intent.source, intent.book, intent.toc)
+            }
+
+            is BookInfoIntent.AddSourceAsNewBook -> {
+                addToBookshelf(intent.book, intent.toc) {
+                    context.toastOnUi("已添加到书架")
+                }
+            }
+
+            is BookInfoIntent.SelectWebFile -> handleWebFileSelection(
+                intent.webFile,
+                intent.openAfterImport
+            )
+
+            is BookInfoIntent.OpenUnsupportedWebFile -> {
+                dismissDialog()
+                importOrDownloadWebFile<Uri>(intent.webFile) { uri ->
+                    emitEffect(BookInfoEffect.OpenFile(uri, "*/*"))
+                }
+            }
+
+            is BookInfoIntent.SelectArchiveEntry -> {
+                dismissSheet()
+                importArchiveBook(intent.archiveUri, intent.entryName) { book ->
+                    if (intent.openAfterImport) {
+                        openReader(book)
+                    }
+                }
             }
-            book?.let { upBook(it) }
         }
     }
 
-    private fun upBook(book: Book) {
+    fun openEdit() {
+        currentBook?.let {
+            emitEffect(BookInfoEffect.OpenBookInfoEdit(it.bookUrl))
+        }
+    }
+
+    fun showAppLog() {
+        _uiState.update { it.copy(showAppLogSheet = true) }
+    }
+
+    fun refreshCurrentBook() {
+        currentBook?.let {
+            refreshBook(it)
+        }
+    }
+
+    fun onSourceEdited() {
+        currentBook?.let { book ->
+            bookSource = appDb.bookSourceDao.getBookSource(book.origin)
+            syncUiState()
+            refreshBook(book)
+        }
+    }
+
+    fun onInfoEdited() {
+        currentBook?.bookUrl?.let { bookUrl ->
+            appDb.bookDao.getBook(bookUrl)?.let { upBook(it) }
+        }
+    }
+
+    fun onTocResult(result: Triple<Int, Int, Boolean>?) {
+        if (result == null) {
+            if (!inBookshelf) {
+                delBook()
+            }
+            return
+        }
+        chapterChanged = result.third
+        val book = currentBook ?: return
         execute {
-            bookData.postValue(book)
-            upCoverByRule(book)
-            bookSource = if (book.isLocal) null else
-                appDb.bookSourceDao.getBookSource(book.origin)
-            if (book.tocUrl.isEmpty() && !book.isLocal) {
-                loadBookInfo(book, runPreUpdateJs = inBookshelf)
-            } else {
-                val chapterList = appDb.bookChapterDao.getChapterList(book.bookUrl)
-                if (chapterList.isNotEmpty()) {
-                    chapterListData.postValue(chapterList)
-                } else {
-                    loadChapter(book)
-                }
+            book.durChapterIndex = result.first
+            book.durChapterPos = result.second
+            appDb.bookDao.update(book)
+            book
+        }.onSuccess {
+            currentBook = it
+            syncUiState(isTocLoading = false)
+            openReader(it)
+        }
+    }
+
+    fun onReaderResult(resultCode: Int) {
+        when (resultCode) {
+            RESULT_OK -> {
+                inBookshelf = true
+                syncUiState()
+            }
+
+            io.legado.app.ui.book.read.ReadBookActivity.RESULT_DELETED -> {
+                emitEffect(BookInfoEffect.Finish(resultCode = RESULT_OK))
             }
         }
     }
 
-    private fun upCoverByRule(book: Book) {
-        execute {
-            if (book.coverUrl.isNullOrBlank() && book.customCoverUrl.isNullOrBlank()) {
-                val coverUrl = BookCover.searchCover(book)
-                if (coverUrl.isNullOrBlank()) {
-                    return@execute
-                }
-                book.customCoverUrl = coverUrl
-                bookData.postValue(book)
-                if (inBookshelf) {
-                    saveBook(book)
+    fun toggleCanUpdate() {
+        currentBook?.let { book ->
+            book.canUpdate = !book.canUpdate
+            if (inBookshelf) {
+                if (!book.canUpdate) {
+                    book.removeType(BookType.updateError)
                 }
+                saveBook(book)
             }
+            syncUiState()
         }
     }
 
-    fun refreshBook(book: Book) {
-        executeLazy(executeContext = IO) {
-            if (book.isLocal) {
-                book.tocUrl = ""
-                remoteBookRepository.refreshLocalBook(book)
-            } else {
-                val bs = bookSource ?: return@executeLazy
-                if (book.originName != bs.bookSourceName) {
-                    book.originName = bs.bookSourceName
-                }
+    fun toggleSplitLongChapter() {
+        currentBook?.takeIf { it.isLocal && it.type and BookType.text > 0 }?.let { book ->
+            book.setSplitLongChapter(!book.getSplitLongChapter())
+            syncUiState(isTocLoading = true)
+            loadBookInfo(book, canReName = false)
+            if (!book.getSplitLongChapter()) {
+                context.toastOnUi(context.getString(R.string.need_more_time_load_content))
             }
+        }
+    }
+
+    fun toggleDeleteAlert() {
+        LocalConfig.bookInfoDeleteAlert = !LocalConfig.bookInfoDeleteAlert
+        syncUiState()
+    }
+
+    fun requestSourceVariableDialog() {
+        execute {
+            val source = bookSource ?: throw NoStackTraceException("书源不存在")
+            val comment = source.getDisplayVariableComment("源变量可在js中通过source.getVariable()获取")
+            val variable = source.getVariable()
+            BookInfoEffect.ShowVariableDialog(
+                title = context.getString(R.string.set_source_variable),
+                key = source.getKey(),
+                variable = variable,
+                comment = comment,
+            )
+        }.onSuccess {
+            emitEffect(it)
         }.onError {
-            when (it) {
-                is ObjectNotFoundException -> {
-                    book.origin = BookType.localTag
-                }
+            context.toastOnUi(it.localizedMessage ?: "书源不存在")
+        }
+    }
 
-                else -> {
-                    AppLog.put("下载远程书籍<${book.name}>失败", it)
+    fun requestBookVariableDialog() {
+        execute {
+            val source = bookSource ?: throw NoStackTraceException("书源不存在")
+            val book = currentBook ?: throw NoStackTraceException("book is null")
+            val variable = book.getCustomVariable()
+            val comment = source.getDisplayVariableComment(
+                "书籍变量可在js中通过book.getVariable(\"custom\")获取"
+            )
+            BookInfoEffect.ShowVariableDialog(
+                title = context.getString(R.string.set_book_variable),
+                key = book.bookUrl,
+                variable = variable,
+                comment = comment,
+            )
+        }.onSuccess {
+            emitEffect(it)
+        }.onError {
+            context.toastOnUi(it.localizedMessage ?: "书源不存在")
+        }
+    }
+
+    fun setVariable(key: String, variable: String?) {
+        when (key) {
+            bookSource?.getKey() -> bookSource?.setVariable(variable)
+            currentBook?.bookUrl -> currentBook?.let {
+                it.putCustomVariable(variable)
+                if (inBookshelf) {
+                    saveBook(it)
                 }
             }
-        }.onFinally {
-            loadBookInfo(book, false)
-        }.start()
+        }
     }
 
+    fun topBook() {
+        currentBook?.let { book ->
+            execute {
+                val minOrder = appDb.bookDao.minOrder
+                book.order = minOrder - 1
+                book.durChapterTime = System.currentTimeMillis()
+                appDb.bookDao.update(book)
+                book
+            }.onSuccess {
+                currentBook = it
+                syncUiState()
+            }
+        }
+    }
     fun syncFromRemote() {
-        val book = bookData.value ?: return
+        val book = currentBook ?: return
         if (!book.isLocal) return
 
         execute {
-            waitDialogData.postValue(true)
+            setBusy(true)
             val newBook = remoteBookRepository.syncBookFromRemote(book)
-
             appDb.bookDao.delete(book)
             appDb.bookDao.insert(newBook)
-
+            newBook
+        }.onSuccess { newBook ->
+            currentBook = newBook
+            inBookshelf = true
+            syncUiState(isTocLoading = true)
             loadChapter(newBook)
-
-            bookData.postValue(newBook)
             context.toastOnUi("同步完成")
         }.onFinally {
-            waitDialogData.postValue(false)
+            setBusy(false)
         }.onError {
             context.toastOnUi(it.localizedMessage)
         }
     }
 
-    fun uploadBook(book: Book, success: () -> Unit) {
+    fun uploadBook(success: () -> Unit) {
+        val book = currentBook ?: return
         execute {
-            waitDialogData.postValue(true)
+            setBusy(true)
             remoteBookRepository.uploadBook(book)
             saveBook(book)
+        }.onSuccess {
             success.invoke()
         }.onFinally {
-            waitDialogData.postValue(false)
+            setBusy(false)
         }.onError {
             context.toastOnUi(it.localizedMessage)
         }
     }
 
+    fun clearCache() {
+        currentBook?.let { book ->
+            execute {
+                BookHelp.clearCache(book)
+                if (ReadBook.book?.bookUrl == book.bookUrl) {
+                    ReadBook.clearTextChapter()
+                }
+                if (ReadManga.book?.bookUrl == book.bookUrl) {
+                    ReadManga.clearMangaChapter()
+                }
+            }.onSuccess {
+                context.toastOnUi(R.string.clear_cache_success)
+            }.onError {
+                context.toastOnUi("清理缓存出错\n${it.localizedMessage}")
+            }
+        }
+    }
+
+    fun saveRemark(remark: String, success: (() -> Unit)? = null) {
+        currentBook?.let { book ->
+            execute {
+                book.remark = remark
+                book.save()
+                book
+            }.onSuccess {
+                currentBook = it
+                syncUiState()
+                success?.invoke()
+            }
+        }
+    }
+
+    fun saveBook(book: Book?, success: (() -> Unit)? = null) {
+        book ?: return
+        execute {
+            if (book.order == 0) {
+                book.order = appDb.bookDao.minOrder - 1
+            }
+            appDb.bookDao.getBook(book.name, book.author)?.let {
+                book.durChapterIndex = it.durChapterIndex
+                book.durChapterPos = it.durChapterPos
+                book.durChapterTitle = it.durChapterTitle
+            }
+            book.save()
+            if (ReadBook.book?.isSameNameAuthor(book) == true) {
+                ReadBook.book = book
+            } else if (AudioPlay.book?.isSameNameAuthor(book) == true) {
+                AudioPlay.book = book
+            }
+            book
+        }.onSuccess {
+            if (currentBook?.bookUrl == it.bookUrl) {
+                currentBook = it
+                syncUiState()
+            }
+            success?.invoke()
+        }
+    }
+
+    fun saveChapterList(success: (() -> Unit)? = null) {
+        execute {
+            appDb.bookChapterDao.insert(*currentChapterList.toTypedArray())
+        }.onSuccess {
+            success?.invoke()
+        }
+    }
+
+    fun addToBookshelf(success: (() -> Unit)? = null) {
+        val book = currentBook ?: return
+        execute {
+            book.removeType(BookType.notShelf)
+            if (book.order == 0) {
+                book.order = appDb.bookDao.minOrder - 1
+            }
+            appDb.bookDao.getBook(book.name, book.author)?.let {
+                book.durChapterIndex = it.durChapterIndex
+                book.durChapterPos = it.durChapterPos
+                book.durChapterTitle = it.durChapterTitle
+            }
+            if (ReadBook.book?.isSameNameAuthor(book) == true) {
+                ReadBook.book = book
+            } else if (AudioPlay.book?.isSameNameAuthor(book) == true) {
+                AudioPlay.book = book
+            }
+            book.save()
+            SourceCallBack.callBackBook(SourceCallBack.ADD_BOOK_SHELF, bookSource, book)
+            appDb.bookChapterDao.insert(*currentChapterList.toTypedArray())
+            book
+        }.onSuccess {
+            currentBook = it
+            inBookshelf = true
+            syncUiState()
+            success?.invoke()
+        }
+    }
+
+    fun addToBookshelf(book: Book, toc: List<BookChapter>, success: (() -> Unit)? = null) {
+        execute {
+            book.removeType(BookType.notShelf)
+            if (book.order == 0) {
+                book.order = appDb.bookDao.minOrder - 1
+            }
+            appDb.bookDao.insert(book)
+            appDb.bookChapterDao.insert(*toc.toTypedArray())
+            book
+        }.onSuccess {
+            if (currentBook?.bookUrl == it.bookUrl) {
+                currentBook = it
+                currentChapterList = toc
+                inBookshelf = true
+                syncUiState(isTocLoading = false)
+            }
+            success?.invoke()
+        }.onError {
+            AppLog.put("添加书籍到书架失败", it)
+            context.toastOnUi("添加书籍失败")
+        }
+    }
+
+    fun delBook(deleteOriginal: Boolean = false, success: (() -> Unit)? = null) {
+        val book = currentBook ?: return
+        execute {
+            inBookshelf = false
+            if (book.isLocal) {
+                LocalBook.deleteBook(book, deleteOriginal)
+            }
+            book.delete()
+        }.onSuccess {
+            success?.invoke()
+        }
+    }
+
+    fun refreshBook(book: Book) {
+        syncUiState(isTocLoading = true)
+        execute {
+            if (book.isLocal) {
+                book.tocUrl = ""
+                remoteBookRepository.refreshLocalBook(book)
+            } else {
+                val bs = bookSource ?: return@execute
+                if (book.originName != bs.bookSourceName) {
+                    book.originName = bs.bookSourceName
+                }
+            }
+            book
+        }.onError {
+            when (it) {
+                is ObjectNotFoundException -> {
+                    book.origin = BookType.localTag
+                }
+
+                else -> {
+                    AppLog.put("下载远程书籍<${book.name}>失败", it)
+                }
+            }
+        }.onFinally {
+            loadBookInfo(book, canReName = false)
+        }
+    }
+
     fun loadBookInfo(
         book: Book,
         canReName: Boolean = true,
         runPreUpdateJs: Boolean = true,
-        scope: CoroutineScope = viewModelScope
+        scope: CoroutineScope = viewModelScope,
     ) {
+        syncUiState(isTocLoading = true)
         if (book.isLocal) {
             LocalBook.upBookInfo(book)
-            bookData.postValue(book)
+            currentBook = book
+            syncUiState(isTocLoading = true)
             loadChapter(book)
         } else {
-            val bookSource = bookSource ?: let {
-                chapterListData.postValue(emptyList())
+            val source = bookSource ?: run {
+                currentChapterList = emptyList()
+                syncUiState(isTocLoading = false)
                 context.toastOnUi(R.string.error_no_source)
                 return
             }
-            WebBook.getBookInfo(scope, bookSource, book, canReName = canReName)
-                .onSuccess(IO) {
-                    val dbBook = appDb.bookDao.getBook(book.name, book.author)
-                    if (!inBookshelf && dbBook != null && !dbBook.isNotShelf && dbBook.origin == book.origin) {
-                        /**
-                         * book 来自搜索时(inBookshelf == false),搜索的书名不存在于书架,但是加载详情后,书名更新,存在同名书籍
-                         * 此时 book 的数据会与数据库中的不同,需要更新 #3652 #4619
-                         * book 加载详情后虽然书名作者相同,但是又可能不是数据库中(书源不同)的那本书 #3149
-                         */
-                        dbBook.updateTo(it)
+            WebBook.getBookInfo(scope, source, book, canReName = canReName)
+                .onSuccess(IO) { loadedBook ->
+                    val dbBook = appDb.bookDao.getBook(loadedBook.name, loadedBook.author)
+                    if (!inBookshelf && dbBook != null && !dbBook.isNotShelf && dbBook.origin == loadedBook.origin) {
+                        dbBook.updateTo(loadedBook)
                         inBookshelf = true
                     }
-                    bookData.postValue(it)
+                    currentBook = loadedBook
                     if (inBookshelf) {
-                        it.save()
+                        loadedBook.save()
                     }
-                    if (it.isWebFile) {
-                        loadWebFile(it)
+                    syncUiState(isTocLoading = true)
+                    refreshMeta(loadedBook)
+                    if (loadedBook.isWebFile) {
+                        loadWebFile(loadedBook)
+                        currentChapterList = emptyList()
+                        syncUiState(isTocLoading = false)
                     } else {
-                        loadChapter(it, runPreUpdateJs)
+                        loadChapter(loadedBook, runPreUpdateJs)
                     }
                 }.onError {
                     AppLog.put("获取书籍信息失败\n${it.localizedMessage}", it)
                     context.toastOnUi(R.string.error_get_book_info)
+                    syncUiState(isTocLoading = false)
                 }
         }
     }
+    fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>) {
+        changeSourceCoroutine?.cancel()
+        changeSourceCoroutine = execute {
+            bookSource = source
+            currentBook?.migrateTo(book, toc)
+            if (inBookshelf) {
+                book.removeType(BookType.updateError)
+                currentBook?.delete()
+                appDb.bookDao.insert(book)
+                appDb.bookChapterDao.insert(*toc.toTypedArray())
+            }
+            book
+        }.onSuccess {
+            currentBook = it
+            currentChapterList = toc
+            currentGroupNames = null
+            currentKindLabels = emptyList()
+            syncUiState(isTocLoading = false)
+            refreshMeta(it)
+        }.onFinally {
+            postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
+        }
+    }
+
+    private fun upBook(book: Book) {
+        currentBook = book
+        currentChapterList = emptyList()
+        currentWebFiles = emptyList()
+        currentKindLabels = emptyList()
+        currentGroupNames = null
+        syncUiState(isTocLoading = true)
+        refreshMeta(book)
+        upCoverByRule(book)
+        bookSource = if (book.isLocal) null else appDb.bookSourceDao.getBookSource(book.origin)
+        syncUiState(isTocLoading = true)
+        if (book.tocUrl.isEmpty() && !book.isLocal) {
+            loadBookInfo(book, runPreUpdateJs = inBookshelf)
+        } else {
+            execute {
+                appDb.bookChapterDao.getChapterList(book.bookUrl)
+            }.onSuccess { chapters ->
+                if (chapters.isNotEmpty()) {
+                    currentChapterList = chapters
+                    syncUiState(isTocLoading = false)
+                } else {
+                    loadChapter(book)
+                }
+            }.onError {
+                loadChapter(book)
+            }
+        }
+    }
+
+    private fun upCoverByRule(book: Book) {
+        execute {
+            if (book.coverUrl.isNullOrBlank() && book.customCoverUrl.isNullOrBlank()) {
+                val coverUrl = BookCover.searchCover(book)
+                if (!coverUrl.isNullOrBlank()) {
+                    book.customCoverUrl = coverUrl
+                    if (inBookshelf) {
+                        saveBook(book)
+                    }
+                }
+            }
+            book
+        }.onSuccess {
+            if (currentBook?.bookUrl == it.bookUrl) {
+                currentBook = it
+                syncUiState()
+            }
+        }
+    }
+
+    private fun refreshMeta(book: Book) {
+        execute {
+            val kinds = book.getKindList().toMutableList()
+            if (book.isLocal) {
+                val size = FileDoc.fromFile(book.bookUrl).size
+                if (size > 0) {
+                    kinds.add(ConvertUtils.formatFileSize(size))
+                }
+            }
+            val groupNames = appDb.bookGroupDao.getGroupNames(book.group).joinToString(",")
+            kinds.toList() to groupNames.ifBlank { null }
+        }.onSuccess {
+            currentKindLabels = it.first
+            currentGroupNames = it.second
+            syncUiState()
+        }
+    }
 
     private fun loadChapter(
         book: Book,
         runPreUpdateJs: Boolean = true,
-        scope: CoroutineScope = viewModelScope
+        scope: CoroutineScope = viewModelScope,
     ) {
+        syncUiState(isTocLoading = true)
         if (book.isLocal) {
             execute(scope) {
-                LocalBook.getChapterList(book).let {
+                LocalBook.getChapterList(book).also {
                     appDb.bookDao.update(book)
                     appDb.bookChapterDao.delByBook(book.bookUrl)
                     appDb.bookChapterDao.insert(*it.toTypedArray())
                     ReadBook.onChapterListUpdated(book)
-                    bookData.postValue(book)
-                    chapterListData.postValue(it)
                 }
+            }.onSuccess {
+                currentBook = book
+                currentChapterList = it
+                syncUiState(isTocLoading = false)
             }.onError {
+                currentChapterList = emptyList()
+                syncUiState(isTocLoading = false)
                 context.toastOnUi("LoadTocError:${it.localizedMessage}")
             }
         } else {
-            val bookSource = bookSource ?: let {
-                chapterListData.postValue(emptyList())
+            val source = bookSource ?: run {
+                currentChapterList = emptyList()
+                syncUiState(isTocLoading = false)
                 context.toastOnUi(R.string.error_no_source)
                 return
             }
             val oldBook = book.copy()
-            WebBook.getChapterList(scope, bookSource, book, runPreUpdateJs)
-                .onSuccess(IO) {
+            WebBook.getChapterList(scope, source, book, runPreUpdateJs)
+                .onSuccess(IO) { chapters ->
                     if (inBookshelf) {
                         appDb.bookDao.replace(oldBook, book)
-                        /**
-                         * runPreUpdateJs 有可能会修改 book 的 bookUrl
-                         */
                         if (oldBook.bookUrl != book.bookUrl) {
                             BookHelp.updateCacheFolder(oldBook, book)
                         }
                         appDb.bookChapterDao.delByBook(oldBook.bookUrl)
-                        appDb.bookChapterDao.insert(*it.toTypedArray())
+                        appDb.bookChapterDao.insert(*chapters.toTypedArray())
                         ReadBook.onChapterListUpdated(book)
                     }
-                    bookData.postValue(book)
-                    chapterListData.postValue(it)
+                    currentBook = book
+                    currentChapterList = chapters
+                    syncUiState(isTocLoading = false)
                 }.onError {
-                    chapterListData.postValue(emptyList())
+                    currentChapterList = emptyList()
+                    syncUiState(isTocLoading = false)
                     AppLog.put("获取目录失败\n${it.localizedMessage}", it)
                     context.toastOnUi(R.string.error_get_chapter_list)
                 }
         }
     }
 
-
-    fun loadGroup(groupId: Long, success: ((groupNames: String?) -> Unit)) {
-        execute {
-            appDb.bookGroupDao.getGroupNames(groupId).joinToString(",")
-        }.onSuccess {
-            success.invoke(it)
-        }
-    }
-
     private fun loadWebFile(book: Book) {
         execute {
-            webFiles.clear()
-            val fileNameNoExtension = if (book.author.isBlank()) book.name
-            else "${book.name} 作者:${book.author}"
-            book.downloadUrls!!.map {
+            val fileNameNoExtension = if (book.author.isBlank()) book.name else "${book.name} 作者:${book.author}"
+            book.downloadUrls.orEmpty().map { url ->
                 val analyzeUrl = AnalyzeUrl(
-                    it, source = bookSource,
-                    coroutineContext = coroutineContext
+                    url,
+                    source = bookSource,
+                    coroutineContext = coroutineContext,
                 )
-                val mFileName = UrlUtil.getFileName(analyzeUrl)
+                val fileName = UrlUtil.getFileName(analyzeUrl)
                     ?: "${fileNameNoExtension}.${analyzeUrl.type}"
-                WebFile(it, mFileName)
+                BookInfoWebFile(url = url, name = fileName)
             }
+        }.onSuccess {
+            currentWebFiles = it
+            syncUiState(isTocLoading = false)
         }.onError {
+            currentWebFiles = emptyList()
             context.toastOnUi("LoadWebFileError\n${it.localizedMessage}")
-        }.onSuccess {
-            webFiles.addAll(it)
+            syncUiState(isTocLoading = false)
         }
     }
 
-    /* 导入或者下载在线文件 */
-    fun <T> importOrDownloadWebFile(webFile: WebFile, success: ((T) -> Unit)?) {
-        bookSource ?: return
-        execute {
-            waitDialogData.postValue(true)
-            if (webFile.isSupported) {
-                val book = LocalBook.importFileOnLine(
-                    webFile.url,
-                    bookData.value!!.getExportFileName(webFile.suffix),
-                    bookSource
-                )
-                changeToLocalBook(book)
-            } else {
-                LocalBook.saveBookFile(
-                    webFile.url,
-                    bookData.value!!.getExportFileName(webFile.suffix),
-                    bookSource
-                )
-            }
-        }.onSuccess {
-            @Suppress("unchecked_cast")
-            success?.invoke(it as T)
-        }.onError {
-            when (it) {
-                is NoBooksDirException -> actionLive.postValue("selectBooksDir")
-                else -> {
-                    AppLog.put("ImportWebFileError\n${it.localizedMessage}", it)
-                    context.toastOnUi("ImportWebFileError\n${it.localizedMessage}")
-                    webFiles.remove(webFile)
-                }
-            }
-        }.onFinally {
-            waitDialogData.postValue(false)
+    private fun onBackPressed() {
+        if (!inBookshelf && AppConfig.showAddToShelfAlert && currentBook != null) {
+            showDialog(BookInfoDialog.AddToShelfOnBack)
+        } else {
+            emitEffect(BookInfoEffect.Finish(afterTransition = true))
         }
     }
 
-    fun getArchiveFilesName(archiveFileUri: Uri, onSuccess: (List<String>) -> Unit) {
-        execute {
-            ArchiveUtils.getArchiveFilesName(archiveFileUri) {
-                AppPattern.bookFileRegex.matches(it)
-            }
-        }.onError {
-            AppLog.put("getArchiveEntriesName Error:\n${it.localizedMessage}", it)
-            context.toastOnUi("getArchiveEntriesName Error:\n${it.localizedMessage}")
-        }.onSuccess {
-            onSuccess.invoke(it)
+    private fun onReadClick() {
+        val book = currentBook ?: return
+        if (book.isWebFile) {
+            setSheet(BookInfoSheet.WebFiles(openAfterImport = true))
+        } else {
+            readBook(book)
         }
     }
 
-    fun importArchiveBook(
-        archiveFileUri: Uri,
-        archiveEntryName: String,
-        success: ((Book) -> Unit)? = null
-    ) {
-        execute {
-            val suffix = archiveEntryName.substringAfterLast(".")
-            LocalBook.importArchiveFile(
-                archiveFileUri,
-                bookData.value!!.getExportFileName(suffix)
-            ) {
-                it.contains(archiveEntryName)
-            }.first()
-        }.onSuccess {
-            val book = changeToLocalBook(it)
-            success?.invoke(book)
-        }.onError {
-            AppLog.put("importArchiveBook Error:\n${it.localizedMessage}", it)
-            context.toastOnUi("importArchiveBook Error:\n${it.localizedMessage}")
+    private fun onShelfClick() {
+        val book = currentBook ?: return
+        if (inBookshelf) {
+            showDialog(BookInfoDialog.DeleteBook(book.isLocal))
+        } else if (book.isWebFile) {
+            setSheet(BookInfoSheet.WebFiles(openAfterImport = false))
+        } else {
+            addToBookshelf()
         }
     }
 
-    fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>) {
-        changeSourceCoroutine?.cancel()
-        changeSourceCoroutine = execute {
-            bookSource = source
-            bookData.value?.migrateTo(book, toc)
-            if (inBookshelf) {
-                book.removeType(BookType.updateError)
-                bookData.value?.delete()
-                appDb.bookDao.insert(book)
-                appDb.bookChapterDao.insert(*toc.toTypedArray())
+    private fun onTocClick() {
+        val book = currentBook ?: return
+        if (currentChapterList.isEmpty()) {
+            context.toastOnUi(R.string.chapter_list_empty)
+            return
+        }
+        if (!inBookshelf) {
+            saveBook(book) {
+                saveChapterList {
+                    emitEffect(BookInfoEffect.OpenToc(book.bookUrl))
+                }
             }
-            bookData.postValue(book)
-            chapterListData.postValue(toc)
-        }.onFinally {
-            postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
+        } else {
+            emitEffect(BookInfoEffect.OpenToc(book.bookUrl))
         }
     }
 
-    fun topBook() {
-        execute {
-            bookData.value?.let { book ->
-                val minOrder = appDb.bookDao.minOrder
-                book.order = minOrder - 1
-                book.durChapterTime = System.currentTimeMillis()
-                appDb.bookDao.update(book)
+    private fun updateGroup(groupId: Long) {
+        currentBook?.let { book ->
+            book.group = groupId
+            currentGroupNames = null
+            refreshMeta(book)
+            if (inBookshelf) {
+                saveBook(book)
+            } else if (groupId > 0) {
+                addToBookshelf()
+            } else {
+                syncUiState()
             }
         }
     }
 
-    fun saveRemark(remark: String, success: (() -> Unit)? = null) {
-        execute {
-            bookData.value?.let {
-                it.remark = remark
-                it.save()
+    private fun updateCover(coverUrl: String) {
+        currentBook?.let { book ->
+            book.customCoverUrl = coverUrl
+            currentBook = book
+            syncUiState()
+            if (inBookshelf) {
+                saveBook(book)
+            }
+        }
+    }
+    private fun deleteBook(deleteOriginal: Boolean) {
+        currentBook?.let { book ->
+            SourceCallBack.callBackBook(SourceCallBack.DEL_BOOK_SHELF, bookSource, book)
+            delBook(deleteOriginal) {
+                emitEffect(BookInfoEffect.Finish(resultCode = RESULT_OK))
             }
-        }.onSuccess {
-            success?.invoke()
         }
     }
 
-    fun saveBook(book: Book?, success: (() -> Unit)? = null) {
-        book ?: return
-        execute {
-            if (book.order == 0) {
-                book.order = appDb.bookDao.minOrder - 1
+    private fun handleWebFileSelection(webFile: BookInfoWebFile, openAfterImport: Boolean) {
+        when {
+            webFile.isSupported -> {
+                dismissSheet()
+                importOrDownloadWebFile<Book>(webFile) { book ->
+                    if (openAfterImport) {
+                        openReader(book)
+                    }
+                }
             }
-            appDb.bookDao.getBook(book.name, book.author)?.let {
-                book.durChapterIndex = it.durChapterIndex
-                book.durChapterPos = it.durChapterPos
-                book.durChapterTitle = it.durChapterTitle
+
+            webFile.isSupportDecompress -> {
+                importOrDownloadWebFile<Uri>(webFile) { uri ->
+                    getArchiveFilesName(uri) { fileNames ->
+                        if (fileNames.size == 1) {
+                            importArchiveBook(uri, fileNames.first()) { book ->
+                                if (openAfterImport) {
+                                    openReader(book)
+                                }
+                            }
+                        } else {
+                            setSheet(
+                                BookInfoSheet.ArchiveEntries(
+                                    archiveUri = uri,
+                                    entries = fileNames,
+                                    openAfterImport = openAfterImport,
+                                )
+                            )
+                        }
+                    }
+                }
             }
-            book.save()
-            if (ReadBook.book?.isSameNameAuthor(book) == true) {
-                ReadBook.book = book
-            } else if (AudioPlay.book?.isSameNameAuthor(book) == true) {
-                AudioPlay.book = book
+
+            else -> {
+                showDialog(BookInfoDialog.UnsupportedWebFile(webFile, openAfterImport))
             }
-        }.onSuccess {
-            success?.invoke()
         }
     }
 
-    fun saveChapterList(success: (() -> Unit)?) {
-        execute {
-            chapterListData.value?.let {
-                appDb.bookChapterDao.insert(*it.toTypedArray())
+    private fun readBook(book: Book) {
+        if (!inBookshelf) {
+            book.addType(BookType.notShelf)
+            saveBook(book) {
+                saveChapterList {
+                    openReader(book)
+                }
+            }
+        } else {
+            saveBook(book) {
+                openReader(book)
             }
-        }.onSuccess {
-            success?.invoke()
         }
     }
 
-    fun addToBookshelf(success: (() -> Unit)?) { //点击书架按钮或在加分组时触发
-        execute {
-            bookData.value?.let { book ->
-                book.removeType(BookType.notShelf)
-                if (book.order == 0) {
-                    book.order = appDb.bookDao.minOrder - 1
-                }
-                appDb.bookDao.getBook(book.name, book.author)?.let {
-                    book.durChapterIndex = it.durChapterIndex
-                    book.durChapterPos = it.durChapterPos
-                    book.durChapterTitle = it.durChapterTitle
-                }
-                if (ReadBook.book?.isSameNameAuthor(book) == true) {
-                    ReadBook.book = book
-                } else if (AudioPlay.book?.isSameNameAuthor(book) == true) {
-                    AudioPlay.book = book
-                }
-                book.save()
-                SourceCallBack.callBackBook(SourceCallBack.ADD_BOOK_SHELF, bookSource, book)
+    private fun openReader(book: Book) {
+        emitEffect(BookInfoEffect.OpenReader(book.uiCopy(), inBookshelf, chapterChanged))
+    }
+
+    private fun handleMenuAction(action: BookInfoMenuAction) {
+        val book = currentBook ?: return
+        when (action) {
+            BookInfoMenuAction.Edit -> openEdit()
+            BookInfoMenuAction.Share -> {
+                val bookJson = GSON.toJson(book)
+                emitEffect(
+                    BookInfoEffect.RunSourceCallback(
+                        event = SourceCallBack.CLICK_SHARE_BOOK,
+                        source = bookSource,
+                        book = book.uiCopy(),
+                        action = BookInfoCallbackAction.ShareText(
+                            chooserTitle = book.name,
+                            text = "${book.bookUrl}#$bookJson",
+                        )
+                    )
+                )
             }
-            chapterListData.value?.let {
-                appDb.bookChapterDao.insert(*it.toTypedArray())
+
+            BookInfoMenuAction.Upload -> uploadBook {
+                context.toastOnUi("上传成功")
             }
-            inBookshelf = true
-        }.onSuccess {
-            success?.invoke()
+            BookInfoMenuAction.SyncRemote -> syncFromRemote()
+            BookInfoMenuAction.Refresh -> refreshCurrentBook()
+            BookInfoMenuAction.Login -> bookSource?.let {
+                emitEffect(BookInfoEffect.OpenSourceLogin(it.bookSourceUrl))
+            }
+
+            BookInfoMenuAction.Top -> topBook()
+            BookInfoMenuAction.SetSourceVariable -> requestSourceVariableDialog()
+            BookInfoMenuAction.SetBookVariable -> requestBookVariableDialog()
+            BookInfoMenuAction.CopyBookUrl -> emitEffect(
+                BookInfoEffect.RunSourceCallback(
+                    event = SourceCallBack.CLICK_COPY_BOOK_URL,
+                    source = bookSource,
+                    book = book.uiCopy(),
+                    action = BookInfoCallbackAction.CopyText(book.bookUrl),
+                )
+            )
+
+            BookInfoMenuAction.CopyTocUrl -> emitEffect(
+                BookInfoEffect.RunSourceCallback(
+                    event = SourceCallBack.CLICK_COPY_TOC_URL,
+                    source = bookSource,
+                    book = book.uiCopy(),
+                    action = BookInfoCallbackAction.CopyText(book.tocUrl),
+                )
+            )
+
+            BookInfoMenuAction.ToggleCanUpdate -> toggleCanUpdate()
+            BookInfoMenuAction.ToggleSplitLongChapter -> toggleSplitLongChapter()
+            BookInfoMenuAction.ToggleDeleteAlert -> toggleDeleteAlert()
+            BookInfoMenuAction.ClearCache -> emitEffect(
+                BookInfoEffect.RunSourceCallback(
+                    event = SourceCallBack.CLICK_CLEAR_CACHE,
+                    source = bookSource,
+                    book = book.uiCopy(),
+                    action = BookInfoCallbackAction.ClearCache,
+                )
+            )
+
+            BookInfoMenuAction.ShowLog -> showAppLog()
         }
     }
 
-    fun getBook(toastNull: Boolean = true): Book? {
-        val book = bookData.value
-        if (toastNull && book == null) {
-            context.toastOnUi("book is null")
+    private fun onAuthorClick(longClick: Boolean) {
+        val book = currentBook ?: return
+        emitEffect(
+            BookInfoEffect.RunSourceCallback(
+                event = if (longClick) SourceCallBack.LONG_CLICK_AUTHOR else SourceCallBack.CLICK_AUTHOR,
+                source = bookSource,
+                book = book.uiCopy(),
+                action = BookInfoCallbackAction.Search(book.author),
+            )
+        )
+    }
+
+    private fun onBookNameClick(longClick: Boolean) {
+        val book = currentBook ?: return
+        emitEffect(
+            BookInfoEffect.RunSourceCallback(
+                event = if (longClick) SourceCallBack.LONG_CLICK_BOOK_NAME else SourceCallBack.CLICK_BOOK_NAME,
+                source = bookSource,
+                book = book.uiCopy(),
+                action = BookInfoCallbackAction.Search(book.name),
+            )
+        )
+    }
+
+    private fun onOriginClick() {
+        val book = currentBook ?: return
+        if (book.isLocal) return
+        if (!appDb.bookSourceDao.has(book.origin)) {
+            context.toastOnUi(R.string.error_no_source)
+            return
         }
-        return book
+        emitEffect(BookInfoEffect.OpenBookSourceEdit(book.origin))
     }
 
-    fun delBook(deleteOriginal: Boolean = false, success: (() -> Unit)? = null) {
+    fun getArchiveFilesName(archiveFileUri: Uri, onSuccess: (List<String>) -> Unit) {
         execute {
-            bookData.value?.let {
-                inBookshelf = false
-                if (it.isLocal) {
-                    LocalBook.deleteBook(it, deleteOriginal)
-                }
-                it.delete()
+            ArchiveUtils.getArchiveFilesName(archiveFileUri) {
+                AppPattern.bookFileRegex.matches(it)
             }
+        }.onError {
+            AppLog.put("getArchiveEntriesName Error:\n${it.localizedMessage}", it)
+            context.toastOnUi("getArchiveEntriesName Error:\n${it.localizedMessage}")
         }.onSuccess {
-            success?.invoke()
+            onSuccess.invoke(it)
         }
     }
 
-    fun clearCache(book: Book) {
+    fun importArchiveBook(
+        archiveFileUri: Uri,
+        archiveEntryName: String,
+        success: ((Book) -> Unit)? = null,
+    ) {
         execute {
-            BookHelp.clearCache(book)
-            if (ReadBook.book?.bookUrl == book.bookUrl) {
-                ReadBook.clearTextChapter()
-            }
-            if (ReadManga.book?.bookUrl == book.bookUrl) {
-                ReadManga.clearMangaChapter()
-            }
+            val suffix = archiveEntryName.substringAfterLast(".")
+            LocalBook.importArchiveFile(
+                archiveFileUri,
+                currentBook!!.getExportFileName(suffix)
+            ) {
+                it.contains(archiveEntryName)
+            }.first()
         }.onSuccess {
-            context.toastOnUi(R.string.clear_cache_success)
+            val book = changeToLocalBook(it)
+            success?.invoke(book)
         }.onError {
-            context.toastOnUi("清理缓存出错\n${it.localizedMessage}")
+            AppLog.put("importArchiveBook Error:\n${it.localizedMessage}", it)
+            context.toastOnUi("importArchiveBook Error:\n${it.localizedMessage}")
         }
     }
 
-    fun addToBookshelf(book: Book, toc: List<BookChapter>, success: (() -> Unit)? = null) {
+    fun <T> importOrDownloadWebFile(webFile: BookInfoWebFile, success: ((T) -> Unit)? = null) {
+        bookSource ?: return
+        val book = currentBook ?: return
         execute {
-            book.removeType(BookType.notShelf)
-            if (book.order == 0) {
-                book.order = appDb.bookDao.minOrder - 1
+            setBusy(true)
+            if (webFile.isSupported) {
+                val localBook = LocalBook.importFileOnLine(
+                    webFile.url,
+                    book.getExportFileName(webFile.suffix),
+                    bookSource
+                )
+                changeToLocalBook(localBook)
+            } else {
+                LocalBook.saveBookFile(
+                    webFile.url,
+                    book.getExportFileName(webFile.suffix),
+                    bookSource
+                )
             }
-
-            appDb.bookDao.insert(book)
-            appDb.bookChapterDao.insert(*toc.toTypedArray())
         }.onSuccess {
-            success?.invoke()
+            @Suppress("UNCHECKED_CAST")
+            success?.invoke(it as T)
         }.onError {
-            AppLog.put("添加书籍到书架失败", it)
-            context.toastOnUi("添加书籍失败")
-        }
-    }
-
-    fun upEditBook() {
-        bookData.value?.let {
-            appDb.bookDao.getBook(it.bookUrl)?.let { book ->
-                bookData.postValue(book)
+            when (it) {
+                is NoBooksDirException -> emitEffect(BookInfoEffect.OpenSelectBooksDir)
+                else -> {
+                    AppLog.put("ImportWebFileError\n${it.localizedMessage}", it)
+                    context.toastOnUi("ImportWebFileError\n${it.localizedMessage}")
+                }
             }
+        }.onFinally {
+            setBusy(false)
         }
     }
 
     private fun changeToLocalBook(localBook: Book): Book {
-        return LocalBook.mergeBook(localBook, bookData.value).let {
-            bookData.postValue(it)
-            loadChapter(it)
+        return LocalBook.mergeBook(localBook, currentBook).let {
+            currentBook = it
+            currentWebFiles = emptyList()
             inBookshelf = true
+            syncUiState(isTocLoading = true)
+            refreshMeta(it)
+            loadChapter(it)
             it
         }
     }
 
-    data class WebFile(
-        val url: String,
-        val name: String,
-    ) {
+    private fun dismissSheet() {
+        setSheet(BookInfoSheet.None)
+    }
 
-        override fun toString(): String {
-            return name
-        }
+    private fun setSheet(sheet: BookInfoSheet) {
+        _uiState.update { it.copy(sheet = sheet) }
+    }
 
-        // 后缀
-        val suffix: String = UrlUtil.getSuffix(name)
+    private fun dismissDialog() {
+        showDialog(null)
+    }
 
-        // txt epub umd pdf等文件
-        val isSupported: Boolean = AppPattern.bookFileRegex.matches(name)
+    private fun showDialog(dialog: BookInfoDialog?) {
+        _uiState.update { it.copy(dialog = dialog) }
+    }
 
-        // 压缩包形式的txt epub umd pdf文件
-        val isSupportDecompress: Boolean = AppPattern.archiveFileRegex.matches(name)
+    private fun setBusy(isBusy: Boolean) {
+        _uiState.update { it.copy(isBusy = isBusy) }
+    }
 
+    private fun syncUiState(isTocLoading: Boolean = _uiState.value.isTocLoading) {
+        _uiState.update {
+            it.copy(
+                book = currentBook?.uiCopy(),
+                chapterList = currentChapterList,
+                webFiles = currentWebFiles,
+                kindLabels = currentKindLabels,
+                groupNames = currentGroupNames,
+                inBookshelf = inBookshelf,
+                bookSource = bookSource,
+                isTocLoading = isTocLoading,
+            )
+        }
     }
 
+    private fun emitEffect(effect: BookInfoEffect) {
+        _effects.tryEmit(effect)
+    }
+
+    private fun Book.uiCopy(): Book {
+        return copy().also { snapshot ->
+            snapshot.infoHtml = infoHtml
+            snapshot.tocHtml = tocHtml
+            snapshot.downloadUrls = downloadUrls
+        }
+    }
 }
+
+private val BookInfoWebFile.suffix: String
+    get() = UrlUtil.getSuffix(name)
+
+private val BookInfoWebFile.isSupported: Boolean
+    get() = AppPattern.bookFileRegex.matches(name)
+
+private val BookInfoWebFile.isSupportDecompress: Boolean
+    get() = AppPattern.archiveFileRegex.matches(name)

+ 5 - 1
app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt

@@ -5,9 +5,11 @@ import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.consumeWindowInsets
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.rememberScrollState
@@ -89,8 +91,10 @@ fun BookInfoEditScreen(
             uiState.book?.let {
                 BookInfoEditContent(
                     modifier = Modifier
-                        .padding(paddingValues)
                         .fillMaxSize()
+                        .padding(paddingValues)
+                        .consumeWindowInsets(paddingValues)
+                        .imePadding()
                         .verticalScroll(rememberScrollState()),
                     uiState = uiState,
                     viewModel = viewModel

+ 5 - 9
app/src/main/java/io/legado/app/ui/config/themeConfig/LauncherIconPickerSheet.kt

@@ -57,18 +57,14 @@ fun LauncherIconPickerSheet(
 
     AppModalBottomSheet(
         show = show,
-        onDismissRequest = onDismissRequest
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.change_icon)
     ) {
         Column(
             modifier = Modifier
                 .fillMaxWidth()
                 .padding(bottom = 24.dp)
         ) {
-            AppText(
-                text = stringResource(R.string.change_icon),
-                style = LegadoTheme.typography.titleMedium,
-                modifier = Modifier.padding(16.dp)
-            )
 
             LazyVerticalGrid(
                 columns = GridCells.Fixed(3),
@@ -89,15 +85,15 @@ fun LauncherIconPickerSheet(
                             .clip(MaterialTheme.shapes.large)
                             .background(
                                 if (isSelected)
-                                    MaterialTheme.colorScheme.secondaryContainer
+                                    LegadoTheme.colorScheme.secondaryContainer
                                 else
-                                    MaterialTheme.colorScheme.surfaceContainer
+                                    LegadoTheme.colorScheme.surfaceContainer
                             )
                             .then(
                                 if (isSelected) {
                                     Modifier.border(
                                         width = 2.dp,
-                                        color = MaterialTheme.colorScheme.primary,
+                                        color = LegadoTheme.colorScheme.primary,
                                         shape = MaterialTheme.shapes.large
                                     )
                                 } else {

+ 4 - 0
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt

@@ -24,6 +24,10 @@ object ThemeConfig {
     //m3 or miuix
     var composeEngine by prefDelegate(PreferKey.composeEngine, "material")
 
+    var useMiuixMonet by prefDelegate(PreferKey.useMiuixMonet, false) {
+        postEvent(EventBus.RECREATE, "")
+    }
+
     var materialVersion by prefDelegate(PreferKey.materialVersion, "material3")
 
     var appTheme by prefDelegate(PreferKey.appTheme, "0")

+ 74 - 37
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt

@@ -75,7 +75,7 @@ import io.legado.app.help.config.OldThemeConfig
 import io.legado.app.lib.theme.ThemeStore
 import io.legado.app.lib.theme.primaryColor
 import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.theme.ThemeManager
+import io.legado.app.ui.theme.ThemeEngine
 import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.AppScaffold
@@ -112,6 +112,7 @@ fun ThemeConfigScreen(
 
     var selectedThemeMode by remember { mutableStateOf(ThemeConfig.themeMode) }
     var selectedTheme by remember { mutableStateOf(ThemeConfig.appTheme) }
+    var useMiuixMonet by remember { mutableStateOf(ThemeConfig.useMiuixMonet) }
     var showRestartDialog by remember { mutableStateOf(false) }
     var showColorPicker by remember { mutableStateOf(false) }
     var showLauncherIconPicker by remember { mutableStateOf(false) }
@@ -202,6 +203,37 @@ fun ThemeConfigScreen(
                             OldThemeConfig.applyDayNight(context)
                         }
                     )
+
+                    SwitchSettingItem(
+                        title = stringResource(R.string.miuix_monet),
+                        description = stringResource(R.string.miuix_monet_summary),
+                        checked = useMiuixMonet,
+                        onCheckedChange = {
+                            useMiuixMonet = it
+                            ThemeConfig.useMiuixMonet = it
+                            if (it && selectedTheme != "0" && selectedTheme != "12") {
+                                selectedTheme = "0"
+                                ThemeConfig.appTheme = "0"
+                            }
+                        }
+                    )
+
+                    if (useMiuixMonet) {
+                        SwitchSettingItem(
+                            title = stringResource(R.string.dynamic_colors),
+                            description = stringResource(R.string.dynamic_colors_summary),
+                            checked = selectedTheme == "0",
+                            onCheckedChange = { checked ->
+                                val newTheme = if (checked) "0" else "12"
+                                val oldTheme = selectedTheme
+                                selectedTheme = newTheme
+                                ThemeConfig.appTheme = newTheme
+                                if (oldTheme != newTheme) {
+                                    showRestartDialog = true
+                                }
+                            }
+                        )
+                    }
                 } else {
                     ThemeModeSelector(
                         selectedMode = selectedThemeMode,
@@ -249,10 +281,14 @@ fun ThemeConfigScreen(
             }
 
             SplicedColumnGroup {
-                SwitchSettingItem(
-                    title = stringResource(R.string.pure_black),
-                    checked = ThemeConfig.isPureBlack,
-                    onCheckedChange = { ThemeConfig.isPureBlack = it }
+                DropdownListSettingItem(
+                    title = stringResource(R.string.compose_engine),
+                    selectedValue = ThemeConfig.composeEngine,
+                    displayEntries = stringArrayResource(R.array.composeEngine),
+                    entryValues = stringArrayResource(R.array.composeEngine_value),
+                    onValueChange = {
+                        ThemeConfig.composeEngine = it
+                    }
                 )
                 ClickableSettingItem(
                     title = stringResource(R.string.change_icon),
@@ -268,15 +304,6 @@ fun ThemeConfigScreen(
                         context.toastOnUi(R.string.restart_to_apply)
                     }
                 )
-                DropdownListSettingItem(
-                    title = stringResource(R.string.compose_engine),
-                    selectedValue = ThemeConfig.composeEngine,
-                    displayEntries = stringArrayResource(R.array.composeEngine),
-                    entryValues = stringArrayResource(R.array.composeEngine_value),
-                    onValueChange = {
-                        ThemeConfig.composeEngine = it
-                    }
-                )
                 SliderSettingItem(
                     title = stringResource(R.string.font_scale),
                     description = stringResource(
@@ -294,7 +321,8 @@ fun ThemeConfigScreen(
                 )
             }
 
-            if (selectedTheme == "12") {
+            val showCustomThemeOptions = selectedTheme == "12" && (!isMiuixEngine || useMiuixMonet)
+            if (showCustomThemeOptions) {
                 SplicedColumnGroup(title = stringResource(R.string.custom_theme)) {
                     ClickableSettingItem(
                         title = stringResource(R.string.seed_color),
@@ -426,11 +454,18 @@ fun ThemeConfigScreen(
             }
 
             SplicedColumnGroup(title = stringResource(R.string.compose_related)) {
-                SwitchSettingItem(
-                    title = stringResource(R.string.use_flexible_top_bar),
-                    checked = ThemeConfig.useFlexibleTopAppBar,
-                    onCheckedChange = { ThemeConfig.useFlexibleTopAppBar = it }
-                )
+                if (!isMiuixEngine) {
+                    SwitchSettingItem(
+                        title = stringResource(R.string.pure_black),
+                        checked = ThemeConfig.isPureBlack,
+                        onCheckedChange = { ThemeConfig.isPureBlack = it }
+                    )
+                    SwitchSettingItem(
+                        title = stringResource(R.string.use_flexible_top_bar),
+                        checked = ThemeConfig.useFlexibleTopAppBar,
+                        onCheckedChange = { ThemeConfig.useFlexibleTopAppBar = it }
+                    )
+                }
                 SwitchSettingItem(
                     title = stringResource(R.string.is_blur_enable),
                     checked = ThemeConfig.enableBlur,
@@ -446,7 +481,7 @@ fun ThemeConfigScreen(
                         onCheckedChange = { ThemeConfig.enableProgressiveBlur = it }
                     )
                 }
-                AnimatedVisibility(visible = !ThemeConfig.enableBlur) {
+                AnimatedVisibility(visible = !isMiuixEngine && !ThemeConfig.enableBlur) {
                     Column(
                         verticalArrangement = Arrangement.spacedBy(2.dp)
                     ) {
@@ -476,18 +511,20 @@ fun ThemeConfigScreen(
                         )
                     }
                 }
-                SliderSettingItem(
-                    title = stringResource(R.string.container_opacity),
-                    description = stringResource(
-                        R.string.container_opacity_summary,
-                        ThemeConfig.containerOpacity
-                    ),
-                    value = ThemeConfig.containerOpacity.toFloat(),
-                    defaultValue = 100f,
-                    valueRange = 0f..100f,
-                    steps = 99,
-                    onValueChange = { ThemeConfig.containerOpacity = it.toInt() }
-                )
+                if (!isMiuixEngine) {
+                    SliderSettingItem(
+                        title = stringResource(R.string.container_opacity),
+                        description = stringResource(
+                            R.string.container_opacity_summary,
+                            ThemeConfig.containerOpacity
+                        ),
+                        value = ThemeConfig.containerOpacity.toFloat(),
+                        defaultValue = 100f,
+                        valueRange = 0f..100f,
+                        steps = 99,
+                        onValueChange = { ThemeConfig.containerOpacity = it.toInt() }
+                    )
+                }
             }
 
             SplicedColumnGroup(title = stringResource(R.string.day)) {
@@ -738,7 +775,7 @@ fun ThemeColorButton(
             shape = RoundedCornerShape(16.dp),
             border = if (isSelected) BorderStroke(
                 borderWidth,
-                MaterialTheme.colorScheme.primary
+                LegadoTheme.colorScheme.primary
             ) else null,
             colors = CardDefaults.cardColors(containerColor = colors.surfaceContainer)
         ) {
@@ -801,7 +838,7 @@ fun ThemeColorButton(
         AppText(
             text = label,
             style = LegadoTheme.typography.labelSmall,
-            color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
+            color = if (isSelected) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.onSurface
         )
     }
 }
@@ -915,7 +952,7 @@ private fun getThemeColorPalette(
     materialVersion: String? = null
 ): ThemeColorPalette {
     val appThemeMode = ThemeResolver.resolveThemeMode(value)
-    val colorScheme = ThemeManager.getColorScheme(
+    val colorScheme = ThemeEngine.getColorScheme(
         context = context,
         mode = appThemeMode,
         darkTheme = isDark,
@@ -942,7 +979,7 @@ private fun getThemeColors(
     materialVersion: String? = null
 ): ThemeColors {
     val appThemeMode = ThemeResolver.resolveThemeMode(value)
-    val colorScheme = ThemeManager.getColorScheme(
+    val colorScheme = ThemeEngine.getColorScheme(
         context = context,
         mode = appThemeMode,
         darkTheme = isDark,

+ 17 - 31
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeListDialog.kt

@@ -38,6 +38,7 @@ import io.legado.app.help.config.OldThemeConfig
 import io.legado.app.lib.theme.primaryColor
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.button.MediumIconButton
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.text.AppText
@@ -59,41 +60,27 @@ fun ThemeListDialog(
 
     AppModalBottomSheet(
         show = show,
-        onDismissRequest = onDismissRequest
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.theme_list),
+        endAction = {
+            MediumIconButton(
+                onClick = {
+                    val clipText = context.getClipText()
+                    if (clipText != null && OldThemeConfig.addConfig(clipText)) {
+                        listVersion++
+                    } else {
+                        context.toastOnUi("Import failed")
+                    }
+                },
+                imageVector = Icons.Default.FileDownload
+            )
+        }
     ) {
         Column(
             modifier = Modifier
                 .fillMaxWidth()
                 .padding(bottom = 24.dp)
         ) {
-            Row(
-                modifier = Modifier
-                    .fillMaxWidth()
-                    .padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
-                verticalAlignment = Alignment.CenterVertically
-            ) {
-                AppText(
-                    text = stringResource(R.string.theme_list),
-                    style = LegadoTheme.typography.titleMedium,
-                    modifier = Modifier.weight(1f)
-                )
-                IconButton(
-                    onClick = {
-                        val clipText = context.getClipText()
-                        if (clipText != null && OldThemeConfig.addConfig(clipText)) {
-                            listVersion++
-                        } else {
-                            context.toastOnUi("Import failed")
-                        }
-                    }
-                ) {
-                    Icon(
-                        imageVector = Icons.Default.FileDownload,
-                        contentDescription = stringResource(R.string.import_theme)
-                    )
-                }
-            }
-
             LazyColumn(
                 modifier = Modifier.fillMaxWidth(),
                 verticalArrangement = Arrangement.spacedBy(12.dp)
@@ -104,8 +91,7 @@ fun ThemeListDialog(
                             OldThemeConfig.applyConfig(context, item)
                         },
                         modifier = Modifier
-                            .fillMaxWidth()
-                            .padding(horizontal = 16.dp),
+                            .fillMaxWidth(),
                         shape = RoundedCornerShape(20.dp),
                         containerColor = if (item.primaryColor.toColorInt() == context.primaryColor) {
                             MaterialTheme.colorScheme.secondaryContainer

+ 19 - 0
app/src/main/java/io/legado/app/ui/theme/AppContentColor.kt

@@ -0,0 +1,19 @@
+package io.legado.app.ui.theme
+
+import androidx.compose.material3.LocalContentColor as MaterialLocalContentColor
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.graphics.Color
+import top.yukonga.miuix.kmp.theme.LocalContentColor as MiuixLocalContentColor
+
+@Composable
+fun ProvideAppContentColor(
+    contentColor: Color,
+    content: @Composable () -> Unit
+) {
+    CompositionLocalProvider(
+        MaterialLocalContentColor provides contentColor,
+        MiuixLocalContentColor provides contentColor,
+        content = content
+    )
+}

+ 121 - 129
app/src/main/java/io/legado/app/ui/theme/AppTheme.kt

@@ -1,5 +1,6 @@
 package io.legado.app.ui.theme
 
+import android.os.Build
 import androidx.compose.foundation.isSystemInDarkTheme
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.MaterialExpressiveTheme
@@ -11,6 +12,8 @@ import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.remember
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.text.TextStyle
 import androidx.compose.ui.text.font.FontWeight
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import top.yukonga.miuix.kmp.theme.MiuixTheme
@@ -24,33 +27,56 @@ fun AppTheme(
 ) {
     val context = LocalContext.current
     val appThemeMode = ThemeResolver.resolveThemeMode(ThemeConfig.appTheme)
+    val themeModeValue = ThemeConfig.themeMode
     val isPureBlack = ThemeConfig.isPureBlack
-    val paletteStyleStr = ThemeConfig.paletteStyle
+    val paletteStyleValue = ThemeConfig.paletteStyle
     val materialVersion = ThemeConfig.materialVersion
     val composeEngine = ThemeConfig.composeEngine
-    val colorSchemeMode = ThemeResolver.resolveColorSchemeMode(ThemeConfig.themeMode)
-    val paletteStyle =
-        remember(paletteStyleStr) { ThemeResolver.resolvePaletteStyle(paletteStyleStr) }
-    val seedColor = remember(ThemeConfig.cPrimary) {
-        if (ThemeConfig.cPrimary != 0) Color(ThemeConfig.cPrimary) else Color(0xFF3482FF)
+    val useMiuixMonet = ThemeConfig.useMiuixMonet
+    val customPrimary = ThemeConfig.cPrimary
+    val colorSchemeMode = ThemeResolver.resolveColorSchemeMode(themeModeValue)
+    val miuixColorSchemeMode = remember(themeModeValue, useMiuixMonet) {
+        ThemeResolver.resolveMiuixColorSchemeMode(themeModeValue, useMiuixMonet)
     }
+    val paletteStyle =
+        remember(paletteStyleValue) { ThemeResolver.resolvePaletteStyle(paletteStyleValue) }
 
     val colorScheme =
-        remember(context, appThemeMode, darkTheme, isPureBlack, paletteStyleStr, materialVersion) {
-            ThemeManager.getColorScheme(
+        remember(
+            context,
+            appThemeMode,
+            darkTheme,
+            isPureBlack,
+            paletteStyleValue,
+            materialVersion
+        ) {
+            ThemeEngine.getColorScheme(
                 context = context,
                 mode = appThemeMode,
                 darkTheme = darkTheme,
                 isAmoled = isPureBlack,
-                paletteStyle = paletteStyleStr,
+                paletteStyle = paletteStyleValue,
                 materialVersion = materialVersion
             )
         }
 
+    val customSeedColor = remember(customPrimary, colorScheme.primary) {
+        if (customPrimary != 0) Color(customPrimary) else colorScheme.primary
+    }
+    val themeSeedColor = remember(appThemeMode, customSeedColor, colorScheme.primary) {
+        if (appThemeMode == AppThemeMode.Custom) customSeedColor else colorScheme.primary
+    }
+    val miuixPaletteStyle = remember(paletteStyleValue) {
+        ThemeResolver.resolveMiuixPaletteStyle(paletteStyleValue)
+    }
+    val miuixColorSpec = remember(materialVersion, paletteStyleValue) {
+        ThemeResolver.resolveMiuixColorSpec(materialVersion, paletteStyleValue)
+    }
+
     val themeColors = remember(
         colorScheme,
         darkTheme,
-        seedColor,
+        themeSeedColor,
         paletteStyle,
         colorSchemeMode,
         composeEngine
@@ -58,7 +84,7 @@ fun AppTheme(
         LegadoThemeMode(
             colorScheme = colorScheme,
             isDark = darkTheme,
-            seedColor = seedColor,
+            seedColor = themeSeedColor,
             paletteStyle = paletteStyle,
             themeMode = colorSchemeMode,
             useDynamicColor = appThemeMode == AppThemeMode.Dynamic,
@@ -70,8 +96,37 @@ fun AppTheme(
         LocalLegadoThemeColors provides themeColors
     ) {
         if (ThemeResolver.isMiuixEngine(themeColors.composeEngine)) {
-            val controller = remember(colorSchemeMode, darkTheme) {
-                ThemeController(colorSchemeMode = colorSchemeMode, isDark = darkTheme)
+            val keyColor = if (useMiuixMonet &&
+                themeColors.useDynamicColor &&
+                Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
+            ) {
+                colorResource(id = android.R.color.system_accent1_500)
+            } else {
+                themeSeedColor
+            }
+
+            val controller = remember(
+                miuixColorSchemeMode,
+                useMiuixMonet,
+                keyColor,
+                miuixPaletteStyle,
+                miuixColorSpec,
+                darkTheme
+            ) {
+                if (useMiuixMonet) {
+                    ThemeController(
+                        colorSchemeMode = miuixColorSchemeMode,
+                        keyColor = keyColor,
+                        paletteStyle = miuixPaletteStyle,
+                        colorSpec = miuixColorSpec,
+                        isDark = darkTheme
+                    )
+                } else {
+                    ThemeController(
+                        colorSchemeMode = miuixColorSchemeMode,
+                        isDark = darkTheme
+                    )
+                }
             }
 
             MiuixTheme(controller = controller) {
@@ -79,34 +134,34 @@ fun AppTheme(
                 val legadoTypography = remember(miuixStyles) {
                     LegadoTypography(
                         headlineLarge = miuixStyles.title1,
-                        headlineLargeEmphasized = miuixStyles.title1.copy(fontWeight = FontWeight.Medium),
+                        headlineLargeEmphasized = miuixStyles.title1.emphasized(),
                         headlineMedium = miuixStyles.title2,
-                        headlineMediumEmphasized = miuixStyles.title2.copy(fontWeight = FontWeight.Medium),
+                        headlineMediumEmphasized = miuixStyles.title2.emphasized(),
                         headlineSmall = miuixStyles.title3,
-                        headlineSmallEmphasized = miuixStyles.title3.copy(fontWeight = FontWeight.Medium),
+                        headlineSmallEmphasized = miuixStyles.title3.emphasized(),
 
 
                         titleLarge = miuixStyles.headline1,
-                        titleLargeEmphasized = miuixStyles.headline1.copy(fontWeight = FontWeight.Medium),
+                        titleLargeEmphasized = miuixStyles.headline1.emphasized(),
                         titleMedium = miuixStyles.headline2,
-                        titleMediumEmphasized = miuixStyles.headline2.copy(fontWeight = FontWeight.Medium),
+                        titleMediumEmphasized = miuixStyles.headline2.emphasized(),
                         titleSmall = miuixStyles.subtitle,
-                        titleSmallEmphasized = miuixStyles.subtitle.copy(fontWeight = FontWeight.Medium),
+                        titleSmallEmphasized = miuixStyles.subtitle.emphasized(),
 
 
                         bodyLarge = miuixStyles.paragraph,
-                        bodyLargeEmphasized = miuixStyles.paragraph.copy(fontWeight = FontWeight.Medium),
+                        bodyLargeEmphasized = miuixStyles.paragraph.emphasized(),
                         bodyMedium = miuixStyles.body1,
-                        bodyMediumEmphasized = miuixStyles.body1.copy(fontWeight = FontWeight.Medium),
+                        bodyMediumEmphasized = miuixStyles.body1.emphasized(),
                         bodySmall = miuixStyles.body2,
-                        bodySmallEmphasized = miuixStyles.body2.copy(fontWeight = FontWeight.Medium),
+                        bodySmallEmphasized = miuixStyles.body2.emphasized(),
 
                         labelLarge = miuixStyles.button,
-                        labelLargeEmphasized = miuixStyles.button.copy(fontWeight = FontWeight.Medium),
+                        labelLargeEmphasized = miuixStyles.button.emphasized(),
                         labelMedium = miuixStyles.footnote1,
-                        labelMediumEmphasized = miuixStyles.footnote1.copy(fontWeight = FontWeight.Medium),
+                        labelMediumEmphasized = miuixStyles.footnote1.emphasized(),
                         labelSmall = miuixStyles.footnote2,
-                        labelSmallEmphasized = miuixStyles.footnote2.copy(fontWeight = FontWeight.Medium)
+                        labelSmallEmphasized = miuixStyles.footnote2.emphasized()
                     )
                 }
 
@@ -130,39 +185,28 @@ fun AppTheme(
                         tertiaryContainer = miuixColorScheme.primaryContainer,
                         onTertiaryContainer = miuixColorScheme.primaryVariant,
 
-                        // ================= 4. 背景与表面 (Background & Surface) =================
                         background = miuixColorScheme.background,
                         onBackground = miuixColorScheme.onBackground,
 
                         surface = miuixColorScheme.surface,
                         onSurface = miuixColorScheme.onSurface,
                         surfaceVariant = miuixColorScheme.surfaceVariant,
-                        // M3 的 onSurfaceVariant 通常是次级文字色。Miuix 的 onSurfaceSecondary 完美契合这个语义
                         onSurfaceVariant = miuixColorScheme.onSurfaceSecondary,
-
-                        // M3 中用于给 Surface 叠加一层极淡主题色的属性,通常直接取 primary
                         surfaceTint = miuixColorScheme.primary,
-
-                        // Inverse 系列通常用于深色模式下的反色提示(如 Snackbar)。
-                        // 简单映射法:直接用现有的 onSurface 和 surface 交叉互换。
                         inverseSurface = miuixColorScheme.onSurface,
                         inverseOnSurface = miuixColorScheme.surface,
 
-                        // ================= 5. 错误状态 (Error) =================
                         error = miuixColorScheme.error,
                         onError = miuixColorScheme.onError,
                         errorContainer = miuixColorScheme.errorContainer,
                         onErrorContainer = miuixColorScheme.onErrorContainer,
 
-                        // ================= 6. 边框、分割线与遮罩 (Outline & Scrim) =================
                         outline = miuixColorScheme.outline,
-                        // outlineVariant 在 M3 中常用于分割线。Miuix 刚好有 dividerLine
                         outlineVariant = miuixColorScheme.dividerLine,
-                        // scrim 是 M3 的遮罩层(如弹窗背后的阴影)。Miuix 刚好有 windowDimming
                         scrim = miuixColorScheme.windowDimming,
 
-                        surfaceBright = miuixColorScheme.surface, // Miuix 缺省,用 surface 兜底
-                        surfaceDim = miuixColorScheme.background, // Miuix 缺省,用 background 兜底
+                        surfaceBright = miuixColorScheme.surface,
+                        surfaceDim = miuixColorScheme.background,
                         surfaceContainer = miuixColorScheme.surfaceContainer,
                         surfaceContainerHigh = miuixColorScheme.surfaceContainerHigh,
                         surfaceContainerHighest = miuixColorScheme.surfaceContainerHighest,
@@ -182,7 +226,7 @@ fun AppTheme(
                         onTertiaryFixed = miuixColorScheme.onTertiaryContainer,
                         onTertiaryFixedVariant = miuixColorScheme.onTertiaryContainer,
 
-                        cardContainer = miuixColorScheme.tertiaryContainer,
+                        cardContainer = miuixColorScheme.disabledPrimary,
                         onCardContainer = miuixColorScheme.primary
                     )
                 }
@@ -195,102 +239,17 @@ fun AppTheme(
                 }
             }
         } else {
-            val Typography = Typography()
+            val materialTypography = remember { Typography() }
             MaterialExpressiveTheme(
                 colorScheme = colorScheme,
-                typography = Typography,
+                typography = materialTypography,
                 motionScheme = MotionScheme.expressive(),
                 shapes = Shapes()
             ) {
-                val legadoTypography = remember(Typography) {
-                    LegadoTypography(
-                        headlineLarge = Typography.headlineLarge,
-                        headlineLargeEmphasized = Typography.headlineLargeEmphasized,
-                        headlineMedium = Typography.headlineMedium,
-                        headlineMediumEmphasized = Typography.headlineMediumEmphasized,
-                        headlineSmall = Typography.headlineSmall,
-                        headlineSmallEmphasized = Typography.headlineSmallEmphasized,
-
-
-                        titleLarge = Typography.titleLarge,
-                        titleLargeEmphasized = Typography.titleLargeEmphasized,
-                        titleMedium = Typography.titleMedium,
-                        titleMediumEmphasized = Typography.titleMediumEmphasized,
-                        titleSmall = Typography.titleSmall,
-                        titleSmallEmphasized = Typography.titleSmallEmphasized,
-
-
-                        bodyLarge = Typography.bodyLarge,
-                        bodyLargeEmphasized = Typography.bodyLargeEmphasized,
-                        bodyMedium = Typography.bodyMedium,
-                        bodyMediumEmphasized = Typography.bodyMediumEmphasized,
-                        bodySmall = Typography.bodySmall,
-                        bodySmallEmphasized = Typography.bodySmallEmphasized,
-
-                        labelLarge = Typography.labelLarge,
-                        labelLargeEmphasized = Typography.labelLargeEmphasized,
-                        labelMedium = Typography.labelMedium,
-                        labelMediumEmphasized = Typography.labelMediumEmphasized,
-                        labelSmall = Typography.labelSmall,
-                        labelSmallEmphasized = Typography.labelSmallEmphasized
-                    )
-                }
-
-                val semanticColors = remember(colorScheme) {
-                    LegadoColorScheme(
-                        primary = colorScheme.primary,
-                        onPrimary = colorScheme.onPrimary,
-                        primaryContainer = colorScheme.primaryContainer,
-                        onPrimaryContainer = colorScheme.onPrimaryContainer,
-                        inversePrimary = colorScheme.inversePrimary,
-                        secondary = colorScheme.secondary,
-                        onSecondary = colorScheme.onSecondary,
-                        secondaryContainer = colorScheme.secondaryContainer,
-                        onSecondaryContainer = colorScheme.onSecondaryContainer,
-                        tertiary = colorScheme.tertiary,
-                        onTertiary = colorScheme.onTertiary,
-                        tertiaryContainer = colorScheme.tertiaryContainer,
-                        onTertiaryContainer = colorScheme.onTertiaryContainer,
-                        background = colorScheme.background,
-                        onBackground = colorScheme.onBackground,
-                        surface = colorScheme.surface,
-                        onSurface = colorScheme.onSurface,
-                        surfaceVariant = colorScheme.surfaceVariant,
-                        onSurfaceVariant = colorScheme.onSurfaceVariant,
-                        surfaceTint = colorScheme.surfaceTint,
-                        inverseSurface = colorScheme.inverseSurface,
-                        inverseOnSurface = colorScheme.inverseOnSurface,
-                        error = colorScheme.error,
-                        onError = colorScheme.onError,
-                        errorContainer = colorScheme.errorContainer,
-                        onErrorContainer = colorScheme.onErrorContainer,
-                        outline = colorScheme.outline,
-                        outlineVariant = colorScheme.outlineVariant,
-                        scrim = colorScheme.scrim,
-                        surfaceBright = colorScheme.surfaceBright,
-                        surfaceDim = colorScheme.surfaceDim,
-                        surfaceContainer = colorScheme.surfaceContainer,
-                        surfaceContainerHigh = colorScheme.surfaceContainerHigh,
-                        surfaceContainerHighest = colorScheme.surfaceContainerHighest,
-                        surfaceContainerLow = colorScheme.surfaceContainerLow,
-                        surfaceContainerLowest = colorScheme.surfaceContainerLowest,
-                        primaryFixed = colorScheme.primaryFixed,
-                        primaryFixedDim = colorScheme.primaryFixedDim,
-                        onPrimaryFixed = colorScheme.onPrimaryFixed,
-                        onPrimaryFixedVariant = colorScheme.onPrimaryFixedVariant,
-                        secondaryFixed = colorScheme.secondaryFixed,
-                        secondaryFixedDim = colorScheme.secondaryFixedDim,
-                        onSecondaryFixed = colorScheme.onSecondaryFixed,
-                        onSecondaryFixedVariant = colorScheme.onSecondaryFixedVariant,
-                        tertiaryFixed = colorScheme.tertiaryFixed,
-                        tertiaryFixedDim = colorScheme.tertiaryFixedDim,
-                        onTertiaryFixed = colorScheme.onTertiaryFixed,
-                        onTertiaryFixedVariant = colorScheme.onTertiaryFixedVariant,
-
-                        cardContainer = colorScheme.primaryContainer.copy(alpha = 0.5f),
-                        onCardContainer = colorScheme.primary
-                    )
+                val legadoTypography = remember(materialTypography) {
+                    materialTypography.toLegadoTypography()
                 }
+                val semanticColors = remember(colorScheme) { colorScheme.toLegadoColorScheme() }
 
                 CompositionLocalProvider(
                     LocalLegadoTypography provides legadoTypography,
@@ -302,3 +261,36 @@ fun AppTheme(
         }
     }
 }
+
+private fun Typography.toLegadoTypography(): LegadoTypography {
+    return LegadoTypography(
+        headlineLarge = headlineLarge,
+        headlineLargeEmphasized = headlineLargeEmphasized,
+        headlineMedium = headlineMedium,
+        headlineMediumEmphasized = headlineMediumEmphasized,
+        headlineSmall = headlineSmall,
+        headlineSmallEmphasized = headlineSmallEmphasized,
+        titleLarge = titleLarge,
+        titleLargeEmphasized = titleLargeEmphasized,
+        titleMedium = titleMedium,
+        titleMediumEmphasized = titleMediumEmphasized,
+        titleSmall = titleSmall,
+        titleSmallEmphasized = titleSmallEmphasized,
+        bodyLarge = bodyLarge,
+        bodyLargeEmphasized = bodyLargeEmphasized,
+        bodyMedium = bodyMedium,
+        bodyMediumEmphasized = bodyMediumEmphasized,
+        bodySmall = bodySmall,
+        bodySmallEmphasized = bodySmallEmphasized,
+        labelLarge = labelLarge,
+        labelLargeEmphasized = labelLargeEmphasized,
+        labelMedium = labelMedium,
+        labelMediumEmphasized = labelMediumEmphasized,
+        labelSmall = labelSmall,
+        labelSmallEmphasized = labelSmallEmphasized
+    )
+}
+
+private fun TextStyle.emphasized(): TextStyle {
+    return copy(fontWeight = FontWeight.Medium)
+}

+ 1 - 1
app/src/main/java/io/legado/app/ui/theme/AppThemeMode.kt

@@ -13,7 +13,7 @@ enum class AppThemeMode {
     Yuuka,
     Phoebe,
     Mujika,
-    CUSTOM,
+    Custom,
     Transparent
 }
 

+ 137 - 0
app/src/main/java/io/legado/app/ui/theme/ImageSeedColorExtractor.kt

@@ -0,0 +1,137 @@
+package io.legado.app.ui.theme
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.Drawable
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.graphics.drawable.toBitmap
+import androidx.core.graphics.scale
+import coil.ImageLoader
+import coil.request.ImageRequest
+import coil.request.SuccessResult
+import com.materialkolor.quantize.QuantizerCelebi
+import com.materialkolor.score.Score
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+private const val IMAGE_COLOR_EXTRACT_SIZE_PX = 256
+private const val IMAGE_QUANTIZE_BITMAP_MAX_SIZE = 128
+private const val IMAGE_MAX_QUANTIZE_COLORS = 128
+private const val IMAGE_FALLBACK_SEED_COLOR = 0xFF4285F4.toInt()
+
+suspend fun ImageLoader.extractSeedColor(
+    context: Context,
+    data: Any,
+    configureRequest: ImageRequest.Builder.() -> Unit = {},
+): Color? {
+    val request = ImageRequest.Builder(context)
+        .data(data)
+        .allowHardware(false)
+        .apply(configureRequest)
+        .build()
+
+    val result = withContext(Dispatchers.IO) {
+        execute(request)
+    } as? SuccessResult ?: return null
+
+    return withContext(Dispatchers.Default) {
+        val bitmap = result.drawable.toSafeBitmap(IMAGE_COLOR_EXTRACT_SIZE_PX)
+        Color(bitmap.extractSeedColor())
+    }
+}
+
+@Composable
+fun rememberImageSeedColor(
+    imageLoader: ImageLoader,
+    data: Any?,
+    requestKey: Any? = data,
+    configureRequest: ImageRequest.Builder.() -> Unit = {},
+): Color? {
+    val context = LocalContext.current
+    val seedColor by produceState<Color?>(initialValue = null, imageLoader, requestKey) {
+        value = if (data == null) {
+            null
+        } else {
+            imageLoader.extractSeedColor(
+                context = context,
+                data = data,
+                configureRequest = configureRequest
+            )
+        }
+    }
+    return seedColor
+}
+
+internal fun Bitmap.extractSeedColor(
+    maxColors: Int = IMAGE_MAX_QUANTIZE_COLORS,
+    fallbackColorArgb: Int = IMAGE_FALLBACK_SEED_COLOR,
+): Int {
+    val needsScaling =
+        width > IMAGE_QUANTIZE_BITMAP_MAX_SIZE || height > IMAGE_QUANTIZE_BITMAP_MAX_SIZE
+
+    val scaledBitmap = if (needsScaling) {
+        val scale = minOf(
+            IMAGE_QUANTIZE_BITMAP_MAX_SIZE.toFloat() / width,
+            IMAGE_QUANTIZE_BITMAP_MAX_SIZE.toFloat() / height
+        )
+        scale(
+            (width * scale).toInt().coerceAtLeast(1),
+            (height * scale).toInt().coerceAtLeast(1)
+        )
+    } else {
+        this
+    }
+
+    return try {
+        val pixels = IntArray(scaledBitmap.width * scaledBitmap.height)
+        scaledBitmap.getPixels(
+            pixels,
+            0,
+            scaledBitmap.width,
+            0,
+            0,
+            scaledBitmap.width,
+            scaledBitmap.height
+        )
+
+        val quantized = QuantizerCelebi.quantize(pixels, maxColors)
+        Score.score(quantized, 1, fallbackColorArgb, true).first()
+    } finally {
+        if (scaledBitmap !== this) {
+            scaledBitmap.recycle()
+        }
+    }
+}
+
+internal fun Drawable.toSafeBitmap(maxSizePx: Int): Bitmap {
+    if (this is BitmapDrawable && bitmap != null) {
+        val rawBitmap = bitmap
+        if (rawBitmap.width <= maxSizePx && rawBitmap.height <= maxSizePx) {
+            return rawBitmap
+        }
+
+        val scale = minOf(
+            1f,
+            maxSizePx.toFloat() / rawBitmap.width,
+            maxSizePx.toFloat() / rawBitmap.height
+        )
+        return rawBitmap.scale(
+            (rawBitmap.width * scale).toInt().coerceAtLeast(1),
+            (rawBitmap.height * scale).toInt().coerceAtLeast(1)
+        )
+    }
+
+    val rawWidth = intrinsicWidth.takeIf { it > 0 } ?: maxSizePx
+    val rawHeight = intrinsicHeight.takeIf { it > 0 } ?: maxSizePx
+    val scale = minOf(1f, maxSizePx.toFloat() / rawWidth, maxSizePx.toFloat() / rawHeight)
+
+    return toBitmap(
+        width = (rawWidth * scale).toInt().coerceAtLeast(1),
+        height = (rawHeight * scale).toInt().coerceAtLeast(1)
+    )
+}

+ 2 - 2
app/src/main/java/io/legado/app/ui/theme/OpaqueColorScheme.kt

@@ -6,7 +6,7 @@ import androidx.compose.runtime.Composable
 import androidx.compose.runtime.remember
 import androidx.compose.ui.platform.LocalContext
 import io.legado.app.ui.config.themeConfig.ThemeConfig
-import io.legado.app.ui.theme.ThemeManager.getColorScheme
+import io.legado.app.ui.theme.ThemeEngine.getColorScheme
 
 @Composable
 fun rememberOpaqueColorScheme(): ColorScheme {
@@ -27,4 +27,4 @@ fun rememberOpaqueColorScheme(): ColorScheme {
             forceOpaque = true
         )
     }
-}
+}

+ 242 - 0
app/src/main/java/io/legado/app/ui/theme/ThemeColorSchemeOverride.kt

@@ -0,0 +1,242 @@
+package io.legado.app.ui.theme
+
+import androidx.compose.animation.animateColor
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.core.updateTransition
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.remember
+import androidx.compose.ui.graphics.Color
+import io.legado.app.ui.config.themeConfig.ThemeConfig
+import top.yukonga.miuix.kmp.theme.ColorSchemeMode
+import top.yukonga.miuix.kmp.theme.MiuixTheme
+import top.yukonga.miuix.kmp.theme.ThemeController
+
+fun ColorScheme.toLegadoColorScheme(): LegadoColorScheme {
+    return LegadoColorScheme(
+        primary = primary,
+        onPrimary = onPrimary,
+        primaryContainer = primaryContainer,
+        onPrimaryContainer = onPrimaryContainer,
+        inversePrimary = inversePrimary,
+        secondary = secondary,
+        onSecondary = onSecondary,
+        secondaryContainer = secondaryContainer,
+        onSecondaryContainer = onSecondaryContainer,
+        tertiary = tertiary,
+        onTertiary = onTertiary,
+        tertiaryContainer = tertiaryContainer,
+        onTertiaryContainer = onTertiaryContainer,
+        background = background,
+        onBackground = onBackground,
+        surface = surface,
+        onSurface = onSurface,
+        surfaceVariant = surfaceVariant,
+        onSurfaceVariant = onSurfaceVariant,
+        surfaceTint = surfaceTint,
+        inverseSurface = inverseSurface,
+        inverseOnSurface = inverseOnSurface,
+        error = error,
+        onError = onError,
+        errorContainer = errorContainer,
+        onErrorContainer = onErrorContainer,
+        outline = outline,
+        outlineVariant = outlineVariant,
+        scrim = scrim,
+        surfaceBright = surfaceBright,
+        surfaceDim = surfaceDim,
+        surfaceContainer = surfaceContainer,
+        surfaceContainerHigh = surfaceContainerHigh,
+        surfaceContainerHighest = surfaceContainerHighest,
+        surfaceContainerLow = surfaceContainerLow,
+        surfaceContainerLowest = surfaceContainerLowest,
+        primaryFixed = primaryFixed,
+        primaryFixedDim = primaryFixedDim,
+        onPrimaryFixed = onPrimaryFixed,
+        onPrimaryFixedVariant = onPrimaryFixedVariant,
+        secondaryFixed = secondaryFixed,
+        secondaryFixedDim = secondaryFixedDim,
+        onSecondaryFixed = onSecondaryFixed,
+        onSecondaryFixedVariant = onSecondaryFixedVariant,
+        tertiaryFixed = tertiaryFixed,
+        tertiaryFixedDim = tertiaryFixedDim,
+        onTertiaryFixed = onTertiaryFixed,
+        onTertiaryFixedVariant = onTertiaryFixedVariant,
+        cardContainer = primaryContainer.copy(alpha = 0.5f),
+        onCardContainer = primary
+    )
+}
+
+@Composable
+fun ProvideColorSchemeOverride(
+    colorScheme: ColorScheme,
+    seedColor: Color = colorScheme.primary,
+    content: @Composable () -> Unit,
+) {
+    val themeAnimationSpec = tween<Color>(
+        durationMillis = 700,
+        easing = FastOutSlowInEasing
+    )
+    val baseThemeMode = LocalLegadoThemeColors.current
+    val animatedColorScheme = colorScheme.animateColorSchemeAsState(themeAnimationSpec)
+    val animatedSeedColor = animateColorAsState(
+        targetValue = seedColor,
+        animationSpec = themeAnimationSpec,
+        label = "theme_seed_animation"
+    ).value
+    val legadoColorScheme = remember(animatedColorScheme) { animatedColorScheme.toLegadoColorScheme() }
+    val overrideThemeMode = remember(baseThemeMode, animatedColorScheme, animatedSeedColor) {
+        baseThemeMode.copy(
+            colorScheme = animatedColorScheme,
+            seedColor = animatedSeedColor,
+        )
+    }
+    val materialTypography = MaterialTheme.typography
+    val materialShapes = MaterialTheme.shapes
+    val isMiuixEngine = ThemeResolver.isMiuixEngine(overrideThemeMode.composeEngine)
+    val miuixColorSchemeMode = remember(overrideThemeMode.themeMode) {
+        overrideThemeMode.themeMode.toMiuixMonetMode()
+    }
+    val miuixPaletteStyle = remember(ThemeConfig.paletteStyle) {
+        ThemeResolver.resolveMiuixPaletteStyle(ThemeConfig.paletteStyle)
+    }
+    val miuixColorSpec = remember(ThemeConfig.materialVersion, ThemeConfig.paletteStyle) {
+        ThemeResolver.resolveMiuixColorSpec(ThemeConfig.materialVersion, ThemeConfig.paletteStyle)
+    }
+    val miuixController = remember(
+        isMiuixEngine,
+        miuixColorSchemeMode,
+        overrideThemeMode.isDark,
+        animatedSeedColor,
+        miuixPaletteStyle,
+        miuixColorSpec
+    ) {
+        if (!isMiuixEngine) {
+            null
+        } else {
+            ThemeController(
+                colorSchemeMode = miuixColorSchemeMode,
+                keyColor = animatedSeedColor,
+                paletteStyle = miuixPaletteStyle,
+                colorSpec = miuixColorSpec,
+                isDark = overrideThemeMode.isDark
+            )
+        }
+    }
+
+    CompositionLocalProvider(
+        LocalLegadoThemeColors provides overrideThemeMode,
+        LocalLegadoColorScheme provides legadoColorScheme
+    ) {
+        if (miuixController != null) {
+            MiuixTheme(controller = miuixController) {
+                MaterialTheme(
+                    colorScheme = animatedColorScheme,
+                    typography = materialTypography,
+                    shapes = materialShapes
+                ) {
+                    content()
+                }
+            }
+        } else {
+            MaterialTheme(
+                colorScheme = animatedColorScheme,
+                typography = materialTypography,
+                shapes = materialShapes
+            ) {
+                content()
+            }
+        }
+    }
+}
+
+private fun ColorSchemeMode.toMiuixMonetMode(): ColorSchemeMode {
+    return when (this) {
+        ColorSchemeMode.Light,
+        ColorSchemeMode.MonetLight -> ColorSchemeMode.MonetLight
+
+        ColorSchemeMode.Dark,
+        ColorSchemeMode.MonetDark -> ColorSchemeMode.MonetDark
+
+        else -> ColorSchemeMode.MonetSystem
+    }
+}
+
+@Composable
+fun ColorScheme.animateColorSchemeAsState(
+    animationSpec: FiniteAnimationSpec<Color> = tween(
+        durationMillis = 700,
+        easing = FastOutSlowInEasing
+    )
+): ColorScheme {
+    val transition = updateTransition(
+        targetState = this,
+        label = "theme_color_scheme_transition"
+    )
+
+    @Composable
+    fun animateColor(label: String, color: ColorScheme.() -> Color): Color {
+        return transition.animateColor(
+            transitionSpec = { animationSpec },
+            label = label
+        ) { scheme ->
+            scheme.color()
+        }.value
+    }
+
+    return ColorScheme(
+        primary = animateColor("scheme-primary") { primary },
+        onPrimary = animateColor("scheme-onPrimary") { onPrimary },
+        primaryContainer = animateColor("scheme-primaryContainer") { primaryContainer },
+        onPrimaryContainer = animateColor("scheme-onPrimaryContainer") { onPrimaryContainer },
+        inversePrimary = animateColor("scheme-inversePrimary") { inversePrimary },
+        secondary = animateColor("scheme-secondary") { secondary },
+        onSecondary = animateColor("scheme-onSecondary") { onSecondary },
+        secondaryContainer = animateColor("scheme-secondaryContainer") { secondaryContainer },
+        onSecondaryContainer = animateColor("scheme-onSecondaryContainer") { onSecondaryContainer },
+        tertiary = animateColor("scheme-tertiary") { tertiary },
+        onTertiary = animateColor("scheme-onTertiary") { onTertiary },
+        tertiaryContainer = animateColor("scheme-tertiaryContainer") { tertiaryContainer },
+        onTertiaryContainer = animateColor("scheme-onTertiaryContainer") { onTertiaryContainer },
+        background = animateColor("scheme-background") { background },
+        onBackground = animateColor("scheme-onBackground") { onBackground },
+        surface = animateColor("scheme-surface") { surface },
+        onSurface = animateColor("scheme-onSurface") { onSurface },
+        surfaceVariant = animateColor("scheme-surfaceVariant") { surfaceVariant },
+        onSurfaceVariant = animateColor("scheme-onSurfaceVariant") { onSurfaceVariant },
+        surfaceTint = animateColor("scheme-surfaceTint") { surfaceTint },
+        inverseSurface = animateColor("scheme-inverseSurface") { inverseSurface },
+        inverseOnSurface = animateColor("scheme-inverseOnSurface") { inverseOnSurface },
+        error = animateColor("scheme-error") { error },
+        onError = animateColor("scheme-onError") { onError },
+        errorContainer = animateColor("scheme-errorContainer") { errorContainer },
+        onErrorContainer = animateColor("scheme-onErrorContainer") { onErrorContainer },
+        outline = animateColor("scheme-outline") { outline },
+        outlineVariant = animateColor("scheme-outlineVariant") { outlineVariant },
+        scrim = animateColor("scheme-scrim") { scrim },
+        surfaceBright = animateColor("scheme-surfaceBright") { surfaceBright },
+        surfaceDim = animateColor("scheme-surfaceDim") { surfaceDim },
+        surfaceContainer = animateColor("scheme-surfaceContainer") { surfaceContainer },
+        surfaceContainerHigh = animateColor("scheme-surfaceContainerHigh") { surfaceContainerHigh },
+        surfaceContainerHighest = animateColor("scheme-surfaceContainerHighest") { surfaceContainerHighest },
+        surfaceContainerLow = animateColor("scheme-surfaceContainerLow") { surfaceContainerLow },
+        surfaceContainerLowest = animateColor("scheme-surfaceContainerLowest") { surfaceContainerLowest },
+        primaryFixed = animateColor("scheme-primaryFixed") { primaryFixed },
+        primaryFixedDim = animateColor("scheme-primaryFixedDim") { primaryFixedDim },
+        onPrimaryFixed = animateColor("scheme-onPrimaryFixed") { onPrimaryFixed },
+        onPrimaryFixedVariant = animateColor("scheme-onPrimaryFixedVariant") { onPrimaryFixedVariant },
+        secondaryFixed = animateColor("scheme-secondaryFixed") { secondaryFixed },
+        secondaryFixedDim = animateColor("scheme-secondaryFixedDim") { secondaryFixedDim },
+        onSecondaryFixed = animateColor("scheme-onSecondaryFixed") { onSecondaryFixed },
+        onSecondaryFixedVariant = animateColor("scheme-onSecondaryFixedVariant") { onSecondaryFixedVariant },
+        tertiaryFixed = animateColor("scheme-tertiaryFixed") { tertiaryFixed },
+        tertiaryFixedDim = animateColor("scheme-tertiaryFixedDim") { tertiaryFixedDim },
+        onTertiaryFixed = animateColor("scheme-onTertiaryFixed") { onTertiaryFixed },
+        onTertiaryFixedVariant = animateColor("scheme-onTertiaryFixedVariant") { onTertiaryFixedVariant }
+    )
+}

+ 142 - 0
app/src/main/java/io/legado/app/ui/theme/ThemeEngine.kt

@@ -0,0 +1,142 @@
+package io.legado.app.ui.theme
+
+import android.content.Context
+import android.os.Build
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.ui.graphics.Color
+import io.legado.app.lib.theme.primaryColor
+import io.legado.app.ui.theme.ThemeResolver.resolvePaletteStyle
+import io.legado.app.ui.theme.colorScheme.AugustColorScheme
+import io.legado.app.ui.theme.colorScheme.CarlottaColorScheme
+import io.legado.app.ui.theme.colorScheme.ElinkColorScheme
+import io.legado.app.ui.theme.colorScheme.GRColorScheme
+import io.legado.app.ui.theme.colorScheme.KoharuColorScheme
+import io.legado.app.ui.theme.colorScheme.LemonColorScheme
+import io.legado.app.ui.theme.colorScheme.MujikaColorScheme
+import io.legado.app.ui.theme.colorScheme.PhoebeColorScheme
+import io.legado.app.ui.theme.colorScheme.SoraColorScheme
+import io.legado.app.ui.theme.colorScheme.TransparentColorScheme
+import io.legado.app.ui.theme.colorScheme.WHColorScheme
+import io.legado.app.ui.theme.colorScheme.YuukaColorScheme
+
+object ThemeEngine {
+
+    private val predefinedColorSchemes: Map<AppThemeMode, BaseColorScheme> = mapOf(
+        AppThemeMode.GR to GRColorScheme,
+        AppThemeMode.Lemon to LemonColorScheme,
+        AppThemeMode.WH to WHColorScheme,
+        AppThemeMode.Elink to ElinkColorScheme,
+        AppThemeMode.Sora to SoraColorScheme,
+        AppThemeMode.August to AugustColorScheme,
+        AppThemeMode.Carlotta to CarlottaColorScheme,
+        AppThemeMode.Koharu to KoharuColorScheme,
+        AppThemeMode.Yuuka to YuukaColorScheme,
+        AppThemeMode.Phoebe to PhoebeColorScheme,
+        AppThemeMode.Mujika to MujikaColorScheme,
+        AppThemeMode.Transparent to TransparentColorScheme,
+    )
+
+    fun getColorScheme(
+        context: Context,
+        mode: AppThemeMode,
+        darkTheme: Boolean,
+        isAmoled: Boolean,
+        paletteStyle: String?,
+        materialVersion: String? = null,
+        forceOpaque: Boolean = false
+    ): ColorScheme {
+        val resolvedMode = resolveMode(mode = mode, forceOpaque = forceOpaque)
+        val baseColorScheme = resolveBaseColorScheme(
+            context = context,
+            mode = resolvedMode,
+            darkTheme = darkTheme,
+            paletteStyle = paletteStyle,
+            materialVersion = materialVersion
+        )
+
+        return baseColorScheme
+            .applyAmoledIfNeeded(darkTheme = darkTheme, isAmoled = isAmoled)
+            .applyTransparentIfNeeded(mode = resolvedMode, forceOpaque = forceOpaque)
+    }
+
+    private fun resolveMode(
+        mode: AppThemeMode,
+        forceOpaque: Boolean
+    ): AppThemeMode {
+        return if (forceOpaque && mode == AppThemeMode.Transparent) {
+            AppThemeMode.WH
+        } else {
+            mode
+        }
+    }
+
+    private fun resolveBaseColorScheme(
+        context: Context,
+        mode: AppThemeMode,
+        darkTheme: Boolean,
+        paletteStyle: String?,
+        materialVersion: String?
+    ): ColorScheme {
+        if (mode == AppThemeMode.Dynamic) {
+            return resolveDynamicColorScheme(context = context, darkTheme = darkTheme)
+        }
+        if (mode == AppThemeMode.Custom) {
+            return resolveCustomColorScheme(
+                seedColor = context.primaryColor,
+                darkTheme = darkTheme,
+                paletteStyle = paletteStyle,
+                materialVersion = materialVersion
+            )
+        }
+        return (predefinedColorSchemes[mode] ?: GRColorScheme).getColorScheme(darkTheme)
+    }
+
+    private fun resolveDynamicColorScheme(
+        context: Context,
+        darkTheme: Boolean
+    ): ColorScheme {
+        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+            return GRColorScheme.getColorScheme(darkTheme)
+        }
+        return if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+    }
+
+    private fun resolveCustomColorScheme(
+        seedColor: Int,
+        darkTheme: Boolean,
+        paletteStyle: String?,
+        materialVersion: String?
+    ): ColorScheme {
+        val style = resolvePaletteStyle(paletteStyle)
+        val colorSpec = ThemeResolver.resolveColorSpecFromMaterialVersion(materialVersion)
+        return CustomColorScheme(seedColor, style, colorSpec).getColorScheme(darkTheme)
+    }
+
+    private fun ColorScheme.applyAmoledIfNeeded(
+        darkTheme: Boolean,
+        isAmoled: Boolean
+    ): ColorScheme {
+        if (!darkTheme || !isAmoled) return this
+        return copy(
+            surface = Color.Black,
+            background = Color.Black,
+            surfaceContainerLow = Color(0xFF0A0A0A),
+            surfaceContainer = Color(0xFF121212)
+        )
+    }
+
+    private fun ColorScheme.applyTransparentIfNeeded(
+        mode: AppThemeMode,
+        forceOpaque: Boolean
+    ): ColorScheme {
+        if (forceOpaque || mode != AppThemeMode.Transparent) return this
+        return copy(
+            surface = Color.Transparent,
+            background = Color.Transparent,
+            surfaceContainerLow = Color.Transparent,
+            surfaceContainer = Color.Transparent,
+        )
+    }
+}

+ 0 - 97
app/src/main/java/io/legado/app/ui/theme/ThemeManager.kt

@@ -1,97 +0,0 @@
-package io.legado.app.ui.theme
-
-import android.content.Context
-import android.os.Build
-import androidx.compose.material3.ColorScheme
-import androidx.compose.material3.dynamicDarkColorScheme
-import androidx.compose.material3.dynamicLightColorScheme
-import androidx.compose.ui.graphics.Color
-import io.legado.app.lib.theme.primaryColor
-import io.legado.app.ui.theme.ThemeResolver.resolvePaletteStyle
-import io.legado.app.ui.theme.colorScheme.AugustColorScheme
-import io.legado.app.ui.theme.colorScheme.CarlottaColorScheme
-import io.legado.app.ui.theme.colorScheme.ElinkColorScheme
-import io.legado.app.ui.theme.colorScheme.GRColorScheme
-import io.legado.app.ui.theme.colorScheme.KoharuColorScheme
-import io.legado.app.ui.theme.colorScheme.LemonColorScheme
-import io.legado.app.ui.theme.colorScheme.MujikaColorScheme
-import io.legado.app.ui.theme.colorScheme.PhoebeColorScheme
-import io.legado.app.ui.theme.colorScheme.SoraColorScheme
-import io.legado.app.ui.theme.colorScheme.TransparentColorScheme
-import io.legado.app.ui.theme.colorScheme.WHColorScheme
-import io.legado.app.ui.theme.colorScheme.YuukaColorScheme
-
-object ThemeManager {
-
-    val colorSchemes: Map<AppThemeMode, BaseColorScheme> = mapOf(
-        AppThemeMode.GR to GRColorScheme,
-        AppThemeMode.Lemon to LemonColorScheme,
-        AppThemeMode.WH to WHColorScheme,
-        AppThemeMode.Elink to ElinkColorScheme,
-        AppThemeMode.Sora to SoraColorScheme,
-        AppThemeMode.August to AugustColorScheme,
-        AppThemeMode.Carlotta to CarlottaColorScheme,
-        AppThemeMode.Koharu to KoharuColorScheme,
-        AppThemeMode.Yuuka to YuukaColorScheme,
-        AppThemeMode.Phoebe to PhoebeColorScheme,
-        AppThemeMode.Mujika to MujikaColorScheme,
-        AppThemeMode.Transparent to TransparentColorScheme,
-    )
-
-
-    fun getColorScheme(
-        context: Context,
-        mode: AppThemeMode,
-        darkTheme: Boolean,
-        isAmoled: Boolean,
-        paletteStyle: String?,
-        materialVersion: String? = null,
-        forceOpaque: Boolean = false
-    ): ColorScheme {
-
-        val style = resolvePaletteStyle(paletteStyle)
-        val actualMode = if (forceOpaque && mode == AppThemeMode.Transparent) {
-            AppThemeMode.WH
-        } else {
-            mode
-        }
-
-        var scheme = when (actualMode) {
-            AppThemeMode.Dynamic -> {
-                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
-                    if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
-                        context
-                    )
-                } else GRColorScheme.getColorScheme(darkTheme)
-            }
-
-            AppThemeMode.CUSTOM -> {
-                val colorSpec = ThemeResolver.resolveColorSpecFromMaterialVersion(materialVersion)
-                CustomColorScheme(context.primaryColor, style, colorSpec)
-                    .getColorScheme(darkTheme)
-            }
-
-            else -> (colorSchemes[actualMode] ?: GRColorScheme).getColorScheme(darkTheme)
-        }
-
-        if (darkTheme && isAmoled) {
-            scheme = scheme.copy(
-                surface = Color.Black,
-                background = Color.Black,
-                surfaceContainerLow = Color(0xFF0A0A0A),
-                surfaceContainer = Color(0xFF121212)
-            )
-        }
-
-        if (!forceOpaque && actualMode == AppThemeMode.Transparent) {
-            return scheme.copy(
-                surface = Color.Transparent,
-                background = Color.Transparent,
-                surfaceContainerLow = Color.Transparent,
-                surfaceContainer = Color.Transparent,
-            )
-        }
-
-        return scheme
-    }
-}

+ 100 - 0
app/src/main/java/io/legado/app/ui/theme/ThemeOverride.kt

@@ -0,0 +1,100 @@
+package io.legado.app.ui.theme
+
+import androidx.compose.material3.ColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.withFrameNanos
+import androidx.compose.ui.graphics.Color
+import com.materialkolor.PaletteStyle
+import com.materialkolor.dynamicColorScheme
+import io.legado.app.ui.config.themeConfig.ThemeConfig
+
+data class ThemeOverrideState(
+    val seedColor: Color,
+    val colorScheme: ColorScheme,
+)
+
+fun buildThemeOverrideState(
+    seedColor: Color,
+    isDark: Boolean,
+    paletteStyle: PaletteStyle,
+    colorSpec: ThemeColorSpec,
+    usePureBlack: Boolean,
+): ThemeOverrideState {
+    var colorScheme = dynamicColorScheme(
+        seedColor = seedColor,
+        isDark = isDark,
+        isAmoled = false,
+        style = paletteStyle,
+        contrastLevel = ThemeResolver.resolveContrastLevel(),
+        specVersion = ThemeResolver.resolveColorSpecVersion(colorSpec)
+    )
+
+    if (isDark && usePureBlack) {
+        colorScheme = colorScheme.copy(
+            surface = Color.Black,
+            background = Color.Black,
+            surfaceContainerLow = Color(0xFF0A0A0A),
+            surfaceContainer = Color(0xFF121212)
+        )
+    }
+
+    return ThemeOverrideState(
+        seedColor = seedColor,
+        colorScheme = colorScheme
+    )
+}
+
+@Composable
+fun ProvideThemeOverride(
+    theme: ThemeOverrideState?,
+    content: @Composable () -> Unit,
+) {
+    var appliedTheme by remember { mutableStateOf<ThemeOverrideState?>(null) }
+
+    LaunchedEffect(theme) {
+        if (theme == null) {
+            appliedTheme = null
+        } else {
+            withFrameNanos { }
+            appliedTheme = theme
+        }
+    }
+
+    val currentTheme = appliedTheme
+    if (currentTheme == null) {
+        content()
+    } else {
+        ProvideColorSchemeOverride(
+            colorScheme = currentTheme.colorScheme,
+            seedColor = currentTheme.seedColor,
+            content = content
+        )
+    }
+}
+
+@Composable
+fun rememberThemeOverride(
+    seedColor: Color?,
+): ThemeOverrideState? {
+    val isDark = LegadoTheme.isDark
+    val paletteStyle = LegadoTheme.paletteStyle
+    val colorSpec = ThemeResolver.resolveColorSpecFromMaterialVersion(ThemeConfig.materialVersion)
+    val usePureBlack = ThemeConfig.isPureBlack
+
+    return remember(seedColor, isDark, paletteStyle, colorSpec, usePureBlack) {
+        seedColor?.let { color ->
+            buildThemeOverrideState(
+                seedColor = color,
+                isDark = isDark,
+                paletteStyle = paletteStyle,
+                colorSpec = colorSpec,
+                usePureBlack = usePureBlack
+            )
+        }
+    }
+}

+ 101 - 42
app/src/main/java/io/legado/app/ui/theme/ThemeResolver.kt

@@ -5,58 +5,100 @@ import com.materialkolor.PaletteStyle
 import com.materialkolor.dynamiccolor.ColorSpec
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import top.yukonga.miuix.kmp.theme.ColorSchemeMode
+import top.yukonga.miuix.kmp.theme.ThemeColorSpec as MiuixThemeColorSpec
+import top.yukonga.miuix.kmp.theme.ThemePaletteStyle as MiuixPaletteStyle
 
 object ThemeResolver {
 
-    fun resolveThemeMode(value: String): AppThemeMode = when (value) {
-        "0" -> AppThemeMode.Dynamic
-        "1" -> AppThemeMode.GR
-        "2" -> AppThemeMode.Lemon
-        "3" -> AppThemeMode.WH
-        "4" -> AppThemeMode.Elink
-        "5" -> AppThemeMode.Sora
-        "6" -> AppThemeMode.August
-        "7" -> AppThemeMode.Carlotta
-        "8" -> AppThemeMode.Koharu
-        "9" -> AppThemeMode.Yuuka
-        "10" -> AppThemeMode.Phoebe
-        "11" -> AppThemeMode.Mujika
-        "12" -> AppThemeMode.CUSTOM
-        "13" -> AppThemeMode.Transparent
-        else -> AppThemeMode.Dynamic
+    private const val COMPOSE_ENGINE_MIUIX = "miuix"
+    private const val MATERIAL_VERSION_EXPRESSIVE = "material3Expressive"
+
+    private val appThemeModes = mapOf(
+        "0" to AppThemeMode.Dynamic,
+        "1" to AppThemeMode.GR,
+        "2" to AppThemeMode.Lemon,
+        "3" to AppThemeMode.WH,
+        "4" to AppThemeMode.Elink,
+        "5" to AppThemeMode.Sora,
+        "6" to AppThemeMode.August,
+        "7" to AppThemeMode.Carlotta,
+        "8" to AppThemeMode.Koharu,
+        "9" to AppThemeMode.Yuuka,
+        "10" to AppThemeMode.Phoebe,
+        "11" to AppThemeMode.Mujika,
+        "12" to AppThemeMode.Custom,
+        "13" to AppThemeMode.Transparent,
+    )
+
+    private val materialPaletteStyles = mapOf(
+        "tonalSpot" to PaletteStyle.TonalSpot,
+        "neutral" to PaletteStyle.Neutral,
+        "vibrant" to PaletteStyle.Vibrant,
+        "expressive" to PaletteStyle.Expressive,
+        "rainbow" to PaletteStyle.Rainbow,
+        "fruitSalad" to PaletteStyle.FruitSalad,
+        "monochrome" to PaletteStyle.Monochrome,
+        "fidelity" to PaletteStyle.Fidelity,
+        "content" to PaletteStyle.Content,
+    )
+
+    private val miuixPaletteStyles = mapOf(
+        "tonalSpot" to MiuixPaletteStyle.TonalSpot,
+        "neutral" to MiuixPaletteStyle.Neutral,
+        "vibrant" to MiuixPaletteStyle.Vibrant,
+        "expressive" to MiuixPaletteStyle.Expressive,
+        "rainbow" to MiuixPaletteStyle.Rainbow,
+        "fruitSalad" to MiuixPaletteStyle.FruitSalad,
+        "monochrome" to MiuixPaletteStyle.Monochrome,
+        "fidelity" to MiuixPaletteStyle.Fidelity,
+        "content" to MiuixPaletteStyle.Content,
+    )
+
+    private val supportedSpec2025PaletteStyles = setOf(
+        "tonalSpot",
+        "neutral",
+        "vibrant",
+        "expressive"
+    )
+
+    fun resolveThemeMode(value: String): AppThemeMode {
+        return appThemeModes[value] ?: AppThemeMode.Dynamic
     }
 
     fun resolvePaletteStyle(value: String?): PaletteStyle {
-        return when (value) {
-            "tonalSpot" -> PaletteStyle.TonalSpot
-            "neutral" -> PaletteStyle.Neutral
-            "vibrant" -> PaletteStyle.Vibrant
-            "expressive" -> PaletteStyle.Expressive
-            "rainbow" -> PaletteStyle.Rainbow
-            "fruitSalad" -> PaletteStyle.FruitSalad
-            "monochrome" -> PaletteStyle.Monochrome
-            "fidelity" -> PaletteStyle.Fidelity
-            "content" -> PaletteStyle.Content
-            else -> PaletteStyle.TonalSpot
-        }
+        return materialPaletteStyles[value] ?: PaletteStyle.TonalSpot
     }
 
     fun resolveContrastLevel(): Double {
-        return try {
-            Contrast.valueOf(ThemeConfig.customContrast).value
-        } catch (e: Exception) {
-            Contrast.Default.value
+        return runCatching { Contrast.valueOf(ThemeConfig.customContrast).value }
+            .getOrDefault(Contrast.Default.value)
+    }
+
+    fun resolveColorSchemeMode(value: String): ColorSchemeMode {
+        return when (value) {
+            "1" -> ColorSchemeMode.Light
+            "2" -> ColorSchemeMode.Dark
+            else -> ColorSchemeMode.System
         }
     }
 
-    fun resolveColorSchemeMode(value: String): ColorSchemeMode = when (value) {
-        "0" -> ColorSchemeMode.System
-        "1" -> ColorSchemeMode.Light
-        "2" -> ColorSchemeMode.Dark
-        else -> ColorSchemeMode.System
+    fun resolveMiuixColorSchemeMode(
+        value: String,
+        useMonet: Boolean
+    ): ColorSchemeMode {
+        val baseMode = resolveColorSchemeMode(value)
+        if (!useMonet) return baseMode
+
+        return when (baseMode) {
+            ColorSchemeMode.Light -> ColorSchemeMode.MonetLight
+            ColorSchemeMode.Dark -> ColorSchemeMode.MonetDark
+            else -> ColorSchemeMode.MonetSystem
+        }
     }
 
-    fun isMiuixEngine(composeEngine: String): Boolean = composeEngine == "miuix"
+    fun isMiuixEngine(composeEngine: String): Boolean {
+        return composeEngine.equals(COMPOSE_ENGINE_MIUIX, ignoreCase = true)
+    }
 
     fun resolveColorSpecVersion(colorSpec: ThemeColorSpec): ColorSpec.SpecVersion {
         return when (colorSpec) {
@@ -66,11 +108,28 @@ object ThemeResolver {
     }
 
     fun resolveColorSpecFromMaterialVersion(value: String?): ThemeColorSpec {
-        return when (value) {
-            "material3Expressive" -> ThemeColorSpec.SPEC_2025
-            "material3" -> ThemeColorSpec.SPEC_2021
-            else -> ThemeColorSpec.SPEC_2021
+        return if (value == MATERIAL_VERSION_EXPRESSIVE) {
+            ThemeColorSpec.SPEC_2025
+        } else {
+            ThemeColorSpec.SPEC_2021
         }
     }
 
+    fun resolveMiuixPaletteStyle(value: String?): MiuixPaletteStyle {
+        return miuixPaletteStyles[value] ?: MiuixPaletteStyle.TonalSpot
+    }
+
+    fun resolveMiuixColorSpec(
+        materialVersion: String?,
+        paletteStyle: String?
+    ): MiuixThemeColorSpec {
+        val useSpec2025 = resolveColorSpecFromMaterialVersion(materialVersion) == ThemeColorSpec.SPEC_2025
+        val supportsSpec2025 = paletteStyle in supportedSpec2025PaletteStyles
+
+        return if (useSpec2025 && supportsSpec2025) {
+            MiuixThemeColorSpec.Spec2025
+        } else {
+            MiuixThemeColorSpec.Spec2021
+        }
+    }
 }

+ 8 - 1
app/src/main/java/io/legado/app/ui/widget/components/AppFloatingActionButton.kt

@@ -9,6 +9,7 @@ import androidx.compose.material3.TooltipDefaults
 import androidx.compose.material3.rememberTooltipState
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.ui.widget.components.text.AppText
@@ -20,6 +21,8 @@ fun AppFloatingActionButton(
     onClick: () -> Unit,
     modifier: Modifier = Modifier,
     tooltipText: String? = null,
+    containerColor: Color = LegadoTheme.colorScheme.primaryContainer,
+    contentColor: Color = LegadoTheme.colorScheme.onPrimaryContainer,
     content: @Composable () -> Unit
 ) {
     val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
@@ -42,6 +45,8 @@ fun AppFloatingActionButton(
                 FloatingActionButton(
                     onClick = onClick,
                     modifier = modifier,
+                    containerColor = containerColor,
+                    contentColor = contentColor,
                     content = content
                 )
             }
@@ -49,8 +54,10 @@ fun AppFloatingActionButton(
             FloatingActionButton(
                 onClick = onClick,
                 modifier = modifier,
+                containerColor = containerColor,
+                contentColor = contentColor,
                 content = content
             )
         }
     }
-}
+}

+ 17 - 3
app/src/main/java/io/legado/app/ui/widget/components/alert/AppAlertDialog.kt

@@ -6,6 +6,8 @@ import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
 import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.AlertDialogDefaults
+import androidx.compose.material3.ButtonDefaults
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.OutlinedButton
 import androidx.compose.material3.Text
@@ -18,6 +20,7 @@ import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.unit.dp
+import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme.composeEngine
 import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.ui.widget.components.button.MiuixPrimaryButton
@@ -91,6 +94,11 @@ fun AppAlertDialog(
             AlertDialog(
                 onDismissRequest = onDismissRequest,
                 modifier = modifier,
+                containerColor = LegadoTheme.colorScheme.surfaceContainerHigh,
+                iconContentColor = LegadoTheme.colorScheme.primary,
+                titleContentColor = LegadoTheme.colorScheme.onSurface,
+                textContentColor = LegadoTheme.colorScheme.onSurfaceVariant,
+                tonalElevation = AlertDialogDefaults.TonalElevation,
                 title = title?.let { { Text(text = it) } },
                 text = {
                     Column {
@@ -108,7 +116,10 @@ fun AppAlertDialog(
                 confirmButton = {
                     if (onConfirm != null) {
                         OutlinedButton(
-                            onClick = onConfirm
+                            onClick = onConfirm,
+                            colors = ButtonDefaults.outlinedButtonColors(
+                                contentColor = LegadoTheme.colorScheme.primary
+                            )
                         ) {
                             Text(text = confirmText)
                         }
@@ -120,7 +131,10 @@ fun AppAlertDialog(
                             onClick = {
                                 onDismiss()
                                 onDismissRequest()
-                            }
+                            },
+                            colors = ButtonDefaults.textButtonColors(
+                                contentColor = LegadoTheme.colorScheme.primary
+                            )
                         ) {
                             Text(text = dismissText)
                         }
@@ -167,4 +181,4 @@ fun <T> AppAlertDialog(
             content = content?.let { { it(currentData) } }
         )
     }
-}
+}

+ 50 - 1
app/src/main/java/io/legado/app/ui/widget/components/button/AppButton.kt → app/src/main/java/io/legado/app/ui/widget/components/button/ConfirmDismissButtonsRow.kt

@@ -1,10 +1,16 @@
 package io.legado.app.ui.widget.components.button
 
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.widthIn
 import androidx.compose.material3.Button
 import androidx.compose.material3.OutlinedButton
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
 import io.legado.app.ui.theme.LegadoTheme.composeEngine
 import io.legado.app.ui.theme.ThemeResolver
 import top.yukonga.miuix.kmp.basic.Button as MiuixButton
@@ -71,4 +77,47 @@ fun SecondaryButton(
             )
         }
     }
-}
+}
+
+@Composable
+fun ConfirmDismissButtonsRow(
+    onDismiss: () -> Unit,
+    onConfirm: () -> Unit,
+    modifier: Modifier = Modifier,
+    dismissText: String,
+    confirmText: String,
+    dismissEnabled: Boolean = true,
+    confirmEnabled: Boolean = true,
+) {
+    val isMiuix = ThemeResolver.isMiuixEngine(composeEngine)
+
+    Row(
+        modifier = modifier.fillMaxWidth(),
+        horizontalArrangement = if (isMiuix) {
+            Arrangement.spacedBy(12.dp)
+        } else {
+            Arrangement.spacedBy(12.dp, Alignment.End)
+        }
+    ) {
+        SecondaryButton(
+            onClick = onDismiss,
+            modifier = if (isMiuix) {
+                Modifier.weight(1f)
+            } else {
+                Modifier.widthIn(min = 88.dp)
+            },
+            enabled = dismissEnabled,
+            text = dismissText
+        )
+        PrimaryButton(
+            onClick = onConfirm,
+            modifier = if (isMiuix) {
+                Modifier.weight(1f)
+            } else {
+                Modifier.widthIn(min = 88.dp)
+            },
+            enabled = confirmEnabled,
+            text = confirmText
+        )
+    }
+}

+ 6 - 2
app/src/main/java/io/legado/app/ui/widget/components/button/MediumIconButton.kt

@@ -26,6 +26,7 @@ import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.compose.ui.unit.dp
 import io.legado.app.ui.theme.LegadoTheme
@@ -42,6 +43,7 @@ import top.yukonga.miuix.kmp.basic.Text as MiuixText
 fun MediumIconButton(
     onClick: () -> Unit,
     imageVector: ImageVector,
+    tint: Color = LegadoTheme.colorScheme.onSurface,
     modifier: Modifier = Modifier,
     contentDescription: String? = null
 ) {
@@ -52,7 +54,8 @@ fun MediumIconButton(
         ) {
             MiuixIcon(
                 imageVector = imageVector,
-                contentDescription = contentDescription
+                contentDescription = contentDescription,
+                tint = tint
             )
         }
     } else {
@@ -62,7 +65,8 @@ fun MediumIconButton(
         ) {
             Icon(
                 imageVector = imageVector,
-                contentDescription = contentDescription
+                contentDescription = contentDescription,
+                tint = tint
             )
         }
     }

+ 2 - 3
app/src/main/java/io/legado/app/ui/widget/components/button/TopBarButton.kt

@@ -22,7 +22,6 @@ import androidx.compose.material3.FilledTonalIconButton
 import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
 import androidx.compose.material3.IconButtonDefaults
-import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.OutlinedIconButton
 import androidx.compose.material3.Text
 import androidx.compose.material3.ToggleButton
@@ -72,7 +71,7 @@ fun TopBarButton(
                 modifier = commonModifier,
                 colors = IconButtonDefaults.filledTonalIconButtonColors(
                     containerColor = GlassTopAppBarDefaults.controlContainerColor(),
-                    contentColor = MaterialTheme.colorScheme.onSurface
+                    contentColor = LegadoTheme.colorScheme.onSurface
                 )
             ) {
                 AnimatedIcon(
@@ -162,7 +161,7 @@ fun TopbarNavigationButton(
 fun TopBarActionButton(
     onClick: () -> Unit,
     imageVector: ImageVector,
-    contentDescription: String?,
+    contentDescription: String? = "",
     modifier: Modifier = Modifier
 ) {
     val enableProgressive = ThemeConfig.enableProgressiveBlur

+ 6 - 7
app/src/main/java/io/legado/app/ui/widget/components/card/GlassCard.kt

@@ -6,7 +6,6 @@ import androidx.compose.material.ExperimentalMaterialApi
 import androidx.compose.material3.Card
 import androidx.compose.material3.CardDefaults
 import androidx.compose.material3.CardElevation
-import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
@@ -37,8 +36,8 @@ private fun BaseCard(
 ) {
     if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) {
         val colors = MiuixCardDefaults.defaultColors(
-            color = (containerColor ?: MiuixTheme.colorScheme.secondaryContainer.copy(alpha)),
-            contentColor = contentColor ?: MiuixTheme.colorScheme.onSurface
+            color = (containerColor ?: LegadoTheme.colorScheme.secondaryContainer.copy(alpha)),
+            contentColor = contentColor ?: LegadoTheme.colorScheme.onSurface
         )
         MiuixCard(
             modifier = modifier,
@@ -51,12 +50,12 @@ private fun BaseCard(
         )
     } else {
         val colors = CardDefaults.cardColors(
-            containerColor = (containerColor ?: MaterialTheme.colorScheme.secondaryContainer).copy(
+            containerColor = (containerColor ?: LegadoTheme.colorScheme.secondaryContainer).copy(
                 alpha = alpha
             ),
-            contentColor = contentColor ?: MaterialTheme.colorScheme.onSecondaryContainer,
-            disabledContainerColor = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = alpha * 0.38f),
-            disabledContentColor = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = alpha * 0.38f)
+            contentColor = contentColor ?: LegadoTheme.colorScheme.onSecondaryContainer,
+            disabledContainerColor = LegadoTheme.colorScheme.onSecondaryContainer.copy(alpha = alpha * 0.38f),
+            disabledContentColor = LegadoTheme.colorScheme.onSecondaryContainer.copy(alpha = alpha * 0.38f)
         )
         if (onClick != null) {
             Card(

+ 28 - 18
app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt

@@ -56,6 +56,7 @@ fun SelectionItemCard(
     title: String,
     modifier: Modifier = Modifier,
     subtitle: String? = null,
+    supportingContent: @Composable (() -> Unit)? = null,
     isEnabled: Boolean = true,
     isSelected: Boolean = false,
     inSelectionMode: Boolean = false,
@@ -75,10 +76,10 @@ fun SelectionItemCard(
     val animatedContainerColor by animateColorAsState(
         targetValue = if (isSelected)
             selectedContainerColor
-                ?: if (composeEngine) MiuixTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.secondaryContainer
+                ?: if (composeEngine) LegadoTheme.colorScheme.secondaryContainer else LegadoTheme.colorScheme.secondaryContainer
         else
             containerColor
-                ?: if (composeEngine) MiuixTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.surfaceContainerLow,
+                ?: if (composeEngine) LegadoTheme.colorScheme.surfaceContainer else LegadoTheme.colorScheme.surfaceContainerLow,
         animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
         label = "CardColor"
     )
@@ -130,13 +131,16 @@ fun SelectionItemCard(
                         maxLines = 1,
                         overflow = TextOverflow.Ellipsis
                     )
-                    if (!subtitle.isNullOrBlank()) {
-                        AppText(
-                            text = subtitle,
-                            style = LegadoTheme.typography.bodySmall,
-                            maxLines = 1,
-                            overflow = TextOverflow.Ellipsis
-                        )
+                    when {
+                        supportingContent != null -> supportingContent()
+                        !subtitle.isNullOrBlank() -> {
+                            AppText(
+                                text = subtitle,
+                                style = LegadoTheme.typography.bodySmall,
+                                maxLines = 1,
+                                overflow = TextOverflow.Ellipsis
+                            )
+                        }
                     }
                 }
             } else {
@@ -150,16 +154,20 @@ fun SelectionItemCard(
                             overflow = TextOverflow.Ellipsis
                         )
                     },
-                    supportingContent = if (!subtitle.isNullOrBlank()) {
-                        {
-                            AppText(
-                                text = subtitle,
-                                style = LegadoTheme.typography.bodySmall,
-                                maxLines = 1,
-                                overflow = TextOverflow.Ellipsis
-                            )
+                    supportingContent = when {
+                        supportingContent != null -> supportingContent
+                        !subtitle.isNullOrBlank() -> {
+                            {
+                                AppText(
+                                    text = subtitle,
+                                    style = LegadoTheme.typography.bodySmall,
+                                    maxLines = 1,
+                                    overflow = TextOverflow.Ellipsis
+                                )
+                            }
                         }
-                    } else null,
+                        else -> null
+                    },
                     colors = ListItemDefaults.colors(containerColor = Color.Transparent)
                 )
             }
@@ -218,6 +226,7 @@ fun LazyItemScope.ReorderableSelectionItem(
     title: String,
     modifier: Modifier = Modifier,
     subtitle: String? = null,
+    supportingContent: @Composable (() -> Unit)? = null,
     isEnabled: Boolean = true,
     isSelected: Boolean = false,
     inSelectionMode: Boolean = false,
@@ -242,6 +251,7 @@ fun LazyItemScope.ReorderableSelectionItem(
         SelectionItemCard(
             title = title,
             subtitle = subtitle,
+            supportingContent = supportingContent,
             isEnabled = isEnabled,
             isSelected = isSelected,
             inSelectionMode = inSelectionMode,

+ 1 - 1
app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt

@@ -31,7 +31,7 @@ fun TextCard(
     contentColor: Color? = null,
     cornerRadius: Dp = 8.dp,
     horizontalPadding: Dp = 8.dp,
-    verticalPadding: Dp = 2.dp,
+    verticalPadding: Dp = 4.dp,
     iconSize: Dp = 14.dp,
     spacing: Dp = 4.dp,
     textStyle: TextStyle = LegadoTheme.typography.labelSmallEmphasized

+ 99 - 123
app/src/main/java/io/legado/app/ui/widget/components/dialog/ColorPickerSheet.kt

@@ -2,7 +2,6 @@ package io.legado.app.ui.widget.components.dialog
 
 import androidx.compose.foundation.background
 import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
@@ -10,20 +9,17 @@ import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.heightIn
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.lazy.grid.GridCells
-import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
-import androidx.compose.foundation.lazy.grid.items
-import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Button
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.TextButton
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
@@ -34,15 +30,20 @@ import androidx.compose.ui.draw.clip
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.toArgb
 import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.text.input.KeyboardType
 import androidx.compose.ui.unit.dp
-import com.github.skydoves.colorpicker.compose.AlphaSlider
-import com.github.skydoves.colorpicker.compose.BrightnessSlider
-import com.github.skydoves.colorpicker.compose.HsvColorPicker
-import com.github.skydoves.colorpicker.compose.rememberColorPickerController
 import io.legado.app.R
 import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.utils.isHex
+import top.yukonga.miuix.kmp.basic.ColorPalette
+import top.yukonga.miuix.kmp.basic.ColorPicker
+import top.yukonga.miuix.kmp.basic.ColorSpace
 
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
@@ -52,111 +53,50 @@ fun ColorPickerSheet(
     onDismissRequest: () -> Unit,
     onColorSelected: (Int) -> Unit
 ) {
-    val controller = rememberColorPickerController()
     var currentColor by remember { mutableStateOf(Color(initialColor)) }
+    var hexInput by remember { mutableStateOf(initialColor.asHexColorString()) }
+    var isHexInputError by remember { mutableStateOf(false) }
 
-    val presets = remember {
-        listOf(
-            Color(0xFFF44336),
-            Color(0xFFE91E63),
-            Color(0xFF9C27B0),
-            Color(0xFF673AB7),
-            Color(0xFF3F51B5),
-            Color(0xFF2196F3),
-            Color(0xFF03A9F4),
-            Color(0xFF00BCD4),
-            Color(0xFF009688),
-            Color(0xFF4CAF50),
-            Color(0xFF8BC34A),
-            Color(0xFFCDDC39),
-            Color(0xFFFFEB3B),
-            Color(0xFFFFC107),
-            Color(0xFFFF9800),
-            Color(0xFFFF5722),
-            Color(0xFF795548),
-            Color(0xFF9E9E9E),
-            Color(0xFF607D8B),
-            Color(0xFF000000),
-            Color(0xFFFFFFFF)
-        )
+    LaunchedEffect(show, initialColor) {
+        if (show) {
+            currentColor = Color(initialColor)
+            hexInput = initialColor.asHexColorString()
+            isHexInputError = false
+        }
     }
 
-    AppModalBottomSheet(show = show, onDismissRequest = onDismissRequest) {
+    val parsedHexColor = parseHexColor(hexInput)
+
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = "选择颜色",
+    ) {
         Column(
             modifier = Modifier
                 .fillMaxWidth()
-                .padding(16.dp),
+                .padding(bottom = 16.dp)
+                .verticalScroll(rememberScrollState()),
             horizontalAlignment = Alignment.CenterHorizontally
         ) {
-            AppText(
-                text = "颜色选择",
-                style = LegadoTheme.typography.titleLarge,
-                modifier = Modifier.padding(bottom = 16.dp)
-            )
-
-            HsvColorPicker(
-                modifier = Modifier
-                    .fillMaxWidth()
-                    .height(240.dp)
-                    .padding(10.dp),
-                controller = controller,
-                initialColor = Color(initialColor),
-                onColorChanged = { envelope ->
-                    currentColor = envelope.color
-                }
-            )
-
-            Spacer(modifier = Modifier.height(16.dp))
-
-            AlphaSlider(
-                modifier = Modifier
-                    .fillMaxWidth()
-                    .padding(10.dp)
-                    .height(35.dp),
-                controller = controller,
+            ColorPalette(
+                color = currentColor,
+                onColorChanged = { color ->
+                    currentColor = color
+                    hexInput = color.toArgb().asHexColorString()
+                    isHexInputError = false
+                },
+                rows = 8,
+                hueColumns = 12,
+                modifier = Modifier.fillMaxWidth(),
+                showPreview = false
             )
 
-            BrightnessSlider(
-                modifier = Modifier
-                    .fillMaxWidth()
-                    .padding(10.dp)
-                    .height(35.dp),
-                controller = controller,
-            )
-
-            LazyVerticalGrid(
-                columns = GridCells.Adaptive(36.dp),
-                verticalArrangement = Arrangement.spacedBy(8.dp),
-                horizontalArrangement = Arrangement.spacedBy(8.dp),
-                modifier = Modifier
-                    .heightIn(max = 80.dp)
-                    .padding(horizontal = 10.dp)
-            ) {
-                items(presets) { color ->
-                    Box(
-                        modifier = Modifier
-                            .size(36.dp)
-                            .clip(CircleShape)
-                            .background(color)
-                            .border(
-                                if (color.toArgb() == currentColor.toArgb()) 2.dp else 0.dp,
-                                MaterialTheme.colorScheme.primary,
-                                CircleShape
-                            )
-                            .clickable {
-                                controller.selectByColor(color, true)
-                            }
-                    )
-                }
-            }
-
-            Spacer(modifier = Modifier.height(16.dp))
+            Spacer(modifier = Modifier.height(32.dp))
 
             Row(
                 verticalAlignment = Alignment.CenterVertically,
-                modifier = Modifier
-                    .fillMaxWidth()
-                    .padding(horizontal = 10.dp)
+                modifier = Modifier.fillMaxWidth()
             ) {
                 Box(
                     modifier = Modifier
@@ -165,32 +105,68 @@ fun ColorPickerSheet(
                         .background(currentColor)
                         .border(
                             1.dp,
-                            MaterialTheme.colorScheme.outlineVariant,
+                            LegadoTheme.colorScheme.outlineVariant,
                             RoundedCornerShape(8.dp)
                         )
                 )
                 Spacer(modifier = Modifier.width(12.dp))
-                AppText(
-                    text = "#${Integer.toHexString(currentColor.toArgb()).uppercase()}",
-                    style = LegadoTheme.typography.bodyLarge
-                )
-
-                Row(
+                AppTextField(
+                    value = hexInput,
+                    onValueChange = { value ->
+                        hexInput = normalizeHexInput(value)
+                        val parsedColor = parseHexColor(hexInput)
+                        if (parsedColor != null) {
+                            currentColor = Color(parsedColor)
+                            isHexInputError = false
+                        } else {
+                            isHexInputError = hexInput.isNotBlank()
+                        }
+                    },
                     modifier = Modifier.fillMaxWidth(),
-                    horizontalArrangement = Arrangement.End
-                ) {
-                    TextButton(onClick = onDismissRequest) {
-                        AppText(stringResource(R.string.cancel))
-                    }
-                    Spacer(modifier = Modifier.width(8.dp))
-                    Button(onClick = {
-                        onColorSelected(currentColor.toArgb())
-                        onDismissRequest()
-                    }) {
-                        AppText(stringResource(R.string.ok))
-                    }
-                }
+                    label = "色值",
+                    singleLine = true,
+                    isError = isHexInputError,
+                    backgroundColor = LegadoTheme.colorScheme.surface,
+                    keyboardOptions = KeyboardOptions(
+                        capitalization = KeyboardCapitalization.Characters,
+                        keyboardType = KeyboardType.Ascii,
+                        imeAction = ImeAction.Done
+                    )
+                )
             }
+
+            Spacer(modifier = Modifier.height(16.dp))
+
+            ConfirmDismissButtonsRow(
+                modifier = Modifier.fillMaxWidth(),
+                onDismiss = onDismissRequest,
+                onConfirm = {
+                    onColorSelected(currentColor.toArgb())
+                    onDismissRequest()
+                },
+                dismissText = stringResource(R.string.cancel),
+                confirmText = stringResource(R.string.ok),
+                confirmEnabled = parsedHexColor != null && !isHexInputError
+            )
         }
     }
 }
+
+private fun normalizeHexInput(input: String): String {
+    val trimmed = input.trim().uppercase()
+    return if (trimmed.startsWith("#")) {
+        "#${trimmed.removePrefix("#")}"
+    } else {
+        trimmed
+    }
+}
+
+private fun parseHexColor(input: String): Int? {
+    val hex = input.trim().removePrefix("#")
+    if (hex.length !in setOf(6, 8) || !hex.isHex()) return null
+    val argb = if (hex.length == 6) "FF$hex" else hex
+    return argb.toLong(16).toInt()
+}
+
+private fun Int.asHexColorString(): String =
+    "#${Integer.toHexString(this).uppercase().padStart(8, '0')}"

+ 15 - 6
app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenu.kt

@@ -1,6 +1,7 @@
 package io.legado.app.ui.widget.components.menuItem
 
 import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.ColumnScope
 import androidx.compose.foundation.layout.Spacer
@@ -13,15 +14,20 @@ import androidx.compose.material3.MotionScheme
 import androidx.compose.material3.Shapes
 import androidx.compose.material3.Typography
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.staticCompositionLocalOf
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Shape
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.ProvideAppContentColor
 import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.ui.theme.rememberOpaqueColorScheme
 import top.yukonga.miuix.kmp.basic.ListPopupColumn
 import top.yukonga.miuix.kmp.overlay.OverlayListPopup
+import top.yukonga.miuix.kmp.window.WindowListPopup
+
+val LocalUseMiuixWindowPopup = staticCompositionLocalOf { false }
 
 @OptIn(ExperimentalMaterial3ExpressiveApi::class)
 @Composable
@@ -29,25 +35,28 @@ fun RoundDropdownMenu(
     expanded: Boolean,
     onDismissRequest: () -> Unit,
     modifier: Modifier = Modifier,
-    // M3 专属视觉参数,Miuix 模式下会优雅降级(忽略)
     shape: Shape = MaterialTheme.shapes.medium,
     shadowElevation: Dp = 4.dp,
     verticalSpacing: Dp = 8.dp,
     content: @Composable ColumnScope.(dismiss: () -> Unit) -> Unit
 ) {
     val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
+    val popupContainerColor = LegadoTheme.colorScheme.surfaceContainer
 
     if (isMiuix) {
+        val popupContentColor = LegadoTheme.colorScheme.onSurface
         OverlayListPopup(
             show = expanded,
             onDismissRequest = onDismissRequest,
             popupModifier = modifier
         ) {
-            ListPopupColumn {
-                Column() {
-                    Spacer(Modifier.height(12.dp))
-                    content(onDismissRequest)
-                    Spacer(Modifier.height(12.dp))
+            ProvideAppContentColor(popupContentColor) {
+                ListPopupColumn {
+                    Column(modifier = Modifier.background(popupContainerColor)) {
+                        Spacer(Modifier.height(12.dp))
+                        content(onDismissRequest)
+                        Spacer(Modifier.height(12.dp))
+                    }
                 }
             }
         }

+ 56 - 19
app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenuItem.kt

@@ -1,5 +1,6 @@
 package io.legado.app.ui.widget.components.menuItem
 
+import androidx.compose.foundation.LocalIndication
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.foundation.layout.Arrangement
@@ -13,10 +14,12 @@ import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.layout.widthIn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
 import androidx.compose.material3.LocalContentColor
 import androidx.compose.material3.LocalTextStyle
-import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.MenuDefaults
+import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Surface
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
@@ -26,6 +29,7 @@ import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.drawBehind
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Color.Companion
 import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.dp
@@ -33,13 +37,16 @@ import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.ui.theme.rememberOpaqueColorScheme
 import io.legado.app.ui.widget.components.icon.AppIcon
+import top.yukonga.miuix.kmp.basic.DropdownDefaults
+import top.yukonga.miuix.kmp.icon.MiuixIcons
+import top.yukonga.miuix.kmp.icon.basic.Check
 import top.yukonga.miuix.kmp.theme.MiuixTheme
 import top.yukonga.miuix.kmp.basic.Text as MiuixText
 
 @Composable
 fun RoundDropdownMenuItem(
     text: String,
-    color: Color = LegadoTheme.colorScheme.surface,
+    color: Color = Color.Unspecified,
     onClick: () -> Unit,
     modifier: Modifier = Modifier,
     isSelected: Boolean = false,
@@ -51,18 +58,19 @@ fun RoundDropdownMenuItem(
 ) {
     val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
     val interaction = interactionSource ?: remember { MutableInteractionSource() }
+    val hasCustomContentColor = color != Color.Unspecified
 
     if (isMiuix) {
-        val backgroundColor = if (isSelected) {
-            LegadoTheme.colorScheme.primaryContainer
+        val dropdownColors = DropdownDefaults.dropdownColors()
+        val (textColor, backgroundColor) = if (isSelected) {
+            dropdownColors.selectedContentColor to dropdownColors.selectedContainerColor
         } else {
-            Color.Transparent
+            dropdownColors.contentColor to dropdownColors.containerColor
         }
-
-        val contentColor = if (isSelected) {
-            LegadoTheme.colorScheme.onPrimaryContainer
+        val checkColor = if (isSelected) {
+            dropdownColors.selectedContentColor
         } else {
-            LegadoTheme.colorScheme.onSurface
+            Color.Transparent
         }
 
         Row(
@@ -73,16 +81,13 @@ fun RoundDropdownMenuItem(
                 .drawBehind { drawRect(backgroundColor) }
                 .clickable(
                     interactionSource = interaction,
-                    indication = androidx.compose.foundation.LocalIndication.current,
+                    indication = LocalIndication.current,
                     enabled = enabled,
                     onClick = onClick
                 )
                 .padding(horizontal = 20.dp, vertical = 12.dp)
         ) {
-            CompositionLocalProvider(
-                LocalContentColor provides contentColor,
-                LocalTextStyle provides MiuixTheme.textStyles.body1
-            ) {
+            CompositionLocalProvider(LocalContentColor provides textColor) {
                 if (leadingIcon != null) {
                     leadingIcon()
                     Spacer(Modifier.width(12.dp))
@@ -93,24 +98,46 @@ fun RoundDropdownMenuItem(
                     contentAlignment = Alignment.CenterStart
                 ) {
                     MiuixText(
+                        modifier = Modifier.widthIn(max = 200.dp),
                         text = text,
-                        fontWeight = FontWeight.Medium
+                        fontSize = MiuixTheme.textStyles.body1.fontSize,
+                        fontWeight = FontWeight.Medium,
+                        color = textColor,
                     )
                 }
 
                 if (trailingIcon != null) {
                     Spacer(Modifier.width(12.dp))
                     trailingIcon()
+                } else {
+                    Spacer(Modifier.width(12.dp))
+                    AppIcon(
+                        imageVector = MiuixIcons.Basic.Check,
+                        contentDescription = null,
+                        modifier = Modifier.size(20.dp),
+                        tint = checkColor
+                    )
                 }
             }
         }
     } else {
         val colorScheme = rememberOpaqueColorScheme()
+        val selectedContentColor = colorScheme.onPrimaryContainer
+        val defaultContentColor = colorScheme.onSurface
         val contentColor = if (enabled) {
-            MaterialTheme.colorScheme.onSurface
+            when {
+                hasCustomContentColor -> color
+                isSelected -> selectedContentColor
+                else -> defaultContentColor
+            }
         } else {
-            MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
+            when {
+                hasCustomContentColor -> color.copy(alpha = 0.38f)
+                isSelected -> selectedContentColor.copy(alpha = 0.38f)
+                else -> defaultContentColor.copy(alpha = 0.38f)
+            }
         }
+        val containerColor = if (isSelected) colorScheme.primaryContainer else colorScheme.surface
 
         Surface(
             onClick = onClick,
@@ -119,7 +146,8 @@ fun RoundDropdownMenuItem(
                 .fillMaxWidth(),
             enabled = enabled,
             shape = MaterialTheme.shapes.small,
-            color = colorScheme.surface,
+            color = containerColor,
+            contentColor = contentColor,
             interactionSource = interaction
         ) {
             Row(
@@ -141,13 +169,22 @@ fun RoundDropdownMenuItem(
                     Text(
                         modifier = Modifier.widthIn(max = 200.dp),
                         text = text,
-                        style = LegadoTheme.typography.bodyMediumEmphasized
+                        style = LegadoTheme.typography.bodyMediumEmphasized,
+                        color = contentColor
                     )
                 }
 
                 if (trailingIcon != null) {
                     Spacer(Modifier.width(8.dp))
                     trailingIcon()
+                } else {
+                    Spacer(Modifier.width(8.dp))
+                    AppIcon(
+                        imageVector = Icons.Default.Check,
+                        contentDescription = null,
+                        modifier = Modifier.size(18.dp),
+                        tint = if (isSelected) contentColor else Color.Transparent
+                    )
                 }
             }
         }

+ 44 - 14
app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt

@@ -18,6 +18,7 @@ import androidx.compose.material3.Text
 import androidx.compose.material3.Typography
 import androidx.compose.material3.rememberModalBottomSheetState
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
@@ -28,8 +29,10 @@ import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.DpSize
 import androidx.compose.ui.unit.dp
 import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.LocalLegadoThemeColors
+import io.legado.app.ui.theme.ProvideAppContentColor
 import io.legado.app.ui.theme.ThemeResolver
-import io.legado.app.ui.theme.rememberOpaqueColorScheme
+import io.legado.app.ui.widget.components.menuItem.LocalUseMiuixWindowPopup
 import top.yukonga.miuix.kmp.window.WindowBottomSheet
 
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@@ -43,30 +46,56 @@ fun AppModalBottomSheet(
     endAction: @Composable (() -> Unit)? = null,
     content: @Composable ColumnScope.() -> Unit
 ) {
+    val colorScheme = LocalLegadoThemeColors.current.colorScheme
+    val sheetContainerColor = LegadoTheme.colorScheme.surfaceContainer
+    val sheetContentColor = LegadoTheme.colorScheme.onSurface
+    val sheetDragHandleColor = LegadoTheme.colorScheme.onSurfaceVariant
+
     if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) {
         WindowBottomSheet(
             show = show,
             modifier = modifier,
             title = title,
-            startAction = startAction,
-            endAction = endAction,
+            startAction = startAction?.let { action ->
+                {
+                    ProvideAppContentColor(sheetContentColor) {
+                        CompositionLocalProvider(LocalUseMiuixWindowPopup provides true) {
+                            action()
+                        }
+                    }
+                }
+            },
+            endAction = endAction?.let { action ->
+                {
+                    ProvideAppContentColor(sheetContentColor) {
+                        CompositionLocalProvider(LocalUseMiuixWindowPopup provides true) {
+                            action()
+                        }
+                    }
+                }
+            },
             insideMargin = DpSize(16.dp, 12.dp),
+            backgroundColor = sheetContainerColor,
+            dragHandleColor = sheetDragHandleColor,
             onDismissRequest = onDismissRequest,
             onDismissFinished = onDismissRequest,
             enableWindowDim = true,
             allowDismiss = true
         ) {
-            Column(
-                modifier = Modifier
-                    .fillMaxWidth()
-                    .animateContentSize(),
-                content = content
-            )
+            ProvideAppContentColor(sheetContentColor) {
+                CompositionLocalProvider(LocalUseMiuixWindowPopup provides true) {
+                    Column(
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .animateContentSize(),
+                        content = content
+                    )
+                }
+            }
         }
     } else {
         if (show) {
             val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
-            val colorScheme = rememberOpaqueColorScheme()
             val density = LocalDensity.current
             val maxHeight = with(density) {
                 LocalWindowInfo.current.containerSize.height.toDp() * 0.8f
@@ -75,8 +104,9 @@ fun AppModalBottomSheet(
             ModalBottomSheet(
                 onDismissRequest = onDismissRequest,
                 sheetState = sheetState,
-                contentColor = colorScheme.onSurface,
-                dragHandle = { BottomSheetDefaults.DragHandle() }
+                containerColor = sheetContainerColor,
+                contentColor = sheetContentColor,
+                dragHandle = { BottomSheetDefaults.DragHandle(color = sheetDragHandleColor) }
             ) {
                 MaterialExpressiveTheme(
                     colorScheme = colorScheme,
@@ -112,7 +142,7 @@ fun AppModalBottomSheet(
                                     Text(
                                         text = title,
                                         style = LegadoTheme.typography.titleMediumEmphasized,
-                                        color = colorScheme.onSurface,
+                                        color = sheetContentColor,
                                         textAlign = TextAlign.Center,
                                         maxLines = 1,
                                         overflow = TextOverflow.Ellipsis,
@@ -134,4 +164,4 @@ fun AppModalBottomSheet(
             }
         }
     }
-}
+}

+ 25 - 48
app/src/main/java/io/legado/app/ui/widget/components/settingItem/InputSettingItem.kt

@@ -1,17 +1,12 @@
 package io.legado.app.ui.widget.components.settingItem
 
 import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.heightIn
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.text.input.rememberTextFieldState
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Check
-import androidx.compose.material.icons.filled.Replay
 import androidx.compose.material3.TextField
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
@@ -25,7 +20,7 @@ import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.ThemeResolver
-import io.legado.app.ui.widget.components.button.SmallTextButton
+import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
 import io.legado.app.ui.widget.components.text.AppText
 import top.yukonga.miuix.kmp.basic.BasicComponent
 import top.yukonga.miuix.kmp.basic.TextField as MiuixTextField
@@ -76,28 +71,18 @@ fun InputSettingItem(
                         }
                     )
 
-                    Row(
-                        modifier = Modifier
-                            .fillMaxWidth()
-                            .padding(top = 8.dp),
-                        horizontalArrangement = Arrangement.End
-                    ) {
-                        SmallTextButton(
-                            text = "默认",
-                            imageVector = Icons.Default.Replay,
-                            onClick = {
-                                state.edit { replace(0, length, defaultValue.toString()) }
-                            }
-                        )
-                        SmallTextButton(
-                            text = "确认",
-                            imageVector = Icons.Default.Check,
-                            onClick = {
-                                onConfirm(state.text.toString())
-                                expanded = false
-                            }
-                        )
-                    }
+                    ConfirmDismissButtonsRow(
+                        modifier = Modifier.padding(top = 16.dp),
+                        onDismiss = {
+                            state.edit { replace(0, length, defaultValue.toString()) }
+                        },
+                        onConfirm = {
+                            onConfirm(state.text.toString())
+                            expanded = false
+                        },
+                        dismissText = stringResource(R.string.text_default),
+                        confirmText = stringResource(R.string.confirm)
+                    )
                 }
             }
         }
@@ -127,26 +112,18 @@ fun InputSettingItem(
                     }
                 )
 
-                Row(
-                    modifier = Modifier.fillMaxWidth(),
-                    horizontalArrangement = Arrangement.End
-                ) {
-                    SmallTextButton(
-                        text = "默认",
-                        imageVector = Icons.Default.Replay,
-                        onClick = {
-                            state.edit { replace(0, length, defaultValue.toString()) }
-                        }
-                    )
-                    SmallTextButton(
-                        text = "确认",
-                        imageVector = Icons.Default.Check,
-                        onClick = {
-                            onConfirm(state.text.toString())
-                            expanded = false
-                        }
-                    )
-                }
+                ConfirmDismissButtonsRow(
+                    modifier = Modifier.padding(top = 16.dp),
+                    onDismiss = {
+                        state.edit { replace(0, length, defaultValue.toString()) }
+                    },
+                    onConfirm = {
+                        onConfirm(state.text.toString())
+                        expanded = false
+                    },
+                    dismissText = stringResource(R.string.text_default),
+                    confirmText = stringResource(R.string.confirm)
+                )
             }
         )
     }

+ 55 - 60
app/src/main/java/io/legado/app/ui/widget/components/settingItem/SliderSettingItem.kt

@@ -2,15 +2,12 @@
 
 import androidx.compose.animation.AnimatedContent
 import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.heightIn
 import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.text.KeyboardOptions
 import androidx.compose.foundation.text.input.TextFieldLineLimits
 import androidx.compose.foundation.text.input.rememberTextFieldState
@@ -29,11 +26,13 @@ import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.input.KeyboardType
 import androidx.compose.ui.unit.dp
+import io.legado.app.R
 import io.legado.app.ui.theme.LegadoTheme.composeEngine
 import io.legado.app.ui.theme.ThemeResolver
-import io.legado.app.ui.widget.components.button.SmallTextButton
+import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
 import io.legado.app.ui.widget.components.text.AppText
 import top.yukonga.miuix.kmp.basic.BasicComponent
 import top.yukonga.miuix.kmp.basic.Slider as MiuixSlider
@@ -78,7 +77,11 @@ fun SliderSettingItem(
                             MiuixTextField(
                                 state = textFieldState,
                                 lineLimits = TextFieldLineLimits.SingleLine,
-                                label = "杈撳叆鏁板€?(${valueRange.start.toInt()}-${valueRange.endInclusive.toInt()})",
+                                label = stringResource(
+                                    R.string.input_value_range,
+                                    valueRange.start.toInt(),
+                                    valueRange.endInclusive.toInt()
+                                ),
                                 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
                                 modifier = Modifier.fillMaxWidth(),
                                 inputTransformation = {
@@ -113,35 +116,26 @@ fun SliderSettingItem(
                         }
                     }
 
-                    Row(
-                        modifier = Modifier
-                            .fillMaxWidth()
-                            .padding(top = 16.dp),
-                        horizontalArrangement = Arrangement.End
-                    ) {
-                        SmallTextButton(
-                            text = if (isInputMode) "婊戝潡" else "杈撳叆",
-                            imageVector = if (isInputMode) Icons.Default.LinearScale else Icons.Default.Edit,
-                            onClick = { isInputMode = !isInputMode }
-                        )
-
-                        Spacer(Modifier.width(8.dp))
-
-                        SmallTextButton(
-                            text = "榛樿",
-                            imageVector = Icons.Default.RestartAlt,
-                            onClick = {
-                                onValueChange(defaultValue)
-                                textFieldState.edit {
-                                    replace(
-                                        0,
-                                        length,
-                                        defaultValue.toInt().toString()
-                                    )
-                                }
+                    ConfirmDismissButtonsRow(
+                        modifier = Modifier.padding(top = 16.dp),
+                        onDismiss = { isInputMode = !isInputMode },
+                        onConfirm = {
+                            onValueChange(defaultValue)
+                            textFieldState.edit {
+                                replace(
+                                    0,
+                                    length,
+                                    defaultValue.toInt().toString()
+                                )
                             }
-                        )
-                    }
+                        },
+                        dismissText = if (isInputMode) {
+                            stringResource(R.string.slider)
+                        } else {
+                            stringResource(R.string.edit)
+                        },
+                        confirmText = stringResource(R.string.text_default)
+                    )
                 }
             }
         }
@@ -163,7 +157,15 @@ fun SliderSettingItem(
                             TextField(
                                 state = textFieldState,
                                 lineLimits = TextFieldLineLimits.SingleLine,
-                                label = { AppText("杈撳叆鏁板€?(${valueRange.start.toInt()}-${valueRange.endInclusive.toInt()})") },
+                                label = {
+                                    AppText(
+                                        stringResource(
+                                            R.string.input_value_range,
+                                            valueRange.start.toInt(),
+                                            valueRange.endInclusive.toInt()
+                                        )
+                                    )
+                                },
                                 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
                                 modifier = Modifier
                                     .fillMaxWidth()
@@ -207,33 +209,26 @@ fun SliderSettingItem(
                     }
                 }
 
-                Row(
-                    modifier = Modifier.fillMaxWidth(),
-                    horizontalArrangement = Arrangement.End
-                ) {
-                    SmallTextButton(
-                        text = if (isInputMode) "婊戝潡" else "杈撳叆",
-                        imageVector = if (isInputMode) Icons.Default.LinearScale else Icons.Default.Edit,
-                        onClick = { isInputMode = !isInputMode }
-                    )
-
-                    Spacer(Modifier.width(8.dp))
-
-                    SmallTextButton(
-                        text = "榛樿",
-                        imageVector = Icons.Default.RestartAlt,
-                        onClick = {
-                            onValueChange(defaultValue)
-                            textFieldState.edit {
-                                replace(
-                                    0,
-                                    length,
-                                    defaultValue.toInt().toString()
-                                )
-                            }
+                ConfirmDismissButtonsRow(
+                    modifier = Modifier.padding(top = 16.dp),
+                    onDismiss = { isInputMode = !isInputMode },
+                    onConfirm = {
+                        onValueChange(defaultValue)
+                        textFieldState.edit {
+                            replace(
+                                0,
+                                length,
+                                defaultValue.toInt().toString()
+                            )
                         }
-                    )
-                }
+                    },
+                    dismissText = if (isInputMode) {
+                        stringResource(R.string.slider)
+                    } else {
+                        stringResource(R.string.edit)
+                    },
+                    confirmText = stringResource(R.string.text_default)
+                )
             }
         )
     }

+ 1 - 1
app/src/main/java/io/legado/app/ui/widget/components/topbar/MiuixScrollBehavior.kt

@@ -33,4 +33,4 @@ class MiuixGlassScrollBehavior(
 
     override val collapsedFraction: Float
         get() = miuixBehavior.state.collapsedFraction
-}
+}

+ 1 - 1
app/src/main/res/layout/dialog_content_edit.xml

@@ -33,7 +33,7 @@
         <io.legado.app.ui.widget.text.TextInputLayout
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
-            android:layout_marginBottom="80dp"
+            android:layout_marginBottom="240dp"
             app:layout_constraintTop_toBottomOf="@+id/tool_bar">
 
             <com.google.android.material.textfield.TextInputEditText

+ 5 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -394,6 +394,7 @@
     <string name="imm_navigation_bar">沉浸式导航栏</string>
     <string name="imm_navigation_bar_s">导航栏颜色透明</string>
     <string name="add_to_bookshelf">放入书架</string>
+    <string name="click_to_remove">点击删除</string>
     <string name="continue_read">继续阅读</string>
     <string name="cover_path">封面地址</string>
     <string name="page_anim_cover">覆盖</string>
@@ -1237,6 +1238,8 @@
     <string name="refresh_explore">刷新发现</string>
     <string name="ungroup">未分组</string>
     <string name="dynamic_colors">动态取色</string>
+    <string name="dynamic_colors_summary">使用基于壁纸的主题颜色</string>
+    <string name="input_value_range">输入数值(%1$d-%2$d)</string>
     <string name="change_icon_success">图标已修改</string>
     <string name="replace_rule_title_setting">配置替换规则</string>
     <string name="more_setting">更多设置</string>
@@ -1289,6 +1292,8 @@
     <string name="light_mode">浅色</string>
     <string name="dark_mode">深色</string>
     <string name="pure_black">纯黑深色模式</string>
+    <string name="miuix_monet">自定义主题色</string>
+    <string name="miuix_monet_summary">为 Miuix 使用自定义主题配色</string>
     <string name="transparent_theme_alarm">透明主题请于设置背景图片后设置</string>
     <string name="seed_color_summary">使用种子颜色生成主题颜色</string>
     <string name="seed_color">种子色</string>

+ 5 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -370,6 +370,7 @@
     <string name="imm_navigation_bar">沉浸式导航栏</string>
     <string name="imm_navigation_bar_s">导航栏颜色透明</string>
     <string name="add_to_bookshelf">放入書架</string>
+    <string name="click_to_remove">點擊刪除</string>
     <string name="continue_read">繼續閲讀</string>
     <string name="cover_path">封面地址</string>
     <string name="page_anim_cover">覆蓋</string>
@@ -1223,4 +1224,8 @@
     <string name="bottom_bar_opacity_summary">%d</string>
     <string name="web_file">網頁文件</string>
     <string name="show_book_count">顯示分組書籍數量</string>
+    <string name="dynamic_colors_summary">使用基於桌布的主題顏色</string>
+    <string name="input_value_range">輸入數值(%1$d-%2$d)</string>
+    <string name="miuix_monet">Miuix Monet</string>
+    <string name="miuix_monet_summary">為 Miuix 使用 Monet 演算法生成配色。目前主題為動態主題且系統為 Android 12 或以上時,會優先使用系統取色;否則使用下方種子色。</string>
 </resources>

+ 5 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -372,6 +372,7 @@
     <string name="imm_navigation_bar">沉浸式導航欄</string>
     <string name="imm_navigation_bar_s">導航欄顏色透明</string>
     <string name="add_to_bookshelf">放入書架</string>
+    <string name="click_to_remove">點擊刪除</string>
     <string name="continue_read">繼續閱讀</string>
     <string name="cover_path">封面地址</string>
     <string name="page_anim_cover">覆蓋</string>
@@ -1225,4 +1226,8 @@
     <string name="bottom_bar_opacity_summary">%d</string>
     <string name="web_file">網頁文件</string>
     <string name="show_book_count">顯示分組書籍數量</string>
+    <string name="dynamic_colors_summary">使用基於桌布的主題顏色</string>
+    <string name="input_value_range">輸入數值(%1$d-%2$d)</string>
+    <string name="miuix_monet">Miuix Monet</string>
+    <string name="miuix_monet_summary">為 Miuix 使用 Monet 演算法產生配色。目前主題為動態主題且系統為 Android 12 以上時,會優先使用系統取色;否則使用下方種子色。</string>
 </resources>

+ 5 - 0
app/src/main/res/values/strings.xml

@@ -397,6 +397,7 @@
     <string name="imm_navigation_bar">immersion navigation bar</string>
     <string name="imm_navigation_bar_s">The navigation bar becomes transparent</string>
     <string name="add_to_bookshelf">Add to Bookshelf</string>
+    <string name="click_to_remove">Tap to remove</string>
     <string name="continue_read">Continue reading</string>
     <string name="cover_path">Cover path</string>
     <string name="page_anim_cover">Cover</string>
@@ -1240,6 +1241,8 @@
     <string name="refresh_explore">Refresh Explore</string>
     <string name="ungroup">Ungrouped</string>
     <string name="dynamic_colors">Dynamic Colors</string>
+    <string name="dynamic_colors_summary">Use wallpaper-based theme colors</string>
+    <string name="input_value_range">Enter value (%1$d-%2$d)</string>
     <string name="change_icon_success">Icon Updated</string>
     <string name="replace_rule_title_setting">Configure Replace Rules</string>
     <string name="more_setting">More Settings</string>
@@ -1292,6 +1295,8 @@
     <string name="light_mode">Light</string>
     <string name="dark_mode">Dark</string>
     <string name="pure_black">True Black Mode</string>
+    <string name="miuix_monet">Miuix Monet</string>
+    <string name="miuix_monet_summary">Use Monet to generate the Miuix palette. If the current theme is Dynamic on Android 12+, the system accent is used; otherwise the seed color below is used.</string>
     <string name="transparent_theme_alarm">Please set the transparent theme after setting a background image.</string>
     <string name="seed_color_summary">Create a theme from a seed color</string>
     <string name="seed_color">Seed Color</string>