This commit is contained in:
HapeLee
2026-05-23 14:36:47 +08:00
parent 5fa4196521
commit be2041c7da
16 changed files with 1678 additions and 1356 deletions
+2 -1
View File
@@ -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,
)
}
@@ -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)
+1 -1
View File
@@ -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
View File
@@ -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" }