优化
This commit is contained in:
@@ -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 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<HomepageSourceManageUi> = persistentListOf(),
|
||||
val browseSources: ImmutableList<HomepageSourceManageUi> = persistentListOf(),
|
||||
val allJoinedModules: ImmutableList<HomepageModuleManageUi> = persistentListOf(),
|
||||
val sourceNames: Map<String, String> = emptyMap()
|
||||
)
|
||||
|
||||
@Stable
|
||||
data class HomepageManageActions(
|
||||
val onToggleSet: (String, Boolean) -> Unit = { _, _ -> },
|
||||
val onGetSourceModules: (String, String?) -> List<HomepageModuleManageUi> = { _, _ -> 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<String>) -> Unit = { _, _, _, _ -> },
|
||||
val onGetExploreKinds: (String) -> List<Pair<String, String>> = { emptyList() },
|
||||
val onUpdateModule: (String, ModuleDef) -> Unit = { _, _ -> },
|
||||
val onDeleteModule: (String) -> Unit = {},
|
||||
val onReorderModules: (List<String>) -> Unit = {},
|
||||
val onReorderSets: (List<String>) -> 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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<String?>(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)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -82,46 +82,113 @@ class HomepageViewModel(
|
||||
val effects = _effects.asSharedFlow()
|
||||
|
||||
private val loadJobs = ConcurrentHashMap<String, Job>()
|
||||
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<Map<String, ModuleLoadState>>(emptyMap())
|
||||
|
||||
private val localModulesFlow = gateway.flowEnabled()
|
||||
private val _bookSourcesCache = MutableStateFlow<Map<String, BookSource>>(emptyMap())
|
||||
private val _layoutConfigCache = MutableStateFlow<Map<String, Map<String, String>>>(emptyMap())
|
||||
private val _pendingEnabled = MutableStateFlow<Map<String, Boolean>>(emptyMap())
|
||||
private val _pendingUserModules = MutableStateFlow<List<ModuleItem>>(emptyList())
|
||||
private val _exploreKindsCache =
|
||||
MutableStateFlow<Map<String, List<Pair<String, String>>>>(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<String>(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<HomepageUiState> = 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<HomepageUiState> = 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<String>(HomepageConfig.homepageSourceHidden)
|
||||
.getOrDefault(emptyList()).toSet()
|
||||
val moduleCountsBySet =
|
||||
allModules.mapNotNull { it.customSetId }.groupBy { it }.mapValues { it.value.size }
|
||||
|
||||
val list = mutableListOf<HomepageSourceManageUi>()
|
||||
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<Map<String, List<Pair<String, String>>>>(emptyMap())
|
||||
private val _pendingEnabled = MutableStateFlow<Map<String, Boolean>>(emptyMap())
|
||||
private val _pendingUserModules = MutableStateFlow<List<ModuleItem>>(emptyList())
|
||||
|
||||
init {
|
||||
// 解析并缓存模块 layoutConfig,避免在 combine 中重复解析
|
||||
// 解析并缓存模块 layoutConfig
|
||||
viewModelScope.launch {
|
||||
localModulesFlow.collect { modules ->
|
||||
val cache = mutableMapOf<String, Map<String, String>>()
|
||||
@@ -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<ModuleItem>()
|
||||
|
||||
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<String>(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<String>(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<Pair<String, String>> {
|
||||
return _exploreKindsCache.value[sourceUrl].orEmpty()
|
||||
}
|
||||
fun getSourceExploreKinds(sourceUrl: String): List<Pair<String, String>> =
|
||||
_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<Application>().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<String>) {
|
||||
viewModelScope.launch {
|
||||
val orders = orderedIds.mapIndexed { index, id -> id to index }.toMap()
|
||||
gateway.batchSetSortOrders(orders)
|
||||
notifyConfigChanged()
|
||||
gateway.batchSetSortOrders(orders); notifyConfigChanged()
|
||||
}
|
||||
}
|
||||
|
||||
fun reorderCustomSets(orderedUrls: List<String>) {
|
||||
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<HomepageModuleManageUi> {
|
||||
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<String, List<HomepageModuleManageUi>> {
|
||||
return allModulesCache.value
|
||||
.distinctBy { it.sourceUrl to it.moduleKey }
|
||||
.map { uiFromModule(it) }
|
||||
fun getAllModulesGroupedBySource(): Map<String, List<HomepageModuleManageUi>> =
|
||||
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<Application>().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<HomepageModuleManageUi> {
|
||||
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<String>
|
||||
) {
|
||||
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<Application>().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<ModuleDef> =
|
||||
parseModuleDefs(source, json)
|
||||
|
||||
}
|
||||
|
||||
private data class HomepageUiFlags(
|
||||
|
||||
@@ -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 <T> 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
|
||||
)
|
||||
}
|
||||
@@ -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<HomepageSourceManageUi>,
|
||||
getSourceModules: (String, String?) -> List<HomepageModuleManageUi>,
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<HomepageModuleManageUi>,
|
||||
sourceNames: Map<String, String>,
|
||||
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}")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<HomepageModuleManageUi>,
|
||||
onToggleModule: (String, Boolean) -> Unit,
|
||||
onReorderModules: (List<String>) -> 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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<HomepageSourceManageUi>,
|
||||
onToggleSet: (String, Boolean) -> Unit,
|
||||
onReorderSets: (List<String>) -> 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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<HomepageModuleManageUi>,
|
||||
canSelectInfiniteGlobal: Boolean,
|
||||
onGetSourceModules: (String, String?) -> List<HomepageModuleManageUi>,
|
||||
onGetExploreKinds: (String) -> List<Pair<String, String>>,
|
||||
onToggleModule: (String, Boolean) -> Unit,
|
||||
onJoinModule: (String, String?, ModuleDef) -> Unit,
|
||||
onRequestDeleteModule: (String) -> Unit,
|
||||
onReorderModules: (List<String>) -> Unit,
|
||||
onEditModule: (HomepageModuleManageUi) -> Unit,
|
||||
onAddDialogPrefill: (AddDialogPrefill?) -> Unit,
|
||||
onShowAddButtonGroupDialog: () -> Unit,
|
||||
browseTab: Int,
|
||||
onBrowseTabChange: (Int) -> Unit,
|
||||
browseModuleType: String,
|
||||
onBrowseModuleTypeChange: (String) -> Unit,
|
||||
selectedKindTitles: Set<String>,
|
||||
onSelectedKindTitlesChange: (Set<String>) -> 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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,6 +229,7 @@ fun SearchBookGridItem(
|
||||
style = LegadoTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
minLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
@@ -1689,7 +1689,7 @@
|
||||
<string name="homepage_tab_discover">发现</string>
|
||||
<string name="homepage_no_joined_modules">暂无已加入的模块</string>
|
||||
<string name="homepage_standard_modules_sortable">标准模块 (可拖拽排序)</string>
|
||||
<string name="homepage_infinite_module_bottom">底栏无限模块</string>
|
||||
<string name="homepage_infinite_module_bottom">底栏模块 (无限加载,仅可存在一个)</string>
|
||||
<string name="homepage_in_effect">当前生效</string>
|
||||
<string name="homepage_blocked">已被屏蔽</string>
|
||||
<string name="homepage_source_json_empty">该书源的 homepageModules JSON 为空</string>
|
||||
|
||||
+13
-49
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user