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

feat: 排行榜模块可以多选发现

HapeLee 4 месяцев назад
Родитель
Сommit
dcb0511177

+ 9 - 1
app/src/main/java/io/legado/app/ui/main/home/HomeScreen.kt

@@ -203,6 +203,15 @@ fun HomeRouteScreen(
             onAddButtonGroupFromKinds = { sourceUrl, targetSetId, title, kinds ->
                 homepageViewModel.addButtonGroupFromKinds(sourceUrl, targetSetId, title, kinds)
             },
+            onAddRankingFromKinds = { sourceUrl, targetSetId, title, type, kinds ->
+                homepageViewModel.addRankingFromKinds(
+                    sourceUrl,
+                    targetSetId,
+                    title,
+                    type,
+                    kinds
+                )
+            },
             onGetExploreKinds = { homepageViewModel.getSourceExploreKinds(it) },
             onUpdateModule = { globalId, def ->
                 homepageViewModel.updateModule(globalId, def)
@@ -519,7 +528,6 @@ fun HomeScreen(
                             animatedVisibilityScope = animatedVisibilityScope,
                             modifier = Modifier.padding(horizontal = 16.dp),
                         )
-                        Spacer(modifier = Modifier.height(12.dp))
                     }
 
                     Column(

+ 14 - 0
app/src/main/java/io/legado/app/ui/main/homepage/HomepageContract.kt

@@ -40,6 +40,8 @@ data class HomepageManageActions(
     val onJoinModule: (String, String?, ModuleDef) -> Unit = { _, _, _ -> },
     val onAddCustomModule: (String, String?, ModuleDef) -> Unit = { _, _, _ -> },
     val onAddButtonGroupFromKinds: (String, String?, String, List<String>) -> Unit = { _, _, _, _ -> },
+    val onAddRankingFromKinds: (String, String?, String, String, List<String>) -> Unit =
+        { _, _, _, _, _ -> },
     val onGetExploreKinds: (String) -> List<Pair<String, String>> = { emptyList() },
     val onUpdateModule: (String, ModuleDef) -> Unit = { _, _ -> },
     val onDeleteModule: (String) -> Unit = {},
@@ -118,6 +120,18 @@ sealed interface ModuleLoadState {
     @Stable
     data class Buttons(val kinds: ImmutableList<ExploreKind>) : ModuleLoadState
 
+    @Stable
+    data class Rankings(
+        val sources: ImmutableList<HomepageRankingSourceUi>
+    ) : ModuleLoadState
+
     @Stable
     data class Error(val message: String) : ModuleLoadState
 }
+
+@Stable
+data class HomepageRankingSourceUi(
+    val title: String,
+    val url: String?,
+    val state: ModuleLoadState,
+)

+ 95 - 13
app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleFeed.kt

@@ -18,12 +18,15 @@ import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
 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.itemsIndexed
 import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
+import androidx.compose.foundation.selection.selectable
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.ArrowForward
 import androidx.compose.material.icons.filled.Refresh
@@ -31,9 +34,14 @@ import androidx.compose.material.icons.outlined.Info
 import androidx.compose.material3.HorizontalDivider
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.semantics.Role
 import androidx.compose.ui.text.font.FontWeight
@@ -61,6 +69,7 @@ import io.legado.app.ui.widget.components.button.series.SmallTonalButton
 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.text.AppText
+import kotlinx.collections.immutable.ImmutableList
 
 @OptIn(ExperimentalSharedTransitionApi::class)
 @Composable
@@ -92,6 +101,9 @@ fun HomepageModuleFeed(
             val others = modules.filter { !isInfinite(it) }
             if (infinite != null) others + infinite else others
         }
+        var selectedRankingSources by rememberSaveable {
+            mutableStateOf(hashMapOf<String, String>())
+        }
 
         val gridColumns = remember(processedModules) {
             val infiniteModule = processedModules.find { m ->
@@ -117,24 +129,38 @@ fun HomepageModuleFeed(
             ),
         ) {
             processedModules.forEach { moduleUi ->
+                val rankingSources =
+                    (moduleUi.state as? ModuleLoadState.Rankings)?.sources
+                val selectedRankingSource = rankingSources?.firstOrNull {
+                    it.title == selectedRankingSources[moduleUi.globalId]
+                } ?: rankingSources?.firstOrNull()
+                val displayState = selectedRankingSource?.state ?: moduleUi.state
+
                 item(key = "header_${moduleUi.globalId}", span = StaggeredGridItemSpan.FullLine) {
                     ModuleHeader(
-                        title = moduleUi.title,
+                        title = rankingSources?.firstOrNull()?.title ?: moduleUi.title,
+                        sourceTabs = rankingSources,
+                        selectedSourceTitle = selectedRankingSource?.title,
+                        onSourceSelected = { sourceTitle ->
+                            selectedRankingSources = HashMap(selectedRankingSources).apply {
+                                put(moduleUi.globalId, sourceTitle)
+                            }
+                        },
                         onNavigate = if (moduleUi.type == HomepageModuleType.ButtonGroup) {
                             null
                         } else {
                             {
                                 actions.onModuleHeaderClick(
                                     moduleUi.sourceUrl,
-                                    moduleUi.exploreUrl,
-                                    moduleUi.title,
+                                    selectedRankingSource?.url ?: moduleUi.exploreUrl,
+                                    selectedRankingSource?.title ?: moduleUi.title,
                                 )
                             }
                         },
                     )
                 }
 
-                when (val state = moduleUi.state) {
+                when (val state = displayState) {
                     is ModuleLoadState.Loading,
                     is ModuleLoadState.Error -> {
                         item(
@@ -462,6 +488,8 @@ fun HomepageModuleFeed(
                             else -> {}
                         }
                     }
+
+                    is ModuleLoadState.Rankings -> Unit
                 }
             }
         }
@@ -471,22 +499,47 @@ fun HomepageModuleFeed(
 @Composable
 private fun ModuleHeader(
     title: String,
+    sourceTabs: ImmutableList<HomepageRankingSourceUi>?,
+    selectedSourceTitle: String?,
+    onSourceSelected: (String) -> Unit,
     onNavigate: (() -> Unit)? = null,
 ) {
+    val sourceTabItems = sourceTabs.orEmpty()
+    val hasSourceTabs = sourceTabItems.size > 1
+
     Row(
         modifier = Modifier
             .fillMaxWidth()
-            .padding(top = 12.dp, bottom = 4.dp),
+            .padding(vertical = 4.dp),
         verticalAlignment = Alignment.CenterVertically,
     ) {
-        AppText(
-            text = title,
-            style = MaterialTheme.typography.titleMedium,
-            fontWeight = FontWeight.Bold,
-            maxLines = 1,
-            overflow = TextOverflow.Ellipsis,
-            modifier = Modifier.weight(1f),
-        )
+        if (hasSourceTabs) {
+            LazyRow(
+                modifier = Modifier.weight(1f),
+                horizontalArrangement = Arrangement.spacedBy(12.dp),
+                verticalAlignment = Alignment.CenterVertically,
+            ) {
+                items(
+                    items = sourceTabItems,
+                    key = { it.title },
+                ) { source ->
+                    val isSelected = selectedSourceTitle == source.title
+                    ModuleHeaderTab(
+                        title = source.title,
+                        selected = isSelected,
+                        onClick = { onSourceSelected(source.title) },
+                    )
+                }
+            }
+        } else {
+            ModuleHeaderTab(
+                title = title,
+                selected = true,
+                highlightSelected = false,
+                onClick = {},
+                modifier = Modifier.weight(1f),
+            )
+        }
         if (onNavigate != null) {
             SmallTonalButton(
                 onClick = onNavigate,
@@ -496,3 +549,32 @@ private fun ModuleHeader(
         }
     }
 }
+
+@Composable
+private fun ModuleHeaderTab(
+    title: String,
+    selected: Boolean,
+    highlightSelected: Boolean = true,
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    AppText(
+        text = title,
+        style = MaterialTheme.typography.titleMedium,
+        fontWeight = FontWeight.Bold,
+        color = when {
+            selected && highlightSelected -> LegadoTheme.colorScheme.primary
+            selected -> Color.Unspecified
+            else -> LegadoTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.72f)
+        },
+        maxLines = 1,
+        overflow = TextOverflow.Ellipsis,
+        modifier = modifier
+            .selectable(
+                selected = selected,
+                role = Role.Tab,
+                onClick = onClick,
+            )
+            .padding(vertical = 4.dp),
+    )
+}

+ 50 - 19
app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt

@@ -28,6 +28,7 @@ import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
+import io.legado.app.domain.model.HomepageModuleType
 import io.legado.app.ui.main.homepage.manage.AddCustomModuleDialog
 import io.legado.app.ui.main.homepage.manage.AddDialogPrefill
 import io.legado.app.ui.main.homepage.manage.BrowseSourcesPage
@@ -69,9 +70,12 @@ 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 showAddButtonGroupDialog by remember(data != null) { mutableStateOf(false) }
+    var showAddKindGroupDialog by remember(data != null) { mutableStateOf(false) }
     val defaultQuickActionsTitle = stringResource(R.string.homepage_quick_actions)
-    var tempButtonGroupTitle by remember(data != null) { mutableStateOf(defaultQuickActionsTitle) }
+    var tempKindGroupTitle by remember(data != null) { mutableStateOf(defaultQuickActionsTitle) }
+    val supportsMultipleKinds = browseModuleType == HomepageModuleType.ButtonGroup.key ||
+            browseModuleType == HomepageModuleType.Ranking.key ||
+            browseModuleType == HomepageModuleType.GridRanking.key
 
     val effectiveTargetSetId = remember(selectingSetUrl, browsingSourceUrl) {
         val url =
@@ -148,9 +152,14 @@ fun <T> HomepageModuleManageSheet(
             }
         },
         endAction = {
-            if (browsingDetail && browseTab == 2 && browseModuleType == "buttonGroup" && selectedKindTitles.isNotEmpty()) {
+            if (
+                browsingDetail &&
+                browseTab == 2 &&
+                supportsMultipleKinds &&
+                selectedKindTitles.isNotEmpty()
+            ) {
                 SmallPlainButton(
-                    onClick = { showAddButtonGroupDialog = true },
+                    onClick = { showAddKindGroupDialog = true },
                     icon = Icons.Default.Check
                 )
             } else if ((showSourceBrowser || browsingSourceUrl != null) && !browsingDetail) {
@@ -250,7 +259,7 @@ fun <T> HomepageModuleManageSheet(
                         onEditModule = { editingModule = it },
                         onRequestDeleteModule = { deleteConfirmId = it },
                         onAddDialogPrefill = { addDialogPrefill = it },
-                        onShowAddButtonGroupDialog = { showAddButtonGroupDialog = true },
+                        onShowAddKindGroupDialog = { showAddKindGroupDialog = true },
                         browseTab = browseTab,
                         onBrowseTabChange = { browseTab = it },
                         browseModuleType = browseModuleType,
@@ -443,32 +452,54 @@ fun <T> HomepageModuleManageSheet(
 
         var titleState by remember { mutableStateOf("") }
         AppAlertDialog(
-            data = if (showAddButtonGroupDialog) Unit else null,
-            onDismissRequest = { showAddButtonGroupDialog = false },
-            title = stringResource(R.string.homepage_add_button_group),
+            data = if (showAddKindGroupDialog) Unit else null,
+            onDismissRequest = { showAddKindGroupDialog = false },
+            title = if (browseModuleType == HomepageModuleType.ButtonGroup.key) {
+                stringResource(R.string.homepage_add_button_group)
+            } else {
+                stringResource(R.string.homepage_add_module)
+            },
             content = {
                 val quickActionsLabel = stringResource(R.string.homepage_quick_actions)
-                LaunchedEffect(Unit) { tempButtonGroupTitle = quickActionsLabel }
+                LaunchedEffect(browseModuleType, selectedKindTitles) {
+                    tempKindGroupTitle =
+                        if (browseModuleType == HomepageModuleType.ButtonGroup.key) {
+                            quickActionsLabel
+                        } else {
+                            selectedKindTitles.firstOrNull()
+                                ?: HomepageModuleType.fromKey(browseModuleType).title
+                        }
+                }
                 AppTextField(
-                    value = tempButtonGroupTitle,
-                    onValueChange = { tempButtonGroupTitle = it },
+                    value = tempKindGroupTitle,
+                    onValueChange = { tempKindGroupTitle = it },
                     label = stringResource(R.string.homepage_module_title_label),
                     modifier = Modifier.fillMaxWidth()
                 )
             },
             onConfirm = {
-                actions.onAddButtonGroupFromKinds(
-                    browsingSourceUrl!!,
-                    effectiveTargetSetId,
-                    tempButtonGroupTitle,
-                    selectedKindTitles.toList()
-                )
+                if (browseModuleType == HomepageModuleType.ButtonGroup.key) {
+                    actions.onAddButtonGroupFromKinds(
+                        browsingSourceUrl!!,
+                        effectiveTargetSetId,
+                        tempKindGroupTitle,
+                        selectedKindTitles.toList()
+                    )
+                } else {
+                    actions.onAddRankingFromKinds(
+                        browsingSourceUrl!!,
+                        effectiveTargetSetId,
+                        tempKindGroupTitle,
+                        browseModuleType,
+                        selectedKindTitles.toList()
+                    )
+                }
                 selectedKindTitles = emptySet()
-                showAddButtonGroupDialog = false
+                showAddKindGroupDialog = false
             },
             confirmText = stringResource(R.string.dialog_confirm),
             dismissText = stringResource(R.string.dialog_cancel),
-            onDismiss = { showAddButtonGroupDialog = false }
+            onDismiss = { showAddKindGroupDialog = false }
         )
 
         AppAlertDialog(

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

@@ -28,6 +28,9 @@ import kotlinx.collections.immutable.persistentListOf
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
@@ -238,15 +241,11 @@ class HomepageViewModel(
     ) { modules, bookshelf ->
         if (bookshelf.isEmpty()) {
             modules.map { module ->
-                val state = module.state
-                if (state is ModuleLoadState.Loaded) {
-                    module.copy(state = state.copy(
-                        books = state.books.map { item ->
-                            if (item.shelfState == BookShelfState.NOT_IN_SHELF) item
-                            else item.copy(shelfState = BookShelfState.NOT_IN_SHELF)
-                        }.toImmutableList()
-                    ))
-                } else module
+                val state = module.state.mapBooks { item ->
+                    if (item.shelfState == BookShelfState.NOT_IN_SHELF) item
+                    else item.copy(shelfState = BookShelfState.NOT_IN_SHELF)
+                }
+                if (state === module.state) module else module.copy(state = state)
             }.toImmutableList()
         } else {
             val exactKeys = HashSet<Triple<String, String, String?>>(bookshelf.size)
@@ -256,22 +255,19 @@ class HomepageViewModel(
                 nameAuthorKeys.add(key.name to key.author)
             }
             modules.map { module ->
-                val state = module.state
-                if (state is ModuleLoadState.Loaded) {
-                    module.copy(state = state.copy(
-                        books = state.books.map { item ->
-                            val bookTriple = Triple(item.book.name, item.book.author, item.book.bookUrl)
-                            val newShelfState = when {
-                                bookTriple in exactKeys -> BookShelfState.IN_SHELF
-                                (item.book.name to item.book.author) in nameAuthorKeys ->
-                                    BookShelfState.SAME_NAME_AUTHOR
-                                else -> BookShelfState.NOT_IN_SHELF
-                            }
-                            if (item.shelfState == newShelfState) item
-                            else item.copy(shelfState = newShelfState)
-                        }.toImmutableList()
-                    ))
-                } else module
+                val state = module.state.mapBooks { item ->
+                    val bookTriple = Triple(item.book.name, item.book.author, item.book.bookUrl)
+                    val newShelfState = when {
+                        bookTriple in exactKeys -> BookShelfState.IN_SHELF
+                        (item.book.name to item.book.author) in nameAuthorKeys ->
+                            BookShelfState.SAME_NAME_AUTHOR
+
+                        else -> BookShelfState.NOT_IN_SHELF
+                    }
+                    if (item.shelfState == newShelfState) item
+                    else item.copy(shelfState = newShelfState)
+                }
+                if (state === module.state) module else module.copy(state = state)
             }.toImmutableList()
         }
     }
@@ -426,6 +422,66 @@ class HomepageViewModel(
 
     private fun loadModule(module: ModuleItem) {
         loadJobs[module.id]?.cancel()
+        val rankingKindTitles = module.rankingKindTitles()
+        if (rankingKindTitles != null) {
+            loadJobs[module.id] = viewModelScope.launch {
+                kotlin.runCatching {
+                    val source = bookSourceRepository.getBookSource(module.sourceUrl)
+                        ?: throw Exception("Source not found")
+                    val allKinds = withContext(Dispatchers.IO) { source.exploreKinds() }
+                    val selectedKinds = rankingKindTitles.mapNotNull { title ->
+                        allKinds.find { it.title == title }
+                    }
+                    if (selectedKinds.isEmpty()) throw Exception("Ranking kinds not found")
+
+                    val shelf = _bookshelf.value
+                    coroutineScope {
+                        selectedKinds.map { kind ->
+                            async {
+                                val state = kotlin.runCatching {
+                                    exploreBooksUseCase.executeForRanking(
+                                        module.sourceUrl,
+                                        kind.url,
+                                        null
+                                    )
+                                }.fold(
+                                    onSuccess = { books ->
+                                        ModuleLoadState.Loaded(
+                                            books = books.map { book ->
+                                                HomepageBookItemUi(
+                                                    book = book,
+                                                    shelfState = resolveBookShelfStateUseCase.execute(
+                                                        name = book.name,
+                                                        author = book.author,
+                                                        url = book.bookUrl,
+                                                        shelf = shelf
+                                                    )
+                                                )
+                                            }.toImmutableList()
+                                        )
+                                    },
+                                    onFailure = { ModuleLoadState.Error(it.stackTraceStr) }
+                                )
+                                HomepageRankingSourceUi(
+                                    title = kind.title,
+                                    url = kind.url,
+                                    state = state,
+                                )
+                            }
+                        }.awaitAll().toImmutableList()
+                    }
+                }.onSuccess { sources ->
+                    _moduleContentStates.update {
+                        it + (module.id to ModuleLoadState.Rankings(sources))
+                    }
+                }.onFailure { e ->
+                    _moduleContentStates.update {
+                        it + (module.id to ModuleLoadState.Error(e.stackTraceStr))
+                    }
+                }
+            }.also { it.invokeOnCompletion { loadJobs.remove(module.id) } }
+            return
+        }
         if (module.type == HomepageModuleType.ButtonGroup.key) {
             loadJobs[module.id] = viewModelScope.launch {
                 kotlin.runCatching {
@@ -835,6 +891,66 @@ class HomepageViewModel(
         }
     }
 
+    fun addRankingFromKinds(
+        sourceUrl: String,
+        targetSetId: String?,
+        title: String,
+        type: String,
+        kindTitles: List<String>
+    ) {
+        if (
+            kindTitles.isEmpty() ||
+            (type != HomepageModuleType.Ranking.key &&
+                    type != HomepageModuleType.GridRanking.key)
+        ) {
+            return
+        }
+        viewModelScope.launch {
+            val source = bookSourceRepository.getBookSource(sourceUrl) ?: return@launch
+            val allKinds = withContext(Dispatchers.IO) { source.exploreKinds() }
+            val selectedKinds = kindTitles.mapNotNull { selectedTitle ->
+                allKinds.find { it.title == selectedTitle }
+            }
+            if (selectedKinds.isEmpty()) return@launch
+
+            val setId = targetSetId ?: ensureSetForSource(sourceUrl, source.bookSourceName)
+            val isGroup = selectedKinds.size > 1
+            val key = if (isGroup) {
+                "${type}_${jsonHash(GSON.toJson(kindTitles)).take(12)}"
+            } else {
+                selectedKinds.first().title
+            }
+            val id = ModuleDef.globalIdOf(sourceUrl, key, setId)
+            val module = ModuleItem(
+                id = id,
+                sourceUrl = sourceUrl,
+                moduleKey = key,
+                type = type,
+                title = title,
+                args = if (isGroup) {
+                    GSON.toJson(
+                        RankingKindsArgs(
+                            isHomepageRankingGroup = true,
+                            kindTitles = selectedKinds.map { it.title }
+                        )
+                    )
+                } else {
+                    null
+                },
+                url = if (isGroup) null else selectedKinds.first().url,
+                isEnabled = true,
+                isUserCreated = true,
+                customSetId = setId,
+                syncedAt = System.currentTimeMillis(),
+            )
+            gateway.upsertAll(listOf(module))
+            _pendingUserModules.update { pending ->
+                if (pending.any { it.id == id }) pending else pending + module
+            }
+            notifyConfigChanged()
+        }
+    }
+
     fun joinModule(sourceUrl: String, targetSetId: String?, def: ModuleDef) =
         addCustomModule(sourceUrl, targetSetId, def)
 
@@ -960,3 +1076,40 @@ private data class HomepageUiFlags(
     val isRefreshing: Boolean,
     val isManageMode: Boolean
 )
+
+private data class RankingKindsArgs(
+    val isHomepageRankingGroup: Boolean = false,
+    val kindTitles: List<String> = emptyList()
+)
+
+private fun ModuleItem.rankingKindTitles(): List<String>? {
+    if (
+        type != HomepageModuleType.Ranking.key &&
+        type != HomepageModuleType.GridRanking.key
+    ) {
+        return null
+    }
+    val rankingArgs = args ?: return null
+    return runCatching {
+        GSON.fromJson(rankingArgs, RankingKindsArgs::class.java)
+            ?.takeIf { it.isHomepageRankingGroup }
+            ?.kindTitles
+            ?.takeIf { it.size > 1 }
+    }.getOrNull()
+}
+
+private fun ModuleLoadState.mapBooks(
+    transform: (HomepageBookItemUi) -> HomepageBookItemUi
+): ModuleLoadState = when (this) {
+    is ModuleLoadState.Loaded -> copy(
+        books = books.map(transform).toImmutableList()
+    )
+
+    is ModuleLoadState.Rankings -> copy(
+        sources = sources.map { source ->
+            source.copy(state = source.state.mapBooks(transform))
+        }.toImmutableList()
+    )
+
+    else -> this
+}

+ 9 - 6
app/src/main/java/io/legado/app/ui/main/homepage/manage/SourceBrowseDetailPage.kt

@@ -60,7 +60,7 @@ fun SourceBrowseDetailPage(
     onReorderModules: (List<String>) -> Unit,
     onEditModule: (HomepageModuleManageUi) -> Unit,
     onAddDialogPrefill: (AddDialogPrefill?) -> Unit,
-    onShowAddButtonGroupDialog: () -> Unit,
+    onShowAddKindGroupDialog: () -> Unit,
     browseTab: Int,
     onBrowseTabChange: (Int) -> Unit,
     browseModuleType: String,
@@ -281,6 +281,9 @@ fun SourceBrowseDetailPage(
             2 -> {
                 val effectiveTargetSetId = currentSetId
                 val isButtonGroup = browseModuleType == "buttonGroup"
+                val isRanking = browseModuleType == HomepageModuleType.Ranking.key ||
+                        browseModuleType == HomepageModuleType.GridRanking.key
+                val supportsMultipleKinds = isButtonGroup || isRanking
                 Column {
                     val typeList = remember(canSelectInfiniteGlobal) {
                         HomepageModuleType.entries.filter {
@@ -321,7 +324,7 @@ fun SourceBrowseDetailPage(
 
                     SelectionItemCard(
                         title = stringResource(R.string.homepage_select_from_kinds),
-                        subtitle = if (isButtonGroup) {
+                        subtitle = if (supportsMultipleKinds) {
                             if (selectedKindTitles.isEmpty()) stringResource(R.string.homepage_select_multiple_kinds)
                             else stringResource(
                                 R.string.homepage_n_selected,
@@ -333,9 +336,9 @@ fun SourceBrowseDetailPage(
                         containerColor = LegadoTheme.colorScheme.onSheetContent,
                         onToggleSelection = { showKindSelect = true },
                         trailingAction = {
-                            if (isButtonGroup && selectedKindTitles.isNotEmpty()) {
+                            if (supportsMultipleKinds && selectedKindTitles.isNotEmpty()) {
                                 SmallPlainButton(
-                                    onClick = { onShowAddButtonGroupDialog() },
+                                    onClick = { onShowAddKindGroupDialog() },
                                     icon = Icons.Default.Check
                                 )
                             }
@@ -346,10 +349,10 @@ fun SourceBrowseDetailPage(
                         show = showKindSelect,
                         onDismissRequest = { showKindSelect = false },
                         sourceUrl = browseUrl,
-                        multiple = isButtonGroup,
+                        multiple = supportsMultipleKinds,
                         initialSelectedTitles = selectedKindTitles.toList(),
                         onSelected = { kinds ->
-                            if (isButtonGroup) {
+                            if (supportsMultipleKinds) {
                                 onSelectedKindTitlesChange(kinds.map { it.title }.toSet())
                             } else {
                                 kinds.firstOrNull()?.let { kind ->