HapeLee 5 месяцев назад
Родитель
Сommit
4afff284b3
57 измененных файлов с 1723 добавлено и 880 удалено
  1. 1 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  2. 13 0
      app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt
  3. 35 0
      app/src/main/java/io/legado/app/data/local/preferences/LocalPreferencesRepository.kt
  4. 2 0
      app/src/main/java/io/legado/app/di/appModule.kt
  5. 7 5
      app/src/main/java/io/legado/app/domain/usecase/ExploreBooksUseCase.kt
  6. 4 3
      app/src/main/java/io/legado/app/domain/usecase/ExploreKindUiUseCase.kt
  7. 3 0
      app/src/main/java/io/legado/app/help/config/AppConfig.kt
  8. 3 4
      app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt
  9. 3 1
      app/src/main/java/io/legado/app/ui/association/AddToBookshelfDialog.kt
  10. 34 143
      app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt
  11. 2 1
      app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt
  12. 12 2
      app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt
  13. 49 15
      app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt
  14. 4 2
      app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt
  15. 4 1
      app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt
  16. 21 5
      app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt
  17. 6 1
      app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt
  18. 2 0
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt
  19. 52 37
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt
  20. 22 1
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigViewModel.kt
  21. 5 1
      app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt
  22. 4 1
      app/src/main/java/io/legado/app/ui/main/BookCoverSharedElement.kt
  23. 5 2
      app/src/main/java/io/legado/app/ui/main/MainActivity.kt
  24. 7 1
      app/src/main/java/io/legado/app/ui/main/MainIntent.kt
  25. 21 8
      app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt
  26. 3 0
      app/src/main/java/io/legado/app/ui/main/MainNavKey.kt
  27. 3 1
      app/src/main/java/io/legado/app/ui/main/MainNavigator.kt
  28. 19 5
      app/src/main/java/io/legado/app/ui/main/MainScreen.kt
  29. 3 0
      app/src/main/java/io/legado/app/ui/main/MainViewModel.kt
  30. 12 4
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt
  31. 3 0
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageEffect.kt
  32. 99 102
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt
  33. 160 84
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt
  34. 30 25
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt
  35. 10 5
      app/src/main/java/io/legado/app/ui/main/homepage/modules/BannerModule.kt
  36. 63 68
      app/src/main/java/io/legado/app/ui/main/homepage/modules/ButtonGroupModule.kt
  37. 13 7
      app/src/main/java/io/legado/app/ui/main/homepage/modules/CardModule.kt
  38. 11 5
      app/src/main/java/io/legado/app/ui/main/homepage/modules/GridModule.kt
  39. 12 4
      app/src/main/java/io/legado/app/ui/main/homepage/modules/GridRankingModule.kt
  40. 10 4
      app/src/main/java/io/legado/app/ui/main/homepage/modules/RankingModule.kt
  41. 2 2
      app/src/main/java/io/legado/app/ui/main/homepage/modules/WaterfallModule.kt
  42. 10 34
      app/src/main/java/io/legado/app/ui/rss/article/RssArticlesCompose.kt
  43. 2 1
      app/src/main/java/io/legado/app/ui/theme/Typography.kt
  44. 3 2
      app/src/main/java/io/legado/app/ui/widget/components/AppFloatingActionButton.kt
  45. 4 18
      app/src/main/java/io/legado/app/ui/widget/components/JsonRawEditor.kt
  46. 251 0
      app/src/main/java/io/legado/app/ui/widget/components/LoadMoreFooter.kt
  47. 16 8
      app/src/main/java/io/legado/app/ui/widget/components/alert/AppAlertDialog.kt
  48. 15 11
      app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt
  49. 21 9
      app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindItem.kt
  50. 85 0
      app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindItemState.kt
  51. 303 247
      app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindMultiTypeItem.kt
  52. 156 0
      app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindSelectSheet.kt
  53. 69 0
      app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindTextField.kt
  54. 6 0
      app/src/main/res/values-zh-rCN/strings.xml
  55. 1 0
      app/src/main/res/values-zh-rHK/strings.xml
  56. 1 0
      app/src/main/res/values-zh-rTW/strings.xml
  57. 6 0
      app/src/main/res/values/strings.xml

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

