From be2041c7da3d4a46d717d41c761970c94d437c9d Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Sat, 23 May 2026 14:36:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 3 +- .../ui/main/bookshelf/BookshelfConfigSheet.kt | 11 +- .../app/ui/main/homepage/HomepageContract.kt | 31 + .../homepage/HomepageModuleManageSheet.kt | 1265 +++++------------ .../app/ui/main/homepage/HomepageScreen.kt | 89 +- .../app/ui/main/homepage/HomepageViewModel.kt | 557 ++++---- .../homepage/manage/AddCustomModuleDialog.kt | 185 +++ .../main/homepage/manage/BrowseSourcesPage.kt | 44 + .../manage/CustomSetAddModulesPage.kt | 90 ++ .../ui/main/homepage/manage/SetDetailPage.kt | 188 +++ .../ui/main/homepage/manage/SetListPage.kt | 119 ++ .../homepage/manage/SourceBrowseDetailPage.kt | 383 +++++ .../widget/components/book/SearchBookItem.kt | 1 + .../modalBottomSheet/AppModalBottomSheet.kt | 4 +- app/src/main/res/values-zh-rCN/strings.xml | 2 +- gradle/libs.versions.toml | 62 +- 16 files changed, 1678 insertions(+), 1356 deletions(-) create mode 100644 app/src/main/java/io/legado/app/ui/main/homepage/manage/AddCustomModuleDialog.kt create mode 100644 app/src/main/java/io/legado/app/ui/main/homepage/manage/BrowseSourcesPage.kt create mode 100644 app/src/main/java/io/legado/app/ui/main/homepage/manage/CustomSetAddModulesPage.kt create mode 100644 app/src/main/java/io/legado/app/ui/main/homepage/manage/SetDetailPage.kt create mode 100644 app/src/main/java/io/legado/app/ui/main/homepage/manage/SetListPage.kt create mode 100644 app/src/main/java/io/legado/app/ui/main/homepage/manage/SourceBrowseDetailPage.kt diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 828484ab9..275c5e74d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -101,7 +101,8 @@ "Bash(Get-ChildItem -Path \"D:\\\\AndroidPrj\\\\legado-with-MD3\" -Directory -Depth 0)", "Bash(Select-Object Name)", "PowerShell(Get-ChildItem -Path \"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\" -Directory -Depth 1 | ForEach-Object { $_.FullName.Replace\\(\"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\\\\\", \"\"\\) })", - "Bash(gh pr *)" + "Bash(gh pr *)", + "PowerShell(Get-Content *)" ] } } diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfConfigSheet.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfConfigSheet.kt index 6d707b365..05f3e7ef5 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfConfigSheet.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfConfigSheet.kt @@ -8,8 +8,9 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape @@ -63,9 +64,8 @@ fun BookshelfConfigSheet( modifier = Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()) - .animateContentSize() - .padding(bottom = 32.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + .animateContentSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { CompactDropdownSettingItem( title = stringResource(R.string.group_style), @@ -506,6 +506,9 @@ fun BookshelfConfigSheet( steps = 100, onValueChange = { BookshelfConfig.bookshelfRefreshingLimit = it.toInt() } ) + + Spacer(modifier = Modifier.height(32.dp)) + } LabelColorManageSheet( diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageContract.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageContract.kt index 77dad74b8..047b1bd65 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageContract.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageContract.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.rule.ExploreKind import io.legado.app.domain.model.HomepageModuleType +import io.legado.app.domain.model.ModuleDef import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -13,6 +14,36 @@ data class HomepageUiState( val isManageMode: Boolean = false, val isConfigMode: Boolean = false, val isRefreshing: Boolean = false, + val manageState: HomepageManageUiState = HomepageManageUiState() +) + +@Stable +data class HomepageManageUiState( + val sets: ImmutableList = persistentListOf(), + val browseSources: ImmutableList = persistentListOf(), + val allJoinedModules: ImmutableList = persistentListOf(), + val sourceNames: Map = emptyMap() +) + +@Stable +data class HomepageManageActions( + val onToggleSet: (String, Boolean) -> Unit = { _, _ -> }, + val onGetSourceModules: (String, String?) -> List = { _, _ -> emptyList() }, + val onSyncSourceModules: (String) -> Unit = {}, + val onToggleModule: (String, Boolean) -> Unit = { _, _ -> }, + val onJoinModule: (String, String?, ModuleDef) -> Unit = { _, _, _ -> }, + val onAddCustomModule: (String, String?, ModuleDef) -> Unit = { _, _, _ -> }, + val onAddButtonGroupFromKinds: (String, String?, String, List) -> Unit = { _, _, _, _ -> }, + val onGetExploreKinds: (String) -> List> = { emptyList() }, + val onUpdateModule: (String, ModuleDef) -> Unit = { _, _ -> }, + val onDeleteModule: (String) -> Unit = {}, + val onReorderModules: (List) -> Unit = {}, + val onReorderSets: (List) -> Unit = {}, + val onSetCustomSetTitle: (String, String?) -> Unit = { _, _ -> }, + val onCreateCustomSet: (String) -> Unit = {}, + val onRenameCustomSet: (String, String) -> Unit = { _, _ -> }, + val onDeleteCustomSet: (String) -> Unit = {}, + val onAssignModuleToCustomSet: (String, String?) -> Unit = { _, _ -> }, ) @Stable diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt index 8a19c31bb..84f72c697 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt @@ -1,93 +1,56 @@ package io.legado.app.ui.main.homepage -import androidx.compose.foundation.layout.Arrangement +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.DriveFileRenameOutline -import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.FilterList import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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 -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.google.gson.JsonParser import io.legado.app.R -import io.legado.app.domain.model.HomepageModuleType -import io.legado.app.domain.model.ModuleDef -import io.legado.app.ui.theme.LegadoTheme +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 +import io.legado.app.ui.main.homepage.manage.CustomSetAddModulesPage +import io.legado.app.ui.main.homepage.manage.SetDetailPage +import io.legado.app.ui.main.homepage.manage.SetListPage +import io.legado.app.ui.main.homepage.manage.SourceBrowseDetailPage import io.legado.app.ui.widget.components.AppTextField -import io.legado.app.ui.widget.components.JsonConfigEditor -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 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet -import io.legado.app.ui.widget.components.settingItem.CompactClickableSettingItem -import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem -import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem -import io.legado.app.ui.widget.components.tabRow.AppTabRow -import io.legado.app.ui.widget.components.text.AppText -import io.legado.app.utils.move -import sh.calvin.reorderable.rememberReorderableLazyListState +import kotlinx.collections.immutable.toImmutableList @OptIn(ExperimentalMaterial3Api::class) @Composable fun HomepageModuleManageSheet( data: T?, onDismissRequest: () -> Unit, - sets: List, - browseSources: List, - onToggleSet: (String, Boolean) -> Unit, - onGetModulesInSet: (String) -> List, - onGetSourceModules: (String, String?) -> List, - onToggleModule: (String, Boolean) -> Unit, - onJoinModule: (String, String?, ModuleDef) -> Unit, - onAddCustomModule: (String, String?, ModuleDef) -> Unit, - onAddButtonGroupFromKinds: (String, String?, String, List) -> Unit, - onGetExploreKinds: (String) -> List>, - onUpdateModule: (String, ModuleDef) -> Unit, - onDeleteModule: (String) -> Unit, - onReorderModules: (List) -> Unit, - onReorderSets: (List) -> Unit = {}, - onSetCustomSetTitle: (String, String?) -> Unit, - onCreateCustomSet: (String) -> Unit, - onRenameCustomSet: (String, String) -> Unit, - onDeleteCustomSet: (String) -> Unit, - onGetAllModulesGroupedBySource: () -> Map> = { emptyMap() }, - onGetSourceName: (String) -> String = { it }, - onAssignModuleToCustomSet: (String, String?) -> Unit = { _, _ -> }, - onSyncSourceModules: (String) -> Unit = {}, + state: HomepageManageUiState, + actions: HomepageManageActions, ) { var selectingSetUrl by remember(data != null) { mutableStateOf(null) } var browsingSourceUrl by remember(data != null) { mutableStateOf(null) } @@ -106,23 +69,38 @@ fun HomepageModuleManageSheet( var browseModuleType by remember(data != null) { mutableStateOf("card") } var selectedKindTitles by remember(data != null) { mutableStateOf>(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) } - val currentTargetSetId = remember(selectingSetUrl) { - selectingSetUrl?.let { HomepageViewModel.customSetIdFromUrl(it) } + val effectiveTargetSetId = remember(selectingSetUrl, browsingSourceUrl) { + val url = + selectingSetUrl ?: browsingSourceUrl?.let { HomepageViewModel.customSetUrl("src_$it") } + url?.let { HomepageViewModel.customSetIdFromUrl(it) } } - val allGroups = remember(browseSources) { - browseSources.flatMap { it.sourceGroup?.split(",") ?: emptyList() } + val infiniteModuleIdInTargetSet = remember(state.allJoinedModules, effectiveTargetSetId) { + state.allJoinedModules.find { + it.customSetId == effectiveTargetSetId && HomepageViewModel.isInfinite( + it.type, + it.layoutConfig + ) + }?.id + } + + val canSelectInfiniteGlobal = infiniteModuleIdInTargetSet == null + + val allGroups = remember(state.browseSources) { + state.browseSources.flatMap { it.sourceGroup?.split(",") ?: emptyList() } .filter { it.isNotBlank() }.distinct().sorted() } - val filteredBrowseSources = remember(browseSources, groupFilter) { - if (groupFilter == null) browseSources - else browseSources.filter { it.sourceGroup?.split(",")?.contains(groupFilter) == true } + val filteredBrowseSources = remember(state.browseSources, groupFilter) { + val list = if (groupFilter == null) state.browseSources + else state.browseSources.filter { + it.sourceGroup?.split(",")?.contains(groupFilter) == true + } + list.toImmutableList() } AppModalBottomSheet( @@ -137,12 +115,12 @@ fun HomepageModuleManageSheet( title = when { showCustomSetAddModules -> stringResource(R.string.homepage_add_module) browsingSourceUrl != null && browsingDetail -> - browseSources.find { it.sourceUrl == browsingSourceUrl }?.sourceName + state.browseSources.find { it.sourceUrl == browsingSourceUrl }?.sourceName ?: stringResource(R.string.homepage_module_list) showSourceBrowser || browsingSourceUrl != null -> stringResource(R.string.homepage_browse_source_modules) selectingSetUrl != null && HomepageViewModel.isCustomSetUrl(selectingSetUrl!!) -> - (sets.find { it.sourceUrl == selectingSetUrl }?.sourceName + (state.sets.find { it.sourceUrl == selectingSetUrl }?.sourceName ?: stringResource(R.string.homepage_set_detail)) else -> stringResource(R.string.homepage_module_manage) @@ -206,896 +184,313 @@ fun HomepageModuleManageSheet( } } ) { + var backProgress by remember { mutableFloatStateOf(0f) } + PredictiveBackHandler( + enabled = showCustomSetAddModules || browsingDetail || showSourceBrowser || browsingSourceUrl != null || selectingSetUrl != null + ) { progress -> + try { + progress.collect { event -> + backProgress = event.progress + } + when { + showCustomSetAddModules -> showCustomSetAddModules = false + browsingDetail -> browsingDetail = false + showSourceBrowser -> showSourceBrowser = false + browsingSourceUrl != null -> browsingSourceUrl = null + selectingSetUrl != null -> selectingSetUrl = null + } + } finally { + backProgress = 0f + } + } + val setUrl = selectingSetUrl val browseUrl = browsingSourceUrl val isBrowsing = showSourceBrowser || browseUrl != null LaunchedEffect(browseUrl) { - browseUrl?.let { onSyncSourceModules(it) } + browseUrl?.let { actions.onSyncSourceModules(it) } } - when { - browseUrl != null && browsingDetail -> { - // 三级:浏览书源的模块列表(已加入 / 书源模块 / 发现) - val displaySetUrl = - selectingSetUrl ?: HomepageViewModel.customSetUrl("src_$browseUrl") - val currentSetId = HomepageViewModel.customSetIdFromUrl(displaySetUrl) - val joinedModules = remember(displaySetUrl, sets, browseSources) { - onGetModulesInSet(displaySetUrl).distinctBy { it.id } - } + val currentLevel = when { + showCustomSetAddModules || (browseUrl != null && browsingDetail) -> 2 + isBrowsing || (setUrl != null && HomepageViewModel.isCustomSetUrl(setUrl)) -> 1 + else -> 0 + } - val standardModules = remember(joinedModules) { - joinedModules.filter { !HomepageViewModel.isInfinite(it.type, it.layoutConfig) } - } - val infiniteModules = remember(joinedModules) { - joinedModules.filter { HomepageViewModel.isInfinite(it.type, it.layoutConfig) } - } - - val joinedKeys = joinedModules.map { it.moduleKey }.toSet() - val sourceModules = onGetSourceModules(browseUrl, currentSetId) - val exploreKinds = remember(browseUrl) { onGetExploreKinds(browseUrl) } - - Column { - AppTabRow( - tabTitles = listOf( - stringResource(R.string.homepage_tab_joined), - stringResource(R.string.homepage_tab_source_modules), - stringResource(R.string.homepage_tab_discover) - ), - selectedTabIndex = browseTab, - onTabSelected = { browseTab = it } - ) - when (browseTab) { - 0 -> { - if (joinedModules.isEmpty()) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - contentAlignment = Alignment.Center - ) { - AppText(stringResource(R.string.homepage_no_joined_modules)) - } - } else { - var listData by remember(displaySetUrl, standardModules) { - mutableStateOf( - standardModules.distinctBy { it.id } - ) - } - val listState = rememberLazyListState() - val reorderableState = - rememberReorderableLazyListState(listState) { from, to -> - listData = listData.toMutableList().apply { - val fromIndex = (from.index - 1).coerceIn(0, lastIndex) - val toIndex = (to.index - 1).coerceIn(0, lastIndex) - move(fromIndex, toIndex) - } - } - LaunchedEffect(standardModules) { - if (!reorderableState.isAnyItemDragging) listData = - standardModules.distinctBy { it.id } - } - LaunchedEffect(reorderableState.isAnyItemDragging) { - if (!reorderableState.isAnyItemDragging) { - val orderedIds = - (listData.map { it.id } + infiniteModules.map { it.id }).distinct() - if (orderedIds != joinedModules.map { it.id }) { - onReorderModules(orderedIds) - } - } - } - LazyColumn( - state = listState, - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - item(key = "header_standard") { - AppText( - text = stringResource(R.string.homepage_standard_modules_sortable), - style = LegadoTheme.typography.labelMedium, - color = LegadoTheme.colorScheme.primary, - modifier = Modifier.padding( - horizontal = 16.dp, - vertical = 4.dp - ) - ) - } - - items(listData, key = { it.id }) { module -> - ReorderableSelectionItem( - state = reorderableState, - key = module.id, - title = module.title, - subtitle = HomepageModuleType.fromKey(module.type).title, - isEnabled = module.isVisible, - containerColor = LegadoTheme.colorScheme.onSheetContent, - onEnabledChange = { enabled -> - onToggleModule(module.id, enabled) - listData = listData.map { - if (it.id == module.id) it.copy(isVisible = enabled) else it - } - }, - trailingAction = { - SmallIconButton( - onClick = { editingModule = module }, - imageVector = Icons.Default.Edit - ) - SmallIconButton( - onClick = { deleteConfirmId = module.id }, - imageVector = Icons.Default.Delete - ) - } - ) - } - - if (infiniteModules.isNotEmpty()) { - item(key = "header_infinite") { - PillDivider( - modifier = Modifier.padding( - vertical = 8.dp, - horizontal = 16.dp - ) - ) - AppText( - text = stringResource(R.string.homepage_infinite_module_bottom), - style = LegadoTheme.typography.labelMedium, - modifier = Modifier.padding( - horizontal = 16.dp, - vertical = 4.dp - ) - ) - } - - items(infiniteModules, key = { it.id }) { module -> - val isEffective = - infiniteModules.firstOrNull() == module - SelectionItemCard( - title = module.title, - subtitle = HomepageModuleType.fromKey(module.type).title + if (isEffective) stringResource( - R.string.homepage_status_in_effect - ) else stringResource(R.string.homepage_status_blocked), - isEnabled = module.isVisible, - containerColor = if (isEffective) LegadoTheme.colorScheme.surfaceContainerHigh else LegadoTheme.colorScheme.onSheetContent, - onEnabledChange = { onToggleModule(module.id, it) }, - trailingAction = { - SmallIconButton( - onClick = { editingModule = module }, - imageVector = Icons.Default.Edit - ) - SmallIconButton( - onClick = { deleteConfirmId = module.id }, - imageVector = Icons.Default.Delete - ) - } - ) - } - } - } - } - } - - 1 -> { - if (sourceModules.isEmpty()) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - contentAlignment = Alignment.Center - ) { - AppText(stringResource(R.string.homepage_source_json_empty)) - } - } else { - LazyColumn( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - items( - sourceModules.distinctBy { it.id }, - key = { it.id }) { module -> - val isJoined = joinedKeys.contains(module.moduleKey) - SelectionItemCard( - title = module.title, - subtitle = module.moduleKey + if (isJoined) stringResource( - R.string.homepage_status_joined - ) else "", - containerColor = LegadoTheme.colorScheme.onSheetContent, - isSelected = isJoined, - inSelectionMode = true, - onToggleSelection = { - if (!isJoined) onJoinModule( - browseUrl, currentTargetSetId, ModuleDef( - key = module.moduleKey, - type = module.type, - title = module.title, - sourceUrl = browseUrl, - ) - ) - } - ) - } - } - } - } - - 2 -> { - val isButtonGroup = browseModuleType == "buttonGroup" - Column { - val typeList = remember { - HomepageModuleType.entries.filter { it != HomepageModuleType.Unknown } - } - - 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 - ) - } - } - ) - - 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 - ) - } - } - } - ) - - PillDivider( - modifier = Modifier.padding(vertical = 12.dp) - ) - - SecondaryButton( - text = stringResource(R.string.homepage_manual_add), - onClick = { - addDialogPrefill = AddDialogPrefill(type = browseModuleType) - }, - modifier = Modifier.fillMaxWidth() - ) - } - } - } - } - } - - showCustomSetAddModules -> { - // 自定义集添加模块 - val currentSetUrl = selectingSetUrl!! - val currentSetId = HomepageViewModel.customSetIdFromUrl(currentSetUrl) - val initialJoined = onGetModulesInSet(currentSetUrl) - .associateBy({ it.moduleKey }, { it.id }) - var joinedInCurrent by remember(initialJoined) { mutableStateOf(initialJoined) } - - val grouped = onGetAllModulesGroupedBySource() - LazyColumn( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - grouped.forEach { (sourceUrl, modules) -> - item(key = "header_$sourceUrl") { - AppText( - text = onGetSourceName(sourceUrl), - style = LegadoTheme.typography.labelLarge, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) - ) - } - items(modules, key = { it.id }) { module -> - val instanceIdInCurrentSet = joinedInCurrent[module.moduleKey] - val inCurrentSet = instanceIdInCurrentSet != null - SelectionItemCard( - title = module.title, - subtitle = module.moduleKey, - containerColor = LegadoTheme.colorScheme.onSheetContent, - isSelected = inCurrentSet, - inSelectionMode = true, - onToggleSelection = { - if (inCurrentSet) { - onDeleteModule(instanceIdInCurrentSet!!) - joinedInCurrent = joinedInCurrent - module.moduleKey - } else { - onAssignModuleToCustomSet(module.id, currentSetId) - joinedInCurrent = - joinedInCurrent + (module.moduleKey to "temp_${module.id}") - } - } - ) - } - } - } - } - - isBrowsing -> { - // 二级:浏览书源列表 - val sources = remember(filteredBrowseSources) { - filteredBrowseSources.distinctBy { it.sourceUrl } - } - LazyColumn( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(sources, key = { it.sourceUrl }) { source -> - val moduleCount = onGetSourceModules(source.sourceUrl, null).size - SelectionItemCard( - title = source.sourceName, - subtitle = stringResource(R.string.homepage_n_modules, moduleCount), - containerColor = LegadoTheme.colorScheme.onSheetContent, - onToggleSelection = { - browsingSourceUrl = source.sourceUrl - browsingDetail = true - } - ) - } - } - } - - setUrl != null && HomepageViewModel.isCustomSetUrl(setUrl) -> { - // 二级:集详情 - val setId = HomepageViewModel.customSetIdFromUrl(setUrl) - val modules = remember(setUrl, sets) { - onGetModulesInSet(setUrl).distinctBy { it.id } - } - - val standardModules = remember(modules) { - modules.filter { !HomepageViewModel.isInfinite(it.type, it.layoutConfig) } - } - val infiniteModules = remember(modules) { - modules.filter { HomepageViewModel.isInfinite(it.type, it.layoutConfig) } - } - - if (modules.isEmpty()) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - AppText(stringResource(R.string.homepage_no_modules)) - SecondaryButton( - text = stringResource(R.string.homepage_browse_to_add), - onClick = { - if (setId.startsWith("src_")) { - browsingSourceUrl = setId.removePrefix("src_") - browsingDetail = true - } else { - showCustomSetAddModules = true - } - } - ) - } + AnimatedContent( + targetState = currentLevel to (showCustomSetAddModules to browsingDetail), + transitionSpec = { + if (targetState.first > initialState.first) { + (slideInHorizontally { it } + fadeIn()).togetherWith(slideOutHorizontally { -it / 3 } + fadeOut()) } else { - var listData by remember(setUrl, standardModules) { - mutableStateOf(standardModules) - } - val listState = rememberLazyListState() - val reorderableState = rememberReorderableLazyListState(listState) { from, to -> - listData = listData.toMutableList().apply { - val fromIndex = (from.index - 1).coerceIn(0, lastIndex) - val toIndex = (to.index - 1).coerceIn(0, lastIndex) - move(fromIndex, toIndex) - } - } - - LaunchedEffect(standardModules) { - if (!reorderableState.isAnyItemDragging) listData = - standardModules - } - LaunchedEffect(reorderableState.isAnyItemDragging) { - if (!reorderableState.isAnyItemDragging) { - val orderedIds = - (listData.map { it.id } + infiniteModules.map { it.id }).distinct() - if (orderedIds != modules.map { it.id }) onReorderModules(orderedIds) - } - } - - LazyColumn( - state = listState, - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - if (listData.isNotEmpty()) { - item(key = "header_std_detail") { - AppText( - text = stringResource(R.string.homepage_standard_module), - style = LegadoTheme.typography.labelMedium, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) - ) - } - items(listData, key = { it.id }) { module -> - ReorderableSelectionItem( - state = reorderableState, - key = module.id, - title = module.title, - subtitle = HomepageModuleType.fromKey(module.type).title, - isEnabled = module.isVisible, - containerColor = LegadoTheme.colorScheme.onSheetContent, - onEnabledChange = { enabled -> - onToggleModule(module.id, enabled) - listData = listData.map { - if (it.id == module.id) it.copy(isVisible = enabled) else it - } - }, - trailingAction = { - SmallIconButton( - onClick = { editingModule = module }, - imageVector = Icons.Default.Edit - ) - SmallIconButton( - onClick = { deleteConfirmId = module.id }, - imageVector = Icons.Default.Delete - ) - } - ) - } - } - - if (infiniteModules.isNotEmpty()) { - item(key = "header_inf_detail") { - PillDivider() - AppText( - text = stringResource(R.string.homepage_infinite_module_slot), - style = LegadoTheme.typography.labelMedium, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) - ) - } - items(infiniteModules, key = { it.id }) { module -> - val isEffective = infiniteModules.firstOrNull() == module - SelectionItemCard( - title = module.title, - subtitle = HomepageModuleType.fromKey(module.type).title, - isEnabled = module.isVisible, - containerColor = if (isEffective) LegadoTheme.colorScheme.surfaceContainerHigh else LegadoTheme.colorScheme.onSheetContent, - onEnabledChange = { onToggleModule(module.id, it) }, - trailingAction = { - SmallIconButton( - onClick = { editingModule = module }, - imageVector = Icons.Default.Edit - ) - SmallIconButton( - onClick = { deleteConfirmId = module.id }, - imageVector = Icons.Default.Delete - ) - } - ) - } - } - - item(key = "browse_from_set") { - SecondaryButton( - text = stringResource(R.string.homepage_browse_source_modules), - onClick = { - if (setId.startsWith("src_")) { - browsingSourceUrl = setId.removePrefix("src_") - browsingDetail = true - } else { - showCustomSetAddModules = true - } - }, - modifier = Modifier.fillMaxWidth() - ) - } - } + (slideInHorizontally { -it / 3 } + fadeIn()).togetherWith(slideOutHorizontally { it } + fadeOut()) } - } - - else -> { - // 一级:集列表 - var localSets by remember(data != null, sets) { - mutableStateOf(sets.distinctBy { it.sourceUrl }) - } - val setsListState = rememberLazyListState() - val setsReorderableState = - rememberReorderableLazyListState(setsListState) { from, to -> - localSets = localSets.toMutableList().apply { - val fromIndex = from.index.coerceIn(0, lastIndex) - val toIndex = to.index.coerceIn(0, lastIndex) - move(fromIndex, toIndex) - } - } - - LaunchedEffect(sets) { - if (!setsReorderableState.isAnyItemDragging) localSets = sets - } - LaunchedEffect(setsReorderableState.isAnyItemDragging) { - if (!setsReorderableState.isAnyItemDragging) { - val orderedUrls = localSets.map { it.sourceUrl } - if (orderedUrls != sets.map { it.sourceUrl }) onReorderSets(orderedUrls) - } + }, + modifier = Modifier.graphicsLayer { + val scale = 1f - (backProgress * 0.08f) + scaleX = scale + scaleY = scale + alpha = 1f - (backProgress * 0.1f) + }, + label = "PageTransition" + ) { _ -> + when { + browseUrl != null && browsingDetail -> { + SourceBrowseDetailPage( + browseUrl = browseUrl, + selectingSetUrl = selectingSetUrl, + allJoinedModules = state.allJoinedModules, + canSelectInfiniteGlobal = canSelectInfiniteGlobal, + onGetSourceModules = actions.onGetSourceModules, + onGetExploreKinds = actions.onGetExploreKinds, + onToggleModule = actions.onToggleModule, + onJoinModule = actions.onJoinModule, + onReorderModules = actions.onReorderModules, + onEditModule = { editingModule = it }, + onRequestDeleteModule = { deleteConfirmId = it }, + onAddDialogPrefill = { addDialogPrefill = it }, + onShowAddButtonGroupDialog = { showAddButtonGroupDialog = true }, + browseTab = browseTab, + onBrowseTabChange = { browseTab = it }, + browseModuleType = browseModuleType, + onBrowseModuleTypeChange = { browseModuleType = it }, + selectedKindTitles = selectedKindTitles, + onSelectedKindTitlesChange = { selectedKindTitles = it }, + ) } - LazyColumn( - state = setsListState, - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(localSets, key = { it.sourceUrl }) { set -> - ReorderableSelectionItem( - state = setsReorderableState, - key = set.sourceUrl, - title = set.sourceName, - subtitle = stringResource(R.string.homepage_n_modules, set.moduleCount), - containerColor = LegadoTheme.colorScheme.onSheetContent, - isEnabled = set.isSelected, - onToggleSelection = { selectingSetUrl = set.sourceUrl }, - onEnabledChange = { enabled -> - onToggleSet(set.sourceUrl, enabled) - localSets = localSets.map { - if (it.sourceUrl == set.sourceUrl) it.copy(isSelected = enabled) else it + showCustomSetAddModules -> { + CustomSetAddModulesPage( + setUrl = selectingSetUrl!!, + allJoinedModules = state.allJoinedModules, + sourceNames = state.sourceNames, + onToggleModuleToSet = { module, inCurrentSet, isBlocked -> + if (inCurrentSet) { + val instanceId = state.allJoinedModules + .find { + it.customSetId == HomepageViewModel.customSetIdFromUrl( + selectingSetUrl!! + ) && it.moduleKey == module.moduleKey + }?.id + if (instanceId != null) { + actions.onDeleteModule(instanceId) } - }, - trailingAction = { - SmallIconButton( - onClick = { renameSetId = set.sourceUrl }, - imageVector = Icons.Default.DriveFileRenameOutline - ) - SmallIconButton( - onClick = { deleteSetConfirmId = set.sourceUrl }, - imageVector = Icons.Default.Delete + } else if (!isBlocked) { + actions.onAssignModuleToCustomSet( + module.id, + HomepageViewModel.customSetIdFromUrl(selectingSetUrl!!) ) } - ) - } - item { - PillDivider( - modifier = Modifier.padding(vertical = 12.dp) - ) - } - item(key = "create_set") { - SecondaryButton( - text = stringResource(R.string.homepage_new_custom_set), - onClick = { showCreateSetDialog = true }, - modifier = Modifier.fillMaxWidth() - ) - } - item(key = "browse_sources") { - SecondaryButton( - text = stringResource(R.string.homepage_browse_source_modules), - onClick = { showSourceBrowser = true }, - modifier = Modifier.fillMaxWidth() - ) - } + }, + ) + } + + isBrowsing -> { + BrowseSourcesPage( + browseSources = filteredBrowseSources, + getSourceModules = actions.onGetSourceModules, + onSelectSource = { + browsingSourceUrl = it + browsingDetail = true + }, + ) + } + + setUrl != null && HomepageViewModel.isCustomSetUrl(setUrl) -> { + SetDetailPage( + setUrl = setUrl, + allJoinedModules = state.allJoinedModules, + onToggleModule = actions.onToggleModule, + onReorderModules = actions.onReorderModules, + onEditModule = { editingModule = it }, + onRequestDeleteModule = { deleteConfirmId = it }, + onBrowseSourceModules = { + val setId = HomepageViewModel.customSetIdFromUrl(setUrl) + browsingSourceUrl = setId.removePrefix("src_") + browsingDetail = true + }, + onAddModules = { + showCustomSetAddModules = true + }, + ) + } + + else -> { + SetListPage( + sets = state.sets, + onToggleSet = actions.onToggleSet, + onReorderSets = actions.onReorderSets, + onSelectSet = { selectingSetUrl = it }, + onRenameSet = { renameSetId = it }, + onDeleteSet = { deleteSetConfirmId = it }, + onCreateSet = { showCreateSetDialog = true }, + onBrowseSources = { showSourceBrowser = true }, + ) } } } - } - var tempName by remember { mutableStateOf("") } - AppAlertDialog( - data = renameSetId, - onDismissRequest = { renameSetId = null }, - title = stringResource(R.string.homepage_rename_custom_set), - content = { setId -> - val currentName = - remember(setId) { sets.find { it.sourceUrl == setId }?.sourceName ?: "" } - LaunchedEffect(setId) { tempName = currentName } - AppTextField( - value = tempName, - onValueChange = { tempName = it }, - label = stringResource(R.string.homepage_name_label), - modifier = Modifier.fillMaxWidth() - ) - }, - onConfirm = { setId -> - if (tempName.isNotBlank()) onRenameCustomSet( - HomepageViewModel.customSetIdFromUrl(setId), - tempName - ) - renameSetId = null - }, - confirmText = stringResource(R.string.dialog_confirm), - dismissText = stringResource(R.string.dialog_cancel), - onDismiss = { renameSetId = null } - ) - - AppAlertDialog( - data = if (showCreateSetDialog) Unit else null, - onDismissRequest = { showCreateSetDialog = false }, - title = stringResource(R.string.homepage_new_custom_set_title), - content = { - LaunchedEffect(Unit) { tempName = "" } - AppTextField( - value = tempName, - onValueChange = { tempName = it }, - label = stringResource(R.string.homepage_name_label), - modifier = Modifier.fillMaxWidth() - ) - }, - onConfirm = { - if (tempName.isNotBlank()) onCreateCustomSet(tempName) - showCreateSetDialog = false - }, - confirmText = stringResource(R.string.dialog_confirm), - dismissText = stringResource(R.string.dialog_cancel), - onDismiss = { showCreateSetDialog = false } - ) - - AppAlertDialog( - data = deleteSetConfirmId, - onDismissRequest = { deleteSetConfirmId = null }, - title = stringResource(R.string.homepage_delete_custom_set), - text = stringResource(R.string.homepage_delete_custom_set_confirm), - onConfirm = { setId -> - onDeleteCustomSet(HomepageViewModel.customSetIdFromUrl(setId)) - deleteSetConfirmId = null - }, - confirmText = stringResource(R.string.delete), - dismissText = stringResource(R.string.dialog_cancel), - onDismiss = { deleteSetConfirmId = null } - ) - - AppAlertDialog( - data = deleteConfirmId, - onDismissRequest = { deleteConfirmId = null }, - title = stringResource(R.string.homepage_remove_module), - text = stringResource(R.string.homepage_remove_module_confirm), - onConfirm = { id -> - onDeleteModule(id) - deleteConfirmId = null - }, - confirmText = stringResource(R.string.remove), - dismissText = stringResource(R.string.dialog_cancel), - onDismiss = { deleteConfirmId = null } - ) - - AddCustomModuleDialog( - data = addDialogPrefill, - sourceUrl = browsingSourceUrl ?: "", - targetSetId = currentTargetSetId ?: "", - prefillTitle = addDialogPrefill?.title ?: "", - prefillUrl = addDialogPrefill?.url ?: "", - prefillType = addDialogPrefill?.type ?: "card", - onDismissRequest = { addDialogPrefill = null }, - onConfirm = { def -> - onAddCustomModule(browsingSourceUrl!!, currentTargetSetId, def) - addDialogPrefill = null - } - ) - - AddCustomModuleDialog( - data = editingModule, - sourceUrl = editingModule?.sourceUrl ?: "", - targetSetId = editingModule?.customSetId ?: "", - prefillTitle = editingModule?.title ?: "", - prefillUrl = editingModule?.url ?: "", - prefillType = editingModule?.type ?: "card", - prefillArgs = editingModule?.args ?: "", - prefillLayoutConfig = editingModule?.layoutConfig ?: "", - onDismissRequest = { editingModule = null }, - onConfirm = { def -> - onUpdateModule(editingModule!!.id, def) - editingModule = null - } - ) - - var titleState by remember { mutableStateOf("") } - AppAlertDialog( - data = if (showAddButtonGroupDialog) Unit else null, - onDismissRequest = { showAddButtonGroupDialog = false }, - title = stringResource(R.string.homepage_add_button_group), - content = { - val quickActionsLabel = stringResource(R.string.homepage_quick_actions) - LaunchedEffect(Unit) { tempButtonGroupTitle = quickActionsLabel } - AppTextField( - value = tempButtonGroupTitle, - onValueChange = { tempButtonGroupTitle = it }, - label = stringResource(R.string.homepage_module_title_label), - modifier = Modifier.fillMaxWidth() - ) - }, - onConfirm = { - onAddButtonGroupFromKinds( - browsingSourceUrl!!, - currentTargetSetId, - tempButtonGroupTitle, - selectedKindTitles.toList() - ) - selectedKindTitles = emptySet() - showAddButtonGroupDialog = false - }, - confirmText = stringResource(R.string.dialog_confirm), - dismissText = stringResource(R.string.dialog_cancel), - onDismiss = { showAddButtonGroupDialog = false } - ) - - AppAlertDialog( - data = customSetTitleEdit, - onDismissRequest = { customSetTitleEdit = null }, - title = stringResource(R.string.homepage_custom_title), - content = { (_, title) -> - LaunchedEffect(title) { titleState = title } - AppTextField( - value = titleState, - onValueChange = { titleState = it }, - label = stringResource(R.string.homepage_title_label), - modifier = Modifier.fillMaxWidth() - ) - }, - onConfirm = { (id, _) -> - onSetCustomSetTitle(id, titleState.takeIf { it.isNotBlank() }) - customSetTitleEdit = null - }, - confirmText = stringResource(R.string.dialog_confirm), - dismissText = stringResource(R.string.dialog_cancel), - onDismiss = { customSetTitleEdit = null } - ) -} - -data class AddDialogPrefill( - val title: String = "", - val url: String = "", - val type: String = "card" -) - -@Composable -fun AddCustomModuleDialog( - data: T?, - sourceUrl: String = "", - targetSetId: String = "", - prefillTitle: String = "", - prefillUrl: String = "", - prefillType: String = "card", - prefillArgs: String = "", - prefillLayoutConfig: String = "", - onDismissRequest: () -> Unit, - onConfirm: (ModuleDef) -> Unit, -) { - var title by remember(data) { mutableStateOf(prefillTitle) } - var url by remember(data) { mutableStateOf(prefillUrl) } - var type by remember(data) { mutableStateOf(prefillType) } - var args by remember(data) { mutableStateOf(prefillArgs) } - var layoutConfig by remember(data) { mutableStateOf(prefillLayoutConfig) } - var showRawLayoutConfig by remember(data) { mutableStateOf(false) } - - val hasVisualizableKeys = remember(layoutConfig) { - runCatching { - val jsonObject = JsonParser.parseString(layoutConfig).asJsonObject - jsonObject.keySet().any { key -> - key == "columns" || key == "rows" - } - }.getOrElse { false } - } - - AppAlertDialog( - data = data, - onDismissRequest = onDismissRequest, - title = if (prefillTitle.isEmpty()) stringResource(R.string.homepage_add_module) else stringResource( - R.string.homepage_edit_module - ), - content = { - Column( - modifier = Modifier - .fillMaxWidth() - .height(400.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { + var tempName by remember { mutableStateOf("") } + AppAlertDialog( + data = renameSetId, + onDismissRequest = { renameSetId = null }, + title = stringResource(R.string.homepage_rename_custom_set), + content = { setId -> + val currentName = + remember(setId) { state.sets.find { it.sourceUrl == setId }?.sourceName ?: "" } + LaunchedEffect(setId) { tempName = currentName } AppTextField( - value = title, - onValueChange = { title = it }, - backgroundColor = LegadoTheme.colorScheme.onSheetContent, + value = tempName, + onValueChange = { tempName = it }, + label = stringResource(R.string.homepage_name_label), + modifier = Modifier.fillMaxWidth() + ) + }, + onConfirm = { setId -> + if (tempName.isNotBlank()) actions.onRenameCustomSet( + HomepageViewModel.customSetIdFromUrl(setId), + tempName + ) + renameSetId = null + }, + confirmText = stringResource(R.string.dialog_confirm), + dismissText = stringResource(R.string.dialog_cancel), + onDismiss = { renameSetId = null } + ) + + AppAlertDialog( + data = if (showCreateSetDialog) Unit else null, + onDismissRequest = { showCreateSetDialog = false }, + title = stringResource(R.string.homepage_new_custom_set_title), + content = { + LaunchedEffect(Unit) { tempName = "" } + AppTextField( + value = tempName, + onValueChange = { tempName = it }, + label = stringResource(R.string.homepage_name_label), + modifier = Modifier.fillMaxWidth() + ) + }, + onConfirm = { + if (tempName.isNotBlank()) actions.onCreateCustomSet(tempName) + showCreateSetDialog = false + }, + confirmText = stringResource(R.string.dialog_confirm), + dismissText = stringResource(R.string.dialog_cancel), + onDismiss = { showCreateSetDialog = false } + ) + + AppAlertDialog( + data = deleteSetConfirmId, + onDismissRequest = { deleteSetConfirmId = null }, + title = stringResource(R.string.homepage_delete_custom_set), + text = stringResource(R.string.homepage_delete_custom_set_confirm), + onConfirm = { setId -> + actions.onDeleteCustomSet(HomepageViewModel.customSetIdFromUrl(setId)) + deleteSetConfirmId = null + }, + confirmText = stringResource(R.string.delete), + dismissText = stringResource(R.string.dialog_cancel), + onDismiss = { deleteSetConfirmId = null } + ) + + AppAlertDialog( + data = deleteConfirmId, + onDismissRequest = { deleteConfirmId = null }, + title = stringResource(R.string.homepage_remove_module), + text = stringResource(R.string.homepage_remove_module_confirm), + onConfirm = { id -> + actions.onDeleteModule(id) + deleteConfirmId = null + }, + confirmText = stringResource(R.string.remove), + dismissText = stringResource(R.string.dialog_cancel), + onDismiss = { deleteConfirmId = null } + ) + + AddCustomModuleDialog( + data = addDialogPrefill, + sourceUrl = browsingSourceUrl ?: "", + targetSetId = effectiveTargetSetId ?: "", + prefillTitle = addDialogPrefill?.title ?: "", + prefillUrl = addDialogPrefill?.url ?: "", + prefillType = addDialogPrefill?.type ?: "card", + canSelectInfinite = canSelectInfiniteGlobal, + onDismissRequest = { addDialogPrefill = null }, + onConfirm = { def -> + actions.onAddCustomModule(browsingSourceUrl!!, effectiveTargetSetId, def) + addDialogPrefill = null + } + ) + + AddCustomModuleDialog( + data = editingModule, + sourceUrl = editingModule?.sourceUrl ?: "", + targetSetId = editingModule?.customSetId ?: "", + prefillTitle = editingModule?.title ?: "", + prefillUrl = editingModule?.url ?: "", + prefillType = editingModule?.type ?: "card", + prefillArgs = editingModule?.args ?: "", + prefillLayoutConfig = editingModule?.layoutConfig ?: "", + canSelectInfinite = canSelectInfiniteGlobal || (editingModule?.id != null && editingModule?.id == infiniteModuleIdInTargetSet), + onDismissRequest = { editingModule = null }, + onConfirm = { def -> + actions.onUpdateModule(editingModule!!.id, def) + editingModule = null + } + ) + + var titleState by remember { mutableStateOf("") } + AppAlertDialog( + data = if (showAddButtonGroupDialog) Unit else null, + onDismissRequest = { showAddButtonGroupDialog = false }, + title = stringResource(R.string.homepage_add_button_group), + content = { + val quickActionsLabel = stringResource(R.string.homepage_quick_actions) + LaunchedEffect(Unit) { tempButtonGroupTitle = quickActionsLabel } + AppTextField( + value = tempButtonGroupTitle, + onValueChange = { tempButtonGroupTitle = it }, + label = stringResource(R.string.homepage_module_title_label), + modifier = Modifier.fillMaxWidth() + ) + }, + onConfirm = { + actions.onAddButtonGroupFromKinds( + browsingSourceUrl!!, + effectiveTargetSetId, + tempButtonGroupTitle, + selectedKindTitles.toList() + ) + selectedKindTitles = emptySet() + showAddButtonGroupDialog = false + }, + confirmText = stringResource(R.string.dialog_confirm), + dismissText = stringResource(R.string.dialog_cancel), + onDismiss = { showAddButtonGroupDialog = false } + ) + + AppAlertDialog( + data = customSetTitleEdit, + onDismissRequest = { customSetTitleEdit = null }, + title = stringResource(R.string.homepage_custom_title), + content = { (_, title) -> + LaunchedEffect(title) { titleState = title } + AppTextField( + value = titleState, + onValueChange = { titleState = it }, 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 } - } - - 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() - ) - - PillHeaderDivider( - title = stringResource(R.string.homepage_layout_config_label) - ) - - if (hasVisualizableKeys) { - JsonConfigEditor( - jsonString = layoutConfig, - onJsonStringChange = { layoutConfig = it }, - modifier = Modifier.fillMaxWidth() - ) - CompactClickableSettingItem( - title = stringResource(R.string.homepage_edit_raw_json), - onClick = { showRawLayoutConfig = !showRawLayoutConfig } - ) - if (showRawLayoutConfig) { - JsonRawEditor( - value = layoutConfig, - onValueChange = { layoutConfig = it }, - label = "LayoutConfig (JSON) RAW", - modifier = Modifier.fillMaxWidth() - ) - } - } else { - JsonRawEditor( - value = layoutConfig, - onValueChange = { layoutConfig = it }, - label = "LayoutConfig (JSON)", - modifier = Modifier.fillMaxWidth() - ) - } - } - }, - onConfirm = { - onConfirm( - ModuleDef( - title = title, - url = url, - type = type, - args = args, - layoutConfig = layoutConfig - ) - ) - }, - confirmText = stringResource(R.string.dialog_confirm), - dismissText = stringResource(R.string.dialog_cancel), - onDismiss = onDismissRequest - ) + }, + onConfirm = { (id, _) -> + actions.onSetCustomSetTitle(id, titleState.takeIf { it.isNotBlank() }) + customSetTitleEdit = null + }, + confirmText = stringResource(R.string.dialog_confirm), + dismissText = stringResource(R.string.dialog_cancel), + onDismiss = { customSetTitleEdit = null } + ) + } } diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt index 49ad107f4..ea231f810 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt @@ -93,15 +93,16 @@ fun HomepageScreen( animatedVisibilityScope: AnimatedVisibilityScope? = null, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val allSets by viewModel.setsFlow.collectAsStateWithLifecycle() - val browseSources by viewModel.browseSourcesFlow.collectAsStateWithLifecycle() + // Removed allSets and browseSources as they are now part of uiState.manageState val scope = rememberCoroutineScope() val context = LocalContext.current var errorMsg by remember { mutableStateOf(null) } val layoutMode = HomepageConfig.homepageLayoutModeState.value - val selectedSets = remember(allSets) { allSets.filter { it.isSelected } } + val selectedSets = remember(uiState.manageState.sets) { + uiState.manageState.sets.filter { it.isSelected } + } val pagerState = rememberPagerState(pageCount = { if (layoutMode == 1) selectedSets.size.coerceAtLeast(1) else 1 }) @@ -261,50 +262,44 @@ fun HomepageScreen( HomepageModuleManageSheet( data = if (uiState.isManageMode) Unit else null, onDismissRequest = { viewModel.toggleManageMode() }, - sets = allSets, - browseSources = browseSources, - onToggleSet = { url, isEnabled -> viewModel.toggleSourceFilter(url, isEnabled) }, - onGetModulesInSet = { viewModel.getJoinedModules(it) }, - onGetSourceModules = { url, setId -> viewModel.getSourceModules(url, setId) }, - onSyncSourceModules = { viewModel.syncSourceModules(it) }, - onToggleModule = { id, visible -> viewModel.setModuleVisible(id, visible) }, - onJoinModule = { sourceUrl, targetSetId, def -> - viewModel.joinModule( - sourceUrl, - targetSetId, - def - ) - }, - onAddCustomModule = { sourceUrl, targetSetId, def -> - viewModel.addCustomModule( - sourceUrl, - targetSetId, - def - ) - }, - onAddButtonGroupFromKinds = { sourceUrl, targetSetId, title, kinds -> - viewModel.addButtonGroupFromKinds( - sourceUrl, - targetSetId, - title, - kinds - ) - }, - onGetExploreKinds = { viewModel.getSourceExploreKinds(it) }, - onUpdateModule = { globalId, def -> viewModel.updateModule(globalId, def) }, - onDeleteModule = { viewModel.deleteModule(it) }, - onReorderModules = { ids -> viewModel.reorderJoinedModules(ids) }, - onReorderSets = { urls -> viewModel.reorderCustomSets(urls) }, - onSetCustomSetTitle = { id, title -> viewModel.setModuleCustomSetTitle(id, title) }, - onCreateCustomSet = { viewModel.createCustomSet(it) }, - onRenameCustomSet = { id, name -> viewModel.renameCustomSet(id, name) }, - onDeleteCustomSet = { viewModel.deleteCustomSet(it) }, - onGetAllModulesGroupedBySource = { viewModel.getAllModulesGroupedBySource() }, - onGetSourceName = { viewModel.getSourceName(it) }, - onAssignModuleToCustomSet = { id, setId -> - viewModel.assignModuleToCustomSet( - id, - setId + state = uiState.manageState, + actions = remember { + HomepageManageActions( + onToggleSet = { url, isEnabled -> + viewModel.toggleSourceFilter( + url, + isEnabled + ) + }, + onGetSourceModules = { url, setId -> viewModel.getSourceModules(url, setId) }, + onSyncSourceModules = { viewModel.syncSourceModules(it) }, + onToggleModule = { id, visible -> viewModel.setModuleVisible(id, visible) }, + onJoinModule = { sourceUrl, targetSetId, def -> + viewModel.joinModule(sourceUrl, targetSetId, def) + }, + onAddCustomModule = { sourceUrl, targetSetId, def -> + viewModel.addCustomModule(sourceUrl, targetSetId, def) + }, + onAddButtonGroupFromKinds = { sourceUrl, targetSetId, title, kinds -> + viewModel.addButtonGroupFromKinds(sourceUrl, targetSetId, title, kinds) + }, + onGetExploreKinds = { viewModel.getSourceExploreKinds(it) }, + onUpdateModule = { globalId, def -> viewModel.updateModule(globalId, def) }, + onDeleteModule = { viewModel.deleteModule(it) }, + onReorderModules = { ids -> viewModel.reorderJoinedModules(ids) }, + onReorderSets = { urls -> viewModel.reorderCustomSets(urls) }, + onSetCustomSetTitle = { id, title -> + viewModel.setModuleCustomSetTitle( + id, + title + ) + }, + onCreateCustomSet = { viewModel.createCustomSet(it) }, + onRenameCustomSet = { id, name -> viewModel.renameCustomSet(id, name) }, + onDeleteCustomSet = { viewModel.deleteCustomSet(it) }, + onAssignModuleToCustomSet = { id, setId -> + viewModel.assignModuleToCustomSet(id, setId) + } ) } ) diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt index 726e3238a..d244f597a 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt @@ -82,46 +82,113 @@ class HomepageViewModel( val effects = _effects.asSharedFlow() private val loadJobs = ConcurrentHashMap() - private val initModulesSyncFlow = bookSourceRepository.flowHomepageModules() private val exploreSourcesFlow = bookSourceRepository.flowExploreSources() + // 1. 基础原始状态 private val _isRefreshing = MutableStateFlow(false) private val _isManageMode = MutableStateFlow(false) private val _isConfigMode = MutableStateFlow(false) private val _configVersion = MutableStateFlow(0L) private val _moduleContentStates = MutableStateFlow>(emptyMap()) - - private val localModulesFlow = gateway.flowEnabled() private val _bookSourcesCache = MutableStateFlow>(emptyMap()) private val _layoutConfigCache = MutableStateFlow>>(emptyMap()) + private val _pendingEnabled = MutableStateFlow>(emptyMap()) + private val _pendingUserModules = MutableStateFlow>(emptyList()) + private val _exploreKindsCache = + MutableStateFlow>>>(emptyMap()) - val allModulesCache = gateway.flowAll() - .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) - + // 2. 数据库同步流 + private val localModulesFlow = gateway.flowEnabled() + val allModulesCache = + gateway.flowAll().stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) val customSetsFlow = gateway.flowCustomSets() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + // 3. 业务加工流 private val orderedModuleDefsFlow = combine(localModulesFlow, _configVersion) { modules, _ -> modules.groupBySourceOrdered() }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) + val setsFlow = combine( + localModulesFlow, + allModulesCache, + customSetsFlow, + _configVersion + ) { _, allModules, customSets, _ -> + val hiddenSourceUrls = GSON.fromJsonArray(HomepageConfig.homepageSourceHidden) + .getOrDefault(emptyList()).toSet() + val moduleCountsBySet = + allModules.mapNotNull { it.customSetId }.groupBy { it }.mapValues { it.value.size } + + customSets.sortedBy { it.sortOrder }.map { set -> + HomepageSourceManageUi( + sourceUrl = customSetUrl(set.id), + sourceName = set.name, + sourceGroup = null, + isSelected = customSetUrl(set.id) !in hiddenSourceUrls, + moduleCount = moduleCountsBySet[set.id] ?: 0, + isCustomSet = true, + ) + }.toImmutableList() + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf()) + + val browseSourcesFlow = exploreSourcesFlow.map { sources -> + sources.map { source -> + HomepageSourceManageUi( + sourceUrl = source.bookSourceUrl, + sourceName = source.bookSourceName, + sourceGroup = source.bookSourceGroup, + ) + }.toImmutableList() + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf()) + + // 4. 聚合层 private val uiFlagsFlow = combine(_isRefreshing, _isManageMode, _isConfigMode) { refreshing, manage, config -> HomepageUiFlags(refreshing, manage, config) } - val uiState: StateFlow = combine( + private val manageStateFlow = combine( + setsFlow, + browseSourcesFlow, + allModulesCache, + _bookSourcesCache, + _pendingEnabled + ) { sets, browseSources, allModules, sourcesCache, pendingEnabled -> + HomepageManageUiState( + sets = sets, + browseSources = browseSources, + allJoinedModules = allModules.map { module -> + HomepageModuleManageUi( + id = module.id, + sourceUrl = module.sourceUrl, + moduleKey = module.moduleKey, + title = module.displayTitle, + customSetTitle = module.customSetTitle, + customSetId = module.customSetId, + isVisible = pendingEnabled[module.id] ?: module.isEnabled, + type = module.type, + url = module.url, + args = module.args, + layoutConfig = module.layoutConfig, + originalTitle = module.title, + ) + }.toImmutableList(), + sourceNames = sourcesCache.mapValues { it.value.bookSourceName } + ) + } + + private val displayModulesFlow = combine( orderedModuleDefsFlow, _moduleContentStates, - uiFlagsFlow, _bookSourcesCache, - customSetsFlow - ) { grouped, contentStates, flags, sourcesCache, customSets -> + customSetsFlow, + _layoutConfigCache + ) { grouped, contentStates, sourcesCache, customSets, configCache -> val setNames = customSets.associate { it.id to it.name } val sortedSetIds = customSets.sortedBy { it.sortOrder }.map { it.id } - val configCache = _layoutConfigCache.value - val displayModules = sortedSetIds.flatMap { setId -> + sortedSetIds.flatMap { setId -> val setUrl = customSetUrl(setId) val mods = grouped[setUrl] ?: emptyList() mods.map { module -> @@ -144,60 +211,26 @@ class HomepageViewModel( config = configMap ) } - } + }.toImmutableList() + } + + // 5. 最终 UI 状态 + val uiState: StateFlow = combine( + displayModulesFlow, + uiFlagsFlow, + manageStateFlow + ) { modules, flags, manageState -> HomepageUiState( - modules = displayModules.toImmutableList(), + modules = modules, isRefreshing = flags.isRefreshing, isManageMode = flags.isManageMode, isConfigMode = flags.isConfigMode, + manageState = manageState ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomepageUiState()) - val setsFlow = combine( - localModulesFlow, - allModulesCache, - customSetsFlow, - _configVersion - ) { _, allModules, customSets, _ -> - val hiddenSourceUrls = GSON.fromJsonArray(HomepageConfig.homepageSourceHidden) - .getOrDefault(emptyList()).toSet() - val moduleCountsBySet = - allModules.mapNotNull { it.customSetId }.groupBy { it }.mapValues { it.value.size } - - val list = mutableListOf() - customSets.sortedBy { it.sortOrder }.forEach { set -> - list.add( - HomepageSourceManageUi( - sourceUrl = customSetUrl(set.id), - sourceName = set.name, - sourceGroup = null, - isSelected = customSetUrl(set.id) !in hiddenSourceUrls, - moduleCount = moduleCountsBySet[set.id] ?: 0, - isCustomSet = true, - ) - ) - } - list.toImmutableList() - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf()) - - /** 用于「浏览书源模块」:列出有 homepageModules 的书源 */ - val browseSourcesFlow = exploreSourcesFlow.map { sources -> - sources.map { source -> - HomepageSourceManageUi( - sourceUrl = source.bookSourceUrl, - sourceName = source.bookSourceName, - sourceGroup = source.bookSourceGroup, - ) - }.toImmutableList() - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf()) - - private val _exploreKindsCache = - MutableStateFlow>>>(emptyMap()) - private val _pendingEnabled = MutableStateFlow>(emptyMap()) - private val _pendingUserModules = MutableStateFlow>(emptyList()) - init { - // 解析并缓存模块 layoutConfig,避免在 combine 中重复解析 + // 解析并缓存模块 layoutConfig viewModelScope.launch { localModulesFlow.collect { modules -> val cache = mutableMapOf>() @@ -217,7 +250,6 @@ class HomepageViewModel( } } - // 清理 _pendingUserModules 中已入库的条目 viewModelScope.launch { exploreSourcesFlow.collect { sources -> _bookSourcesCache.value = sources.associateBy { it.bookSourceUrl } @@ -235,7 +267,6 @@ class HomepageViewModel( } } - // 清理 _pendingUserModules 中已入库的条目 viewModelScope.launch { allModulesCache.collect { modules -> val dbIds = modules.map { it.id }.toSet() @@ -243,7 +274,6 @@ class HomepageViewModel( } } - // 一次性迁移:将 customSetId=null 的存量模块归属到书源集,并确保所有集存在 viewModelScope.launch { val allModules = allModulesCache.first() val orphans = allModules.filter { it.customSetId == null } @@ -251,20 +281,15 @@ class HomepageViewModel( orphans.groupBy { it.sourceUrl }.forEach { (sourceUrl, modules) -> val source = bookSourceRepository.getBookSource(sourceUrl) ?: return@forEach ensureSetForSource(sourceUrl, source.bookSourceName) - modules.forEach { m -> - gateway.setCustomSetId(m.id, "src_$sourceUrl") - } + modules.forEach { m -> gateway.setCustomSetId(m.id, "src_$sourceUrl") } } } - // 确保所有 customSetId 对应的集都存在 allModules.mapNotNull { it.customSetId }.distinct().forEach { setId -> val isSrcSet = setId.startsWith("src_") if (isSrcSet && gateway.getCustomSetById(setId) == null) { val sourceUrl = setId.removePrefix("src_") val source = bookSourceRepository.getBookSource(sourceUrl) - if (source != null) { - ensureSetForSource(sourceUrl, source.bookSourceName) - } + if (source != null) ensureSetForSource(sourceUrl, source.bookSourceName) } } } @@ -281,22 +306,16 @@ class HomepageViewModel( val parsedIds = parsedDefs.map { it.globalId }.toSet() val toUpsert = mutableListOf() - for (i in parsedDefs.indices) { val def = parsedDefs[i] val existing = existingById[def.globalId] if (existing != null) { - // 用户编辑过的模块不被 JSON 覆盖 if (existing.isUserCreated) continue if (existing.sourceJsonHash == newHash) continue toUpsert.add( existing.copy( - type = def.type, - title = def.title, - args = def.args, - url = def.url, - sourceJsonHash = newHash, - syncedAt = System.currentTimeMillis(), + type = def.type, title = def.title, args = def.args, url = def.url, + sourceJsonHash = newHash, syncedAt = System.currentTimeMillis() ) ) } else { @@ -313,19 +332,13 @@ class HomepageViewModel( customSetId = "src_${source.bookSourceUrl}", sortOrder = i, sourceJsonHash = newHash, - syncedAt = System.currentTimeMillis(), + syncedAt = System.currentTimeMillis() ) ) } } - - if (toUpsert.isNotEmpty()) { - gateway.upsertAll(toUpsert) - } - - if (parsedIds.isNotEmpty()) { - gateway.deleteStale(source.bookSourceUrl, parsedIds.toList()) - } + if (toUpsert.isNotEmpty()) gateway.upsertAll(toUpsert) + if (parsedIds.isNotEmpty()) gateway.deleteStale(source.bookSourceUrl, parsedIds.toList()) } private fun loadModule(module: ModuleItem) { @@ -336,74 +349,50 @@ class HomepageViewModel( val source = bookSourceRepository.getBookSource(module.sourceUrl) ?: throw Exception("Source not found") val allKinds = withContext(Dispatchers.IO) { source.exploreKinds() } - - val selectedTitles = module.args?.let { argsStr -> - GSON.fromJsonArray(argsStr).getOrNull() - } - - if (selectedTitles.isNullOrEmpty()) { - allKinds.take(HOMEPAGE_MAX_BUTTON_GROUP_KINDS) - } else { - selectedTitles.mapNotNull { t -> allKinds.find { it.title == t } } - } + val selectedTitles = + module.args?.let { GSON.fromJsonArray(it).getOrNull() } + if (selectedTitles.isNullOrEmpty()) allKinds.take( + HOMEPAGE_MAX_BUTTON_GROUP_KINDS + ) + else selectedTitles.mapNotNull { t -> allKinds.find { it.title == t } } }.onSuccess { kinds -> _moduleContentStates.update { it + (module.id to ModuleLoadState.Buttons(kinds.toImmutableList())) } }.onFailure { e -> - _moduleContentStates.update { - it + (module.id to ModuleLoadState.Error( - e.stackTraceStr - )) - } + _moduleContentStates.update { it + (module.id to ModuleLoadState.Error(e.stackTraceStr)) } } - }.also { - it.invokeOnCompletion { loadJobs.remove(module.id) } - } + }.also { it.invokeOnCompletion { loadJobs.remove(module.id) } } return } loadJobs[module.id] = viewModelScope.launch { kotlin.runCatching { - val isRanking = module.type == HomepageModuleType.Ranking.key - || module.type == HomepageModuleType.GridRanking.key - val books = if (isRanking) { - exploreBooksUseCase.executeForRanking(module.sourceUrl, module.url, module.args) - } else { - exploreBooksUseCase.execute(module.sourceUrl, module.url, module.args).books - } - val layout = try { - GSON.fromJson(module.layoutConfig, Map::class.java) - } catch (_: Exception) { - null - } - val rows = (layout?.get("rows") as? Number)?.toInt() ?: HOMEPAGE_DEFAULT_GRID_ROWS + val isRanking = + module.type == HomepageModuleType.Ranking.key || module.type == HomepageModuleType.GridRanking.key + val books = if (isRanking) exploreBooksUseCase.executeForRanking( + module.sourceUrl, + module.url, + module.args + ) + else exploreBooksUseCase.execute(module.sourceUrl, module.url, module.args).books val hasMore = isInfinite(module.type, module.layoutConfig) && books.isNotEmpty() books to hasMore }.onSuccess { (books, hasMore) -> _moduleContentStates.update { it + (module.id to ModuleLoadState.Loaded( books.toImmutableList(), - hasMore = hasMore, - page = 1 + hasMore = hasMore )) } }.onFailure { e -> - _moduleContentStates.update { - it + (module.id to ModuleLoadState.Error( - e.stackTraceStr - )) - } + _moduleContentStates.update { it + (module.id to ModuleLoadState.Error(e.stackTraceStr)) } } - }.also { - it.invokeOnCompletion { loadJobs.remove(module.id) } - } + }.also { it.invokeOnCompletion { loadJobs.remove(module.id) } } } fun loadMoreModule(globalId: String) { val currentState = _moduleContentStates.value[globalId] as? ModuleLoadState.Loaded ?: return if (currentState.isLoadingMore || !currentState.hasMore) return - val nextPage = currentState.page + 1 _moduleContentStates.update { it + (globalId to currentState.copy(isLoadingMore = true)) } - viewModelScope.launch { kotlin.runCatching { val module = gateway.getById(globalId) ?: throw Exception("Module not found") @@ -414,18 +403,14 @@ class HomepageViewModel( page = nextPage ) }.onSuccess { result -> - val newBooks = result.books _moduleContentStates.update { states -> val lastState = states[globalId] as? ModuleLoadState.Loaded ?: return@update states val existingUrls = lastState.books.map { it.bookUrl }.toSet() - val deduped = newBooks.filter { it.bookUrl !in existingUrls } - val combinedBooks = (lastState.books + deduped).toImmutableList() + val deduped = result.books.filter { it.bookUrl !in existingUrls } states + (globalId to ModuleLoadState.Loaded( - books = combinedBooks, - hasMore = deduped.isNotEmpty(), - isLoadingMore = false, - page = nextPage + books = (lastState.books + deduped).toImmutableList(), + hasMore = deduped.isNotEmpty(), isLoadingMore = false, page = nextPage )) } }.onFailure { e -> @@ -461,17 +446,12 @@ class HomepageViewModel( _isRefreshing.value = true loadJobs.values.forEach { it.cancel() } loadJobs.clear() - - // 刷新时同步当前已启用模块所属书源的定义 - val activeSourceUrls = uiState.value.modules.map { it.sourceUrl }.distinct() - activeSourceUrls.forEach { url -> + uiState.value.modules.map { it.sourceUrl }.distinct().forEach { url -> resolveBookSource(url)?.let { syncModulesFromSource(it) } } - _moduleContentStates.value = emptyMap() - uiState.map { it.modules }.first { modules -> - modules.all { it.state !is ModuleLoadState.Loading } - } + uiState.map { it.modules } + .first { modules -> modules.all { it.state !is ModuleLoadState.Loading } } _isRefreshing.value = false } } @@ -486,14 +466,11 @@ class HomepageViewModel( fun setModuleVisible(id: String, visible: Boolean) { _pendingEnabled.update { it + (id to visible) } viewModelScope.launch { - val existing = gateway.getById(id) - if (existing != null) { - gateway.setEnabled(id, visible) - } else { - // 如果模块尚未入库(虚拟状态),则根据 ID 规则解析并入库 + if (gateway.getById(id) != null) gateway.setEnabled(id, visible) + else { val parts = id.split("::") if (parts.size >= 3) { - val setId = parts[0] + val setId = parts[0]; val sourceUrl = parts[1] val key = parts.subList(2, parts.size).joinToString("::") ensureModuleInDb(sourceUrl, key, id, setId) @@ -520,24 +497,25 @@ class HomepageViewModel( private suspend fun ensureSetForSource(sourceUrl: String, sourceName: String): String { val setId = "src_$sourceUrl" - if (gateway.getCustomSetById(setId) == null) { - gateway.upsertCustomSet(CustomSetItem(id = setId, name = sourceName)) - } + if (gateway.getCustomSetById(setId) == null) gateway.upsertCustomSet( + CustomSetItem( + id = setId, + name = sourceName + ) + ) return setId } fun addCustomModule(sourceUrl: String, targetSetId: String?, def: ModuleDef) { - val key = def.key.ifBlank { def.title } + val key = def.key.ifBlank { def.title }; val setId = targetSetId ?: "src_$sourceUrl" - if (isInfinite(def.type, def.layoutConfig)) { - val hasInfinite = allModulesCache.value.any { - it.customSetId == setId && isInfinite( - it.type, - it.layoutConfig - ) - } - if (hasInfinite) { + if (allModulesCache.value.any { + it.customSetId == setId && isInfinite( + it.type, + it.layoutConfig + ) + }) { viewModelScope.launch { _effects.emit( HomepageEffect.ShowSnackbar( @@ -550,21 +528,11 @@ class HomepageViewModel( return } } - val id = ModuleDef.globalIdOf(sourceUrl, key, setId) val module = ModuleItem( - id = id, - sourceUrl = sourceUrl, - moduleKey = key, - type = def.type, - title = def.title, - args = def.args, - layoutConfig = def.layoutConfig, - url = def.url, - isEnabled = true, - isUserCreated = true, - customSetId = setId, - syncedAt = System.currentTimeMillis(), + id = id, sourceUrl = sourceUrl, moduleKey = key, type = def.type, title = def.title, + args = def.args, layoutConfig = def.layoutConfig, url = def.url, isEnabled = true, + isUserCreated = true, customSetId = setId, syncedAt = System.currentTimeMillis() ) viewModelScope.launch { val source = bookSourceRepository.getBookSource(sourceUrl) @@ -575,23 +543,37 @@ class HomepageViewModel( } } - fun getSourceExploreKinds(sourceUrl: String): List> { - return _exploreKindsCache.value[sourceUrl].orEmpty() - } + fun getSourceExploreKinds(sourceUrl: String): List> = + _exploreKindsCache.value[sourceUrl].orEmpty() fun updateModule(globalId: String, def: ModuleDef) { viewModelScope.launch { val existing = gateway.getById(globalId) ?: return@launch + // 校验:如果是变更为无限流,检查集内是否已有其他无限流模块 + if (isInfinite(def.type, def.layoutConfig)) { + val hasOtherInfinite = allModulesCache.value.any { + it.customSetId == existing.customSetId && it.id != globalId && isInfinite( + it.type, + it.layoutConfig + ) + } + if (hasOtherInfinite) { + _effects.emit( + HomepageEffect.ShowSnackbar( + getApplication().getString( + R.string.homepage_module_duplicate_infinite + ) + ) + ) + return@launch + } + } gateway.upsertAll( listOf( existing.copy( - customTitle = def.title.takeIf { it != existing.title }, - type = def.type, - url = def.url, - args = def.args, - layoutConfig = def.layoutConfig, - isUserCreated = true, // 标记为用户编辑,阻止 JSON 同步覆盖 - syncedAt = System.currentTimeMillis(), + customTitle = def.title.takeIf { it != existing.title }, type = def.type, + url = def.url, args = def.args, layoutConfig = def.layoutConfig, + isUserCreated = true, syncedAt = System.currentTimeMillis() ) ) ) @@ -601,17 +583,17 @@ class HomepageViewModel( fun setModuleCustomSetTitle(globalId: String, customSetTitle: String?) { viewModelScope.launch { - gateway.setCustomSetTitle(globalId, customSetTitle) - notifyConfigChanged() + gateway.setCustomSetTitle( + globalId, + customSetTitle + ); notifyConfigChanged() } } fun deleteModule(globalId: String) { viewModelScope.launch { - gateway.delete(globalId) - _moduleContentStates.update { it - globalId } - loadJobs.remove(globalId)?.cancel() - _pendingEnabled.update { it - globalId } + gateway.delete(globalId); _moduleContentStates.update { it - globalId } + loadJobs.remove(globalId)?.cancel(); _pendingEnabled.update { it - globalId } _pendingUserModules.update { it.filter { m -> m.id != globalId } } notifyConfigChanged() } @@ -620,30 +602,23 @@ class HomepageViewModel( fun reorderJoinedModules(orderedIds: List) { viewModelScope.launch { val orders = orderedIds.mapIndexed { index, id -> id to index }.toMap() - gateway.batchSetSortOrders(orders) - notifyConfigChanged() + gateway.batchSetSortOrders(orders); notifyConfigChanged() } } fun reorderCustomSets(orderedUrls: List) { viewModelScope.launch { - val orders = orderedUrls.mapIndexed { index, url -> - customSetIdFromUrl(url) to index - }.toMap() - gateway.batchSetCustomSetSortOrders(orders) - notifyConfigChanged() + val orders = + orderedUrls.mapIndexed { index, url -> customSetIdFromUrl(url) to index }.toMap() + gateway.batchSetCustomSetSortOrders(orders); notifyConfigChanged() } } - /** 获取指定集内的模块(sourceUrl 可以是书源 URL 或 custom://xxx) */ fun getJoinedModules(sourceUrl: String): List { - val isSet = isCustomSetUrl(sourceUrl) + val isSet = isCustomSetUrl(sourceUrl); val setId = if (isSet) customSetIdFromUrl(sourceUrl) else null - val dbModules = if (isSet) { - allModulesCache.value.filter { it.customSetId == setId } - } else { - allModulesCache.value.filter { it.sourceUrl == sourceUrl } - } + val dbModules = + if (isSet) allModulesCache.value.filter { it.customSetId == setId } else allModulesCache.value.filter { it.sourceUrl == sourceUrl } val dbIds = dbModules.map { it.id }.toSet() val pendingModules = _pendingUserModules.value.filter { pending -> val matches = @@ -653,67 +628,70 @@ class HomepageViewModel( return (dbModules + pendingModules).map { uiFromModule(it) } } - /** 所有已添加的模块,按书源分组(用于自定义集添加模块) */ - fun getAllModulesGroupedBySource(): Map> { - return allModulesCache.value - .distinctBy { it.sourceUrl to it.moduleKey } - .map { uiFromModule(it) } + fun getAllModulesGroupedBySource(): Map> = + allModulesCache.value.distinctBy { it.sourceUrl to it.moduleKey }.map { uiFromModule(it) } .groupBy { it.sourceUrl } - } - fun getSourceName(sourceUrl: String): String { - return _bookSourcesCache.value[sourceUrl]?.bookSourceName ?: sourceUrl - } + fun getSourceName(sourceUrl: String): String = + _bookSourcesCache.value[sourceUrl]?.bookSourceName ?: sourceUrl fun assignModuleToCustomSet(moduleId: String, customSetId: String?) { viewModelScope.launch { val existing = gateway.getById(moduleId) ?: return@launch if (customSetId == null) { - // 如果是取消分配,且它不是归属于书源默认集的,则直接删除该副本 - if (existing.customSetId != "src_${existing.sourceUrl}") { - gateway.delete(moduleId) - } + if (existing.customSetId != "src_${existing.sourceUrl}") gateway.delete(moduleId) } else { - // 核心逻辑:分配 = 复制。生成带新 setId 的 ID + // 校验:目标集是否已存在无限模块 + if (isInfinite(existing.type, existing.layoutConfig)) { + val hasInfinite = allModulesCache.value.any { + it.customSetId == customSetId && isInfinite(it.type, it.layoutConfig) + } + if (hasInfinite) { + _effects.emit( + HomepageEffect.ShowSnackbar( + getApplication().getString( + R.string.homepage_module_duplicate_infinite + ) + ) + ) + return@launch + } + } val newId = ModuleDef.globalIdOf(existing.sourceUrl, existing.moduleKey, customSetId) - val newModule = existing.copy( - id = newId, - customSetId = customSetId, - isEnabled = true, // 分配到新集时默认开启 - isUserCreated = true // 标记为用户创建,避免被同步清理 + gateway.upsertAll( + listOf( + existing.copy( + id = newId, + customSetId = customSetId, + isEnabled = true, + isUserCreated = true + ) + ) ) - gateway.upsertAll(listOf(newModule)) } notifyConfigChanged() } } fun syncSourceModules(sourceUrl: String) { - viewModelScope.launch { - resolveBookSource(sourceUrl)?.let { syncModulesFromSource(it) } - } + viewModelScope.launch { resolveBookSource(sourceUrl)?.let { syncModulesFromSource(it) } } } - /** 「书源模块」tab:仅 JSON,纯参考 */ fun getSourceModules( sourceUrl: String, targetSetId: String? = null ): List { val source = resolveBookSource(sourceUrl) ?: return emptyList() - val json = source.homepageModules ?: return emptyList() - val jsonDefs = parseBookSourceModules(source, json) - + val jsonDefs = parseModuleDefs(source, json) val effectiveSetId = targetSetId ?: "src_$sourceUrl" - val joinedKeys = allModulesCache.value - .filter { it.sourceUrl == sourceUrl && it.customSetId == effectiveSetId } - .map { it.moduleKey }.toSet() - + val joinedKeys = + allModulesCache.value.filter { it.sourceUrl == sourceUrl && it.customSetId == effectiveSetId } + .map { it.moduleKey }.toSet() return jsonDefs.map { def -> - val id = ModuleDef.globalIdOf(sourceUrl, def.key, effectiveSetId) HomepageModuleManageUi( - id = id, + id = ModuleDef.globalIdOf(sourceUrl, def.key, effectiveSetId), sourceUrl = def.sourceUrl, moduleKey = def.key, title = def.title, @@ -727,15 +705,13 @@ class HomepageViewModel( } } - /** 从书源模块「加入」→ 写入 DB,自动归属到该书源的集 */ - /** 从发现页 Kind 创建一个 ButtonGroup 模块,args 存储选中 Kind 标题的 JSON 数组 */ fun addButtonGroupFromKinds( sourceUrl: String, targetSetId: String?, title: String, kindTitles: List ) { - val key = kindTitles.firstOrNull() ?: title + val key = kindTitles.firstOrNull() ?: title; val setId = targetSetId ?: "src_$sourceUrl" val id = ModuleDef.globalIdOf(sourceUrl, key, setId) val module = ModuleItem( @@ -759,52 +735,8 @@ class HomepageViewModel( } } - fun joinModule(sourceUrl: String, targetSetId: String?, def: ModuleDef) { - val setId = targetSetId ?: "src_$sourceUrl" - - if (isInfinite(def.type, def.layoutConfig)) { - val hasInfinite = allModulesCache.value.any { - it.customSetId == setId && isInfinite( - it.type, - it.layoutConfig - ) - } - if (hasInfinite) { - viewModelScope.launch { - _effects.emit( - HomepageEffect.ShowSnackbar( - getApplication().getString( - R.string.homepage_module_duplicate_infinite - ) - ) - ) - } - return - } - } - - val id = ModuleDef.globalIdOf(sourceUrl, def.key, setId) - val module = ModuleItem( - id = id, - sourceUrl = sourceUrl, - moduleKey = def.key, - type = def.type, - title = def.title, - args = def.args, - layoutConfig = def.layoutConfig, - url = def.url, - isEnabled = true, - customSetId = setId, - syncedAt = System.currentTimeMillis(), - ) - viewModelScope.launch { - val source = bookSourceRepository.getBookSource(sourceUrl) - if (source != null) ensureSetForSource(sourceUrl, source.bookSourceName) - gateway.upsertAll(listOf(module)) - _pendingUserModules.update { list -> if (list.any { it.id == id }) list else list + module } - notifyConfigChanged() - } - } + fun joinModule(sourceUrl: String, targetSetId: String?, def: ModuleDef) = + addCustomModule(sourceUrl, targetSetId, def) private fun uiFromModule(module: ModuleItem) = HomepageModuleManageUi( id = module.id, @@ -822,27 +754,19 @@ class HomepageViewModel( ) fun createCustomSet(name: String) { - viewModelScope.launch { - gateway.createCustomSet(name) - notifyConfigChanged() - } + viewModelScope.launch { gateway.createCustomSet(name); notifyConfigChanged() } } fun renameCustomSet(id: String, name: String) { - viewModelScope.launch { - gateway.renameCustomSet(id, name) - notifyConfigChanged() - } + viewModelScope.launch { gateway.renameCustomSet(id, name); notifyConfigChanged() } } - fun deleteCustomSet(id: String) { viewModelScope.launch { - val moduleIds = allModulesCache.value.filter { it.customSetId == id }.map { it.id } + val ids = allModulesCache.value.filter { it.customSetId == id }.map { it.id } gateway.deleteCustomSet(id) - moduleIds.forEach { mid -> - _moduleContentStates.update { it - mid } - loadJobs.remove(mid)?.cancel() - _pendingEnabled.update { it - mid } + ids.forEach { mid -> + _moduleContentStates.update { it - mid }; loadJobs.remove(mid) + ?.cancel(); _pendingEnabled.update { it - mid } } notifyConfigChanged() } @@ -853,12 +777,12 @@ class HomepageViewModel( saveSearchBooksUseCase.save(book) _effects.emit( HomepageEffect.NavigateToBookInfo( - name = book.name, - author = book.author, - bookUrl = book.bookUrl, - origin = book.origin, - coverPath = book.coverUrl, - sharedCoverKey = sharedCoverKey + book.name, + book.author, + book.bookUrl, + book.origin, + book.coverUrl, + sharedCoverKey ) ) } @@ -866,14 +790,18 @@ class HomepageViewModel( fun onModuleHeaderClick(sourceUrl: String, exploreUrl: String?, title: String?) { viewModelScope.launch { - _effects.emit(HomepageEffect.NavigateToExploreShow(title, sourceUrl, exploreUrl)) + _effects.emit( + HomepageEffect.NavigateToExploreShow( + title, + sourceUrl, + exploreUrl + ) + ) } } - private fun resolveBookSource(sourceUrl: String): BookSource? { - return _bookSourcesCache.value[sourceUrl] - ?: bookSourceRepository.getBookSourceSync(sourceUrl) - } + private fun resolveBookSource(sourceUrl: String): BookSource? = + _bookSourcesCache.value[sourceUrl] ?: bookSourceRepository.getBookSourceSync(sourceUrl) private suspend fun ensureModuleInDb( sourceUrl: String, @@ -883,8 +811,7 @@ class HomepageViewModel( ) { if (gateway.getById(id) != null) return val source = resolveBookSource(sourceUrl) ?: return - val json = source.homepageModules ?: return - val defs = parseBookSourceModules(source, json) + val defs = parseModuleDefs(source, source.homepageModules ?: return) val def = defs.find { it.key == moduleKey } ?: return gateway.upsertAll( listOf( @@ -906,10 +833,6 @@ class HomepageViewModel( private fun notifyConfigChanged() { _configVersion.update { it + 1 } } - - private fun parseBookSourceModules(source: BookSource, json: String): List = - parseModuleDefs(source, json) - } private data class HomepageUiFlags( diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/manage/AddCustomModuleDialog.kt b/app/src/main/java/io/legado/app/ui/main/homepage/manage/AddCustomModuleDialog.kt new file mode 100644 index 000000000..d6a582074 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/homepage/manage/AddCustomModuleDialog.kt @@ -0,0 +1,185 @@ +package io.legado.app.ui.main.homepage.manage + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +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.res.stringResource +import androidx.compose.ui.unit.dp +import com.google.gson.JsonParser +import io.legado.app.R +import io.legado.app.domain.model.HomepageModuleType +import io.legado.app.domain.model.ModuleDef +import io.legado.app.ui.main.homepage.HomepageViewModel +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.AppTextField +import io.legado.app.ui.widget.components.JsonConfigEditor +import io.legado.app.ui.widget.components.JsonRawEditor +import io.legado.app.ui.widget.components.alert.AppAlertDialog +import io.legado.app.ui.widget.components.card.GlassCard +import io.legado.app.ui.widget.components.divider.PillHeaderDivider +import io.legado.app.ui.widget.components.settingItem.CompactClickableSettingItem +import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem +import io.legado.app.ui.widget.components.text.AppText + +data class AddDialogPrefill( + val title: String = "", + val url: String = "", + val type: String = "card" +) + +@Composable +fun AddCustomModuleDialog( + data: T?, + sourceUrl: String = "", + targetSetId: String = "", + prefillTitle: String = "", + prefillUrl: String = "", + prefillType: String = "card", + prefillArgs: String = "", + prefillLayoutConfig: String = "", + canSelectInfinite: Boolean = true, + onDismissRequest: () -> Unit, + onConfirm: (ModuleDef) -> Unit, +) { + var title by remember(data) { mutableStateOf(prefillTitle) } + var url by remember(data) { mutableStateOf(prefillUrl) } + var type by remember(data) { mutableStateOf(prefillType) } + var args by remember(data) { mutableStateOf(prefillArgs) } + var layoutConfig by remember(data) { mutableStateOf(prefillLayoutConfig) } + var showRawLayoutConfig by remember(data) { mutableStateOf(false) } + + val hasVisualizableKeys = remember(layoutConfig) { + runCatching { + val jsonObject = JsonParser.parseString(layoutConfig).asJsonObject + jsonObject.keySet().any { key -> + key == "columns" || key == "rows" + } + }.getOrElse { false } + } + + AppAlertDialog( + data = data, + onDismissRequest = onDismissRequest, + title = if (prefillTitle.isEmpty()) stringResource(R.string.homepage_add_module) else stringResource( + R.string.homepage_edit_module + ), + content = { + Column( + modifier = Modifier + .fillMaxWidth() + .height(400.dp) + .verticalScroll(rememberScrollState()), + 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(canSelectInfinite) { + HomepageModuleType.entries.filter { + it != HomepageModuleType.Unknown && (canSelectInfinite || !HomepageViewModel.isInfinite( + it.key, + null + )) + } + } + + 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 } + ) + } + + if (HomepageViewModel.isInfinite(type, null) && !canSelectInfinite) { + AppText( + text = stringResource(R.string.homepage_module_duplicate_infinite), + color = LegadoTheme.colorScheme.error, + style = LegadoTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + + AppTextField( + value = args, + onValueChange = { args = it }, + backgroundColor = LegadoTheme.colorScheme.onSheetContent, + label = "Args (JSON)", + modifier = Modifier.fillMaxWidth() + ) + + PillHeaderDivider( + title = stringResource(R.string.homepage_layout_config_label) + ) + + if (hasVisualizableKeys) { + JsonConfigEditor( + jsonString = layoutConfig, + onJsonStringChange = { layoutConfig = it }, + modifier = Modifier.fillMaxWidth() + ) + CompactClickableSettingItem( + title = stringResource(R.string.homepage_edit_raw_json), + onClick = { showRawLayoutConfig = !showRawLayoutConfig } + ) + if (showRawLayoutConfig) { + JsonRawEditor( + value = layoutConfig, + onValueChange = { layoutConfig = it }, + label = "LayoutConfig (JSON) RAW", + modifier = Modifier.fillMaxWidth() + ) + } + } else { + JsonRawEditor( + value = layoutConfig, + onValueChange = { layoutConfig = it }, + label = "LayoutConfig (JSON)", + modifier = Modifier.fillMaxWidth() + ) + } + } + }, + onConfirm = { + onConfirm( + ModuleDef( + title = title, + url = url, + type = type, + args = args, + layoutConfig = layoutConfig + ) + ) + }, + confirmText = stringResource(R.string.dialog_confirm), + dismissText = stringResource(R.string.dialog_cancel), + onDismiss = onDismissRequest + ) +} diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/manage/BrowseSourcesPage.kt b/app/src/main/java/io/legado/app/ui/main/homepage/manage/BrowseSourcesPage.kt new file mode 100644 index 000000000..fb82273ea --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/homepage/manage/BrowseSourcesPage.kt @@ -0,0 +1,44 @@ +package io.legado.app.ui.main.homepage.manage + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.legado.app.R +import io.legado.app.ui.main.homepage.HomepageModuleManageUi +import io.legado.app.ui.main.homepage.HomepageSourceManageUi +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.card.SelectionItemCard +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun BrowseSourcesPage( + browseSources: ImmutableList, + getSourceModules: (String, String?) -> List, + onSelectSource: (String) -> Unit, +) { + val sources = remember(browseSources) { + browseSources.distinctBy { it.sourceUrl } + } + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(sources, key = { it.sourceUrl }) { source -> + val moduleCount = getSourceModules(source.sourceUrl, null).size + SelectionItemCard( + title = source.sourceName, + subtitle = stringResource(R.string.homepage_n_modules, moduleCount), + containerColor = LegadoTheme.colorScheme.onSheetContent, + onToggleSelection = { + onSelectSource(source.sourceUrl) + } + ) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/manage/CustomSetAddModulesPage.kt b/app/src/main/java/io/legado/app/ui/main/homepage/manage/CustomSetAddModulesPage.kt new file mode 100644 index 000000000..0d25729d2 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/homepage/manage/CustomSetAddModulesPage.kt @@ -0,0 +1,90 @@ +package io.legado.app.ui.main.homepage.manage + +import androidx.compose.foundation.layout.Arrangement +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.runtime.Composable +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.res.stringResource +import androidx.compose.ui.unit.dp +import io.legado.app.R +import io.legado.app.ui.main.homepage.HomepageModuleManageUi +import io.legado.app.ui.main.homepage.HomepageViewModel +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.card.SelectionItemCard +import io.legado.app.ui.widget.components.text.AppText +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun CustomSetAddModulesPage( + setUrl: String, + allJoinedModules: ImmutableList, + sourceNames: Map, + onToggleModuleToSet: (HomepageModuleManageUi, inCurrentSet: Boolean, isBlocked: Boolean) -> Unit, +) { + val setId = HomepageViewModel.customSetIdFromUrl(setUrl) + val initialJoined = allJoinedModules + .filter { it.customSetId == setId } + .associateBy({ it.moduleKey }, { it.id }) + var joinedInCurrent by remember(initialJoined) { mutableStateOf(initialJoined) } + + val hasInfiniteInCurrentSet = remember(allJoinedModules) { + allJoinedModules.any { + it.customSetId == setId && HomepageViewModel.isInfinite( + it.type, + it.layoutConfig + ) + } + } + + val grouped = remember(allJoinedModules) { + allJoinedModules + .distinctBy { it.sourceUrl to it.moduleKey } + .groupBy { it.sourceUrl } + } + + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + grouped.forEach { (sourceUrl, modules) -> + item(key = "header_$sourceUrl") { + AppText( + text = sourceNames[sourceUrl] ?: sourceUrl, + style = LegadoTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + items(modules, key = { it.id }) { module -> + val instanceIdInCurrentSet = joinedInCurrent[module.moduleKey] + val inCurrentSet = instanceIdInCurrentSet != null + val isInfinite = HomepageViewModel.isInfinite(module.type, module.layoutConfig) + val isBlocked = !inCurrentSet && isInfinite && hasInfiniteInCurrentSet + + SelectionItemCard( + title = module.title, + subtitle = module.moduleKey + if (isBlocked) " (${stringResource(R.string.homepage_module_duplicate_infinite)})" else "", + containerColor = LegadoTheme.colorScheme.onSheetContent, + isSelected = inCurrentSet, + inSelectionMode = true, + isEnabled = !isBlocked, + onToggleSelection = { + onToggleModuleToSet(module, inCurrentSet, isBlocked) + if (inCurrentSet) { + joinedInCurrent = joinedInCurrent - module.moduleKey + } else if (!isBlocked) { + joinedInCurrent = + joinedInCurrent + (module.moduleKey to "temp_${module.id}") + } + } + ) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/manage/SetDetailPage.kt b/app/src/main/java/io/legado/app/ui/main/homepage/manage/SetDetailPage.kt new file mode 100644 index 000000000..3e6f0f86f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/homepage/manage/SetDetailPage.kt @@ -0,0 +1,188 @@ +package io.legado.app.ui.main.homepage.manage + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +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.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.HomepageModuleManageUi +import io.legado.app.ui.main.homepage.HomepageViewModel +import io.legado.app.ui.theme.LegadoTheme +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.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.text.AppText +import io.legado.app.utils.move +import kotlinx.collections.immutable.ImmutableList +import sh.calvin.reorderable.rememberReorderableLazyListState + +@Composable +fun SetDetailPage( + setUrl: String, + allJoinedModules: ImmutableList, + onToggleModule: (String, Boolean) -> Unit, + onReorderModules: (List) -> Unit, + onEditModule: (HomepageModuleManageUi) -> Unit, + onRequestDeleteModule: (String) -> Unit, + onBrowseSourceModules: () -> Unit, + onAddModules: () -> Unit, +) { + val setId = HomepageViewModel.customSetIdFromUrl(setUrl) + val modules = remember(setId, allJoinedModules) { + allJoinedModules.filter { it.customSetId == setId }.distinctBy { it.id } + } + + val standardModules = remember(modules) { + modules.filter { !HomepageViewModel.isInfinite(it.type, it.layoutConfig) } + } + val infiniteModules = remember(modules) { + modules.filter { HomepageViewModel.isInfinite(it.type, it.layoutConfig) } + } + + if (modules.isEmpty()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + AppText(stringResource(R.string.homepage_no_modules)) + SecondaryButton( + text = stringResource(R.string.homepage_browse_to_add), + onClick = { + if (setId.startsWith("src_")) onBrowseSourceModules() + else onAddModules() + } + ) + } + } else { + var listData by remember(setUrl, standardModules) { + mutableStateOf(standardModules) + } + val listState = rememberLazyListState() + val reorderableState = rememberReorderableLazyListState(listState) { from, to -> + listData = listData.toMutableList().apply { + if (isEmpty()) return@apply + val fromIndex = (from.index - 1).coerceIn(0, lastIndex) + val toIndex = (to.index - 1).coerceIn(0, lastIndex) + if (fromIndex in indices && toIndex in indices) { + move(fromIndex, toIndex) + } + } + } + + LaunchedEffect(standardModules) { + if (!reorderableState.isAnyItemDragging) listData = standardModules + } + LaunchedEffect(reorderableState.isAnyItemDragging) { + if (!reorderableState.isAnyItemDragging) { + val orderedIds = + (listData.map { it.id } + infiniteModules.map { it.id }).distinct() + if (orderedIds != modules.map { it.id }) onReorderModules(orderedIds) + } + } + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (listData.isNotEmpty()) { + item(key = "header_std_detail") { + AppText( + text = stringResource(R.string.homepage_standard_module), + style = LegadoTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + } + items(listData, key = { it.id }) { module -> + ReorderableSelectionItem( + state = reorderableState, + key = module.id, + title = module.title, + subtitle = HomepageModuleType.fromKey(module.type).title, + isEnabled = module.isVisible, + containerColor = LegadoTheme.colorScheme.onSheetContent, + onEnabledChange = { enabled -> + onToggleModule(module.id, enabled) + listData = listData.map { + if (it.id == module.id) it.copy(isVisible = enabled) else it + } + }, + trailingAction = { + SmallIconButton( + onClick = { onEditModule(module) }, + imageVector = Icons.Default.Edit + ) + SmallIconButton( + onClick = { onRequestDeleteModule(module.id) }, + imageVector = Icons.Default.Delete + ) + } + ) + } + } + + if (infiniteModules.isNotEmpty()) { + item(key = "header_inf_detail") { + PillDivider() + AppText( + text = stringResource(R.string.homepage_infinite_module_slot), + style = LegadoTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + } + items(infiniteModules, key = { it.id }) { module -> + val isEffective = infiniteModules.firstOrNull() == module + SelectionItemCard( + title = module.title, + subtitle = HomepageModuleType.fromKey(module.type).title, + isEnabled = module.isVisible, + containerColor = if (isEffective) LegadoTheme.colorScheme.surfaceContainerHigh else LegadoTheme.colorScheme.onSheetContent, + onEnabledChange = { onToggleModule(module.id, it) }, + trailingAction = { + SmallIconButton( + onClick = { onEditModule(module) }, + imageVector = Icons.Default.Edit + ) + SmallIconButton( + onClick = { onRequestDeleteModule(module.id) }, + imageVector = Icons.Default.Delete + ) + } + ) + } + } + + item(key = "browse_from_set") { + SecondaryButton( + text = stringResource(R.string.homepage_browse_source_modules), + onClick = { + if (setId.startsWith("src_")) onBrowseSourceModules() + else onAddModules() + }, + modifier = Modifier.fillMaxWidth() + ) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/manage/SetListPage.kt b/app/src/main/java/io/legado/app/ui/main/homepage/manage/SetListPage.kt new file mode 100644 index 000000000..ee703a5fb --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/homepage/manage/SetListPage.kt @@ -0,0 +1,119 @@ +package io.legado.app.ui.main.homepage.manage + +import androidx.compose.foundation.layout.Arrangement +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.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.DriveFileRenameOutline +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.res.stringResource +import androidx.compose.ui.unit.dp +import io.legado.app.R +import io.legado.app.ui.main.homepage.HomepageSourceManageUi +import io.legado.app.ui.theme.LegadoTheme +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.ReorderableSelectionItem +import io.legado.app.ui.widget.components.divider.PillDivider +import io.legado.app.utils.move +import kotlinx.collections.immutable.ImmutableList +import sh.calvin.reorderable.rememberReorderableLazyListState + +@Composable +fun SetListPage( + sets: ImmutableList, + onToggleSet: (String, Boolean) -> Unit, + onReorderSets: (List) -> Unit, + onSelectSet: (String) -> Unit, + onRenameSet: (String) -> Unit, + onDeleteSet: (String) -> Unit, + onCreateSet: () -> Unit, + onBrowseSources: () -> Unit, +) { + var localSets by remember { mutableStateOf(sets.distinctBy { it.sourceUrl }) } + val setsListState = rememberLazyListState() + val setsReorderableState = + rememberReorderableLazyListState(setsListState) { from, to -> + localSets = localSets.toMutableList().apply { + if (isEmpty()) return@apply + val fromIndex = from.index.coerceIn(0, lastIndex) + val toIndex = to.index.coerceIn(0, lastIndex) + if (fromIndex in indices && toIndex in indices) { + move(fromIndex, toIndex) + } + } + } + + LaunchedEffect(sets) { + if (!setsReorderableState.isAnyItemDragging) localSets = sets.distinctBy { it.sourceUrl } + } + LaunchedEffect(setsReorderableState.isAnyItemDragging) { + if (!setsReorderableState.isAnyItemDragging) { + val orderedUrls = localSets.map { it.sourceUrl } + if (orderedUrls != sets.map { it.sourceUrl }) onReorderSets(orderedUrls) + } + } + + LazyColumn( + state = setsListState, + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(localSets, key = { it.sourceUrl }) { set -> + ReorderableSelectionItem( + state = setsReorderableState, + key = set.sourceUrl, + title = set.sourceName, + subtitle = stringResource(R.string.homepage_n_modules, set.moduleCount), + containerColor = LegadoTheme.colorScheme.onSheetContent, + isEnabled = set.isSelected, + onToggleSelection = { onSelectSet(set.sourceUrl) }, + onEnabledChange = { enabled -> + onToggleSet(set.sourceUrl, enabled) + localSets = localSets.map { + if (it.sourceUrl == set.sourceUrl) it.copy(isSelected = enabled) else it + } + }, + trailingAction = { + SmallIconButton( + onClick = { onRenameSet(set.sourceUrl) }, + imageVector = Icons.Default.DriveFileRenameOutline + ) + SmallIconButton( + onClick = { onDeleteSet(set.sourceUrl) }, + imageVector = Icons.Default.Delete + ) + } + ) + } + item { + PillDivider( + modifier = Modifier.padding(vertical = 12.dp) + ) + } + item(key = "create_set") { + SecondaryButton( + text = stringResource(R.string.homepage_new_custom_set), + onClick = onCreateSet, + modifier = Modifier.fillMaxWidth() + ) + } + item(key = "browse_sources") { + SecondaryButton( + text = stringResource(R.string.homepage_browse_source_modules), + onClick = onBrowseSources, + modifier = Modifier.fillMaxWidth() + ) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/manage/SourceBrowseDetailPage.kt b/app/src/main/java/io/legado/app/ui/main/homepage/manage/SourceBrowseDetailPage.kt new file mode 100644 index 000000000..195c2fbe9 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/homepage/manage/SourceBrowseDetailPage.kt @@ -0,0 +1,383 @@ +package io.legado.app.ui.main.homepage.manage + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +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.Alignment +import androidx.compose.ui.Modifier +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.domain.model.ModuleDef +import io.legado.app.ui.main.homepage.HomepageModuleManageUi +import io.legado.app.ui.main.homepage.HomepageViewModel +import io.legado.app.ui.theme.LegadoTheme +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.explore.ExploreKindSelectSheet +import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem +import io.legado.app.ui.widget.components.tabRow.AppTabRow +import io.legado.app.ui.widget.components.text.AppText +import io.legado.app.utils.move +import kotlinx.collections.immutable.ImmutableList +import sh.calvin.reorderable.rememberReorderableLazyListState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SourceBrowseDetailPage( + browseUrl: String, + selectingSetUrl: String?, + allJoinedModules: ImmutableList, + canSelectInfiniteGlobal: Boolean, + onGetSourceModules: (String, String?) -> List, + onGetExploreKinds: (String) -> List>, + onToggleModule: (String, Boolean) -> Unit, + onJoinModule: (String, String?, ModuleDef) -> Unit, + onRequestDeleteModule: (String) -> Unit, + onReorderModules: (List) -> Unit, + onEditModule: (HomepageModuleManageUi) -> Unit, + onAddDialogPrefill: (AddDialogPrefill?) -> Unit, + onShowAddButtonGroupDialog: () -> Unit, + browseTab: Int, + onBrowseTabChange: (Int) -> Unit, + browseModuleType: String, + onBrowseModuleTypeChange: (String) -> Unit, + selectedKindTitles: Set, + onSelectedKindTitlesChange: (Set) -> Unit, +) { + var showKindSelect by remember { mutableStateOf(false) } + + val displaySetUrl = + selectingSetUrl ?: HomepageViewModel.customSetUrl("src_$browseUrl") + val currentSetId = HomepageViewModel.customSetIdFromUrl(displaySetUrl) + + val joinedModules = remember(displaySetUrl, allJoinedModules) { + allJoinedModules.filter { it.customSetId == currentSetId } + } + + val standardModules = remember(joinedModules) { + joinedModules.filter { !HomepageViewModel.isInfinite(it.type, it.layoutConfig) } + } + val infiniteModules = remember(joinedModules) { + joinedModules.filter { HomepageViewModel.isInfinite(it.type, it.layoutConfig) } + } + val hasInfiniteInSet = infiniteModules.isNotEmpty() + + val joinedKeys = joinedModules.map { it.moduleKey }.toSet() + val sourceModules = onGetSourceModules(browseUrl, currentSetId) + Column { + AppTabRow( + tabTitles = listOf( + stringResource(R.string.homepage_tab_joined), + stringResource(R.string.homepage_tab_source_modules), + stringResource(R.string.homepage_tab_discover) + ), + selectedTabIndex = browseTab, + onTabSelected = { onBrowseTabChange(it) } + ) + when (browseTab) { + 0 -> { + if (joinedModules.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + AppText(stringResource(R.string.homepage_no_joined_modules)) + } + } else { + var listData by remember(displaySetUrl, standardModules) { + mutableStateOf(standardModules.distinctBy { it.id }) + } + val listState = rememberLazyListState() + val reorderableState = + rememberReorderableLazyListState(listState) { from, to -> + listData = listData.toMutableList().apply { + if (isEmpty()) return@apply + val fromIndex = (from.index - 1).coerceIn(0, lastIndex) + val toIndex = (to.index - 1).coerceIn(0, lastIndex) + if (fromIndex in indices && toIndex in indices) { + move(fromIndex, toIndex) + } + } + } + LaunchedEffect(standardModules) { + if (!reorderableState.isAnyItemDragging) listData = + standardModules.distinctBy { it.id } + } + LaunchedEffect(reorderableState.isAnyItemDragging) { + if (!reorderableState.isAnyItemDragging) { + val orderedIds = + (listData.map { it.id } + infiniteModules.map { it.id }).distinct() + if (orderedIds != joinedModules.map { it.id }) { + onReorderModules(orderedIds) + } + } + } + LazyColumn( + state = listState, + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + item(key = "header_standard") { + AppText( + text = stringResource(R.string.homepage_standard_modules_sortable), + style = LegadoTheme.typography.labelMedium, + color = LegadoTheme.colorScheme.primary, + modifier = Modifier.padding( + horizontal = 16.dp, + vertical = 4.dp + ) + ) + } + + items(listData, key = { it.id }) { module -> + ReorderableSelectionItem( + state = reorderableState, + key = module.id, + title = module.title, + subtitle = HomepageModuleType.fromKey(module.type).title, + isEnabled = module.isVisible, + containerColor = LegadoTheme.colorScheme.onSheetContent, + onEnabledChange = { enabled -> + onToggleModule(module.id, enabled) + listData = listData.map { + if (it.id == module.id) it.copy(isVisible = enabled) else it + } + }, + trailingAction = { + SmallIconButton( + onClick = { onEditModule(module) }, + imageVector = Icons.Default.Edit + ) + SmallIconButton( + onClick = { onRequestDeleteModule(module.id) }, + imageVector = Icons.Default.Delete + ) + } + ) + } + + if (infiniteModules.isNotEmpty()) { + item(key = "header_infinite") { + PillDivider( + modifier = Modifier.padding( + vertical = 8.dp, + horizontal = 16.dp + ) + ) + AppText( + text = stringResource(R.string.homepage_infinite_module_bottom), + style = LegadoTheme.typography.labelMedium, + modifier = Modifier.padding( + horizontal = 16.dp, + vertical = 4.dp + ) + ) + } + + items(infiniteModules, key = { it.id }) { module -> + val isEffective = + infiniteModules.firstOrNull() == module + SelectionItemCard( + title = module.title, + subtitle = HomepageModuleType.fromKey(module.type).title + if (isEffective) stringResource( + R.string.homepage_status_in_effect + ) else stringResource(R.string.homepage_status_blocked), + isEnabled = module.isVisible, + containerColor = if (isEffective) LegadoTheme.colorScheme.surfaceContainerHigh else LegadoTheme.colorScheme.onSheetContent, + onEnabledChange = { onToggleModule(module.id, it) }, + trailingAction = { + SmallIconButton( + onClick = { onEditModule(module) }, + imageVector = Icons.Default.Edit + ) + SmallIconButton( + onClick = { onRequestDeleteModule(module.id) }, + imageVector = Icons.Default.Delete + ) + } + ) + } + } + } + } + } + + 1 -> { + if (sourceModules.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + AppText(stringResource(R.string.homepage_source_json_empty)) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items( + sourceModules.distinctBy { it.id }, + key = { it.id }) { module -> + val isJoined = joinedKeys.contains(module.moduleKey) + val isInfinite = + HomepageViewModel.isInfinite(module.type, module.layoutConfig) + val isBlocked = !isJoined && isInfinite && hasInfiniteInSet + + SelectionItemCard( + title = module.title, + subtitle = module.moduleKey + if (isJoined) stringResource( + R.string.homepage_status_joined + ) else if (isBlocked) " (${stringResource(R.string.homepage_module_duplicate_infinite)})" else "", + containerColor = LegadoTheme.colorScheme.onSheetContent, + isSelected = isJoined, + inSelectionMode = true, + isEnabled = !isBlocked, + onToggleSelection = { + if (!isJoined && !isBlocked) { + onJoinModule( + browseUrl, currentSetId, ModuleDef( + key = module.moduleKey, + type = module.type, + title = module.title, + sourceUrl = browseUrl, + ) + ) + } + } + ) + } + } + } + } + + 2 -> { + val effectiveTargetSetId = currentSetId + val isButtonGroup = browseModuleType == "buttonGroup" + Column { + val typeList = remember(canSelectInfiniteGlobal) { + HomepageModuleType.entries.filter { + it != HomepageModuleType.Unknown && (canSelectInfiniteGlobal || !HomepageViewModel.isInfinite( + it.key, + null + )) + } + } + + LaunchedEffect(canSelectInfiniteGlobal) { + if (!canSelectInfiniteGlobal && HomepageViewModel.isInfinite( + browseModuleType, + null + ) + ) { + onBrowseModuleTypeChange("card") + } + } + + 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 = { + onBrowseModuleTypeChange(it) + onSelectedKindTitlesChange(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 = { onShowAddButtonGroupDialog() }, + imageVector = Icons.Default.Check + ) + } + } + ) + + ExploreKindSelectSheet( + show = showKindSelect, + onDismissRequest = { showKindSelect = false }, + sourceUrl = browseUrl, + multiple = isButtonGroup, + initialSelectedTitles = selectedKindTitles.toList(), + onSelected = { kinds -> + if (isButtonGroup) { + onSelectedKindTitlesChange(kinds.map { it.title }.toSet()) + } else { + kinds.firstOrNull()?.let { kind -> + onAddDialogPrefill( + AddDialogPrefill( + title = kind.title, + url = kind.url ?: "", + type = browseModuleType + ) + ) + } + } + } + ) + + PillDivider( + modifier = Modifier.padding(vertical = 12.dp) + ) + + SecondaryButton( + text = stringResource(R.string.homepage_manual_add), + onClick = { + onAddDialogPrefill(AddDialogPrefill(type = browseModuleType)) + }, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt index f189d3dbe..cde66ba1c 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt @@ -229,6 +229,7 @@ fun SearchBookGridItem( style = LegadoTheme.typography.bodySmall, fontWeight = FontWeight.Bold, maxLines = 2, + minLines = 2, overflow = TextOverflow.Ellipsis, ) } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt b/app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt index 4340d45c3..640c3f18b 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt @@ -77,7 +77,7 @@ fun AppModalBottomSheet( } } }, - insideMargin = DpSize(16.dp, 12.dp), + insideMargin = DpSize(16.dp, 0.dp), backgroundColor = sheetContainerColor, dragHandleColor = sheetDragHandleColor, onDismissRequest = onDismissRequest, @@ -120,7 +120,7 @@ fun AppModalBottomSheet( Column( modifier = Modifier .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 24.dp) + .padding(start = 16.dp, end = 16.dp, bottom = 0.dp) .heightIn(max = maxHeight) .animateContentSize() .then(modifier) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index f269cf728..3939ea9e4 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1689,7 +1689,7 @@ 发现 暂无已加入的模块 标准模块 (可拖拽排序) - 底栏无限模块 + 底栏模块 (无限加载,仅可存在一个) 当前生效 已被屏蔽 该书源的 homepageModules JSON 为空 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d767bb934..f08e995c2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,20 +1,20 @@ [versions] accompanistWebview = "0.36.0" -adaptive = "1.3.0-beta01" -adaptiveLayout = "1.3.0-beta01" -adaptiveNavigation = "1.3.0-beta01" -animation = "1.11.1" +adaptive = "1.3.0-beta02" +adaptiveLayout = "1.3.0-beta02" +adaptiveNavigation = "1.3.0-beta02" +animation = "1.11.2" biometric = "1.4.0-alpha07" coilCompose = "2.7.0" -composeBom = "2026.05.00" +composeBom = "2026.05.01" constraintlayoutCompose = "1.1.1" coreSplashscreen = "1.2.0" datastorePreferences = "1.2.1" -foundation = "1.11.1" +foundation = "1.11.2" kotlin = "2.3.21" kotlinxSerialization = "1.11.0" -kotlinxCoroutinesAndroid = "1.10.2" +kotlinxCoroutinesAndroid = "1.11.0" kotlinxSerializationJson = "1.11.0" kotlinxCollectionsImmutable = "0.4.0" ksp = "2.3.6" @@ -45,11 +45,11 @@ jsoupxpath = "2.5.3" coroutines = "1.10.2" lifecycleViewmodelCompose = "2.10.0" liveeventbus = "1.8.14" -navigation3 = "1.1.1" +navigation3 = "1.1.2" markdownRenderer = "0.40.2" markwon = "4.6.2" -material = "1.14.0-rc01" -material3 = "1.5.0-alpha19" +material = "1.14.0" +material3 = "1.5.0-alpha20" material3IconsExtended = "1.7.8" materialKolor = "4.1.1" media = "1.8.0" @@ -59,7 +59,7 @@ okhttp = "5.3.2" palette = "1.0.0" preference = "1.2.1" protobufJavalite = "4.26.1" -quickChineseTransfer = "0.2.16" +quickChineseTransfer = "0.2.17" room = "2.8.4" splitties = "3.0.0" rhino = "1.9.1" @@ -96,15 +96,10 @@ profileinstaller = "1.4.1" [libraries] accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanistWebview" } -activity-activity = { module = "androidx.activity:activity", version.ref = "activity" } activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activity" } -androidx-activity-compose = { module = "androidx.activity:activity-compose" } -androidx-annotation = { group = "androidx.annotation", name = "annotation", version = "1.9.1" } -#androidx-annotation-experimental = { group = "androidx.annotation", name = "annotation-experimental", version = "1.3.1" } - -androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } +androidx-annotation = { group = "androidx.annotation", name = "annotation", version = "1.10.0" } androidx-collection = { module = "androidx.collection:collection", version.ref = "collection" } androidx-compose-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" } androidx-compose-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout", version.ref = "adaptiveLayout" } @@ -143,7 +138,6 @@ koin-compose-viewmodel = { group = "io.insert-koin", name = "koin-compose-viewmo coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" } coil-gif = { module = "io.coil-kt:coil-gif", version.ref = "coilCompose" } coil-svg = { module = "io.coil-kt:coil-svg", version.ref = "coilCompose" } -core-core = { module = "androidx.core:core", version.ref = "core" } core-ktx = { module = "androidx.core:core-ktx", version.ref = "core" } androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version = "3.7.0" } @@ -153,17 +147,13 @@ firebase-analytics = { module = "com.google.firebase:firebase-analytics" } firebase-perf = { module = "com.google.firebase:firebase-perf" } -fragment-fragment = { module = "androidx.fragment:fragment", version.ref = "fragment" } fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragment" } -fragment-testing = { module = "androidx.fragment:fragment-testing", version.ref = "fragment" } androidx-junit = { module = "androidx.test.ext:junit", version = "1.3.0" } hutool-crypto = { module = "cn.hutool:hutool-crypto", version.ref = "hutool" } -kotlinx-coroutines-android-v181 = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" } kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } -kotlinx-serialization-json-v163 = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } libarchive = { module = "me.zhanghai.android.libarchive:library", version.ref = "libarchive" } lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "lifecycle" } lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle" } @@ -173,7 +163,6 @@ material-kolor = { module = "com.materialkolor:material-kolor", version.ref = "m media-media = { module = "androidx.media:media", version.ref = "media" } media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3" } media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } -media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" } markwon-core = { module = "io.noties.markwon:core", version.ref = "markwon" } markwon-image-glide = { module = "io.noties.markwon:image-glide", version.ref = "markwon" } @@ -187,8 +176,6 @@ ktor-serialization-gson = { module = "io.ktor:ktor-serialization-gson", version. ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" } ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" } -navigation-compose = { module = "androidx.navigation:navigation-compose" } -preference-preference = { module = "androidx.preference:preference", version.ref = "preference" } preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preference" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } @@ -204,16 +191,11 @@ room-testing = { module = "androidx.room:room-testing", version.ref = "room" } glide-glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } glide-okhttp= { module = "com.github.bumptech.glide:okhttp3-integration", version.ref ="glide" } glide-recyclerview={module="com.github.bumptech.glide:recyclerview-integration",version.ref="glide"} -glide-compiler = { module = "com.github.bumptech.glide:compiler", version.ref = "glide" } -glide-compose = { module = "com.github.bumptech.glide:compose", version = "1.0.0-beta08" } glide-ksp = { module = "com.github.bumptech.glide:ksp", version.ref = "glide" } -glide-avif = { module = "com.github.bumptech.glide:avif-integration", version.ref = "glide" } -avif = { module = "org.aomedia.avif.android:avif", version = "1.3.0.841110fd" } glide-svg = { module = "com.github.qoqa:glide-svg", version = "4.0.2" } androidsvg = { module = "com.caverock:androidsvg-aar", version = "1.4" } splitties-appctx = { module = "com.louiscad.splitties:splitties-appctx", version.ref = "splitties" } -splitties-activities = { module = "com.louiscad.splitties:splitties-activities", version.ref = "splitties" } splitties-systemservices = { module = "com.louiscad.splitties:splitties-systemservices", version.ref = "splitties" } splitties-views = { module = "com.louiscad.splitties:splitties-views", version.ref = "splitties" } @@ -237,18 +219,12 @@ kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutine kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } -kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } - -#kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } -#kotlinx-serialization-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "kotlinxSerialization" } liveeventbus = { module = "com.github.michaellee123:LiveEventBus", version.ref = "liveeventbus" } material = { module = "com.google.android.material:material", version.ref = "material" } mozilla-rhino = { module = "org.mozilla:rhino", version.ref = "rhino" } -renderscript-intrinsics-replacement-toolkit = { module = "com.github.TomasValenta:renderscript-intrinsics-replacement-toolkit", version = "8eaa829ddd" } zxing-lite = { module = "com.github.jenly1314:zxing-lite", version.ref = "zxingLite" } -androidx-biometric-ktx = { group = "androidx.biometric", name = "biometric-ktx", version.ref = "biometricKtx" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } @@ -260,7 +236,6 @@ miuix-core = { module = "top.yukonga.miuix.kmp:miuix-core", version.ref = "miuix miuix-blur-android = { module = "top.yukonga.miuix.kmp:miuix-blur-android", version.ref = "miuix" } miuix-icons-android = { module = "top.yukonga.miuix.kmp:miuix-icons-android", version.ref = "miuix" } miuix-preference-android = { module = "top.yukonga.miuix.kmp:miuix-preference-android", version.ref = "miuix" } -miuix-shapes-android = { module = "top.yukonga.miuix.kmp:miuix-shapes-android", version.ref = "miuix" } miuix-ui-android = { module = "top.yukonga.miuix.kmp:miuix-ui-android", version.ref = "miuix" } capsule = { module = "io.github.kyant0:capsule", version.ref = "capsule" } @@ -280,16 +255,7 @@ android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } android-test = { id = "com.android.test", version.ref = "agp" } -#androidx-benchmark = { id = "androidx.benchmark", version = "1.3.3" } - -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } -kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } -kotlin-allopen = { id = "org.jetbrains.kotlin.plugin.allopen", version.ref = "kotlin" } -kotlin-noarg = { id = "org.jetbrains.kotlin.plugin.noarg", version.ref = "kotlin" } -kotlin-sam = { id = "org.jetbrains.kotlin.plugin.sam.with.receiver", version.ref = "kotlin" } -kotlin-lombok = { id = "org.jetbrains.kotlin.plugin.lombok", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } google-services = { id = "com.google.gms.google-services", version = "4.4.4" } @@ -297,7 +263,5 @@ google-services = { id = "com.google.gms.google-services", version = "4.4.4" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } room = { id = "androidx.room", version.ref = "room" } -#protobuf = { id = "com.google.protobuf", version = "0.9.4" } - -download = { id = "de.undercouch.download", version = "5.6.0" } +download = { id = "de.undercouch.download", version = "5.7.0" } baselineprofile = { id = "androidx.baselineprofile", version.ref = "baselineprofile" }