@@ -45,6 +45,7 @@ object PreferKey {
     const val prevKeys = "prevKeyCodes"
     const val nextKeys = "nextKeyCodes"
     const val showDiscovery = "showDiscovery"
+    const val showHome = "showHome"
     const val enableReview = "enableReview"
     const val showRss = "showRss"
     const val showStatusBar = "showStatusBar"

+ 13 - 0
app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt

@@ -0,0 +1,13 @@
+package io.legado.app.data.local.preferences
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+
+val Context.localDataStore: DataStore<Preferences> by preferencesDataStore(name = "local_ui_status")
+
+object LocalPreferencesKeys {
+    val SHOW_THEME_REFACTOR_TIP = booleanPreferencesKey("show_theme_refactor_tip")
+}

+ 35 - 0
app/src/main/java/io/legado/app/data/local/preferences/LocalPreferencesRepository.kt

@@ -0,0 +1,35 @@
+package io.legado.app.data.local.preferences
+
+import android.content.Context
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.emptyPreferences
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.map
+import java.io.IOException
+
+class LocalPreferencesRepository(private val context: Context) {
+
+    private val dataStore = context.localDataStore
+
+    fun <T> getPreference(key: Preferences.Key<T>, defaultValue: T): Flow<T> {
+        return dataStore.data
+            .catch { exception ->
+                if (exception is IOException) {
+                    emit(emptyPreferences())
+                } else {
+                    throw exception
+                }
+            }
+            .map { preferences ->
+                preferences[key] ?: defaultValue
+            }
+    }
+
+    suspend fun <T> updatePreference(key: Preferences.Key<T>, value: T) {
+        dataStore.edit { preferences ->
+            preferences[key] = value
+        }
+    }
+}

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

@@ -6,6 +6,7 @@ import coil.decode.GifDecoder
 import coil.decode.ImageDecoderDecoder
 import coil.decode.SvgDecoder
 import io.legado.app.data.AppDatabase
+import io.legado.app.data.local.preferences.LocalPreferencesRepository
 import io.legado.app.data.repository.AppStartupRepository
 import io.legado.app.data.repository.BookCacheCleanupRepository
 import io.legado.app.data.repository.BookDomainRepositoryImpl
@@ -137,6 +138,7 @@ val appModule = module {
     singleOf(::SearchContentRepository)
     singleOf(::RemoteBookRepository)
     singleOf(::SettingsRepository)
+    singleOf(::LocalPreferencesRepository)
     singleOf(::ExploreBooksUseCase)
     singleOf(::ExploreKindUiUseCase)
     singleOf(::SaveSearchBooksUseCase)

+ 7 - 5
app/src/main/java/io/legado/app/domain/usecase/ExploreBooksUseCase.kt

@@ -3,6 +3,8 @@ package io.legado.app.domain.usecase
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.repository.BookSourceRepository
 import io.legado.app.model.webBook.WebBook
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
 
 class ExploreBooksUseCase(
     private val bookSourceRepository: BookSourceRepository,
@@ -20,7 +22,7 @@ class ExploreBooksUseCase(
         moduleUrl: String?,
         args: String?,
         page: Int = 1
-    ): ExploreResult {
+    ): ExploreResult = withContext(Dispatchers.IO) {
         val base = bookSourceRepository.getBookSource(sourceUrl)
             ?: throw SourceNotFound(sourceUrl)
         val source = args?.let { base.copy().also { s -> s.setVariable(it) } } ?: base
@@ -33,14 +35,14 @@ class ExploreBooksUseCase(
             throw InvalidUrl(resolvedUrl)
         }
         val books = WebBook.exploreBookSuspend(source, resolvedUrl, page)
-        return ExploreResult(resolvedUrl, books)
+        ExploreResult(resolvedUrl, books)
     }
 
     suspend fun executeForRanking(
         sourceUrl: String,
         moduleUrl: String?,
         args: String?
-    ): List<SearchBook> {
+    ): List<SearchBook> = withContext(Dispatchers.IO) {
         val result = execute(sourceUrl, moduleUrl, args)
         var books = result.books
         var page = 1
@@ -50,7 +52,7 @@ class ExploreBooksUseCase(
                 WebBook.exploreBookSuspend(
                     bookSourceRepository.getBookSource(sourceUrl)
                         ?.let { s -> args?.let { s.copy().also { x -> x.setVariable(it) } } ?: s }
-                        ?: return books.take(MAX_RANKING_BOOKS),
+                        ?: return@withContext books.take(MAX_RANKING_BOOKS),
                     result.resolvedUrl,
                     page,
                 )
@@ -60,7 +62,7 @@ class ExploreBooksUseCase(
             if (next.isEmpty()) break
             books = (books + next)
         }
-        return books.take(MAX_RANKING_BOOKS)
+        books.take(MAX_RANKING_BOOKS)
     }
 
     data class ExploreResult(val resolvedUrl: String, val books: List<SearchBook>)

+ 4 - 3
app/src/main/java/io/legado/app/domain/usecase/ExploreKindUiUseCase.kt

@@ -99,9 +99,10 @@ class ExploreKindUiUseCase(
         }
     }
 
-    private suspend fun evalUiJs(jsStr: String, sourceUrl: String, infoMap: InfoMap): String? {
-        val source = getOrLoadBookSource(sourceUrl) ?: return null
-        return runScriptWithContext {
+    private suspend fun evalUiJs(jsStr: String, sourceUrl: String, infoMap: InfoMap): String? =
+        withContext(Dispatchers.IO) {
+            val source = getOrLoadBookSource(sourceUrl) ?: return@withContext null
+            runScriptWithContext {
             source.evalJS(jsStr) {
                 put("infoMap", infoMap)
             }?.toString()

+ 3 - 0
app/src/main/java/io/legado/app/help/config/AppConfig.kt

@@ -306,6 +306,9 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
     val showDiscovery: Boolean
         get() = appCtx.getPrefBoolean(PreferKey.showDiscovery, true)
 
+    val showHome: Boolean
+        get() = appCtx.getPrefBoolean(PreferKey.showHome, true)
+
     val showRSS: Boolean
         get() = appCtx.getPrefBoolean(PreferKey.showRss, true)
 

+ 3 - 4
app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt

@@ -3,12 +3,8 @@ package io.legado.app.help.config
 import android.content.Context
 import android.net.Uri
 import com.google.gson.GsonBuilder
-import com.google.gson.JsonObject
-import com.google.gson.JsonParser
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.utils.GSON
-import io.legado.app.utils.inputStream
-import io.legado.app.utils.outputStream
 import splitties.init.appCtx
 import java.io.File
 
@@ -175,6 +171,7 @@ object ThemeImportExport {
             customTagColorsJson = ThemeConfig.customTagColorsJson,
 
             // 主界面设置
+            showHome = ThemeConfig.showHome,
             showDiscovery = ThemeConfig.showDiscovery,
             showRss = ThemeConfig.showRss,
             showStatusBar = ThemeConfig.showStatusBar,
@@ -258,6 +255,7 @@ object ThemeImportExport {
         ThemeConfig.customTagColorsJson = data.customTagColorsJson
 
         // 主界面设置
+        ThemeConfig.showHome = data.showHome
         ThemeConfig.showDiscovery = data.showDiscovery
         ThemeConfig.showRss = data.showRss
         ThemeConfig.showStatusBar = data.showStatusBar
@@ -394,6 +392,7 @@ data class ThemeExportData(
     val customTagColorsJson: String? = null,
 
     // 主界面设置
+    val showHome: Boolean = true,
     val showDiscovery: Boolean = true,
     val showRss: Boolean = true,
     val showStatusBar: Boolean = true,

+ 3 - 1
app/src/main/java/io/legado/app/ui/association/AddToBookshelfDialog.kt

@@ -86,7 +86,9 @@ class AddToBookshelfDialog() : BaseDialogFragment(R.layout.dialog_add_to_bookshe
                         context = requireContext(),
                         name = it.name,
                         author = it.author,
-                        bookUrl = it.bookUrl
+                        bookUrl = it.bookUrl,
+                        origin = it.origin,
+                        coverPath = it.coverUrl
                     )
                 )
                 dismiss()

+ 34 - 143
app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt

@@ -1,8 +1,6 @@
 package io.legado.app.ui.book.explore
 
 import android.annotation.SuppressLint
-import androidx.appcompat.app.AppCompatActivity
-import androidx.compose.animation.AnimatedContent
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.animation.AnimatedVisibilityScope
 import androidx.compose.animation.Crossfade
@@ -13,8 +11,6 @@ import androidx.compose.animation.core.tween
 import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeOut
 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
@@ -27,9 +23,9 @@ import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.grid.GridCells
 import androidx.compose.foundation.lazy.grid.GridItemSpan
 import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
-import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.grid.itemsIndexed
 import androidx.compose.foundation.lazy.grid.rememberLazyGridState
-import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted
@@ -52,9 +48,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.input.nestedscroll.nestedScroll
-import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.dp
 import dev.chrisbanes.haze.HazeState
@@ -69,13 +63,11 @@ import io.legado.app.ui.theme.responsiveHazeEffect
 import io.legado.app.ui.theme.responsiveHazeSource
 import io.legado.app.ui.widget.components.AppPullToRefresh
 import io.legado.app.ui.widget.components.AppScaffold
-import io.legado.app.ui.widget.components.SearchBar
+import io.legado.app.ui.widget.components.LoadMoreFooter
 import io.legado.app.ui.widget.components.book.SearchBookGridItem
 import io.legado.app.ui.widget.components.book.SearchBookListItem
-import io.legado.app.ui.widget.components.button.AnimatedTextButton
 import io.legado.app.ui.widget.components.card.TextCard
-import io.legado.app.ui.widget.components.explore.ExploreKindMultiTypeItem
-import io.legado.app.ui.widget.components.explore.calculateExploreKindRows
+import io.legado.app.ui.widget.components.explore.ExploreKindSelectSheet
 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
@@ -98,7 +90,7 @@ fun ExploreShowScreen(
     sourceUrl: String?,
     exploreUrl: String?,
     onBack: () -> Unit,
-    onBookClick: (SearchBook) -> Unit,
+    onBookClick: (SearchBook, String?) -> Unit,
     viewModel: ExploreShowViewModel = koinViewModel(),
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
@@ -111,7 +103,6 @@ fun ExploreShowScreen(
     val books by viewModel.uiBooks.collectAsState()
     val isBookEnd by viewModel.isEnd.collectAsState()
     val shouldTriggerAutoLoad by viewModel.shouldTriggerAutoLoad.collectAsState()
-    val kinds by viewModel.kinds.collectAsState()
     val isLoading by viewModel.isLoading.collectAsState()
     val errorMsg by viewModel.errorMsg.collectAsState()
     val filterState by viewModel.filterState.collectAsState()
@@ -125,8 +116,6 @@ fun ExploreShowScreen(
     var showGridCountSheet by remember { mutableStateOf(false) }
     val gridColumnCount by viewModel.gridCount.collectAsState()
     val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
-    val context = LocalContext.current
-    val activity = context as? AppCompatActivity
     val exploreKindUseCase: ExploreKindUiUseCase = koinInject()
 
     LaunchedEffect(sourceUrl) {
@@ -233,73 +222,16 @@ fun ExploreShowScreen(
     }
 
 
-    AppModalBottomSheet(
+    ExploreKindSelectSheet(
         show = showKindSheet,
-        onDismissRequest = { showKindSheet = false }
-    ) {
-
-        var kindQuery by remember { mutableStateOf("") }
-
-        SearchBar(
-            query = kindQuery,
-            backgroundColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.5f),
-            onQueryChange = { kindQuery = it },
-            placeholder = "选择或搜索分类",
-        )
-
-        val filteredKinds = remember(kindQuery, kinds) {
-            if (kindQuery.isBlank()) kinds
-            else kinds.filter { kind ->
-                kind.title.contains(kindQuery, ignoreCase = true) ||
-                        (kind.url?.contains(kindQuery, ignoreCase = true) == true)
-            }
-        }
-        val kindRows = remember(filteredKinds) {
-            calculateExploreKindRows(filteredKinds, 6)
-        }
-
-        LazyColumn(
-            contentPadding = PaddingValues(vertical = 16.dp),
-            modifier = Modifier.weight(1f, fill = false)
-        ) {
-            items(kindRows) { rowItems ->
-                Row(
-                    modifier = Modifier
-                        .fillMaxWidth()
-                        .animateItem()
-                        .padding(vertical = 4.dp),
-                    horizontalArrangement = Arrangement.spacedBy(8.dp)
-                ) {
-                    rowItems.forEach { (kind, span) ->
-                        ExploreKindMultiTypeItem(
-                            modifier = Modifier
-                                .weight(span.toFloat())
-                                .animateItem(),
-                            kind = kind,
-                            sourceUrl = sourceUrl,
-                            activity = activity,
-                            onOpenUrl = { url ->
-                                showKindSheet = false
-                                viewModel.switchExploreUrl(kind.copy(url = url))
-                            },
-                            onRefreshKinds = viewModel::refreshKinds,
-                            backgroundColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.5f),
-                            isMiuix = isMiuix,
-                            useCase = exploreKindUseCase
-                        )
-                    }
-
-                    val totalSpan = rowItems.sumOf { it.second }
-                    if (totalSpan < 6) {
-                        Spacer(
-                            modifier = Modifier.weight((6 - totalSpan).toFloat())
-                        )
-                    }
-                }
+        onDismissRequest = { showKindSheet = false },
+        sourceUrl = sourceUrl,
+        onSelected = { selectedKinds ->
+            selectedKinds.firstOrNull()?.let { kind ->
+                viewModel.switchExploreUrl(kind)
             }
         }
-    }
-
+    )
 
     AppScaffold(
         modifier = Modifier
@@ -433,17 +365,22 @@ fun ExploreShowScreen(
                         horizontalArrangement = Arrangement.spacedBy(4.dp),
                         verticalArrangement = Arrangement.spacedBy(8.dp)
                     ) {
-                        items(
+                        itemsIndexed(
                             items = books,
-                            key = { it.book.bookUrl }
-                        ) { item ->
+                            key = { index, item -> "${item.book.bookUrl}:$index" }
+                        ) { index, item ->
+                            val sharedCoverKey = bookCoverSharedElementKey(
+                                item.book.bookUrl,
+                                "explore:grid:$index"
+                            )
                             ExploreBookGridItem(
                                 book = item.book,
                                 shelfState = item.shelfState,
-                                onClick = { onBookClick(item.book) },
+                                onClick = { onBookClick(item.book, sharedCoverKey) },
                                 modifier = Modifier.animateItem(),
                                 sharedTransitionScope = sharedTransitionScope,
                                 animatedVisibilityScope = animatedVisibilityScope,
+                                sharedCoverKey = sharedCoverKey,
                             )
                         }
 
@@ -467,17 +404,22 @@ fun ExploreShowScreen(
                             bottom = paddingValues.calculateBottomPadding() + 16.dp
                         )
                     ) {
-                        items(
+                        itemsIndexed(
                             items = books,
-                            key = { it.book.bookUrl }
-                        ) { item ->
+                            key = { index, item -> "${item.book.bookUrl}:$index" }
+                        ) { index, item ->
+                            val sharedCoverKey = bookCoverSharedElementKey(
+                                item.book.bookUrl,
+                                "explore:list:$index"
+                            )
                             ExploreBookItem(
                                 book = item.book,
                                 shelfState = item.shelfState,
-                                onClick = { onBookClick(item.book) },
+                                onClick = { onBookClick(item.book, sharedCoverKey) },
                                 modifier = Modifier.animateItem(),
                                 sharedTransitionScope = sharedTransitionScope,
                                 animatedVisibilityScope = animatedVisibilityScope,
+                                sharedCoverKey = sharedCoverKey,
                             )
                         }
 
@@ -506,6 +448,7 @@ fun ExploreBookItem(
     modifier: Modifier = Modifier,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
+    sharedCoverKey: String? = null,
 ) {
     SearchBookListItem(
         book = book,
@@ -514,7 +457,7 @@ fun ExploreBookItem(
         modifier = modifier,
         sharedTransitionScope = sharedTransitionScope,
         animatedVisibilityScope = animatedVisibilityScope,
-        sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
+        sharedCoverKey = sharedCoverKey
     )
 }
 
@@ -527,6 +470,7 @@ fun ExploreBookGridItem(
     modifier: Modifier = Modifier,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
+    sharedCoverKey: String? = null,
 ) {
     SearchBookGridItem(
         book = book,
@@ -535,60 +479,7 @@ fun ExploreBookGridItem(
         modifier = modifier,
         sharedTransitionScope = sharedTransitionScope,
         animatedVisibilityScope = animatedVisibilityScope,
-        sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
+        sharedCoverKey = sharedCoverKey
     )
 }
 
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
-@Composable
-fun LoadMoreFooter(
-    isLoading: Boolean,
-    errorMsg: String?,
-    isEnd: Boolean,
-    onRetry: () -> Unit
-) {
-
-    LaunchedEffect(isLoading, errorMsg, isEnd) {
-        if (!isLoading && errorMsg == null && !isEnd) {
-            onRetry()
-        }
-    }
-
-    Box(
-        modifier = Modifier
-            .fillMaxWidth()
-            .padding(32.dp),
-        contentAlignment = Alignment.Center
-    ) {
-        Column(horizontalAlignment = Alignment.CenterHorizontally) {
-
-            AnimatedContent(
-                targetState = when {
-                    isLoading -> "加载中…"
-                    errorMsg != null -> "加载失败: $errorMsg"
-                    isEnd -> "已经到底了~"
-                    else -> "我爱你"
-                },
-                label = "FooterTextChange"
-            ) { text ->
-                AppText(
-                    text = text,
-                    color = when {
-                        errorMsg != null -> Color.Red
-                        else -> Color.Gray
-                    },
-                    style = LegadoTheme.typography.bodySmall
-                )
-            }
-
-            Spacer(modifier = Modifier.height(8.dp))
-
-            AnimatedTextButton(
-                isLoading = isLoading,
-                onClick = onRetry,
-                text = if (errorMsg != null) "重试" else "再试一次",
-                modifier = Modifier.padding(top = 4.dp)
-            )
-        }
-    }
-}

+ 2 - 1
app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt

@@ -13,6 +13,7 @@ import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
 import io.legado.app.domain.usecase.SaveSearchBooksUseCase
 import io.legado.app.help.config.AppConfig
 import io.legado.app.utils.exploreLayoutGrid
+import io.legado.app.utils.stackTraceStr
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
@@ -222,7 +223,7 @@ class ExploreShowViewModel(
                 }
             }
                 .onFailure {
-                    _errorMsg.value = it.localizedMessage
+                    _errorMsg.value = it.stackTraceStr
                 }
 
             _isLoading.value = false

+ 12 - 2
app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt

@@ -40,6 +40,10 @@ import kotlinx.coroutines.flow.collectLatest
 @Composable
 fun BookInfoRouteScreen(
     bookUrl: String,
+    name: String? = null,
+    author: String? = null,
+    origin: String? = null,
+    coverPath: String? = null,
     viewModel: BookInfoViewModel,
     onBack: () -> Unit,
     onFinish: (resultCode: Int?, afterTransition: Boolean) -> Unit,
@@ -80,8 +84,14 @@ fun BookInfoRouteScreen(
         viewModel.onReaderResult(it.resultCode)
     }
 
-    LaunchedEffect(bookUrl, viewModel) {
-        viewModel.initData(bookUrl)
+    LaunchedEffect(bookUrl, name, author, origin, coverPath, viewModel) {
+        viewModel.initData(
+            bookUrl = bookUrl,
+            name = name,
+            author = author,
+            origin = origin,
+            coverPath = coverPath
+        )
     }
 
     DisposableEffect(viewModel) {

+ 49 - 15
app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt

@@ -50,6 +50,7 @@ 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.ui.config.coverConfig.CoverConfig
+import io.legado.app.ui.main.MainIntent
 import io.legado.app.ui.widget.components.image.cover.buildCoverImageRequest
 import io.legado.app.utils.ArchiveUtils
 import io.legado.app.utils.GSON
@@ -113,12 +114,37 @@ class BookInfoViewModel(
     private var readRecordObserveJob: Job? = null
 
     fun initData(intent: Intent) {
-        initData(intent.getStringExtra("bookUrl") ?: "")
+        initData(
+            bookUrl = intent.getStringExtra(MainIntent.EXTRA_BOOK_URL) ?: "",
+            name = intent.getStringExtra(MainIntent.EXTRA_BOOK_NAME),
+            author = intent.getStringExtra(MainIntent.EXTRA_BOOK_AUTHOR),
+            origin = intent.getStringExtra(MainIntent.EXTRA_BOOK_ORIGIN),
+            coverPath = intent.getStringExtra(MainIntent.EXTRA_BOOK_COVER)
+        )
     }
 
-    fun initData(bookUrl: String) {
+    fun initData(
+        bookUrl: String,
+        name: String? = null,
+        author: String? = null,
+        origin: String? = null,
+        coverPath: String? = null
+    ) {
         if (currentBook?.bookUrl == bookUrl) return
-        currentBook = null
+        _uiState.value = BookInfoUiState() // 立即重置 UI 状态
+        currentBook = if (!name.isNullOrBlank() && !author.isNullOrBlank()) {
+            Book(
+                bookUrl = bookUrl,
+                name = name,
+                author = author,
+                origin = origin ?: BookType.localTag,
+                coverUrl = coverPath
+            ).apply {
+                addType(BookType.notShelf)
+            }
+        } else {
+            null
+        }
         currentChapterList = emptyList()
         currentWebFiles = emptyList()
         currentKindLabels = emptyList()
@@ -128,24 +154,32 @@ class BookInfoViewModel(
         bookSource = null
         chapterChanged = false
         clearReadRecordObserve()
-        _uiState.value = BookInfoUiState()
+        syncUiState()
         execute {
-            val book = appDb.bookDao.getBook(bookUrl)?.let {
-                inBookshelf = !it.isNotShelf
-                it
-            } ?: appDb.searchBookDao.getSearchBook(bookUrl)?.toBook()?.let {
-                inBookshelf = false
-                it
-            } ?: throw NoStackTraceException("未找到书籍")
-
+            val dbBook = appDb.bookDao.getBook(bookUrl)
+            if (dbBook != null) {
+                inBookshelf = !dbBook.isNotShelf
+                dbBook
+            } else {
+                val searchBook = appDb.searchBookDao.getSearchBook(bookUrl)?.toBook()
+                if (searchBook != null) {
+                    inBookshelf = false
+                    searchBook
+                } else {
+                    currentBook ?: throw NoStackTraceException("未找到书籍")
+                }
+            }
+        }.onSuccess { book ->
+            // 如果从数据库/搜索中拿到的书没有封面,但我们有传入的封面,则保留传入的封面
+            if (book.coverUrl.isNullOrBlank() && !coverPath.isNullOrBlank()) {
+                book.coverUrl = coverPath
+            }
             val source = if (book.isLocal) {
                 null
             } else {
                 appDb.bookSourceDao.getBookSource(book.origin)
             }
-            book to source
-        }.onSuccess {
-            upBook(it.first, it.second)
+            upBook(book, source)
         }.onError {
             context.toastOnUi(it.localizedMessage ?: "未找到书籍")
             emitEffect(BookInfoEffect.Finish(afterTransition = true))

+ 4 - 2
app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt

@@ -40,13 +40,15 @@ class SearchActivity : BaseComposeActivity() {
         SearchScreen(
             viewModel = viewModel,
             onBack = { finish() },
-            onOpenBookInfo = { name, author, bookUrl ->
+            onOpenBookInfo = { name, author, bookUrl, origin, coverPath, _ ->
                 startActivity(
                     MainActivity.createBookInfoIntent(
                         context = this,
                         name = name,
                         author = author,
-                        bookUrl = bookUrl
+                        bookUrl = bookUrl,
+                        origin = origin,
+                        coverPath = coverPath
                     )
                 )
             },

+ 4 - 1
app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt

@@ -58,7 +58,7 @@ sealed interface SearchIntent {
     data object PauseEngine : SearchIntent
     data object ResumeEngine : SearchIntent
     data class UseHistoryKeyword(val keyword: String) : SearchIntent
-    data class OpenSearchBook(val book: SearchBook) : SearchIntent
+    data class OpenSearchBook(val book: SearchBook, val sharedCoverKey: String?) : SearchIntent
     data class OpenBookshelfBook(val book: BookShelfItem) : SearchIntent
     data class DeleteHistory(val item: SearchKeyword) : SearchIntent
     data class SetClearHistoryDialogVisible(val visible: Boolean) : SearchIntent
@@ -82,6 +82,9 @@ sealed interface SearchEffect {
         val name: String,
         val author: String,
         val bookUrl: String,
+        val origin: String? = null,
+        val coverPath: String? = null,
+        val sharedCoverKey: String?,
     ) : SearchEffect
 
     data object OpenSourceManage : SearchEffect

+ 21 - 5
app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt

@@ -90,7 +90,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
 fun SearchScreen(
     viewModel: SearchViewModel,
     onBack: () -> Unit,
-    onOpenBookInfo: (name: String, author: String, bookUrl: String) -> Unit,
+    onOpenBookInfo: (name: String, author: String, bookUrl: String, origin: String?, coverPath: String?, sharedCoverKey: String?) -> Unit,
     onOpenSourceManage: () -> Unit,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
@@ -160,7 +160,14 @@ fun SearchScreen(
         viewModel.effects.collect { effect ->
             when (effect) {
                 is SearchEffect.OpenBookInfo -> {
-                    onOpenBookInfo(effect.name, effect.author, effect.bookUrl)
+                    onOpenBookInfo(
+                        effect.name,
+                        effect.author,
+                        effect.bookUrl,
+                        effect.origin,
+                        effect.coverPath,
+                        effect.sharedCoverKey
+                    )
                 }
 
                 SearchEffect.OpenSourceManage -> onOpenSourceManage()
@@ -394,16 +401,25 @@ fun SearchScreen(
                                 itemsIndexed(
                                     items = state.results,
                                     key = { index, item -> "${item.book.origin}:${item.book.bookUrl}:$index" }
-                                ) { _, item ->
+                                ) { index, item ->
+                                    val sharedCoverKey = bookCoverSharedElementKey(
+                                        item.book.bookUrl,
+                                        "search:${item.book.origin}:$index"
+                                    )
                                     SearchBookListItem(
                                         book = item.book,
                                         shelfState = item.shelfState,
                                         onClick = {
-                                            viewModel.onIntent(SearchIntent.OpenSearchBook(item.book))
+                                            viewModel.onIntent(
+                                                SearchIntent.OpenSearchBook(
+                                                    item.book,
+                                                    sharedCoverKey
+                                                )
+                                            )
                                         },
                                         sharedTransitionScope = sharedTransitionScope,
                                         animatedVisibilityScope = animatedVisibilityScope,
-                                        sharedCoverKey = bookCoverSharedElementKey(item.book.bookUrl)
+                                        sharedCoverKey = sharedCoverKey
                                     )
                                 }
 

+ 6 - 1
app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt

@@ -15,7 +15,6 @@ import io.legado.app.domain.usecase.SearchBooksUseCase
 import io.legado.app.domain.usecase.SearchRunEvent
 import io.legado.app.help.config.AppConfig
 import io.legado.app.ui.config.otherConfig.OtherConfig
-import io.legado.app.ui.main.bookshelf.BookShelfItem
 import io.legado.app.utils.getPrefBoolean
 import io.legado.app.utils.putPrefBoolean
 import kotlinx.coroutines.CancellationException
@@ -107,6 +106,9 @@ class SearchViewModel(
                         name = intent.book.name,
                         author = intent.book.author,
                         bookUrl = intent.book.bookUrl,
+                        origin = intent.book.origin,
+                        coverPath = intent.book.coverUrl,
+                        sharedCoverKey = intent.sharedCoverKey,
                     )
                 )
             }
@@ -117,6 +119,9 @@ class SearchViewModel(
                         name = intent.book.name,
                         author = intent.book.author,
                         bookUrl = intent.book.bookUrl,
+                        origin = intent.book.origin,
+                        coverPath = intent.book.getDisplayCover(),
+                        sharedCoverKey = null,
                     )
                 )
             }

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

@@ -141,6 +141,8 @@ object ThemeConfig {
 
     var showDiscovery by prefDelegate(PreferKey.showDiscovery, true)
 
+    var showHome by prefDelegate(PreferKey.showHome, true)
+
     var showRss by prefDelegate(PreferKey.showRss, true)
 
     var showStatusBar by prefDelegate(PreferKey.showStatusBar, true)

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

@@ -20,7 +20,6 @@ import androidx.compose.foundation.isSystemInDarkTheme
 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
@@ -47,7 +46,6 @@ import androidx.compose.material3.ButtonGroupDefaults
 import androidx.compose.material3.Card
 import androidx.compose.material3.CardDefaults
 import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.ui.viewinterop.AndroidView
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.Icon
 import androidx.compose.material3.MaterialTheme
@@ -57,7 +55,6 @@ import androidx.compose.material3.ToggleButtonDefaults
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableFloatStateOf
-import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
@@ -73,31 +70,29 @@ import androidx.compose.ui.semantics.Role
 import androidx.compose.ui.semantics.role
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
 import androidx.constraintlayout.compose.ConstraintLayout
-import com.google.android.material.color.DynamicColors
-import com.google.android.material.color.DynamicColorsOptions
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.base.AppContextWrapper
-import io.legado.app.constant.PreferKey
 import io.legado.app.constant.EventBus
+import io.legado.app.constant.PreferKey
 import io.legado.app.help.LauncherIconHelp
-import io.legado.app.help.loadFontFiles
 import io.legado.app.help.config.AppConfig
 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.help.loadFontFiles
 import io.legado.app.ui.theme.LegadoTheme
 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
-import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.SplicedColumnGroup
-import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
-import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.ui.widget.components.button.SmallIconButton
+import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
+import io.legado.app.ui.widget.components.icon.AppIcons
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
 import io.legado.app.ui.widget.components.settingItem.SliderSettingItem
@@ -105,7 +100,7 @@ import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
 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.utils.FileDoc
+import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.utils.getPrefString
 import io.legado.app.utils.postEvent
 import io.legado.app.utils.putPrefString
@@ -113,10 +108,6 @@ import io.legado.app.utils.restart
 import io.legado.app.utils.takePersistablePermissionSafely
 import io.legado.app.utils.toastOnUi
 import org.koin.androidx.compose.koinViewModel
-import top.yukonga.miuix.kmp.theme.MiuixTheme
-import top.yukonga.miuix.kmp.basic.Card as MiuixCard
-import top.yukonga.miuix.kmp.basic.CardDefaults as MiuixCardDefaults
-import top.yukonga.miuix.kmp.basic.Text as MiuixText
 
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
 @Composable
@@ -138,16 +129,16 @@ fun ThemeConfigScreen(
     var showBorderColorPicker by remember { mutableStateOf(false) }
     var showNavIconSheet by remember { mutableStateOf(false) }
     var showFontSheet by remember { mutableStateOf(false) }
-    var fontItems by remember { mutableStateOf<List<FileDoc>>(emptyList()) }
-    var fontFolderUri by remember { mutableStateOf<Uri?>(null) }
+    val showThemeRefactorTip by viewModel.showThemeRefactorTip.collectAsStateWithLifecycle()
 
-    fun loadFonts() {
-        fontItems = loadFontFiles(context, fontFolderUri)
+    var fontFolderUri by remember {
+        mutableStateOf(
+            context.getPrefString(PreferKey.fontFolder)?.let { Uri.parse(it) }
+        )
     }
-    remember {
-        val saved = context.getPrefString(PreferKey.fontFolder)
-        if (!saved.isNullOrEmpty()) fontFolderUri = Uri.parse(saved)
-        loadFonts()
+
+    val fontItems = remember(fontFolderUri) {
+        loadFontFiles(context, fontFolderUri)
     }
 
     val fontFolderLauncher = rememberLauncherForActivityResult(
@@ -157,7 +148,6 @@ fun ThemeConfigScreen(
             fontFolderUri = uri
             uri.takePersistablePermissionSafely(context, Intent.FLAG_GRANT_READ_URI_PERMISSION)
             context.putPrefString(PreferKey.fontFolder, uri.toString())
-            loadFonts()
         }
     }
 
@@ -218,16 +208,28 @@ fun ThemeConfigScreen(
                     themeItems.zip(themeValues).toList()
                 }
 
-                if (isMiuixEngine) {
-                    MiuixCard(
+                AnimatedVisibility(visible = showThemeRefactorTip) {
+                    GlassCard(
                         cornerRadius = 16.dp,
-                        insideMargin = PaddingValues(16.dp),
-                        colors = MiuixCardDefaults.defaultColors(
-                            color = MiuixTheme.colorScheme.primaryVariant,
-                            contentColor = MiuixTheme.colorScheme.onPrimary
-                        )
+                        modifier = Modifier.padding(bottom = 16.dp)
                     ) {
-                        MiuixText("Miuix 目前为测试主题,且不对基于View的界面生效!")
+                        Row(
+                            verticalAlignment = Alignment.CenterVertically,
+                            modifier = Modifier.padding(16.dp)
+                        ) {
+                            AppText(
+                                text = "仍有部分界面未用Compose重构,这些界面会与大部分界面有较大差异。",
+                                style = LegadoTheme.typography.labelLargeEmphasized,
+                                modifier = Modifier.weight(1f)
+                            )
+                            SmallIconButton(
+                                imageVector = AppIcons.Close,
+                                contentDescription = "关闭",
+                                onClick = {
+                                    viewModel.setShowThemeRefactorTip(false)
+                                }
+                            )
+                        }
                     }
                 }
 
@@ -376,6 +378,11 @@ fun ThemeConfigScreen(
                 }
 
                 SplicedColumnGroup(title = stringResource(R.string.main_activity)) {
+                    SwitchSettingItem(
+                        title = stringResource(R.string.show_home),
+                        checked = ThemeConfig.showHome,
+                        onCheckedChange = { ThemeConfig.showHome = it }
+                    )
                     SwitchSettingItem(
                         title = stringResource(R.string.show_discovery),
                         checked = ThemeConfig.showDiscovery,
@@ -648,7 +655,11 @@ fun ThemeConfigScreen(
                                             .size(28.dp)
                                             .clip(CircleShape)
                                             .background(Color(ThemeConfig.itemDividerColor))
-                                            .border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
+                                            .border(
+                                                1.dp,
+                                                MaterialTheme.colorScheme.outlineVariant,
+                                                CircleShape
+                                            )
                                     )
                                 }
                             }
@@ -767,7 +778,9 @@ fun ThemeConfigScreen(
         content = {
             if (fontItems.isEmpty()) {
                 Box(
-                    modifier = Modifier.fillMaxWidth().height(120.dp),
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .height(120.dp),
                     contentAlignment = Alignment.Center
                 ) {
                     Text(
@@ -785,7 +798,9 @@ fun ThemeConfigScreen(
                     fontItems.forEach { fontDoc ->
                         item {
                             Card(
-                                modifier = Modifier.fillMaxWidth().height(100.dp),
+                                modifier = Modifier
+                                    .fillMaxWidth()
+                                    .height(100.dp),
                                 onClick = {
                                     ThemeConfig.appFontPath = fontDoc.uri.toString()
                                     showFontSheet = false

+ 22 - 1
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigViewModel.kt

@@ -2,19 +2,40 @@ package io.legado.app.ui.config.themeConfig
 
 import android.net.Uri
 import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
 import io.legado.app.constant.PreferKey
+import io.legado.app.data.local.preferences.LocalPreferencesKeys
+import io.legado.app.data.local.preferences.LocalPreferencesRepository
 import io.legado.app.utils.FileDoc
 import io.legado.app.utils.FileUtils
 import io.legado.app.utils.MD5Utils
 import io.legado.app.utils.externalFiles
 import io.legado.app.utils.inputStream
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import splitties.init.appCtx
 import java.io.File
 import java.io.FileOutputStream
 
-class ThemeConfigViewModel : ViewModel() {
+class ThemeConfigViewModel(
+    private val localPreferencesRepository: LocalPreferencesRepository
+) : ViewModel() {
+
+    val showThemeRefactorTip = localPreferencesRepository
+        .getPreference(LocalPreferencesKeys.SHOW_THEME_REFACTOR_TIP, true)
+        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
+
+    fun setShowThemeRefactorTip(show: Boolean) {
+        viewModelScope.launch {
+            localPreferencesRepository.updatePreference(
+                LocalPreferencesKeys.SHOW_THEME_REFACTOR_TIP,
+                show
+            )
+        }
+    }
 
     /**
      * 设置背景图片

+ 5 - 1
app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt

@@ -30,7 +30,6 @@ import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.help.config.ThemeExportData
 import io.legado.app.ui.widget.components.AppTextField
-import io.legado.app.ui.widget.components.SearchBar
 import io.legado.app.ui.widget.components.button.MediumIconButton
 import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
@@ -163,6 +162,11 @@ fun EditThemeSheet(
 
             // Interface layout
             SectionTitle("界面布局")
+            CompactSwitchSettingItem(
+                title = "首页",
+                checked = data.showHome,
+                onCheckedChange = { data = data.copy(showHome = it) }
+            )
             CompactSwitchSettingItem(
                 title = "发现",
                 checked = data.showDiscovery,

+ 4 - 1
app/src/main/java/io/legado/app/ui/main/BookCoverSharedElement.kt

@@ -1,3 +1,6 @@
 package io.legado.app.ui.main
 
-fun bookCoverSharedElementKey(bookUrl: String): String = "book-cover:$bookUrl"
+fun bookCoverSharedElementKey(bookUrl: String, sourceId: String? = null): String {
+    val source = sourceId?.takeIf { it.isNotBlank() } ?: return "book-cover:$bookUrl"
+    return "book-cover:$source:$bookUrl"
+}

+ 5 - 2
app/src/main/java/io/legado/app/ui/main/MainActivity.kt

@@ -100,8 +100,11 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
             context: Context,
             name: String? = null,
             author: String? = null,
-            bookUrl: String
-        ): Intent = MainIntent.createBookInfoIntent(context, name, author, bookUrl)
+            bookUrl: String,
+            origin: String? = null,
+            coverPath: String? = null
+        ): Intent =
+            MainIntent.createBookInfoIntent(context, name, author, bookUrl, origin, coverPath)
 
         fun createExploreShowIntent(
             context: Context,

+ 7 - 1
app/src/main/java/io/legado/app/ui/main/MainIntent.kt

@@ -12,6 +12,8 @@ object MainIntent {
     const val EXTRA_BOOK_NAME = "name"
     const val EXTRA_BOOK_AUTHOR = "author"
     const val EXTRA_BOOK_URL = "bookUrl"
+    const val EXTRA_BOOK_ORIGIN = "origin"
+    const val EXTRA_BOOK_COVER = "coverPath"
     const val EXTRA_EXPLORE_NAME = "exploreName"
     const val EXTRA_SOURCE_URL = "sourceUrl"
     const val EXTRA_EXPLORE_URL = "exploreUrl"
@@ -114,13 +116,17 @@ object MainIntent {
         context: Context,
         name: String? = null,
         author: String? = null,
-        bookUrl: String
+        bookUrl: String,
+        origin: String? = null,
+        coverPath: String? = null
     ): Intent {
         return createLauncherIntent(context).apply {
             putExtra(EXTRA_START_ROUTE, MainRouteConst.ROUTE_BOOK_INFO)
             putExtra(EXTRA_BOOK_NAME, name)
             putExtra(EXTRA_BOOK_AUTHOR, author)
             putExtra(EXTRA_BOOK_URL, bookUrl)
+            putExtra(EXTRA_BOOK_ORIGIN, origin)
+            putExtra(EXTRA_BOOK_COVER, coverPath)
         }
     }
 

+ 21 - 8
app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt

@@ -90,12 +90,15 @@ fun MainActivity.mainEntryProvider(
             onNavigateToBookCacheManage = {
                 onNavigateToRoute(MainRouteBookCacheManage)
             },
-            onNavigateToBookInfo = { name, author, bookUrl ->
+            onNavigateToBookInfo = { name, author, bookUrl, origin, coverPath, sharedCoverKey ->
                 onNavigateToRoute(
                     MainRouteBookInfo(
                         name = name,
                         author = author,
-                        bookUrl = bookUrl
+                        bookUrl = bookUrl,
+                        origin = origin,
+                        coverPath = coverPath,
+                        sharedCoverKey = sharedCoverKey
                     )
                 )
             },
@@ -246,12 +249,15 @@ fun MainActivity.mainEntryProvider(
                 searchViewModel.onIntent(SearchIntent.ClearSearchResults)
                 onNavigateBack()
             },
-            onOpenBookInfo = { name, author, bookUrl ->
+            onOpenBookInfo = { name, author, bookUrl, origin, coverPath, sharedCoverKey ->
                 onNavigateToRoute(
                     MainRouteBookInfo(
                         name = name,
                         author = author,
-                        bookUrl = bookUrl
+                        bookUrl = bookUrl,
+                        origin = origin,
+                        coverPath = coverPath,
+                        sharedCoverKey = sharedCoverKey
                     )
                 )
             },
@@ -395,9 +401,13 @@ fun MainActivity.mainEntryProvider(
             } else null
         }
     ) { route ->
-        val bookInfoViewModel = koinViewModel<BookInfoViewModel>()
+        val bookInfoViewModel = koinViewModel<BookInfoViewModel>(key = route.bookUrl)
         BookInfoRouteScreen(
             bookUrl = route.bookUrl,
+            name = route.name,
+            author = route.author,
+            origin = route.origin,
+            coverPath = route.coverPath,
             viewModel = bookInfoViewModel,
             onBack = { onNavigateBack() },
             onFinish = { _, _ -> onNavigateBack() },
@@ -406,7 +416,7 @@ fun MainActivity.mainEntryProvider(
             },
             sharedTransitionScope = sharedTransitionScope,
             animatedVisibilityScope = LocalNavAnimatedContentScope.current,
-            sharedCoverKey = bookCoverSharedElementKey(route.bookUrl),
+            sharedCoverKey = route.sharedCoverKey ?: bookCoverSharedElementKey(route.bookUrl),
             onRegisterVariableSetter = { setter ->
                 onRegisterVariableSetter(setter)
             }
@@ -419,12 +429,15 @@ fun MainActivity.mainEntryProvider(
             sourceUrl = route.sourceUrl,
             exploreUrl = route.exploreUrl,
             onBack = { onNavigateBack() },
-            onBookClick = { book ->
+            onBookClick = { book, sharedCoverKey ->
                 onNavigateToRoute(
                     MainRouteBookInfo(
                         name = book.name,
                         author = book.author,
-                        bookUrl = book.bookUrl
+                        bookUrl = book.bookUrl,
+                        origin = book.origin,
+                        coverPath = book.coverUrl,
+                        sharedCoverKey = sharedCoverKey
                     )
                 )
             },

+ 3 - 0
app/src/main/java/io/legado/app/ui/main/MainNavKey.kt

@@ -65,6 +65,9 @@ data class MainRouteBookInfo(
     val name: String?,
     val author: String?,
     val bookUrl: String,
+    val origin: String? = null,
+    val coverPath: String? = null,
+    val sharedCoverKey: String? = null,
 ) : MainRoute
 
 @Serializable

+ 3 - 1
app/src/main/java/io/legado/app/ui/main/MainNavigator.kt

@@ -254,7 +254,9 @@ object MainNavigator {
                     MainRouteBookInfo(
                         name = intent.getStringExtra(MainIntent.EXTRA_BOOK_NAME),
                         author = intent.getStringExtra(MainIntent.EXTRA_BOOK_AUTHOR),
-                        bookUrl = bookUrl
+                        bookUrl = bookUrl,
+                        origin = intent.getStringExtra(MainIntent.EXTRA_BOOK_ORIGIN),
+                        coverPath = intent.getStringExtra(MainIntent.EXTRA_BOOK_COVER)
                     )
                 } ?: MainRouteHome
 

+ 19 - 5
app/src/main/java/io/legado/app/ui/main/MainScreen.kt

@@ -110,7 +110,7 @@ fun MainScreen(
     onNavigateToLocalImport: () -> Unit,
     onNavigateToCache: (Long) -> Unit,
     onNavigateToBookCacheManage: () -> Unit,
-    onNavigateToBookInfo: (name: String, author: String, bookUrl: String) -> Unit,
+    onNavigateToBookInfo: (name: String, author: String, bookUrl: String, origin: String?, coverPath: String?, sharedCoverKey: String?) -> Unit,
     onNavigateToExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
     onNavigateToRssSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit,
     onNavigateToRssRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit,
@@ -368,8 +368,15 @@ fun MainScreen(
                         val destination = destinations.getOrNull(page) ?: return@HorizontalPager
                         when (destination) {
                             MainDestination.Home -> HomepageScreen(
-                                onBookClick = { name, author, bookUrl ->
-                                    onNavigateToBookInfo(name ?: "", author ?: "", bookUrl)
+                                onBookClick = { name, author, bookUrl, origin, coverPath, sharedCoverKey ->
+                                    onNavigateToBookInfo(
+                                        name ?: "",
+                                        author ?: "",
+                                        bookUrl,
+                                        origin,
+                                        coverPath,
+                                        sharedCoverKey
+                                    )
                                 },
                                 onModuleHeaderClick = { title, sourceUrl, exploreUrl ->
                                     onNavigateToExploreShow(title, sourceUrl, exploreUrl)
@@ -382,8 +389,15 @@ fun MainScreen(
                                 onBookClick = { book ->
                                     context.startActivityForBook(book)
                                 },
-                                onBookLongClick = { book ->
-                                    onNavigateToBookInfo(book.name, book.author, book.bookUrl)
+                                onBookLongClick = { book, sharedCoverKey ->
+                                    onNavigateToBookInfo(
+                                        book.name,
+                                        book.author,
+                                        book.bookUrl,
+                                        book.origin,
+                                        book.getDisplayCover(),
+                                        sharedCoverKey
+                                    )
                                 },
                                 onNavigateToSearch = { query -> onNavigateToSearch(query) },
                                 onNavigateToRemoteImport = onNavigateToRemoteImport,

+ 3 - 0
app/src/main/java/io/legado/app/ui/main/MainViewModel.kt

@@ -31,6 +31,7 @@ class MainViewModel(
     private val prefs = context.defaultSharedPreferences
     private val mainPreferenceKeys = setOf(
         PreferKey.showDiscovery,
+        PreferKey.showHome,
         PreferKey.showRss,
         PreferKey.showBottomView,
         PreferKey.useFloatingBottomBar,
@@ -152,10 +153,12 @@ private const val NAV_EXTENDED_KEY = "navExtended"
 
 private fun MainViewModel.readMainUiState(): MainUiState {
     val showDiscovery = context.getPrefBoolean(PreferKey.showDiscovery, true)
+    val showHome = context.getPrefBoolean(PreferKey.showHome, true)
     val showRss = context.getPrefBoolean(PreferKey.showRss, true)
     val destinations = MainDestination.mainDestinations.filter {
         when (it) {
             MainDestination.Explore -> showDiscovery
+            MainDestination.Home -> showHome
             MainDestination.Rss -> showRss
             else -> true
         }

+ 12 - 4
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt

@@ -153,7 +153,7 @@ import sh.calvin.reorderable.rememberReorderableLazyGridState
 fun BookshelfScreen(
     viewModel: BookshelfViewModel = koinViewModel(),
     onBookClick: (BookShelfItem) -> Unit,
-    onBookLongClick: (BookShelfItem) -> Unit,
+    onBookLongClick: (book: BookShelfItem, sharedCoverKey: String?) -> Unit,
     onNavigateToSearch: (String) -> Unit,
     onNavigateToRemoteImport: () -> Unit,
     onNavigateToLocalImport: () -> Unit,
@@ -1253,7 +1253,7 @@ fun BookshelfPage(
     onDragFinished: () -> Unit,
     onGlobalSearch: () -> Unit,
     onBookClick: (BookShelfItem) -> Unit,
-    onBookLongClick: (BookShelfItem) -> Unit,
+    onBookLongClick: (BookShelfItem, String?) -> Unit,
     isCurrentPage: Boolean = true,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
@@ -1369,6 +1369,14 @@ fun BookshelfPage(
         ) {
             items(displayBooks, key = { it.book.bookUrl }) { bookUi ->
                 val isSelected = selectedBookUrls.contains(bookUi.book.bookUrl)
+                val sharedCoverKey = if (isCurrentPage) {
+                    bookCoverSharedElementKey(
+                        bookUi.book.bookUrl,
+                        "bookshelf:${uiState.selectedGroupId}"
+                    )
+                } else {
+                    null
+                }
                 ReorderableItem(
                     state = reorderableState,
                     key = bookUi.book.bookUrl,
@@ -1408,7 +1416,7 @@ fun BookshelfPage(
                         searchKey = uiState.searchKey,
                         sharedTransitionScope = sharedTransitionScope,
                         animatedVisibilityScope = animatedVisibilityScope,
-                        sharedCoverKey = if (isCurrentPage) bookCoverSharedElementKey(bookUi.book.bookUrl) else null,
+                        sharedCoverKey = sharedCoverKey,
                         onClick = {
                             if (uiState.isEditMode) {
                                 onToggleBookSelection(bookUi)
@@ -1423,7 +1431,7 @@ fun BookshelfPage(
                                 if (uiState.isEditMode) {
                                     onToggleBookSelection(bookUi)
                                 } else {
-                                    onBookLongClick(bookUi.book)
+                                    onBookLongClick(bookUi.book, sharedCoverKey)
                                 }
                             }
                         }

+ 3 - 0
app/src/main/java/io/legado/app/ui/main/homepage/HomepageEffect.kt

@@ -5,6 +5,9 @@ sealed interface HomepageEffect {
         val name: String?,
         val author: String?,
         val bookUrl: String,
+        val origin: String? = null,
+        val coverPath: String? = null,
+        val sharedCoverKey: String?,
     ) : HomepageEffect
 
     data class NavigateToExploreShow(

+ 99 - 102
app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt

@@ -43,9 +43,12 @@ import io.legado.app.ui.widget.components.JsonRawEditor
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.SecondaryButton
 import io.legado.app.ui.widget.components.button.SmallIconButton
+import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
 import io.legado.app.ui.widget.components.card.SelectionItemCard
 import io.legado.app.ui.widget.components.divider.PillDivider
+import io.legado.app.ui.widget.components.divider.PillHeaderDivider
+import io.legado.app.ui.widget.components.explore.ExploreKindSelectSheet
 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
@@ -102,6 +105,7 @@ fun <T> HomepageModuleManageSheet(
     var browseModuleType by remember(data != null) { mutableStateOf("card") }
     var selectedKindTitles by remember(data != null) { mutableStateOf<Set<String>>(emptySet()) }
     var showCustomSetAddModules by remember(data != null) { mutableStateOf(false) }
+    var showKindSelect by remember(data != null) { mutableStateOf(false) }
     var showAddButtonGroupDialog by remember(data != null) { mutableStateOf(false) }
     val defaultQuickActionsTitle = stringResource(R.string.homepage_quick_actions)
     var tempButtonGroupTitle by remember(data != null) { mutableStateOf(defaultQuickActionsTitle) }
@@ -310,8 +314,7 @@ fun <T> HomepageModuleManageSheet(
                                                     onClick = { deleteConfirmId = module.id },
                                                     imageVector = Icons.Default.Delete
                                                 )
-                                            },
-                                            modifier = Modifier.padding(horizontal = 4.dp)
+                                            }
                                         )
                                     }
 
@@ -353,8 +356,7 @@ fun <T> HomepageModuleManageSheet(
                                                         onClick = { deleteConfirmId = module.id },
                                                         imageVector = Icons.Default.Delete
                                                     )
-                                                },
-                                                modifier = Modifier.padding(horizontal = 4.dp)
+                                                }
                                             )
                                         }
                                     }
@@ -398,8 +400,7 @@ fun <T> HomepageModuleManageSheet(
                                                         sourceUrl = browseUrl,
                                                     )
                                                 )
-                                            },
-                                            modifier = Modifier.padding(horizontal = 4.dp)
+                                            }
                                         )
                                     }
                                 }
@@ -408,90 +409,76 @@ fun <T> HomepageModuleManageSheet(
 
                         2 -> {
                             val isButtonGroup = browseModuleType == "buttonGroup"
-                            val selectableKinds = exploreKinds
                             Column {
                                 val typeList = remember {
                                     HomepageModuleType.entries.filter { it != HomepageModuleType.Unknown }
                                 }
-                                CompactDropdownSettingItem(
-                                    title = stringResource(R.string.homepage_module_type),
-                                    selectedValue = browseModuleType,
-                                    displayEntries = typeList.map { it.title }.toTypedArray(),
-                                    entryValues = typeList.map { it.key }.toTypedArray(),
-                                    onValueChange = {
-                                        browseModuleType = it; selectedKindTitles = emptySet()
-                                    }
-                                )
-                                if (selectableKinds.isEmpty()) {
-                                    Box(
-                                        modifier = Modifier
-                                            .fillMaxWidth()
-                                            .padding(24.dp),
-                                        contentAlignment = Alignment.Center
-                                    ) {
-                                        AppText(
-                                            stringResource(R.string.homepage_source_no_discover),
-                                            color = LegadoTheme.colorScheme.onSurfaceVariant
+
+                                GlassCard(
+                                    containerColor = LegadoTheme.colorScheme.onSheetContent,
+                                    cornerRadius = 12.dp
+                                ) {
+                                    CompactDropdownSettingItem(
+                                        title = stringResource(R.string.homepage_module_type),
+                                        selectedValue = browseModuleType,
+                                        displayEntries = typeList.map { it.title }.toTypedArray(),
+                                        entryValues = typeList.map { it.key }.toTypedArray(),
+                                        onValueChange = {
+                                            browseModuleType = it; selectedKindTitles = emptySet()
+                                        }
+                                    )
+                                }
+
+                                Spacer(modifier = Modifier.height(16.dp))
+
+                                SelectionItemCard(
+                                    title = stringResource(R.string.homepage_select_from_kinds),
+                                    subtitle = if (isButtonGroup) {
+                                        if (selectedKindTitles.isEmpty()) stringResource(R.string.homepage_select_multiple_kinds)
+                                        else stringResource(
+                                            R.string.homepage_n_selected,
+                                            selectedKindTitles.size
                                         )
+                                    } else {
+                                        stringResource(R.string.homepage_select_one_kind)
+                                    },
+                                    containerColor = LegadoTheme.colorScheme.onSheetContent,
+                                    onToggleSelection = { showKindSelect = true },
+                                    trailingAction = {
+                                        if (isButtonGroup && selectedKindTitles.isNotEmpty()) {
+                                            SmallIconButton(
+                                                onClick = { showAddButtonGroupDialog = true },
+                                                imageVector = Icons.Default.Check
+                                            )
+                                        }
                                     }
-                                } else {
-                                    AppText(
-                                        stringResource(R.string.homepage_select_items),
-                                        style = LegadoTheme.typography.labelMedium,
-                                        modifier = Modifier.padding(
-                                            horizontal = 16.dp,
-                                            vertical = 4.dp
-                                        )
-                                    )
-                                    LazyColumn(
-                                        modifier = Modifier
-                                            .fillMaxWidth()
-                                            .weight(1f),
-                                        verticalArrangement = Arrangement.spacedBy(4.dp)
-                                    ) {
-                                        items(
-                                            selectableKinds.distinctBy { it.first + it.second },
-                                            key = { it.first + it.second }) { (kindTitle, kindUrl) ->
-                                            if (isButtonGroup) {
-                                                val isSelected = kindTitle in selectedKindTitles
-                                                SelectionItemCard(
-                                                    title = kindTitle,
-                                                    subtitle = kindUrl.take(60),
-                                                    containerColor = LegadoTheme.colorScheme.onSheetContent,
-                                                    isSelected = isSelected,
-                                                    inSelectionMode = true,
-                                                    onToggleSelection = {
-                                                        selectedKindTitles =
-                                                            if (isSelected) selectedKindTitles - kindTitle
-                                                            else selectedKindTitles + kindTitle
-                                                    },
-                                                    modifier = Modifier.padding(horizontal = 4.dp)
-                                                )
-                                            } else {
-                                                val isJoined = joinedKeys.contains(kindTitle)
-                                                SelectionItemCard(
-                                                    title = kindTitle,
-                                                    subtitle = kindUrl.take(60) + if (isJoined) stringResource(
-                                                        R.string.homepage_status_joined
-                                                    ) else "",
-                                                    containerColor = LegadoTheme.colorScheme.onSheetContent,
-                                                    isSelected = isJoined,
-                                                    inSelectionMode = true,
-                                                    onToggleSelection = {
-                                                        if (!isJoined) addDialogPrefill =
-                                                            AddDialogPrefill(
-                                                                kindTitle,
-                                                                kindUrl,
-                                                                browseModuleType
-                                                            )
-                                                    },
-                                                    modifier = Modifier.padding(horizontal = 4.dp)
+                                )
+
+                                ExploreKindSelectSheet(
+                                    show = showKindSelect,
+                                    onDismissRequest = { showKindSelect = false },
+                                    sourceUrl = browseUrl,
+                                    multiple = isButtonGroup,
+                                    initialSelectedTitles = selectedKindTitles.toList(),
+                                    onSelected = { kinds ->
+                                        if (isButtonGroup) {
+                                            selectedKindTitles = kinds.map { it.title }.toSet()
+                                        } else {
+                                            kinds.firstOrNull()?.let { kind ->
+                                                addDialogPrefill = AddDialogPrefill(
+                                                    title = kind.title,
+                                                    url = kind.url ?: "",
+                                                    type = browseModuleType
                                                 )
                                             }
                                         }
                                     }
-                                }
-                                Spacer(modifier = Modifier.height(12.dp))
+                                )
+
+                                PillDivider(
+                                    modifier = Modifier.padding(vertical = 12.dp)
+                                )
+
                                 SecondaryButton(
                                     text = stringResource(R.string.homepage_manual_add),
                                     onClick = {
@@ -544,8 +531,7 @@ fun <T> HomepageModuleManageSheet(
                                         joinedInCurrent =
                                             joinedInCurrent + (module.moduleKey to "temp_${module.id}")
                                     }
-                                },
-                                modifier = Modifier.padding(horizontal = 4.dp)
+                                }
                             )
                         }
                     }
@@ -567,8 +553,7 @@ fun <T> HomepageModuleManageSheet(
                             onToggleSelection = {
                                 browsingSourceUrl = source.sourceUrl
                                 browsingDetail = true
-                            },
-                            modifier = Modifier.padding(horizontal = 4.dp)
+                            }
                         )
                     }
                 }
@@ -662,8 +647,7 @@ fun <T> HomepageModuleManageSheet(
                                             onClick = { deleteConfirmId = module.id },
                                             imageVector = Icons.Default.Delete
                                         )
-                                    },
-                                    modifier = Modifier.padding(horizontal = 4.dp)
+                                    }
                                 )
                             }
                         }
@@ -694,8 +678,7 @@ fun <T> HomepageModuleManageSheet(
                                             onClick = { deleteConfirmId = module.id },
                                             imageVector = Icons.Default.Delete
                                         )
-                                    },
-                                    modifier = Modifier.padding(horizontal = 4.dp)
+                                    }
                                 )
                             }
                         }
@@ -770,8 +753,12 @@ fun <T> HomepageModuleManageSheet(
                                     onClick = { deleteSetConfirmId = set.sourceUrl },
                                     imageVector = Icons.Default.Delete
                                 )
-                            },
-                            modifier = Modifier.padding(horizontal = 4.dp)
+                            }
+                        )
+                    }
+                    item {
+                        PillDivider(
+                            modifier = Modifier.padding(vertical = 12.dp)
                         )
                     }
                     item(key = "create_set") {
@@ -1001,41 +988,51 @@ fun <T> AddCustomModuleDialog(
                     .fillMaxWidth()
                     .height(400.dp)
                     .verticalScroll(rememberScrollState()),
-                verticalArrangement = Arrangement.spacedBy(4.dp)
+                verticalArrangement = Arrangement.spacedBy(8.dp),
+                horizontalAlignment = Alignment.CenterHorizontally
             ) {
                 AppTextField(
                     value = title,
                     onValueChange = { title = it },
+                    backgroundColor = LegadoTheme.colorScheme.onSheetContent,
                     label = stringResource(R.string.homepage_title_label),
                     modifier = Modifier.fillMaxWidth()
                 )
                 AppTextField(
                     value = url,
                     onValueChange = { url = it },
+                    backgroundColor = LegadoTheme.colorScheme.onSheetContent,
                     label = "URL",
                     modifier = Modifier.fillMaxWidth()
                 )
                 val typeList = remember {
                     HomepageModuleType.entries.filter { it != HomepageModuleType.Unknown }
                 }
-                DropdownListSettingItem(
-                    title = stringResource(R.string.homepage_type_label),
-                    selectedValue = type,
-                    displayEntries = typeList.map { it.title }.toTypedArray(),
-                    entryValues = typeList.map { it.key }.toTypedArray(),
-                    onValueChange = { type = it }
-                )
+
+                GlassCard(
+                    containerColor = LegadoTheme.colorScheme.onSheetContent
+                ) {
+                    DropdownListSettingItem(
+                        title = stringResource(R.string.homepage_type_label),
+                        selectedValue = type,
+                        displayEntries = typeList.map { it.title }.toTypedArray(),
+                        entryValues = typeList.map { it.key }.toTypedArray(),
+                        onValueChange = { type = it }
+                    )
+                }
+
                 AppTextField(
                     value = args,
                     onValueChange = { args = it },
+                    backgroundColor = LegadoTheme.colorScheme.onSheetContent,
                     label = "Args (JSON)",
                     modifier = Modifier.fillMaxWidth()
                 )
-                AppText(
-                    text = stringResource(R.string.homepage_layout_config_label),
-                    style = LegadoTheme.typography.labelMedium,
-                    modifier = Modifier.padding(top = 16.dp, bottom = 4.dp)
+
+                PillHeaderDivider(
+                    title = stringResource(R.string.homepage_layout_config_label)
                 )
+
                 if (hasVisualizableKeys) {
                     JsonConfigEditor(
                         jsonString = layoutConfig,

+ 160 - 84
app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt

@@ -10,27 +10,28 @@ 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.WindowInsets
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
 import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
 import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
 import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
-import androidx.compose.foundation.lazy.staggeredgrid.items
+import androidx.compose.foundation.lazy.staggeredgrid.itemsIndexed
 import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
 import androidx.compose.foundation.pager.HorizontalPager
 import androidx.compose.foundation.pager.rememberPagerState
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.ArrowForward
 import androidx.compose.material.icons.filled.GridView
+import androidx.compose.material.icons.filled.Refresh
 import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.outlined.Info
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.HorizontalDivider
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
@@ -46,7 +47,6 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -60,12 +60,15 @@ import io.legado.app.ui.main.homepage.modules.GridModule
 import io.legado.app.ui.main.homepage.modules.GridRankingModule
 import io.legado.app.ui.main.homepage.modules.RankingModule
 import io.legado.app.ui.main.homepage.modules.WaterfallItem
+import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AppPullToRefresh
 import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.LoadMoreFooter
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.book.SearchBookGridItem
-import io.legado.app.ui.widget.components.button.SecondaryButton
 import io.legado.app.ui.widget.components.button.SmallTonalIconButton
+import io.legado.app.ui.widget.components.card.GlassCard
+import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import io.legado.app.ui.widget.components.tabRow.AppTabRow
 import io.legado.app.ui.widget.components.text.AppText
@@ -84,7 +87,7 @@ import org.koin.androidx.compose.koinViewModel
 @Composable
 fun HomepageScreen(
     viewModel: HomepageViewModel = koinViewModel(),
-    onBookClick: (name: String?, author: String?, bookUrl: String) -> Unit,
+    onBookClick: (name: String?, author: String?, bookUrl: String, origin: String?, coverPath: String?, sharedCoverKey: String?) -> Unit,
     onModuleHeaderClick: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
@@ -137,7 +140,14 @@ fun HomepageScreen(
         viewModel.effects.collect { effect ->
             when (effect) {
                 is HomepageEffect.NavigateToBookInfo ->
-                    onBookClick(effect.name, effect.author, effect.bookUrl)
+                    onBookClick(
+                        effect.name,
+                        effect.author,
+                        effect.bookUrl,
+                        effect.origin,
+                        effect.coverPath,
+                        effect.sharedCoverKey
+                    )
 
                 is HomepageEffect.NavigateToExploreShow ->
                     onModuleHeaderClick(effect.title, effect.sourceUrl, effect.exploreUrl)
@@ -238,6 +248,7 @@ fun HomepageScreen(
             data = errorMsg,
             onDismissRequest = { errorMsg = null },
             title = stringResource(R.string.homepage_module_error),
+            text = errorMsg,
             confirmText = stringResource(R.string.copy_text),
             onConfirm = {
                 context.sendToClip(it)
@@ -355,12 +366,14 @@ private fun ModuleList(
                 item(key = "header_${moduleUi.globalId}", span = StaggeredGridItemSpan.FullLine) {
                     ModuleHeader(
                         title = moduleUi.title,
-                        onNavigate = {
-                            viewModel.onModuleHeaderClick(
-                                moduleUi.sourceUrl,
-                                moduleUi.exploreUrl,
-                                moduleUi.title,
-                            )
+                        onNavigate = if (moduleUi.type == HomepageModuleType.ButtonGroup) null else {
+                            {
+                                viewModel.onModuleHeaderClick(
+                                    moduleUi.sourceUrl,
+                                    moduleUi.exploreUrl,
+                                    moduleUi.title,
+                                )
+                            }
                         },
                     )
                 }
@@ -390,27 +403,78 @@ private fun ModuleList(
                         ) {
                             Column(
                                 modifier = Modifier
-                                    .fillMaxWidth()
-                                    .height(80.dp)
-                                    .clickable { onErrorClick(state.message) },
-                                horizontalAlignment = Alignment.CenterHorizontally,
-                                verticalArrangement = Arrangement.Center,
+                                    .fillMaxWidth(),
+                                horizontalAlignment = Alignment.CenterHorizontally
                             ) {
-                                AppText(
-                                    text = state.message,
-                                    color = MaterialTheme.colorScheme.error,
-                                    maxLines = 2,
-                                    overflow = TextOverflow.Ellipsis,
-                                    textAlign = TextAlign.Center,
-                                    modifier = Modifier.padding(horizontal = 16.dp)
-                                )
-                                Spacer(modifier = Modifier.height(4.dp))
-                                SecondaryButton(
-                                    text = stringResource(R.string.retry),
-                                    onClick = {
-                                        viewModel.retryModule(moduleUi.globalId)
+                                GlassCard(
+                                    onClick = { onErrorClick(state.message) },
+                                    containerColor = LegadoTheme.colorScheme.errorContainer.copy(
+                                        alpha = 0.6f
+                                    ),
+                                ) {
+                                    Column(
+                                        modifier = Modifier.fillMaxWidth()
+                                    ) {
+
+                                        Row(
+                                            modifier = Modifier
+                                                .fillMaxWidth()
+                                                .padding(
+                                                    horizontal = 16.dp,
+                                                    vertical = 16.dp
+                                                ),
+                                            verticalAlignment = Alignment.CenterVertically,
+                                            horizontalArrangement = Arrangement.spacedBy(12.dp)
+                                        ) {
+
+                                            AppIcon(
+                                                imageVector = Icons.Outlined.Info,
+                                                contentDescription = null,
+                                                tint = LegadoTheme.colorScheme.error
+                                            )
+
+                                            AppText(
+                                                text = state.message,
+                                                color = LegadoTheme.colorScheme.error,
+                                                style = LegadoTheme.typography.bodySmall,
+                                                modifier = Modifier.weight(1f),
+                                                maxLines = 2,
+                                                overflow = TextOverflow.Ellipsis,
+                                            )
+                                        }
+
+                                        HorizontalDivider(
+                                            color = LegadoTheme.colorScheme.error.copy(alpha = 0.3f)
+                                        )
+
+                                        Box(
+                                            modifier = Modifier
+                                                .fillMaxWidth()
+                                                .clickable {
+                                                    viewModel.retryModule(moduleUi.globalId)
+                                                }
+                                                .padding(vertical = 10.dp),
+                                            contentAlignment = Alignment.Center
+                                        ) {
+                                            Row(
+                                                horizontalArrangement = Arrangement.spacedBy(8.dp),
+                                                verticalAlignment = Alignment.CenterVertically
+                                            ) {
+                                                AppIcon(
+                                                    imageVector = Icons.Default.Refresh,
+                                                    contentDescription = null,
+                                                    tint = LegadoTheme.colorScheme.error
+                                                )
+
+                                                AppText(
+                                                    text = "重试",
+                                                    color = LegadoTheme.colorScheme.error,
+                                                    style = LegadoTheme.typography.labelMedium
+                                                )
+                                            }
+                                        }
                                     }
-                                )
+                                }
                             }
                         }
                     }
@@ -435,68 +499,63 @@ private fun ModuleList(
                         val config = moduleUi.config
                         when (moduleUi.type) {
                             HomepageModuleType.Waterfall -> {
-                                items(
+                                itemsIndexed(
                                     state.books,
-                                    key = { "wf_${moduleUi.globalId}_${it.bookUrl}" }) { book ->
+                                    key = { index, book -> "wf_${moduleUi.globalId}_${book.bookUrl}_$index" }) { index, book ->
+                                    val sharedCoverKey = bookCoverSharedElementKey(
+                                        book.bookUrl,
+                                        "home:${moduleUi.globalId}:waterfall:$index"
+                                    )
                                     WaterfallItem(
                                         book = book,
-                                        onClick = { viewModel.onBookClick(book) },
+                                        onClick = { viewModel.onBookClick(book, sharedCoverKey) },
                                         sharedTransitionScope = sharedTransitionScope,
                                         animatedVisibilityScope = animatedVisibilityScope,
+                                        sharedCoverKey = sharedCoverKey,
                                     )
                                 }
 
-                                if (state.hasMore) {
-                                    item(
-                                        key = "wf_more_${moduleUi.globalId}",
-                                        span = StaggeredGridItemSpan.FullLine
-                                    ) {
-                                        LaunchedEffect(state.books.size) {
-                                            viewModel.loadMoreModule(moduleUi.globalId)
-                                        }
-                                        Box(
-                                            modifier = Modifier
-                                                .fillMaxWidth()
-                                                .padding(16.dp),
-                                            contentAlignment = Alignment.Center
-                                        ) {
-                                            AppCircularProgressIndicator(modifier = Modifier.size(24.dp))
-                                        }
-                                    }
+                                item(
+                                    key = "wf_more_${moduleUi.globalId}",
+                                    span = StaggeredGridItemSpan.FullLine
+                                ) {
+                                    LoadMoreFooter(
+                                        isLoading = state.isLoadingMore,
+                                        errorMsg = null,
+                                        isEnd = !state.hasMore,
+                                        onRetry = { viewModel.loadMoreModule(moduleUi.globalId) }
+                                    )
                                 }
                             }
 
                             HomepageModuleType.InfiniteGrid -> {
-                                items(
+                                itemsIndexed(
                                     state.books,
-                                    key = { "inf_grid_${moduleUi.globalId}_${it.bookUrl}" }) { book ->
+                                    key = { index, book -> "inf_grid_${moduleUi.globalId}_${book.bookUrl}_$index" }) { index, book ->
+                                    val sharedCoverKey = bookCoverSharedElementKey(
+                                        book.bookUrl,
+                                        "home:${moduleUi.globalId}:infinite:$index"
+                                    )
                                     SearchBookGridItem(
                                         book = book,
                                         shelfState = io.legado.app.domain.model.BookShelfState.NOT_IN_SHELF,
-                                        onClick = { viewModel.onBookClick(book) },
+                                        onClick = { viewModel.onBookClick(book, sharedCoverKey) },
                                         sharedTransitionScope = sharedTransitionScope,
                                         animatedVisibilityScope = animatedVisibilityScope,
-                                        sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
+                                        sharedCoverKey = sharedCoverKey
                                     )
                                 }
 
-                                if (state.hasMore) {
-                                    item(
-                                        key = "inf_grid_more_${moduleUi.globalId}",
-                                        span = StaggeredGridItemSpan.FullLine
-                                    ) {
-                                        LaunchedEffect(state.books.size) {
-                                            viewModel.loadMoreModule(moduleUi.globalId)
-                                        }
-                                        Box(
-                                            modifier = Modifier
-                                                .fillMaxWidth()
-                                                .padding(16.dp),
-                                            contentAlignment = Alignment.Center
-                                        ) {
-                                            AppCircularProgressIndicator(modifier = Modifier.size(24.dp))
-                                        }
-                                    }
+                                item(
+                                    key = "inf_grid_more_${moduleUi.globalId}",
+                                    span = StaggeredGridItemSpan.FullLine
+                                ) {
+                                    LoadMoreFooter(
+                                        isLoading = state.isLoadingMore,
+                                        errorMsg = null,
+                                        isEnd = !state.hasMore,
+                                        onRetry = { viewModel.loadMoreModule(moduleUi.globalId) }
+                                    )
                                 }
                             }
 
@@ -509,12 +568,15 @@ private fun ModuleList(
                                 ) {
                                     GridModule(
                                         books = state.books,
-                                        onClick = { viewModel.onBookClick(it) },
+                                        onClick = { book, sharedCoverKey ->
+                                            viewModel.onBookClick(book, sharedCoverKey)
+                                        },
                                         modifier = Modifier.fillMaxWidth(),
                                         columns = columns,
                                         maxRows = rows,
                                         sharedTransitionScope = sharedTransitionScope,
                                         animatedVisibilityScope = animatedVisibilityScope,
+                                        sharedCoverKeySourceId = "home:${moduleUi.globalId}:grid",
                                     )
                                 }
                             }
@@ -528,10 +590,13 @@ private fun ModuleList(
                                         ) {
                                             BannerModule(
                                                 books = state.books,
-                                                onClick = { viewModel.onBookClick(it) },
+                                                onClick = { book, sharedCoverKey ->
+                                                    viewModel.onBookClick(book, sharedCoverKey)
+                                                },
                                                 modifier = Modifier.fillMaxWidth(),
                                                 sharedTransitionScope = sharedTransitionScope,
                                                 animatedVisibilityScope = animatedVisibilityScope,
+                                                sharedCoverKeySourceId = "home:${moduleUi.globalId}:banner",
                                             )
                                         }
                                     }
@@ -543,10 +608,13 @@ private fun ModuleList(
                                         ) {
                                             RankingModule(
                                                 books = state.books,
-                                                onClick = { viewModel.onBookClick(it) },
+                                                onClick = { book, sharedCoverKey ->
+                                                    viewModel.onBookClick(book, sharedCoverKey)
+                                                },
                                                 modifier = Modifier.fillMaxWidth(),
                                                 sharedTransitionScope = sharedTransitionScope,
                                                 animatedVisibilityScope = animatedVisibilityScope,
+                                                sharedCoverKeySourceId = "home:${moduleUi.globalId}:ranking",
                                             )
                                         }
                                     }
@@ -558,11 +626,14 @@ private fun ModuleList(
                                         ) {
                                             GridRankingModule(
                                                 books = state.books,
-                                                onClick = { viewModel.onBookClick(it) },
+                                                onClick = { book, sharedCoverKey ->
+                                                    viewModel.onBookClick(book, sharedCoverKey)
+                                                },
                                                 modifier = Modifier.fillMaxWidth(),
                                                 rows = config["layout_rows"]?.toIntOrNull() ?: 4,
                                                 sharedTransitionScope = sharedTransitionScope,
                                                 animatedVisibilityScope = animatedVisibilityScope,
+                                                sharedCoverKeySourceId = "home:${moduleUi.globalId}:grid-ranking",
                                             )
                                         }
                                     }
@@ -574,10 +645,13 @@ private fun ModuleList(
                                         ) {
                                             CardModule(
                                                 books = state.books,
-                                                onClick = { viewModel.onBookClick(it) },
+                                                onClick = { book, sharedCoverKey ->
+                                                    viewModel.onBookClick(book, sharedCoverKey)
+                                                },
                                                 modifier = Modifier.fillMaxWidth(),
                                                 sharedTransitionScope = sharedTransitionScope,
                                                 animatedVisibilityScope = animatedVisibilityScope,
+                                                sharedCoverKeySourceId = "home:${moduleUi.globalId}:card",
                                             )
                                         }
                                     }
@@ -596,7 +670,7 @@ private fun ModuleList(
 @Composable
 private fun ModuleHeader(
     title: String,
-    onNavigate: () -> Unit,
+    onNavigate: (() -> Unit)? = null,
 ) {
     Row(
         modifier = Modifier
@@ -612,9 +686,11 @@ private fun ModuleHeader(
             overflow = TextOverflow.Ellipsis,
             modifier = Modifier.weight(1f),
         )
-        SmallTonalIconButton(
-            onClick = onNavigate,
-            imageVector = Icons.AutoMirrored.Filled.ArrowForward
-        )
+        if (onNavigate != null) {
+            SmallTonalIconButton(
+                onClick = onNavigate,
+                imageVector = Icons.AutoMirrored.Filled.ArrowForward
+            )
+        }
     }
-}
+}

+ 30 - 25
app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt

@@ -17,6 +17,7 @@ import io.legado.app.domain.usecase.SaveSearchBooksUseCase
 import io.legado.app.help.source.exploreKinds
 import io.legado.app.utils.GSON
 import io.legado.app.utils.fromJsonArray
+import io.legado.app.utils.stackTraceStr
 import kotlinx.collections.immutable.persistentListOf
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.Dispatchers
@@ -215,28 +216,10 @@ class HomepageViewModel(
             }
         }
 
-        // sync: 只处理有 homepageModules 的书源
-        viewModelScope.launch {
-            initModulesSyncFlow.collect { sources ->
-                sources.forEach { source -> syncModulesFromSource(source) }
-            }
-        }
-
-        // cache: 所有启用发现的书源(包括无 homepageModules 的)
+        // 清理 _pendingUserModules 中已入库的条目
         viewModelScope.launch {
             exploreSourcesFlow.collect { sources ->
                 _bookSourcesCache.value = sources.associateBy { it.bookSourceUrl }
-                val kindsCache = mutableMapOf<String, List<Pair<String, String>>>()
-                for (source in sources) {
-                    kindsCache[source.bookSourceUrl] = try {
-                        withContext(Dispatchers.IO) {
-                            source.exploreKinds().map { it.title to (it.url ?: "") }
-                        }
-                    } catch (_: Exception) {
-                        emptyList()
-                    }
-                }
-                _exploreKindsCache.value = kindsCache
             }
         }
 
@@ -367,7 +350,7 @@ class HomepageViewModel(
                 }.onFailure { e ->
                     _moduleContentStates.update {
                         it + (module.id to ModuleLoadState.Error(
-                            e.message ?: "Unknown error"
+                            e.stackTraceStr
                         ))
                     }
                 }
@@ -404,7 +387,7 @@ class HomepageViewModel(
             }.onFailure { e ->
                 _moduleContentStates.update {
                     it + (module.id to ModuleLoadState.Error(
-                        e.message ?: "Unknown error"
+                        e.stackTraceStr
                     ))
                 }
             }
@@ -454,7 +437,7 @@ class HomepageViewModel(
                     HomepageEffect.ShowSnackbar(
                         getApplication<Application>().getString(
                             R.string.homepage_load_more_failed,
-                            e.message ?: ""
+                            e.stackTraceStr
                         )
                     )
                 )
@@ -477,6 +460,13 @@ class HomepageViewModel(
             _isRefreshing.value = true
             loadJobs.values.forEach { it.cancel() }
             loadJobs.clear()
+
+            // 刷新时同步当前已启用模块所属书源的定义
+            val activeSourceUrls = uiState.value.modules.map { it.sourceUrl }.distinct()
+            activeSourceUrls.forEach { url ->
+                resolveBookSource(url)?.let { syncModulesFromSource(it) }
+            }
+
             _moduleContentStates.value = emptyMap()
             uiState.map { it.modules }.first { modules ->
                 modules.all { it.state !is ModuleLoadState.Loading }
@@ -704,6 +694,12 @@ class HomepageViewModel(
         targetSetId: String? = null
     ): List<HomepageModuleManageUi> {
         val source = resolveBookSource(sourceUrl) ?: return emptyList()
+
+        // 按需同步:只有进入该源的管理页才同步其 JSON 定义
+        viewModelScope.launch {
+            syncModulesFromSource(source)
+        }
+
         val json = source.homepageModules ?: return emptyList()
         val jsonDefs = parseBookSourceModules(source, json)
 
@@ -850,10 +846,19 @@ class HomepageViewModel(
         }
     }
 
-    fun onBookClick(book: SearchBook) {
+    fun onBookClick(book: SearchBook, sharedCoverKey: String?) {
         viewModelScope.launch {
             saveSearchBooksUseCase.save(book)
-            _effects.emit(HomepageEffect.NavigateToBookInfo(book.name, book.author, book.bookUrl))
+            _effects.emit(
+                HomepageEffect.NavigateToBookInfo(
+                    name = book.name,
+                    author = book.author,
+                    bookUrl = book.bookUrl,
+                    origin = book.origin,
+                    coverPath = book.coverUrl,
+                    sharedCoverKey = sharedCoverKey
+                )
+            )
         }
     }
 
@@ -909,4 +914,4 @@ private data class HomepageUiFlags(
     val isRefreshing: Boolean,
     val isManageMode: Boolean,
     val isConfigMode: Boolean
-)
+)

+ 10 - 5
app/src/main/java/io/legado/app/ui/main/homepage/modules/BannerModule.kt

@@ -8,7 +8,7 @@ import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.lazy.LazyRow
-import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
@@ -23,10 +23,11 @@ import kotlinx.collections.immutable.ImmutableList
 @Composable
 fun BannerModule(
     books: ImmutableList<SearchBook>,
-    onClick: (SearchBook) -> Unit,
+    onClick: (SearchBook, String?) -> Unit,
     modifier: Modifier = Modifier,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
+    sharedCoverKeySourceId: String? = null,
 ) {
     if (books.isEmpty()) return
 
@@ -38,7 +39,11 @@ fun BannerModule(
             .fadingEdge(lazyListState, gradientWidth = 16.dp),
         horizontalArrangement = Arrangement.spacedBy(12.dp),
     ) {
-        items(books) { book ->
+        itemsIndexed(books, key = { index, book -> "${book.bookUrl}:$index" }) { index, book ->
+            val sharedCoverKey = bookCoverSharedElementKey(
+                book.bookUrl,
+                sharedCoverKeySourceId?.let { "$it:$index" }
+            )
             CoilBookCover(
                 name = book.name,
                 author = book.author,
@@ -47,10 +52,10 @@ fun BannerModule(
                 sourceOrigin = book.origin,
                 modifier = Modifier
                     .width(96.dp)
-                    .clickable { onClick(book) },
+                    .clickable { onClick(book, sharedCoverKey) },
                 sharedTransitionScope = sharedTransitionScope,
                 animatedVisibilityScope = animatedVisibilityScope,
-                sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
+                sharedCoverKey = sharedCoverKey
             )
         }
     }

+ 63 - 68
app/src/main/java/io/legado/app/ui/main/homepage/modules/ButtonGroupModule.kt

@@ -3,6 +3,7 @@ package io.legado.app.ui.main.homepage.modules
 import androidx.appcompat.app.AppCompatActivity
 import androidx.compose.foundation.basicMarquee
 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
@@ -12,12 +13,7 @@ import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 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.rememberCoroutineScope
-import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.platform.LocalContext
@@ -26,14 +22,14 @@ import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import io.legado.app.data.entities.rule.ExploreKind
 import io.legado.app.domain.usecase.ExploreKindUiUseCase
-import io.legado.app.help.source.getExploreInfoMap
 import io.legado.app.ui.main.homepage.HomepageViewModel
 import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.ui.widget.components.card.GlassCard
+import io.legado.app.ui.widget.components.explore.ExploreKindMultiTypeItem
 import io.legado.app.ui.widget.components.image.sourceIcon.SourceIcon
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.utils.GSON
-import kotlinx.coroutines.launch
 import org.koin.compose.koinInject
 
 @Composable
@@ -51,10 +47,7 @@ fun ButtonGroupModule(
     val context = LocalContext.current
     val activity = context as? AppCompatActivity
     val useCase: ExploreKindUiUseCase = koinInject()
-    val scope = rememberCoroutineScope()
-    val infoMap = remember(sourceUrl) {
-        sourceUrl.takeIf { it.isNotBlank() }?.let { getExploreInfoMap(it) }
-    }
+    val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
 
     // 解析图标映射表和默认图标
     val (iconMap, defaultIcon) = remember(layoutConfig) {
@@ -90,74 +83,76 @@ fun ButtonGroupModule(
                 horizontalArrangement = Arrangement.spacedBy(8.dp),
             ) {
                 rowKinds.forEach { kind ->
-                    var displayName by remember(kind.title) { mutableStateOf(kind.title) }
-
-                    LaunchedEffect(kind, sourceUrl, infoMap) {
-                        displayName = useCase.resolveDisplayName(kind, sourceUrl, infoMap)
-                    }
-
                     val buttonIcon = iconMap[kind.title] ?: defaultIcon
                     val hasIcon = !buttonIcon.isNullOrBlank()
 
-                    GlassCard(
-                        onClick = {
-                            when (kind.type) {
-                                ExploreKind.Type.url -> {
-                                    kind.url?.takeIf { it.isNotBlank() }?.let {
-                                        viewModel.onKindUrlClick(sourceUrl, it, kind.title)
-                                    }
-                                }
+                    ExploreKindMultiTypeItem(
+                        kind = kind,
+                        sourceUrl = sourceUrl,
+                        activity = activity,
+                        onOpenUrl = { url ->
+                            viewModel.onKindUrlClick(sourceUrl, url, kind.title)
+                        },
+                        onRefreshKinds = {
+                            viewModel.refreshButtonGroup(globalId)
+                        },
+                        useCase = useCase,
+                        isMiuix = isMiuix,
+                        modifier = Modifier.weight(1f),
+                        content = { displayName, isSelected, onClick, trailingIcon ->
+                            GlassCard(
+                                onClick = onClick,
+                                cornerRadius = 8.dp,
+                                containerColor = if (isSelected) LegadoTheme.colorScheme.primaryContainer else LegadoTheme.colorScheme.surfaceContainerLow,
+                                modifier = Modifier.fillMaxWidth()
+                            ) {
+                                Column(
+                                    horizontalAlignment = Alignment.CenterHorizontally,
+                                    verticalArrangement = Arrangement.Center,
+                                    modifier = Modifier
+                                        .fillMaxSize()
+                                        .padding(vertical = 8.dp, horizontal = 4.dp)
+                                ) {
+                                    if (hasIcon) {
+                                        SourceIcon(
+                                            path = buttonIcon!!,
+                                            modifier = Modifier.size(20.dp),
+                                            placeholderIcon = {
 
-                                ExploreKind.Type.button -> {
-                                    scope.launch {
-                                        useCase.executeAction(
-                                            action = kind.action,
-                                            title = kind.title,
-                                            sourceUrl = sourceUrl,
-                                            infoMap = infoMap,
-                                            activity = activity,
-                                            onRefreshKinds = {
-                                                viewModel.refreshButtonGroup(globalId)
                                             }
                                         )
+                                        Spacer(modifier = Modifier.height(6.dp))
                                     }
-                                }
-                            }
-                        },
-                        cornerRadius = 8.dp,
-                        containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
-                        modifier = Modifier.weight(1f)
-                    ) {
-                        Column(
-                            horizontalAlignment = Alignment.CenterHorizontally,
-                            verticalArrangement = Arrangement.Center,
-                            modifier = Modifier
-                                .fillMaxSize()
-                                .padding(vertical = 8.dp, horizontal = 4.dp)
-                        ) {
-                            if (hasIcon) {
-                                SourceIcon(
-                                    path = buttonIcon,
-                                    modifier = Modifier.size(20.dp),
-                                    placeholderIcon = {
 
+                                    Box(
+                                        modifier = Modifier.fillMaxWidth(),
+                                        contentAlignment = Alignment.Center
+                                    ) {
+                                        AppText(
+                                            text = displayName,
+                                            style = LegadoTheme.typography.labelSmallEmphasized,
+                                            textAlign = TextAlign.Center,
+                                            maxLines = 1,
+                                            overflow = TextOverflow.Clip,
+                                            modifier = Modifier
+                                                .padding(horizontal = 4.dp)
+                                                .basicMarquee()
+                                        )
+
+                                        if (trailingIcon != null) {
+                                            Box(
+                                                modifier = Modifier
+                                                    .align(Alignment.TopEnd)
+                                                    .padding(end = 2.dp)
+                                            ) {
+                                                trailingIcon()
+                                            }
+                                        }
                                     }
-                                )
-                                Spacer(modifier = Modifier.height(6.dp))
+                                }
                             }
-
-                            AppText(
-                                text = displayName,
-                                style = LegadoTheme.typography.labelSmallEmphasized,
-                                textAlign = TextAlign.Center,
-                                maxLines = 1,
-                                overflow = TextOverflow.Clip,
-                                modifier = Modifier
-                                    .padding(horizontal = 4.dp)
-                                    .basicMarquee()
-                            )
                         }
-                    }
+                    )
                 }
 
                 if (rowKinds.size < actualColumns) {

+ 13 - 7
app/src/main/java/io/legado/app/ui/main/homepage/modules/CardModule.kt

@@ -12,7 +12,7 @@ import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.layout.wrapContentWidth
 import androidx.compose.foundation.lazy.LazyRow
-import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.runtime.Composable
@@ -35,10 +35,11 @@ import kotlinx.collections.immutable.ImmutableList
 @Composable
 fun CardModule(
     books: ImmutableList<SearchBook>,
-    onClick: (SearchBook) -> Unit,
+    onClick: (SearchBook, String?) -> Unit,
     modifier: Modifier = Modifier,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
+    sharedCoverKeySourceId: String? = null,
 ) {
     if (books.isEmpty()) return
     val lazyListState = rememberLazyListState()
@@ -49,13 +50,17 @@ fun CardModule(
             .fadingEdge(lazyListState, gradientWidth = 8.dp),
         horizontalArrangement = Arrangement.spacedBy(12.dp),
     ) {
-        items(books, key = { it.bookUrl }) { book ->
+        itemsIndexed(books, key = { index, book -> "${book.bookUrl}:$index" }) { index, book ->
+            val sharedCoverKey = bookCoverSharedElementKey(
+                book.bookUrl,
+                sharedCoverKeySourceId?.let { "$it:$index" }
+            )
             Column(
                 modifier = Modifier
                     .width(120.dp)
                     .clip(RoundedCornerShape(16.dp))
                     .background(LegadoTheme.colorScheme.surfaceContainerLow)
-                    .clickable { onClick(book) }
+                    .clickable { onClick(book, sharedCoverKey) }
             ) {
                 CoilBookCover(
                     name = book.name,
@@ -67,19 +72,20 @@ fun CardModule(
                         .wrapContentWidth(),
                     sharedTransitionScope = sharedTransitionScope,
                     animatedVisibilityScope = animatedVisibilityScope,
-                    sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
+                    sharedCoverKey = sharedCoverKey
                 )
 
                 AppText(
                     text = book.name,
                     style = LegadoTheme.typography.labelLargeEmphasized,
                     maxLines = 2,
+                    minLines = 2,
                     overflow = TextOverflow.Ellipsis,
                     modifier = Modifier.padding(
                         start = 8.dp,
                         end = 8.dp,
                         top = 8.dp,
-                        bottom = 2.dp
+                        bottom = 8.dp
                     ),
                 )
 
@@ -88,7 +94,7 @@ fun CardModule(
                 if (intro != null) {
                     AppText(
                         text = intro,
-                        style = LegadoTheme.typography.bodySmall,
+                        style = LegadoTheme.typography.labelSmallEmphasized,
                         color = LegadoTheme.colorScheme.onSurfaceVariant,
                         maxLines = 2,
                         overflow = TextOverflow.Ellipsis,

+ 11 - 5
app/src/main/java/io/legado/app/ui/main/homepage/modules/GridModule.kt

@@ -21,12 +21,13 @@ import kotlinx.collections.immutable.ImmutableList
 @Composable
 fun GridModule(
     books: ImmutableList<SearchBook>,
-    onClick: (SearchBook) -> Unit,
+    onClick: (SearchBook, String?) -> Unit,
     modifier: Modifier = Modifier,
     columns: Int = 3,
     maxRows: Int? = null,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
+    sharedCoverKeySourceId: String? = null,
 ) {
     if (books.isEmpty()) return
     var rows = books.toList().chunked(columns)
@@ -37,20 +38,25 @@ fun GridModule(
         modifier = modifier.fillMaxWidth(),
         verticalArrangement = Arrangement.spacedBy(4.dp),
     ) {
-        for (row in rows) {
+        for ((rowIndex, row) in rows.withIndex()) {
             Row(
                 modifier = Modifier.fillMaxWidth(),
                 horizontalArrangement = Arrangement.spacedBy(12.dp),
             ) {
-                for (book in row) {
+                for ((columnIndex, book) in row.withIndex()) {
+                    val itemIndex = rowIndex * columns + columnIndex
+                    val sharedCoverKey = bookCoverSharedElementKey(
+                        book.bookUrl,
+                        sharedCoverKeySourceId?.let { "$it:$itemIndex" }
+                    )
                     SearchBookGridItem(
                         book = book,
                         shelfState = BookShelfState.NOT_IN_SHELF,
-                        onClick = { onClick(book) },
+                        onClick = { onClick(book, sharedCoverKey) },
                         modifier = Modifier.weight(1f),
                         sharedTransitionScope = sharedTransitionScope,
                         animatedVisibilityScope = animatedVisibilityScope,
-                        sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
+                        sharedCoverKey = sharedCoverKey
                     )
                 }
                 repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }

+ 12 - 4
app/src/main/java/io/legado/app/ui/main/homepage/modules/GridRankingModule.kt

@@ -37,11 +37,12 @@ import kotlinx.collections.immutable.ImmutableList
 @Composable
 fun GridRankingModule(
     books: ImmutableList<SearchBook>,
-    onClick: (SearchBook) -> Unit,
+    onClick: (SearchBook, String?) -> Unit,
     modifier: Modifier = Modifier,
     rows: Int = 4,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
+    sharedCoverKeySourceId: String? = null,
 ) {
     if (books.isEmpty()) return
     // 限制最多显示 20 项
@@ -70,13 +71,19 @@ fun GridRankingModule(
                     .fillMaxWidth()
                     .padding(vertical = 12.dp, horizontal = 12.dp)
             ) {
-                for (book in page) {
+                for ((rowIndex, book) in page.withIndex()) {
+                    val itemIndex = pageIndex * rows + rowIndex
+                    val sharedCoverKey = bookCoverSharedElementKey(
+                        book.bookUrl,
+                        sharedCoverKeySourceId?.let { "$it:$itemIndex" }
+                    )
                     GridRankingItem(
                         rank = pages.flatten().indexOf(book) + 1,
                         book = book,
-                        onClick = { onClick(book) },
+                        onClick = { onClick(book, sharedCoverKey) },
                         sharedTransitionScope = sharedTransitionScope,
                         animatedVisibilityScope = animatedVisibilityScope,
+                        sharedCoverKey = sharedCoverKey,
                     )
                 }
                 // 占位逻辑
@@ -96,6 +103,7 @@ private fun GridRankingItem(
     onClick: () -> Unit,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
+    sharedCoverKey: String? = null,
 ) {
     Row(
         modifier = Modifier
@@ -114,7 +122,7 @@ private fun GridRankingItem(
             modifier = Modifier.width(48.dp),
             sharedTransitionScope = sharedTransitionScope,
             animatedVisibilityScope = animatedVisibilityScope,
-            sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
+            sharedCoverKey = sharedCoverKey
         )
 
         // 2. 排名

+ 10 - 4
app/src/main/java/io/legado/app/ui/main/homepage/modules/RankingModule.kt

@@ -45,10 +45,11 @@ private const val MAX_COUNT = 20
 @Composable
 fun RankingModule(
     books: ImmutableList<SearchBook>,
-    onClick: (SearchBook) -> Unit,
+    onClick: (SearchBook, String?) -> Unit,
     modifier: Modifier = Modifier,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
+    sharedCoverKeySourceId: String? = null,
 ) {
     var visibleCount by rememberSaveable { mutableIntStateOf(INITIAL_COUNT) }
     val displayBooks = books.take(visibleCount)
@@ -72,6 +73,10 @@ fun RankingModule(
                     onClick = onClick,
                     sharedTransitionScope = sharedTransitionScope,
                     animatedVisibilityScope = animatedVisibilityScope,
+                    sharedCoverKey = bookCoverSharedElementKey(
+                        book.bookUrl,
+                        sharedCoverKeySourceId?.let { "$it:$index" }
+                    )
                 )
             }
 
@@ -114,14 +119,15 @@ fun RankingModule(
 private fun RankingItem(
     rank: Int,
     book: SearchBook,
-    onClick: (SearchBook) -> Unit,
+    onClick: (SearchBook, String?) -> Unit,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
+    sharedCoverKey: String? = null,
 ) {
     Row(
         modifier = Modifier
             .fillMaxWidth()
-            .clickable { onClick(book) }
+            .clickable { onClick(book, sharedCoverKey) }
             .padding(vertical = 4.dp, horizontal = 4.dp),
         verticalAlignment = Alignment.CenterVertically,
     ) {
@@ -144,7 +150,7 @@ private fun RankingItem(
             modifier = Modifier.weight(1f),
             sharedTransitionScope = sharedTransitionScope,
             animatedVisibilityScope = animatedVisibilityScope,
-            sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
+            sharedCoverKey = sharedCoverKey
         )
     }
 }

+ 2 - 2
app/src/main/java/io/legado/app/ui/main/homepage/modules/WaterfallModule.kt

@@ -17,7 +17,6 @@ import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import io.legado.app.data.entities.SearchBook
-import io.legado.app.ui.main.bookCoverSharedElementKey
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.book.SearchBookTagChip
 import io.legado.app.ui.widget.components.card.GlassCard
@@ -36,6 +35,7 @@ fun WaterfallItem(
     modifier: Modifier = Modifier,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
+    sharedCoverKey: String? = null,
 ) {
     GlassCard(
         containerColor = LegadoTheme.colorScheme.surfaceContainerLow
@@ -55,7 +55,7 @@ fun WaterfallItem(
                     .fillMaxWidth(),
                 sharedTransitionScope = sharedTransitionScope,
                 animatedVisibilityScope = animatedVisibilityScope,
-                sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
+                sharedCoverKey = sharedCoverKey
             )
 
             Spacer(modifier = Modifier.height(8.dp))

+ 10 - 34
app/src/main/java/io/legado/app/ui/rss/article/RssArticlesCompose.kt

@@ -1,7 +1,6 @@
 package io.legado.app.ui.rss.article
 
 import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
@@ -59,7 +58,7 @@ import io.legado.app.data.entities.RssSource
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.AppPullToRefresh
-import io.legado.app.ui.widget.components.EmptyMessage
+import io.legado.app.ui.widget.components.LoadMoreFooter
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.image.cover.buildCoverImageRequest
 import io.legado.app.utils.toastOnUi
@@ -163,7 +162,9 @@ fun RssArticlesPage(
                     }
                     item {
                         LoadMoreFooter(
-                            state = loadState,
+                            isLoading = loadState.isRefreshing || loadState.isLoadingMore,
+                            errorMsg = loadState.errorMessage,
+                            isEnd = !loadState.hasMore,
                             onRetry = { rssSource?.let(viewModel::loadMore) }
                         )
                     }
@@ -198,7 +199,9 @@ fun RssArticlesPage(
                     }
                     item(span = { GridItemSpan(maxLineSpan) }) {
                         LoadMoreFooter(
-                            state = loadState,
+                            isLoading = loadState.isRefreshing || loadState.isLoadingMore,
+                            errorMsg = loadState.errorMessage,
+                            isEnd = !loadState.hasMore,
                             onRetry = { rssSource?.let(viewModel::loadMore) }
                         )
                     }
@@ -233,7 +236,9 @@ fun RssArticlesPage(
                     }
                     item(span = StaggeredGridItemSpan.FullLine) {
                         LoadMoreFooter(
-                            state = loadState,
+                            isLoading = loadState.isRefreshing || loadState.isLoadingMore,
+                            errorMsg = loadState.errorMessage,
+                            isEnd = !loadState.hasMore,
                             onRetry = { rssSource?.let(viewModel::loadMore) }
                         )
                     }
@@ -297,35 +302,6 @@ private fun StaggeredLoadMoreDetector(
     }
 }
 
-@Composable
-private fun LoadMoreFooter(
-    state: RssArticlesLoadState,
-    onRetry: () -> Unit
-) {
-    val text = when {
-        state.isRefreshing || state.isLoadingMore -> "加载中..."
-        !state.hasMore -> "没有更多了"
-        state.errorMessage != null -> "加载失败,点击重试"
-        else -> "上拉加载更多"
-    }
-    val contentModifier = if (state.errorMessage != null) {
-        Modifier.clickable(onClick = onRetry)
-    } else {
-        Modifier
-    }
-    Box(
-        modifier = Modifier
-            .fillMaxWidth()
-            .padding(vertical = 12.dp),
-        contentAlignment = Alignment.Center
-    ) {
-        EmptyMessage(
-            message = text,
-            isLoading = state.isRefreshing || state.isLoadingMore,
-            modifier = contentModifier
-        )
-    }
-}
 
 @Composable
 private fun RssArticleItem(

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

@@ -2,6 +2,7 @@ package io.legado.app.ui.theme
 
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.Typography
+import androidx.compose.ui.text.font.FontFamily
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.sp
 import top.yukonga.miuix.kmp.theme.TextStyles
@@ -66,7 +67,7 @@ fun Typography.toLegadoTypography(): LegadoTypography {
     )
 }
 
-fun LegadoTypography.withFont(fontFamily: androidx.compose.ui.text.font.FontFamily?): LegadoTypography {
+fun LegadoTypography.withFont(fontFamily: FontFamily?): LegadoTypography {
     if (fontFamily == null) return this
     return copy(
         headlineLarge = headlineLarge.copy(fontFamily = fontFamily),

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

@@ -69,7 +69,7 @@ fun AppFloatingActionButton(
                 MiuixIcon(
                     imageVector = icon,
                     contentDescription = tooltipText,
-                    tint = Color.White
+                    tint = containerColor
                 )
             } else {
                 Icon(
@@ -87,7 +87,8 @@ fun AppFloatingActionButton(
         MiuixFloatingActionButton(
             onClick = onClick,
             modifier = modifier,
-            content = fabContent
+            content = fabContent,
+            containerColor = LegadoTheme.colorScheme.surfaceContainer
         )
     } else {
         if (tooltipText != null) {

+ 4 - 18
app/src/main/java/io/legado/app/ui/widget/components/JsonRawEditor.kt

@@ -8,18 +8,13 @@ import androidx.compose.foundation.layout.heightIn
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.AutoFixHigh
 import androidx.compose.material.icons.filled.Compress
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.TextField
-import androidx.compose.material3.TextFieldDefaults
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.font.FontFamily
 import androidx.compose.ui.unit.dp
 import com.google.gson.GsonBuilder
 import com.google.gson.JsonParser
+import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.button.SmallIconButton
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.utils.GSON
@@ -39,8 +34,7 @@ fun JsonRawEditor(
         ) {
             AppText(
                 text = label,
-                style = MaterialTheme.typography.labelMedium,
-                color = MaterialTheme.colorScheme.primary
+                style = LegadoTheme.typography.labelMediumEmphasized
             )
             Row {
                 SmallIconButton(
@@ -67,21 +61,13 @@ fun JsonRawEditor(
             }
         }
 
-        TextField(
+        AppTextField(
             value = value,
             onValueChange = onValueChange,
             modifier = Modifier
                 .fillMaxWidth()
                 .heightIn(min = 150.dp, max = 400.dp),
-            textStyle = TextStyle(
-                fontFamily = FontFamily.Monospace,
-                fontSize = MaterialTheme.typography.bodySmall.fontSize
-            ),
-            colors = TextFieldDefaults.colors(
-                focusedContainerColor = Color.Transparent,
-                unfocusedContainerColor = Color.Transparent,
-                disabledContainerColor = Color.Transparent,
-            ),
+            backgroundColor = LegadoTheme.colorScheme.onSheetContent,
             maxLines = 1000
         )
     }

+ 251 - 0
app/src/main/java/io/legado/app/ui/widget/components/LoadMoreFooter.kt

@@ -0,0 +1,251 @@
+package io.legado.app.ui.widget.components
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.foundation.clickable
+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.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.outlined.Info
+import androidx.compose.material3.HorizontalDivider
+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.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.adaptiveHorizontalPadding
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.button.AnimatedTextButton
+import io.legado.app.ui.widget.components.card.GlassCard
+import io.legado.app.ui.widget.components.icon.AppIcon
+import io.legado.app.ui.widget.components.progressIndicator.AppContainedLoadingIndicator
+import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.utils.sendToClip
+
+@Composable
+fun LoadMoreFooter(
+    isLoading: Boolean,
+    errorMsg: String?,
+    isEnd: Boolean,
+    onRetry: () -> Unit
+) {
+    val context = LocalContext.current
+    var showFullError by remember { mutableStateOf<String?>(null) }
+
+    LaunchedEffect(isLoading, errorMsg, isEnd) {
+        if (!isLoading && errorMsg == null && !isEnd) {
+            onRetry()
+        }
+    }
+
+    AppAlertDialog(
+        data = showFullError,
+        onDismissRequest = { showFullError = null },
+        title = "错误详情",
+        textProvider = { this },
+        confirmText = "复制",
+        onConfirm = { error ->
+            context.sendToClip(error)
+            showFullError = null
+        },
+        dismissText = "关闭",
+        onDismiss = { showFullError = null }
+    )
+
+    Box(
+        modifier = Modifier
+            .fillMaxWidth()
+            .adaptiveHorizontalPadding(vertical = 8.dp),
+        contentAlignment = Alignment.Center
+    ) {
+
+        AnimatedContent(
+            targetState = Triple(isLoading, errorMsg, isEnd),
+            label = "LoadMoreFooter"
+        ) { (loading, error, end) ->
+
+            when {
+                error != null -> {
+
+                    GlassCard(
+                        onClick = { showFullError = error },
+                        containerColor = LegadoTheme.colorScheme.errorContainer.copy(alpha = 0.6f),
+                    ) {
+                        Column(
+                            modifier = Modifier.fillMaxWidth()
+                        ) {
+
+                            // 信息区域
+                            Row(
+                                modifier = Modifier
+                                    .fillMaxWidth()
+                                    .padding(
+                                        all = 16.dp
+                                    ),
+                                verticalAlignment = Alignment.CenterVertically,
+                                horizontalArrangement = Arrangement.spacedBy(12.dp)
+                            ) {
+
+                                AppIcon(
+                                    imageVector = Icons.Outlined.Info,
+                                    contentDescription = null,
+                                    tint = LegadoTheme.colorScheme.error
+                                )
+
+                                AppText(
+                                    text = error,
+                                    color = LegadoTheme.colorScheme.error,
+                                    style = LegadoTheme.typography.bodySmall,
+                                    modifier = Modifier.weight(1f),
+                                    maxLines = 2,
+                                    overflow = TextOverflow.Ellipsis,
+                                )
+                            }
+
+                            HorizontalDivider(
+                                color = LegadoTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)
+                            )
+
+                            // 操作区域
+                            Box(
+                                modifier = Modifier
+                                    .fillMaxWidth()
+                                    .clickable(onClick = onRetry)
+                                    .padding(vertical = 10.dp),
+                                contentAlignment = Alignment.Center
+                            ) {
+
+                                Row(
+                                    horizontalArrangement = Arrangement.spacedBy(8.dp),
+                                    verticalAlignment = Alignment.CenterVertically
+                                ) {
+
+                                    AppIcon(
+                                        imageVector = Icons.Default.Refresh,
+                                        contentDescription = null,
+                                        tint = LegadoTheme.colorScheme.error
+                                    )
+
+                                    AppText(
+                                        text = "重新加载",
+                                        color = LegadoTheme.colorScheme.error,
+                                        style = LegadoTheme.typography.labelMedium
+                                    )
+                                }
+                            }
+                        }
+                    }
+                }
+
+                loading -> {
+                    Column(
+                        horizontalAlignment = Alignment.CenterHorizontally,
+                        verticalArrangement = Arrangement.spacedBy(12.dp)
+                    ) {
+
+                        AppContainedLoadingIndicator()
+
+                        AppText(
+                            text = "正在加载更多内容…",
+                            color = LegadoTheme.colorScheme.outline,
+                            style = LegadoTheme.typography.bodySmall
+                        )
+                    }
+                }
+
+                end -> {
+                    GlassCard(
+                        modifier = Modifier
+                            .fillMaxWidth(),
+                        containerColor = LegadoTheme.colorScheme.surfaceContainer,
+                    ) {
+                        Column(
+                            modifier = Modifier.fillMaxWidth()
+                        ) {
+                            Row(
+                                modifier = Modifier
+                                    .fillMaxWidth()
+                                    .padding(
+                                        all = 16.dp
+                                    ),
+                                verticalAlignment = Alignment.CenterVertically,
+                                horizontalArrangement = Arrangement.spacedBy(12.dp)
+                            ) {
+
+                                AppIcon(
+                                    imageVector = Icons.Outlined.Info,
+                                    contentDescription = null,
+                                    tint = LegadoTheme.colorScheme.onSurface
+                                )
+
+                                AppText(
+                                    text = "已经到底了~",
+                                    color = LegadoTheme.colorScheme.onSurface,
+                                    style = LegadoTheme.typography.bodySmall,
+                                    modifier = Modifier.weight(1f),
+                                    maxLines = 2,
+                                    overflow = TextOverflow.Ellipsis,
+                                )
+                            }
+                        }
+                    }
+                }
+
+                else -> {
+                    GlassCard(
+                        modifier = Modifier
+                            .fillMaxWidth(),
+                        containerColor = LegadoTheme.colorScheme.surfaceContainer,
+                        onClick = onRetry
+                    ) {
+                        Column(
+                            modifier = Modifier.fillMaxWidth()
+                        ) {
+                            Row(
+                                modifier = Modifier
+                                    .fillMaxWidth()
+                                    .padding(
+                                        all = 16.dp
+                                    ),
+                                verticalAlignment = Alignment.CenterVertically,
+                                horizontalArrangement = Arrangement.spacedBy(12.dp)
+                            ) {
+
+                                AppIcon(
+                                    imageVector = Icons.Outlined.Info,
+                                    contentDescription = null,
+                                    tint = LegadoTheme.colorScheme.onSurface
+                                )
+
+                                AppText(
+                                    text = "加载更多",
+                                    color = LegadoTheme.colorScheme.onSurface,
+                                    style = LegadoTheme.typography.bodySmall,
+                                    modifier = Modifier.weight(1f),
+                                    maxLines = 2,
+                                    overflow = TextOverflow.Ellipsis,
+                                )
+                            }
+                        }
+                    }
+                    AnimatedTextButton(
+                        isLoading = false,
+                        onClick = onRetry,
+                        text = "尝试加载更多"
+                    )
+                }
+            }
+        }
+    }
+}

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

@@ -5,6 +5,9 @@ import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.foundation.verticalScroll
 import androidx.compose.material3.AlertDialog
 import androidx.compose.material3.AlertDialogDefaults
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -98,12 +101,16 @@ fun AppAlertDialog(
                 tonalElevation = AlertDialogDefaults.TonalElevation,
                 title = title?.let { { Text(text = it) } },
                 text = {
-                    Column {
+                    Column(
+                        modifier = Modifier.verticalScroll(rememberScrollState())
+                    ) {
                         if (text != null) {
-                            Text(
-                                text = text,
-                                modifier = Modifier.padding(bottom = if (content != null) 16.dp else 0.dp)
-                            )
+                            SelectionContainer {
+                                Text(
+                                    text = text,
+                                    modifier = Modifier.padding(bottom = if (content != null) 16.dp else 0.dp)
+                                )
+                            }
                         }
                         if (content != null) {
                             content()
@@ -161,16 +168,17 @@ fun <T> AppAlertDialog(
     val currentData = cachedData
     if (currentData != null) {
         val currentText = text ?: textProvider?.invoke(currentData)
-        var cachedText by remember { mutableStateOf(currentText) }
+        var lastValidText by remember { mutableStateOf(currentText) }
+        
         if (currentText != null) {
-            cachedText = currentText
+            lastValidText = currentText
         }
 
         AppAlertDialog(
             show = data != null,
             onDismissRequest = onDismissRequest,
             title = title,
-            text = currentText ?: cachedText,
+            text = currentText ?: lastValidText,
             modifier = modifier,
             confirmText = confirmText,
             onConfirm = onConfirm?.let { { it(currentData) } },

+ 15 - 11
app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt

@@ -82,7 +82,7 @@ fun SmallTextButton(
 @Composable
 fun SmallTonalTextButton(
     text: String? = null,
-    imageVector: ImageVector,
+    imageVector: ImageVector? = null,
     modifier: Modifier = Modifier,
     onClick: () -> Unit
 ) {
@@ -102,11 +102,13 @@ fun SmallTonalTextButton(
                 horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
                 verticalAlignment = Alignment.CenterVertically
             ) {
-                MiuixIcon(
-                    imageVector = imageVector,
-                    contentDescription = null,
-                    modifier = Modifier.size(16.dp)
-                )
+                if (imageVector != null) {
+                    MiuixIcon(
+                        imageVector = imageVector,
+                        contentDescription = null,
+                        modifier = Modifier.size(16.dp)
+                    )
+                }
                 if (text != null) {
                     AppText(
                         text = text,
@@ -121,11 +123,13 @@ fun SmallTonalTextButton(
             modifier = modifier,
             contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)
         ) {
-            Icon(
-                imageVector = imageVector,
-                contentDescription = null,
-                modifier = Modifier.size(16.dp)
-            )
+            if (imageVector != null) {
+                Icon(
+                    imageVector = imageVector,
+                    contentDescription = null,
+                    modifier = Modifier.size(16.dp)
+                )
+            }
             Spacer(Modifier.width(4.dp))
             if (text != null) {
                 AppText(

+ 21 - 9
app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindItem.kt

@@ -3,10 +3,8 @@ package io.legado.app.ui.widget.components.explore
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.CardDefaults
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.LocalMinimumInteractiveComponentSize
-import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.ui.Alignment
@@ -16,11 +14,9 @@ import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import io.legado.app.data.entities.rule.ExploreKind
-import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.text.AppText
-import top.yukonga.miuix.kmp.theme.MiuixTheme
 
 @Composable
 fun ExploreKindItem(
@@ -31,6 +27,7 @@ fun ExploreKindItem(
     isMiuix: Boolean,
     backgroundColor: androidx.compose.ui.graphics.Color = LegadoTheme.colorScheme.surfaceContainer,
     displayText: String = kind.title,
+    isSelected: Boolean = false,
     trailingIcon: (@Composable () -> Unit)? = null
 ) {
     CompositionLocalProvider(
@@ -38,31 +35,45 @@ fun ExploreKindItem(
     ) {
 
         val cornerRadius = 12.dp
+        val containerColor = if (isSelected) {
+            LegadoTheme.colorScheme.primaryContainer
+        } else {
+            backgroundColor
+        }
+        val contentColor = if (isSelected) {
+            LegadoTheme.colorScheme.onPrimaryContainer
+        } else if (isClickable) {
+            LegadoTheme.colorScheme.onSurface
+        } else {
+            LegadoTheme.colorScheme.primary
+        }
 
         if (isClickable) {
             GlassCard(
                 onClick = onClick,
                 cornerRadius = cornerRadius,
-                containerColor = backgroundColor,
-                contentColor = LegadoTheme.colorScheme.onSurface,
+                containerColor = containerColor,
+                contentColor = contentColor,
                 modifier = modifier,
             ) {
                 KindText(
                     text = displayText,
                     isClickable = true,
+                    contentColor = contentColor,
                     trailingIcon = trailingIcon
                 )
             }
         } else {
             GlassCard(
                 cornerRadius = cornerRadius,
-                containerColor = backgroundColor,
-                contentColor = LegadoTheme.colorScheme.primary,
+                containerColor = containerColor,
+                contentColor = contentColor,
                 modifier = modifier,
             ) {
                 KindText(
                     text = displayText,
                     isClickable = false,
+                    contentColor = contentColor,
                     trailingIcon = trailingIcon
                 )
             }
@@ -75,6 +86,7 @@ fun ExploreKindItem(
 private fun KindText(
     text: String,
     isClickable: Boolean,
+    contentColor: androidx.compose.ui.graphics.Color = LegadoTheme.colorScheme.onSurface,
     trailingIcon: (@Composable () -> Unit)? = null
 ) {
     Box(
@@ -84,7 +96,7 @@ private fun KindText(
     ) {
         AppText(
             text = text,
-            color = if (isClickable) LegadoTheme.colorScheme.onSurface else LegadoTheme.colorScheme.primary,
+            color = contentColor,
             modifier = Modifier
                 .fillMaxWidth()
                 .padding(end = if (trailingIcon == null) 0.dp else 18.dp),

+ 85 - 0
app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindItemState.kt

@@ -0,0 +1,85 @@
+package io.legado.app.ui.widget.components.explore
+
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import io.legado.app.data.entities.rule.ExploreKind
+import io.legado.app.domain.usecase.ExploreKindUiUseCase
+import io.legado.app.help.source.getExploreInfoMap
+import io.legado.app.utils.InfoMap
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.launch
+
+/**
+ * 封装 ExploreKind 的业务状态与交互逻辑
+ */
+@Stable
+class ExploreKindItemState(
+    val kind: ExploreKind,
+    val sourceUrl: String?,
+    private val useCase: ExploreKindUiUseCase?,
+    private val scope: CoroutineScope,
+    private val activity: AppCompatActivity?,
+    private val onRefreshKinds: () -> Unit
+) {
+    val infoMap: InfoMap? = if (useCase == null) null else sourceUrl?.takeIf { it.isNotBlank() }
+        ?.let(::getExploreInfoMap)
+    var displayName by mutableStateOf(kind.title)
+        internal set
+
+    fun executeAction(action: String?) {
+        if (action.isNullOrBlank()) return
+        val useCase = useCase ?: return
+        scope.launch(IO) {
+            useCase.executeAction(
+                action = action,
+                title = kind.title,
+                sourceUrl = sourceUrl,
+                infoMap = infoMap,
+                activity = activity,
+                onRefreshKinds = onRefreshKinds
+            )
+        }
+    }
+
+    fun updateValue(value: String, onValueChange: ((String) -> Unit)?) {
+        if (onValueChange != null) {
+            onValueChange(value)
+        } else {
+            infoMap?.let {
+                it[kind.title] = value
+                it.saveNow()
+            }
+        }
+    }
+
+    @Composable
+    fun ResolveDisplayName(override: String?) {
+        LaunchedEffect(override, sourceUrl, kind.title, kind.viewName, useCase) {
+            displayName = override
+                ?: useCase?.resolveDisplayName(kind, sourceUrl, infoMap)
+                        ?: kind.title
+        }
+    }
+}
+
+@Composable
+fun rememberExploreKindItemState(
+    kind: ExploreKind,
+    sourceUrl: String?,
+    useCase: ExploreKindUiUseCase?,
+    activity: AppCompatActivity?,
+    onRefreshKinds: () -> Unit
+): ExploreKindItemState {
+    val scope = rememberCoroutineScope()
+    return remember(kind, sourceUrl, useCase, activity) {
+        ExploreKindItemState(kind, sourceUrl, useCase, scope, activity, onRefreshKinds)
+    }
+}

+ 303 - 247
app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindMultiTypeItem.kt

@@ -1,14 +1,9 @@
 package io.legado.app.ui.widget.components.explore
 
 import androidx.appcompat.app.AppCompatActivity
-import androidx.compose.foundation.background
-import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.text.BasicTextField
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
 import androidx.compose.material.icons.filled.Refresh
@@ -22,24 +17,18 @@ import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
 import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.SolidColor
 import androidx.compose.ui.platform.LocalLayoutDirection
-import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.LayoutDirection
 import androidx.compose.ui.unit.dp
 import io.legado.app.data.entities.rule.ExploreKind
 import io.legado.app.domain.usecase.ExploreKindUiUseCase
-import io.legado.app.help.source.getExploreInfoMap
 import io.legado.app.ui.theme.LegadoTheme
 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.text.AppText
 import io.legado.app.ui.widget.dialog.TextDialog
 import io.legado.app.utils.showDialogFragment
-import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.launch
@@ -56,295 +45,362 @@ fun ExploreKindMultiTypeItem(
     isMiuix: Boolean,
     displayNameOverride: String? = null,
     valueOverride: String? = null,
+    isSelected: Boolean = false,
     onValueChange: ((String) -> Unit)? = null,
     onRunAction: (() -> Unit)? = null,
-    useCase: ExploreKindUiUseCase? = null
+    useCase: ExploreKindUiUseCase? = null,
+    onClick: (() -> Unit)? = null,
+    content: (@Composable (displayName: String, isSelected: Boolean, onClick: () -> Unit, trailingIcon: @Composable (() -> Unit)?) -> Unit)? = null
 ) {
-    val scope = rememberCoroutineScope()
-    val infoMap = remember(sourceUrl, useCase) {
-        if (useCase == null) null else sourceUrl?.takeIf { it.isNotBlank() }?.let(::getExploreInfoMap)
-    }
-    var displayName by remember(sourceUrl, kind.title, kind.viewName) { mutableStateOf(kind.title) }
+    val state = rememberExploreKindItemState(kind, sourceUrl, useCase, activity, onRefreshKinds)
+    state.ResolveDisplayName(displayNameOverride)
 
-    LaunchedEffect(displayNameOverride, sourceUrl, kind.title, kind.viewName, useCase) {
-        displayName = displayNameOverride
-            ?: useCase?.resolveDisplayName(kind, sourceUrl, infoMap)
-                    ?: kind.title
-    }
+    val trailingIcon = rememberTrailingIcon(kind.type, isSelected)
 
-    fun runAction(action: String?) {
-        if (action.isNullOrBlank()) return
-        if (onRunAction != null) {
-            onRunAction()
+    if (onClick != null) {
+        if (content != null) {
+            content(state.displayName, isSelected, onClick, trailingIcon)
         } else {
-            val useCase = useCase ?: return
-            scope.launch(IO) {
-                useCase.executeAction(
-                    action = action,
-                    title = kind.title,
-                    sourceUrl = sourceUrl,
-                    infoMap = infoMap,
-                    activity = activity,
-                    onRefreshKinds = onRefreshKinds
-                )
-            }
-        }
-    }
-
-    fun updateValue(value: String) {
-        if (onValueChange != null) {
-            onValueChange(value)
-        } else {
-            infoMap?.let {
-                it[kind.title] = value
-                it.saveNow()
-            }
+            ExploreKindItem(
+                kind = kind,
+                isClickable = true,
+                onClick = onClick,
+                modifier = modifier,
+                backgroundColor = backgroundColor,
+                isMiuix = isMiuix,
+                displayText = state.displayName,
+                isSelected = isSelected,
+                trailingIcon = trailingIcon
+            )
         }
+        return
     }
 
     when (kind.type) {
         ExploreKind.Type.url -> {
             val url = kind.url?.takeIf { it.isNotBlank() }
-            ExploreKindItem(
-                kind = kind,
-                isClickable = !url.isNullOrBlank(),
-                onClick = {
-                    if (url.isNullOrBlank()) return@ExploreKindItem
+            val internalOnClick = {
+                if (!url.isNullOrBlank()) {
                     if (kind.title.startsWith("ERROR:")) {
                         activity?.showDialogFragment(TextDialog("ERROR", url))
                     } else {
                         onOpenUrl(url)
                     }
-                },
-                modifier = modifier,
-                backgroundColor = backgroundColor,
-                isMiuix = isMiuix,
-                displayText = displayName
-            )
+                }
+            }
+            if (content != null) {
+                content(state.displayName, isSelected, internalOnClick, trailingIcon)
+            } else {
+                ExploreKindItem(
+                    kind = kind,
+                    isClickable = !url.isNullOrBlank(),
+                    onClick = internalOnClick,
+                    modifier = modifier,
+                    backgroundColor = backgroundColor,
+                    isMiuix = isMiuix,
+                    displayText = state.displayName,
+                    isSelected = isSelected
+                )
+            }
         }
 
         ExploreKind.Type.button -> {
-            ExploreKindItem(
-                kind = kind,
-                isClickable = !kind.action.isNullOrBlank(),
-                onClick = { runAction(kind.action) },
-                modifier = modifier,
-                backgroundColor = backgroundColor,
-                isMiuix = isMiuix,
-                displayText = displayName,
-                trailingIcon = {
-                    CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
-                        AppIcon(
-                            imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
-                            contentDescription = null,
-                            modifier = Modifier.height(14.dp),
-                            tint = LegadoTheme.colorScheme.outlineVariant
-                        )
-                    }
-                }
-            )
+            val internalOnClick = {
+                if (onRunAction != null) onRunAction()
+                else state.executeAction(kind.action)
+            }
+            if (content != null) {
+                content(state.displayName, isSelected, internalOnClick, trailingIcon)
+            } else {
+                ExploreKindItem(
+                    kind = kind,
+                    isClickable = !kind.action.isNullOrBlank(),
+                    onClick = internalOnClick,
+                    modifier = modifier,
+                    backgroundColor = backgroundColor,
+                    isMiuix = isMiuix,
+                    displayText = state.displayName,
+                    isSelected = isSelected,
+                    trailingIcon = trailingIcon
+                )
+            }
         }
 
         ExploreKind.Type.text -> {
-            var value by remember(sourceUrl, kind.title) {
-                mutableStateOf(valueOverride ?: infoMap?.get(kind.title).orEmpty())
-            }
-            LaunchedEffect(valueOverride) {
-                if (valueOverride != null) {
-                    value = valueOverride
-                }
+            if (content != null) {
+                content(state.displayName, isSelected, {}, null)
+            } else {
+                TextTypeItem(
+                    kind,
+                    sourceUrl,
+                    state,
+                    valueOverride,
+                    onValueChange,
+                    modifier,
+                    backgroundColor
+                )
             }
-            var actionJob by remember(sourceUrl, kind.title) { mutableStateOf<Job?>(null) }
-            ExploreKindCompactTextField(
-                value = value,
-                onValueChange = { newValue ->
-                    value = newValue
-                    updateValue(newValue)
-                    if (!kind.action.isNullOrBlank()) {
-                        actionJob?.cancel()
-                        actionJob = scope.launch {
-                            delay(600)
-                            runAction(kind.action)
-                        }
-                    }
-                },
-                placeholder = displayName,
-                modifier = modifier,
-                backgroundColor = backgroundColor,
-                isMiuix = isMiuix
-            )
         }
 
         ExploreKind.Type.toggle -> {
-            val chars = remember(kind.chars) {
-                kind.chars?.filterNotNull().takeUnless { it.isNullOrEmpty() } ?: listOf("chars", "is null")
-            }
-            val left = kind.style().layout_justifySelf != "right"
-            var char by remember(sourceUrl, kind.title, kind.default, kind.chars) {
-                mutableStateOf(
-                    valueOverride
-                        ?: infoMap?.get(kind.title)
-                        ?.takeUnless { it.isEmpty() }
-                        ?: (kind.default ?: chars.first()).also {
-                            infoMap?.let { map ->
-                                map[kind.title] = it
-                                map.saveNow()
-                            }
-                        }
-                )
-            }
-            LaunchedEffect(valueOverride) {
-                if (valueOverride != null) {
-                    char = valueOverride
-                }
-            }
-            val text = if (left) "$char$displayName" else "$displayName$char"
-            ExploreKindItem(
-                kind = kind,
-                isClickable = true,
-                onClick = {
-                    val currentIndex = chars.indexOf(char)
-                    val nextIndex = if (currentIndex < 0) 0 else (currentIndex + 1) % chars.size
-                    char = chars.getOrElse(nextIndex) { "" }
-                    updateValue(char)
-                    runAction(kind.action)
-                },
-                modifier = modifier,
-                backgroundColor = backgroundColor,
-                isMiuix = isMiuix,
-                displayText = text,
-                trailingIcon = {
-                    AppIcon(
-                        imageVector = Icons.Default.Refresh,
-                        contentDescription = null,
-                        modifier = Modifier.height(14.dp),
-                        tint = LegadoTheme.colorScheme.outlineVariant
-                    )
-                }
+            ToggleTypeItem(
+                kind,
+                sourceUrl,
+                state,
+                valueOverride,
+                onValueChange,
+                isSelected,
+                modifier,
+                backgroundColor,
+                isMiuix,
+                trailingIcon,
+                content
             )
         }
 
         ExploreKind.Type.select -> {
-            val chars = remember(kind.chars) {
-                kind.chars?.filterNotNull().takeUnless { it.isNullOrEmpty() } ?: listOf("chars", "is null")
-            }
-            var selected by remember(sourceUrl, kind.title, kind.default, kind.chars) {
-                mutableStateOf(
-                    valueOverride
-                        ?: infoMap?.get(kind.title)
-                        ?.takeUnless { it.isEmpty() }
-                        ?: (kind.default ?: chars.first()).also {
-                            infoMap?.let { map ->
-                                map[kind.title] = it
-                                map.saveNow()
-                            }
-                        }
-                )
-            }
-            LaunchedEffect(valueOverride) {
-                if (valueOverride != null) {
-                    selected = valueOverride
-                }
-            }
-            var showSelector by remember(sourceUrl, kind.title) { mutableStateOf(false) }
-            Box(modifier = modifier) {
+            SelectTypeItem(
+                kind,
+                sourceUrl,
+                state,
+                valueOverride,
+                onValueChange,
+                isSelected,
+                modifier,
+                backgroundColor,
+                isMiuix,
+                trailingIcon,
+                content
+            )
+        }
+
+        else -> {
+            if (content != null) {
+                content(state.displayName, isSelected, {}, null)
+            } else {
                 ExploreKindItem(
                     kind = kind,
-                    isClickable = chars.isNotEmpty(),
-                    onClick = { showSelector = true },
-                    modifier = Modifier.fillMaxWidth(),
+                    isClickable = false,
+                    onClick = {},
+                    modifier = modifier,
                     backgroundColor = backgroundColor,
                     isMiuix = isMiuix,
-                    displayText = "$displayName $selected",
-                    trailingIcon = {
-                        AppIcon(
-                            imageVector = Icons.Default.UnfoldMore,
-                            contentDescription = null,
-                            modifier = Modifier.height(14.dp),
-                            tint = LegadoTheme.colorScheme.outlineVariant
-                        )
-                    }
+                    displayText = state.displayName
                 )
-                RoundDropdownMenu(
-                    expanded = showSelector,
-                    onDismissRequest = { showSelector = false }
-                ) {
-                    chars.forEach { option ->
-                        RoundDropdownMenuItem(
-                            text = option,
-                            onClick = {
-                                showSelector = false
-                                if (selected != option) {
-                                    selected = option
-                                    updateValue(option)
-                                    runAction(kind.action)
-                                }
-                            }
-                        )
-                    }
-                }
             }
         }
+    }
+}
 
-        else -> {
+@Composable
+private fun TextTypeItem(
+    kind: ExploreKind,
+    sourceUrl: String?,
+    state: ExploreKindItemState,
+    valueOverride: String?,
+    onValueChange: ((String) -> Unit)?,
+    modifier: Modifier,
+    backgroundColor: Color
+) {
+    val scope = rememberCoroutineScope()
+    var value by remember(sourceUrl, kind.title) {
+        mutableStateOf(valueOverride ?: state.infoMap?.get(kind.title).orEmpty())
+    }
+    LaunchedEffect(valueOverride) {
+        if (valueOverride != null) value = valueOverride
+    }
+    var actionJob by remember(sourceUrl, kind.title) { mutableStateOf<Job?>(null) }
+    ExploreKindCompactTextField(
+        value = value,
+        onValueChange = { newValue ->
+            value = newValue
+            state.updateValue(newValue, onValueChange)
+            if (!kind.action.isNullOrBlank()) {
+                actionJob?.cancel()
+                actionJob = scope.launch {
+                    delay(600)
+                    state.executeAction(kind.action)
+                }
+            }
+        },
+        placeholder = state.displayName,
+        modifier = modifier,
+        backgroundColor = backgroundColor
+    )
+}
+
+@Composable
+private fun ToggleTypeItem(
+    kind: ExploreKind,
+    sourceUrl: String?,
+    state: ExploreKindItemState,
+    valueOverride: String?,
+    onValueChange: ((String) -> Unit)?,
+    isSelected: Boolean,
+    modifier: Modifier,
+    backgroundColor: Color,
+    isMiuix: Boolean,
+    trailingIcon: @Composable (() -> Unit)?,
+    content: (@Composable (displayName: String, isSelected: Boolean, onClick: () -> Unit, trailingIcon: @Composable (() -> Unit)?) -> Unit)?
+) {
+    val chars = remember(kind.chars) {
+        kind.chars?.filterNotNull().takeUnless { it.isNullOrEmpty() } ?: listOf("chars", "is null")
+    }
+    val left = kind.style().layout_justifySelf != "right"
+    var char by remember(sourceUrl, kind.title, kind.default, kind.chars) {
+        mutableStateOf(
+            valueOverride
+                ?: state.infoMap?.get(kind.title)
+                    ?.takeUnless { it.isEmpty() }
+                ?: (kind.default ?: chars.first()).also {
+                    state.updateValue(it, onValueChange)
+                }
+        )
+    }
+    LaunchedEffect(valueOverride) {
+        if (valueOverride != null) char = valueOverride
+    }
+    val text = if (left) "$char${state.displayName}" else "${state.displayName}$char"
+    val internalOnClick = {
+        val currentIndex = chars.indexOf(char)
+        val nextIndex = if (currentIndex < 0) 0 else (currentIndex + 1) % chars.size
+        char = chars.getOrElse(nextIndex) { "" }
+        state.updateValue(char, onValueChange)
+        state.executeAction(kind.action)
+    }
+
+    if (content != null) {
+        content(text, isSelected, internalOnClick, trailingIcon)
+    } else {
+        ExploreKindItem(
+            kind = kind,
+            isClickable = true,
+            onClick = internalOnClick,
+            modifier = modifier,
+            backgroundColor = backgroundColor,
+            isMiuix = isMiuix,
+            displayText = text,
+            isSelected = isSelected,
+            trailingIcon = trailingIcon
+        )
+    }
+}
+
+@Composable
+private fun SelectTypeItem(
+    kind: ExploreKind,
+    sourceUrl: String?,
+    state: ExploreKindItemState,
+    valueOverride: String?,
+    onValueChange: ((String) -> Unit)?,
+    isSelected: Boolean,
+    modifier: Modifier,
+    backgroundColor: Color,
+    isMiuix: Boolean,
+    trailingIcon: @Composable (() -> Unit)?,
+    content: (@Composable (displayName: String, isSelected: Boolean, onClick: () -> Unit, trailingIcon: @Composable (() -> Unit)?) -> Unit)?
+) {
+    val chars = remember(kind.chars) {
+        kind.chars?.filterNotNull().takeUnless { it.isNullOrEmpty() } ?: listOf("chars", "is null")
+    }
+    var selected by remember(sourceUrl, kind.title, kind.default, kind.chars) {
+        mutableStateOf(
+            valueOverride
+                ?: state.infoMap?.get(kind.title)
+                    ?.takeUnless { it.isEmpty() }
+                ?: (kind.default ?: chars.first()).also {
+                    state.updateValue(it, onValueChange)
+                }
+        )
+    }
+    LaunchedEffect(valueOverride) {
+        if (valueOverride != null) selected = valueOverride
+    }
+    var showSelector by remember(sourceUrl, kind.title) { mutableStateOf(false) }
+
+    Box(modifier = modifier) {
+        val internalOnClick = { showSelector = true }
+        val displayText = "${state.displayName} $selected"
+
+        if (content != null) {
+            content(displayText, isSelected, internalOnClick, trailingIcon)
+        } else {
             ExploreKindItem(
                 kind = kind,
-                isClickable = false,
-                onClick = {},
-                modifier = modifier,
+                isClickable = chars.isNotEmpty(),
+                onClick = internalOnClick,
+                modifier = Modifier.fillMaxWidth(),
                 backgroundColor = backgroundColor,
                 isMiuix = isMiuix,
-                displayText = displayName
+                displayText = displayText,
+                isSelected = isSelected,
+                trailingIcon = trailingIcon
             )
         }
+
+        RoundDropdownMenu(
+            expanded = showSelector,
+            onDismissRequest = { showSelector = false }
+        ) {
+            chars.forEach { option ->
+                RoundDropdownMenuItem(
+                    text = option,
+                    onClick = {
+                        showSelector = false
+                        if (selected != option) {
+                            selected = option
+                            state.updateValue(option, onValueChange)
+                            state.executeAction(kind.action)
+                        }
+                    }
+                )
+            }
+        }
     }
 }
 
 @Composable
-private fun ExploreKindCompactTextField(
-    value: String,
-    onValueChange: (String) -> Unit,
-    placeholder: String,
-    modifier: Modifier = Modifier,
-    backgroundColor: Color = LegadoTheme.colorScheme.surfaceContainer,
-    isMiuix: Boolean
-) {
-    val interactionSource = remember { MutableInteractionSource() }
-    val shape = RoundedCornerShape(10.dp)
+private fun rememberTrailingIcon(type: String, isSelected: Boolean): @Composable (() -> Unit)? {
+    return remember(type, isSelected) {
+        when (type) {
+            ExploreKind.Type.button -> {
+                {
+                    CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
+                        AppIcon(
+                            imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
+                            contentDescription = null,
+                            modifier = Modifier.height(14.dp),
+                            tint = if (isSelected) LegadoTheme.colorScheme.onPrimaryContainer.copy(
+                                alpha = 0.7f
+                            ) else LegadoTheme.colorScheme.outlineVariant
+                        )
+                    }
+                }
+            }
 
-    BasicTextField(
-        value = value,
-        onValueChange = onValueChange,
-        singleLine = true,
-        textStyle = LegadoTheme.typography.bodySmall.copy(color = LegadoTheme.colorScheme.onSurface),
-        cursorBrush = SolidColor(LegadoTheme.colorScheme.primary),
-        interactionSource = interactionSource,
-        modifier = modifier
-            .height(34.dp)
-            .clip(shape)
-            .background(backgroundColor),
-        decorationBox = { innerTextField ->
-            Box(
-                modifier = Modifier
-                    .fillMaxWidth()
-                    .height(34.dp)
-                    .padding(horizontal = 10.dp),
-                contentAlignment = androidx.compose.ui.Alignment.CenterStart
-            ) {
-                if (value.isEmpty()) {
-                    AppText(
-                        text = placeholder,
-                        color = LegadoTheme.colorScheme.outline,
-                        style = LegadoTheme.typography.bodySmall,
-                        maxLines = 1,
-                        overflow = TextOverflow.Ellipsis,
-                        modifier = Modifier.fillMaxWidth()
+            ExploreKind.Type.toggle -> {
+                {
+                    AppIcon(
+                        imageVector = Icons.Default.Refresh,
+                        contentDescription = null,
+                        modifier = Modifier.height(14.dp),
+                        tint = if (isSelected) LegadoTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) else LegadoTheme.colorScheme.outlineVariant
                     )
                 }
-                Box(modifier = Modifier.fillMaxWidth()) {
-                    innerTextField()
+            }
+
+            ExploreKind.Type.select -> {
+                {
+                    AppIcon(
+                        imageVector = Icons.Default.UnfoldMore,
+                        contentDescription = null,
+                        modifier = Modifier.height(14.dp),
+                        tint = if (isSelected) LegadoTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) else LegadoTheme.colorScheme.outlineVariant
+                    )
                 }
             }
+
+            else -> null
         }
-    )
+    }
 }

+ 156 - 0
app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindSelectSheet.kt

@@ -0,0 +1,156 @@
+package io.legado.app.ui.widget.components.explore
+
+import androidx.appcompat.app.AppCompatActivity
+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.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material3.ExperimentalMaterial3Api
+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.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import io.legado.app.data.entities.rule.ExploreKind
+import io.legado.app.data.repository.ExploreRepository
+import io.legado.app.domain.usecase.ExploreKindUiUseCase
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.ThemeResolver
+import io.legado.app.ui.widget.components.SearchBar
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.topbar.TopBarActionButton
+import org.koin.compose.koinInject
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ExploreKindSelectSheet(
+    show: Boolean,
+    onDismissRequest: () -> Unit,
+    sourceUrl: String?,
+    onSelected: (List<ExploreKind>) -> Unit,
+    multiple: Boolean = false,
+    initialSelectedTitles: List<String> = emptyList(),
+    repository: ExploreRepository = koinInject(),
+    useCase: ExploreKindUiUseCase = koinInject()
+) {
+    var kinds by remember { mutableStateOf<List<ExploreKind>>(emptyList()) }
+    var selectedTitles by remember(initialSelectedTitles, show) {
+        mutableStateOf(initialSelectedTitles.toSet())
+    }
+    var query by remember { mutableStateOf("") }
+    val context = LocalContext.current
+    val activity = context as? AppCompatActivity
+    val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
+
+    LaunchedEffect(show, sourceUrl) {
+        if (show && !sourceUrl.isNullOrBlank()) {
+            kinds = repository.getSourceExploreKinds(sourceUrl)
+        }
+    }
+
+    val filteredKinds = remember(query, kinds) {
+        if (query.isBlank()) kinds
+        else kinds.filter { kind ->
+            kind.title.contains(query, ignoreCase = true) ||
+                    (kind.url?.contains(query, ignoreCase = true) == true)
+        }
+    }
+    val kindRows = remember(filteredKinds) {
+        calculateExploreKindRows(filteredKinds, 6)
+    }
+
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        endAction = {
+            if (multiple && selectedTitles.isNotEmpty()) {
+                TopBarActionButton(
+                    onClick = {
+                        val selectedKinds = kinds.filter { it.title in selectedTitles }
+                        onSelected(selectedKinds)
+                        onDismissRequest()
+                    },
+                    imageVector = Icons.Default.Check,
+                    contentDescription = "Confirm"
+                )
+            }
+        }
+    ) {
+        Column {
+            SearchBar(
+                query = query,
+                backgroundColor = LegadoTheme.colorScheme.onSheetContent,
+                onQueryChange = { query = it },
+                placeholder = "选择或搜索分类",
+                autoFocus = false
+            )
+
+            LazyColumn(
+                contentPadding = PaddingValues(vertical = 16.dp),
+                modifier = Modifier.weight(1f, fill = false)
+            ) {
+                items(kindRows) { rowItems ->
+                    Row(
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .animateItem()
+                            .padding(vertical = 4.dp),
+                        horizontalArrangement = Arrangement.spacedBy(8.dp)
+                    ) {
+                        rowItems.forEach { (kind, span) ->
+                            val isSelected = kind.title in selectedTitles
+                            ExploreKindMultiTypeItem(
+                                modifier = Modifier
+                                    .weight(span.toFloat())
+                                    .animateItem(),
+                                kind = kind,
+                                sourceUrl = sourceUrl,
+                                activity = activity,
+                                onOpenUrl = { url ->
+                                    if (!multiple) {
+                                        onSelected(listOf(kind.copy(url = url)))
+                                        onDismissRequest()
+                                    }
+                                },
+                                isSelected = isSelected,
+                                onClick = {
+                                    if (multiple) {
+                                        selectedTitles = if (isSelected) {
+                                            selectedTitles - kind.title
+                                        } else {
+                                            selectedTitles + kind.title
+                                        }
+                                    } else {
+                                        onSelected(listOf(kind))
+                                        onDismissRequest()
+                                    }
+                                },
+                                backgroundColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.5f),
+                                isMiuix = isMiuix,
+                                useCase = useCase
+                            )
+                        }
+
+                        val totalSpan = rowItems.sumOf { it.second }
+                        if (totalSpan < 6) {
+                            Spacer(
+                                modifier = Modifier.weight((6 - totalSpan).toFloat())
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+}

+ 69 - 0
app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindTextField.kt

@@ -0,0 +1,69 @@
+package io.legado.app.ui.widget.components.explore
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.text.AppText
+
+@Composable
+fun ExploreKindCompactTextField(
+    value: String,
+    onValueChange: (String) -> Unit,
+    placeholder: String,
+    modifier: Modifier = Modifier,
+    backgroundColor: Color = LegadoTheme.colorScheme.surfaceContainer,
+) {
+    val interactionSource = remember { MutableInteractionSource() }
+    val shape = RoundedCornerShape(10.dp)
+
+    BasicTextField(
+        value = value,
+        onValueChange = onValueChange,
+        singleLine = true,
+        textStyle = LegadoTheme.typography.bodySmall.copy(color = LegadoTheme.colorScheme.onSurface),
+        cursorBrush = SolidColor(LegadoTheme.colorScheme.primary),
+        interactionSource = interactionSource,
+        modifier = modifier
+            .height(34.dp)
+            .clip(shape)
+            .background(backgroundColor),
+        decorationBox = { innerTextField ->
+            Box(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .height(34.dp)
+                    .padding(horizontal = 10.dp),
+                contentAlignment = Alignment.CenterStart
+            ) {
+                if (value.isEmpty()) {
+                    AppText(
+                        text = placeholder,
+                        color = LegadoTheme.colorScheme.outline,
+                        style = LegadoTheme.typography.bodySmall,
+                        maxLines = 1,
+                        overflow = TextOverflow.Ellipsis,
+                        modifier = Modifier.fillMaxWidth()
+                    )
+                }
+                Box(modifier = Modifier.fillMaxWidth()) {
+                    innerTextField()
+                }
+            }
+        }
+    )
+}

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

@@ -925,6 +925,7 @@
     <string name="hide_when_status_bar_show">状态栏显示时隐藏</string>
     <string name="reverse_toc">反转目录</string>
     <string name="show_discovery">显示发现</string>
+    <string name="show_home">显示首页</string>
     <string name="style">样式</string>
     <string name="group_style">分组样式</string>
     <string name="export_file_name">导出文件名</string>
@@ -1568,6 +1569,11 @@
     <string name="homepage_delete_custom_set_confirm">确定要删除该集及其包含的所有模块副本吗?</string>
     <string name="homepage_remove_module">移除模块</string>
     <string name="homepage_remove_module_confirm">确定要从当前集中移除该模块吗?</string>
+    <string name="homepage_select_from_kinds">从发现分类选择</string>
+    <string name="homepage_select_multiple_kinds">选择多个分类</string>
+    <string name="homepage_n_selected">已选择 %1$d 个</string>
+    <string name="homepage_select_one_kind">选择一个分类</string>
+    <string name="or">或</string>
     <string name="homepage_add_button_group">添加按钮组</string>
     <string name="homepage_module_title_label">模块标题</string>
     <string name="homepage_custom_title">自定义标题</string>

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

@@ -890,6 +890,7 @@
     <string name="diy_edit_source_group">輸入自訂源分組名稱</string>
     <string name="reverse_toc">反轉目錄</string>
     <string name="show_discovery">顯示發現</string>
+    <string name="show_home">顯示首頁</string>
     <string name="style">樣式</string>
     <string name="group_style">分組樣式</string>
     <string name="export_file_name">導出文件名</string>

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

@@ -893,6 +893,7 @@
     <string name="hide_when_status_bar_show">狀態欄顯示時隱藏</string>
     <string name="reverse_toc">反轉目錄</string>
     <string name="show_discovery">顯示發現</string>
+    <string name="show_home">顯示首頁</string>
     <string name="style">樣式</string>
     <string name="group_style">分組樣式</string>
     <string name="export_file_name">匯出檔案名</string>

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

@@ -955,6 +955,7 @@
     <string name="hide_when_status_bar_show">Hide when status bar show</string>
     <string name="reverse_toc">Reverse toc</string>
     <string name="show_discovery">Show Discovery</string>
+    <string name="show_home">Show Homepage</string>
     <string name="style">Style</string>
     <string name="group_style">Group style</string>
     <string name="export_file_name">Export file name</string>
@@ -1574,6 +1575,11 @@
     <string name="homepage_delete_custom_set_confirm">Are you sure you want to delete this set and all its module copies?</string>
     <string name="homepage_remove_module">Remove Module</string>
     <string name="homepage_remove_module_confirm">Are you sure you want to remove this module from the current set?</string>
+    <string name="homepage_select_from_kinds">Select from Kinds</string>
+    <string name="homepage_select_multiple_kinds">Select multiple kinds</string>
+    <string name="homepage_n_selected">%1$d selected</string>
+    <string name="homepage_select_one_kind">Select one kind</string>
+    <string name="or">OR</string>
     <string name="homepage_add_button_group">Add Button Group</string>
     <string name="homepage_module_title_label">Module Title</string>
     <string name="homepage_custom_title">Custom Title</string>