This commit is contained in:
HapeLee committed 2026-05-23 14:36:47 +08:00
1 parent 5fa4196521
commit be2041c7da
16 files changed
+1486 -1164

No files matched your search

+2 -1
View File
@@ -101,7 +101,8 @@
"Bash(Get-ChildItem -Path \"D:\\\\AndroidPrj\\\\legado-with-MD3\" -Directory -Depth 0)", "Bash(Get-ChildItem -Path \"D:\\\\AndroidPrj\\\\legado-with-MD3\" -Directory -Depth 0)",
"Bash(Select-Object Name)", "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\\\\\", \"\"\\) })", "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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth 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.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@@ -63,9 +64,8 @@ fun BookshelfConfigSheet(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.animateContentSize() .animateContentSize(),
.padding(bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
CompactDropdownSettingItem( CompactDropdownSettingItem(
title = stringResource(R.string.group_style), title = stringResource(R.string.group_style),
@@ -506,6 +506,9 @@ fun BookshelfConfigSheet(
steps = 100, steps = 100,
onValueChange = { BookshelfConfig.bookshelfRefreshingLimit = it.toInt() } onValueChange = { BookshelfConfig.bookshelfRefreshingLimit = it.toInt() }
) )
Spacer(modifier = Modifier.height(32.dp))
} }
LabelColorManageSheet( LabelColorManageSheet(
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable
import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.SearchBook
import io.legado.app.data.entities.rule.ExploreKind import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.domain.model.HomepageModuleType import io.legado.app.domain.model.HomepageModuleType
import io.legado.app.domain.model.ModuleDef
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@@ -13,6 +14,36 @@ data class HomepageUiState(
val isManageMode: Boolean = false, val isManageMode: Boolean = false,
val isConfigMode: Boolean = false, val isConfigMode: Boolean = false,
val isRefreshing: 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 @Stable
File diff suppressed because it is too large. Load diff
@@ -93,15 +93,16 @@ fun HomepageScreen(
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val allSets by viewModel.setsFlow.collectAsStateWithLifecycle() // Removed allSets and browseSources as they are now part of uiState.manageState
val browseSources by viewModel.browseSourcesFlow.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
var errorMsg by remember { mutableStateOf<String?>(null) } var errorMsg by remember { mutableStateOf<String?>(null) }
val layoutMode = HomepageConfig.homepageLayoutModeState.value 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 = { val pagerState = rememberPagerState(pageCount = {
if (layoutMode == 1) selectedSets.size.coerceAtLeast(1) else 1 if (layoutMode == 1) selectedSets.size.coerceAtLeast(1) else 1
}) })
@@ -261,50 +262,44 @@ fun HomepageScreen(
HomepageModuleManageSheet( HomepageModuleManageSheet(
data = if (uiState.isManageMode) Unit else null, data = if (uiState.isManageMode) Unit else null,
onDismissRequest = { viewModel.toggleManageMode() }, onDismissRequest = { viewModel.toggleManageMode() },
sets = allSets, state = uiState.manageState,
browseSources = browseSources, actions = remember {
onToggleSet = { url, isEnabled -> viewModel.toggleSourceFilter(url, isEnabled) }, HomepageManageActions(
onGetModulesInSet = { viewModel.getJoinedModules(it) }, onToggleSet = { url, isEnabled ->
viewModel.toggleSourceFilter(
url,
isEnabled
)
},
onGetSourceModules = { url, setId -> viewModel.getSourceModules(url, setId) }, onGetSourceModules = { url, setId -> viewModel.getSourceModules(url, setId) },
onSyncSourceModules = { viewModel.syncSourceModules(it) }, onSyncSourceModules = { viewModel.syncSourceModules(it) },
onToggleModule = { id, visible -> viewModel.setModuleVisible(id, visible) }, onToggleModule = { id, visible -> viewModel.setModuleVisible(id, visible) },
onJoinModule = { sourceUrl, targetSetId, def -> onJoinModule = { sourceUrl, targetSetId, def ->
viewModel.joinModule( viewModel.joinModule(sourceUrl, targetSetId, def)
sourceUrl,
targetSetId,
def
)
}, },
onAddCustomModule = { sourceUrl, targetSetId, def -> onAddCustomModule = { sourceUrl, targetSetId, def ->
viewModel.addCustomModule( viewModel.addCustomModule(sourceUrl, targetSetId, def)
sourceUrl,
targetSetId,
def
)
}, },
onAddButtonGroupFromKinds = { sourceUrl, targetSetId, title, kinds -> onAddButtonGroupFromKinds = { sourceUrl, targetSetId, title, kinds ->
viewModel.addButtonGroupFromKinds( viewModel.addButtonGroupFromKinds(sourceUrl, targetSetId, title, kinds)
sourceUrl,
targetSetId,
title,
kinds
)
}, },
onGetExploreKinds = { viewModel.getSourceExploreKinds(it) }, onGetExploreKinds = { viewModel.getSourceExploreKinds(it) },
onUpdateModule = { globalId, def -> viewModel.updateModule(globalId, def) }, onUpdateModule = { globalId, def -> viewModel.updateModule(globalId, def) },
onDeleteModule = { viewModel.deleteModule(it) }, onDeleteModule = { viewModel.deleteModule(it) },
onReorderModules = { ids -> viewModel.reorderJoinedModules(ids) }, onReorderModules = { ids -> viewModel.reorderJoinedModules(ids) },
onReorderSets = { urls -> viewModel.reorderCustomSets(urls) }, onReorderSets = { urls -> viewModel.reorderCustomSets(urls) },
onSetCustomSetTitle = { id, title -> viewModel.setModuleCustomSetTitle(id, title) }, onSetCustomSetTitle = { id, title ->
viewModel.setModuleCustomSetTitle(
id,
title
)
},
onCreateCustomSet = { viewModel.createCustomSet(it) }, onCreateCustomSet = { viewModel.createCustomSet(it) },
onRenameCustomSet = { id, name -> viewModel.renameCustomSet(id, name) }, onRenameCustomSet = { id, name -> viewModel.renameCustomSet(id, name) },
onDeleteCustomSet = { viewModel.deleteCustomSet(it) }, onDeleteCustomSet = { viewModel.deleteCustomSet(it) },
onGetAllModulesGroupedBySource = { viewModel.getAllModulesGroupedBySource() },
onGetSourceName = { viewModel.getSourceName(it) },
onAssignModuleToCustomSet = { id, setId -> onAssignModuleToCustomSet = { id, setId ->
viewModel.assignModuleToCustomSet( viewModel.assignModuleToCustomSet(id, setId)
id, }
setId
) )
} }
) )
@@ -82,46 +82,113 @@ class HomepageViewModel(
val effects = _effects.asSharedFlow() val effects = _effects.asSharedFlow()
private val loadJobs = ConcurrentHashMap<String, Job>() private val loadJobs = ConcurrentHashMap<String, Job>()
private val initModulesSyncFlow = bookSourceRepository.flowHomepageModules()
private val exploreSourcesFlow = bookSourceRepository.flowExploreSources() private val exploreSourcesFlow = bookSourceRepository.flowExploreSources()
// 1. 基础原始状态
private val _isRefreshing = MutableStateFlow(false) private val _isRefreshing = MutableStateFlow(false)
private val _isManageMode = MutableStateFlow(false) private val _isManageMode = MutableStateFlow(false)
private val _isConfigMode = MutableStateFlow(false) private val _isConfigMode = MutableStateFlow(false)
private val _configVersion = MutableStateFlow(0L) private val _configVersion = MutableStateFlow(0L)
private val _moduleContentStates = MutableStateFlow<Map<String, ModuleLoadState>>(emptyMap()) private val _moduleContentStates = MutableStateFlow<Map<String, ModuleLoadState>>(emptyMap())
private val localModulesFlow = gateway.flowEnabled()
private val _bookSourcesCache = MutableStateFlow<Map<String, BookSource>>(emptyMap()) private val _bookSourcesCache = MutableStateFlow<Map<String, BookSource>>(emptyMap())
private val _layoutConfigCache = MutableStateFlow<Map<String, Map<String, String>>>(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() // 2. 数据库同步流
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) private val localModulesFlow = gateway.flowEnabled()
val allModulesCache =
gateway.flowAll().stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
val customSetsFlow = gateway.flowCustomSets() val customSetsFlow = gateway.flowCustomSets()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
// 3. 业务加工流
private val orderedModuleDefsFlow = combine(localModulesFlow, _configVersion) { modules, _ -> private val orderedModuleDefsFlow = combine(localModulesFlow, _configVersion) { modules, _ ->
modules.groupBySourceOrdered() modules.groupBySourceOrdered()
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) }.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 = private val uiFlagsFlow =
combine(_isRefreshing, _isManageMode, _isConfigMode) { refreshing, manage, config -> combine(_isRefreshing, _isManageMode, _isConfigMode) { refreshing, manage, config ->
HomepageUiFlags(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, orderedModuleDefsFlow,
_moduleContentStates, _moduleContentStates,
uiFlagsFlow,
_bookSourcesCache, _bookSourcesCache,
customSetsFlow customSetsFlow,
) { grouped, contentStates, flags, sourcesCache, customSets -> _layoutConfigCache
) { grouped, contentStates, sourcesCache, customSets, configCache ->
val setNames = customSets.associate { it.id to it.name } val setNames = customSets.associate { it.id to it.name }
val sortedSetIds = customSets.sortedBy { it.sortOrder }.map { it.id } 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 setUrl = customSetUrl(setId)
val mods = grouped[setUrl] ?: emptyList() val mods = grouped[setUrl] ?: emptyList()
mods.map { module -> mods.map { module ->
@@ -144,60 +211,26 @@ class HomepageViewModel(
config = configMap config = configMap
) )
} }
}.toImmutableList()
} }
// 5. 最终 UI 状态
val uiState: StateFlow<HomepageUiState> = combine(
displayModulesFlow,
uiFlagsFlow,
manageStateFlow
) { modules, flags, manageState ->
HomepageUiState( HomepageUiState(
modules = displayModules.toImmutableList(), modules = modules,
isRefreshing = flags.isRefreshing, isRefreshing = flags.isRefreshing,
isManageMode = flags.isManageMode, isManageMode = flags.isManageMode,
isConfigMode = flags.isConfigMode, isConfigMode = flags.isConfigMode,
manageState = manageState
) )
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomepageUiState()) }.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 { init {
// 解析并缓存模块 layoutConfig,避免在 combine 中重复解析 // 解析并缓存模块 layoutConfig
viewModelScope.launch { viewModelScope.launch {
localModulesFlow.collect { modules -> localModulesFlow.collect { modules ->
val cache = mutableMapOf<String, Map<String, String>>() val cache = mutableMapOf<String, Map<String, String>>()
@@ -217,7 +250,6 @@ class HomepageViewModel(
} }
} }
// 清理 _pendingUserModules 中已入库的条目
viewModelScope.launch { viewModelScope.launch {
exploreSourcesFlow.collect { sources -> exploreSourcesFlow.collect { sources ->
_bookSourcesCache.value = sources.associateBy { it.bookSourceUrl } _bookSourcesCache.value = sources.associateBy { it.bookSourceUrl }
@@ -235,7 +267,6 @@ class HomepageViewModel(
} }
} }
// 清理 _pendingUserModules 中已入库的条目
viewModelScope.launch { viewModelScope.launch {
allModulesCache.collect { modules -> allModulesCache.collect { modules ->
val dbIds = modules.map { it.id }.toSet() val dbIds = modules.map { it.id }.toSet()
@@ -243,7 +274,6 @@ class HomepageViewModel(
} }
} }
// 一次性迁移:将 customSetId=null 的存量模块归属到书源集,并确保所有集存在
viewModelScope.launch { viewModelScope.launch {
val allModules = allModulesCache.first() val allModules = allModulesCache.first()
val orphans = allModules.filter { it.customSetId == null } val orphans = allModules.filter { it.customSetId == null }
@@ -251,20 +281,15 @@ class HomepageViewModel(
orphans.groupBy { it.sourceUrl }.forEach { (sourceUrl, modules) -> orphans.groupBy { it.sourceUrl }.forEach { (sourceUrl, modules) ->
val source = bookSourceRepository.getBookSource(sourceUrl) ?: return@forEach val source = bookSourceRepository.getBookSource(sourceUrl) ?: return@forEach
ensureSetForSource(sourceUrl, source.bookSourceName) ensureSetForSource(sourceUrl, source.bookSourceName)
modules.forEach { m -> modules.forEach { m -> gateway.setCustomSetId(m.id, "src_$sourceUrl") }
gateway.setCustomSetId(m.id, "src_$sourceUrl")
} }
} }
}
// 确保所有 customSetId 对应的集都存在
allModules.mapNotNull { it.customSetId }.distinct().forEach { setId -> allModules.mapNotNull { it.customSetId }.distinct().forEach { setId ->
val isSrcSet = setId.startsWith("src_") val isSrcSet = setId.startsWith("src_")
if (isSrcSet && gateway.getCustomSetById(setId) == null) { if (isSrcSet && gateway.getCustomSetById(setId) == null) {
val sourceUrl = setId.removePrefix("src_") val sourceUrl = setId.removePrefix("src_")
val source = bookSourceRepository.getBookSource(sourceUrl) val source = bookSourceRepository.getBookSource(sourceUrl)
if (source != null) { if (source != null) ensureSetForSource(sourceUrl, source.bookSourceName)
ensureSetForSource(sourceUrl, source.bookSourceName)
}
} }
} }
} }
@@ -281,22 +306,16 @@ class HomepageViewModel(
val parsedIds = parsedDefs.map { it.globalId }.toSet() val parsedIds = parsedDefs.map { it.globalId }.toSet()
val toUpsert = mutableListOf<ModuleItem>() val toUpsert = mutableListOf<ModuleItem>()
for (i in parsedDefs.indices) { for (i in parsedDefs.indices) {
val def = parsedDefs[i] val def = parsedDefs[i]
val existing = existingById[def.globalId] val existing = existingById[def.globalId]
if (existing != null) { if (existing != null) {
// 用户编辑过的模块不被 JSON 覆盖
if (existing.isUserCreated) continue if (existing.isUserCreated) continue
if (existing.sourceJsonHash == newHash) continue if (existing.sourceJsonHash == newHash) continue
toUpsert.add( toUpsert.add(
existing.copy( existing.copy(
type = def.type, type = def.type, title = def.title, args = def.args, url = def.url,
title = def.title, sourceJsonHash = newHash, syncedAt = System.currentTimeMillis()
args = def.args,
url = def.url,
sourceJsonHash = newHash,
syncedAt = System.currentTimeMillis(),
) )
) )
} else { } else {
@@ -313,19 +332,13 @@ class HomepageViewModel(
customSetId = "src_${source.bookSourceUrl}", customSetId = "src_${source.bookSourceUrl}",
sortOrder = i, sortOrder = i,
sourceJsonHash = newHash, sourceJsonHash = newHash,
syncedAt = System.currentTimeMillis(), syncedAt = System.currentTimeMillis()
) )
) )
} }
} }
if (toUpsert.isNotEmpty()) gateway.upsertAll(toUpsert)
if (toUpsert.isNotEmpty()) { if (parsedIds.isNotEmpty()) gateway.deleteStale(source.bookSourceUrl, parsedIds.toList())
gateway.upsertAll(toUpsert)
}
if (parsedIds.isNotEmpty()) {
gateway.deleteStale(source.bookSourceUrl, parsedIds.toList())
}
} }
private fun loadModule(module: ModuleItem) { private fun loadModule(module: ModuleItem) {
@@ -336,74 +349,50 @@ class HomepageViewModel(
val source = bookSourceRepository.getBookSource(module.sourceUrl) val source = bookSourceRepository.getBookSource(module.sourceUrl)
?: throw Exception("Source not found") ?: throw Exception("Source not found")
val allKinds = withContext(Dispatchers.IO) { source.exploreKinds() } val allKinds = withContext(Dispatchers.IO) { source.exploreKinds() }
val selectedTitles =
val selectedTitles = module.args?.let { argsStr -> module.args?.let { GSON.fromJsonArray<String>(it).getOrNull() }
GSON.fromJsonArray<String>(argsStr).getOrNull() if (selectedTitles.isNullOrEmpty()) allKinds.take(
} HOMEPAGE_MAX_BUTTON_GROUP_KINDS
)
if (selectedTitles.isNullOrEmpty()) { else selectedTitles.mapNotNull { t -> allKinds.find { it.title == t } }
allKinds.take(HOMEPAGE_MAX_BUTTON_GROUP_KINDS)
} else {
selectedTitles.mapNotNull { t -> allKinds.find { it.title == t } }
}
}.onSuccess { kinds -> }.onSuccess { kinds ->
_moduleContentStates.update { it + (module.id to ModuleLoadState.Buttons(kinds.toImmutableList())) } _moduleContentStates.update { it + (module.id to ModuleLoadState.Buttons(kinds.toImmutableList())) }
}.onFailure { e -> }.onFailure { e ->
_moduleContentStates.update { _moduleContentStates.update { it + (module.id to ModuleLoadState.Error(e.stackTraceStr)) }
it + (module.id to ModuleLoadState.Error(
e.stackTraceStr
))
}
}
}.also {
it.invokeOnCompletion { loadJobs.remove(module.id) }
} }
}.also { it.invokeOnCompletion { loadJobs.remove(module.id) } }
return return
} }
loadJobs[module.id] = viewModelScope.launch { loadJobs[module.id] = viewModelScope.launch {
kotlin.runCatching { kotlin.runCatching {
val isRanking = module.type == HomepageModuleType.Ranking.key val isRanking =
|| module.type == HomepageModuleType.GridRanking.key module.type == HomepageModuleType.Ranking.key || module.type == HomepageModuleType.GridRanking.key
val books = if (isRanking) { val books = if (isRanking) exploreBooksUseCase.executeForRanking(
exploreBooksUseCase.executeForRanking(module.sourceUrl, module.url, module.args) module.sourceUrl,
} else { module.url,
exploreBooksUseCase.execute(module.sourceUrl, module.url, module.args).books module.args
} )
val layout = try { else exploreBooksUseCase.execute(module.sourceUrl, module.url, module.args).books
GSON.fromJson(module.layoutConfig, Map::class.java)
} catch (_: Exception) {
null
}
val rows = (layout?.get("rows") as? Number)?.toInt() ?: HOMEPAGE_DEFAULT_GRID_ROWS
val hasMore = isInfinite(module.type, module.layoutConfig) && books.isNotEmpty() val hasMore = isInfinite(module.type, module.layoutConfig) && books.isNotEmpty()
books to hasMore books to hasMore
}.onSuccess { (books, hasMore) -> }.onSuccess { (books, hasMore) ->
_moduleContentStates.update { _moduleContentStates.update {
it + (module.id to ModuleLoadState.Loaded( it + (module.id to ModuleLoadState.Loaded(
books.toImmutableList(), books.toImmutableList(),
hasMore = hasMore, hasMore = hasMore
page = 1
)) ))
} }
}.onFailure { e -> }.onFailure { e ->
_moduleContentStates.update { _moduleContentStates.update { it + (module.id to ModuleLoadState.Error(e.stackTraceStr)) }
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) { fun loadMoreModule(globalId: String) {
val currentState = _moduleContentStates.value[globalId] as? ModuleLoadState.Loaded ?: return val currentState = _moduleContentStates.value[globalId] as? ModuleLoadState.Loaded ?: return
if (currentState.isLoadingMore || !currentState.hasMore) return if (currentState.isLoadingMore || !currentState.hasMore) return
val nextPage = currentState.page + 1 val nextPage = currentState.page + 1
_moduleContentStates.update { it + (globalId to currentState.copy(isLoadingMore = true)) } _moduleContentStates.update { it + (globalId to currentState.copy(isLoadingMore = true)) }
viewModelScope.launch { viewModelScope.launch {
kotlin.runCatching { kotlin.runCatching {
val module = gateway.getById(globalId) ?: throw Exception("Module not found") val module = gateway.getById(globalId) ?: throw Exception("Module not found")
@@ -414,18 +403,14 @@ class HomepageViewModel(
page = nextPage page = nextPage
) )
}.onSuccess { result -> }.onSuccess { result ->
val newBooks = result.books
_moduleContentStates.update { states -> _moduleContentStates.update { states ->
val lastState = val lastState =
states[globalId] as? ModuleLoadState.Loaded ?: return@update states states[globalId] as? ModuleLoadState.Loaded ?: return@update states
val existingUrls = lastState.books.map { it.bookUrl }.toSet() val existingUrls = lastState.books.map { it.bookUrl }.toSet()
val deduped = newBooks.filter { it.bookUrl !in existingUrls } val deduped = result.books.filter { it.bookUrl !in existingUrls }
val combinedBooks = (lastState.books + deduped).toImmutableList()
states + (globalId to ModuleLoadState.Loaded( states + (globalId to ModuleLoadState.Loaded(
books = combinedBooks, books = (lastState.books + deduped).toImmutableList(),
hasMore = deduped.isNotEmpty(), hasMore = deduped.isNotEmpty(), isLoadingMore = false, page = nextPage
isLoadingMore = false,
page = nextPage
)) ))
} }
}.onFailure { e -> }.onFailure { e ->
@@ -461,17 +446,12 @@ class HomepageViewModel(
_isRefreshing.value = true _isRefreshing.value = true
loadJobs.values.forEach { it.cancel() } loadJobs.values.forEach { it.cancel() }
loadJobs.clear() loadJobs.clear()
uiState.value.modules.map { it.sourceUrl }.distinct().forEach { url ->
// 刷新时同步当前已启用模块所属书源的定义
val activeSourceUrls = uiState.value.modules.map { it.sourceUrl }.distinct()
activeSourceUrls.forEach { url ->
resolveBookSource(url)?.let { syncModulesFromSource(it) } resolveBookSource(url)?.let { syncModulesFromSource(it) }
} }
_moduleContentStates.value = emptyMap() _moduleContentStates.value = emptyMap()
uiState.map { it.modules }.first { modules -> uiState.map { it.modules }
modules.all { it.state !is ModuleLoadState.Loading } .first { modules -> modules.all { it.state !is ModuleLoadState.Loading } }
}
_isRefreshing.value = false _isRefreshing.value = false
} }
} }
@@ -486,14 +466,11 @@ class HomepageViewModel(
fun setModuleVisible(id: String, visible: Boolean) { fun setModuleVisible(id: String, visible: Boolean) {
_pendingEnabled.update { it + (id to visible) } _pendingEnabled.update { it + (id to visible) }
viewModelScope.launch { viewModelScope.launch {
val existing = gateway.getById(id) if (gateway.getById(id) != null) gateway.setEnabled(id, visible)
if (existing != null) { else {
gateway.setEnabled(id, visible)
} else {
// 如果模块尚未入库(虚拟状态),则根据 ID 规则解析并入库
val parts = id.split("::") val parts = id.split("::")
if (parts.size >= 3) { if (parts.size >= 3) {
val setId = parts[0] val setId = parts[0];
val sourceUrl = parts[1] val sourceUrl = parts[1]
val key = parts.subList(2, parts.size).joinToString("::") val key = parts.subList(2, parts.size).joinToString("::")
ensureModuleInDb(sourceUrl, key, id, setId) ensureModuleInDb(sourceUrl, key, id, setId)
@@ -520,24 +497,25 @@ class HomepageViewModel(
private suspend fun ensureSetForSource(sourceUrl: String, sourceName: String): String { private suspend fun ensureSetForSource(sourceUrl: String, sourceName: String): String {
val setId = "src_$sourceUrl" val setId = "src_$sourceUrl"
if (gateway.getCustomSetById(setId) == null) { if (gateway.getCustomSetById(setId) == null) gateway.upsertCustomSet(
gateway.upsertCustomSet(CustomSetItem(id = setId, name = sourceName)) CustomSetItem(
} id = setId,
name = sourceName
)
)
return setId return setId
} }
fun addCustomModule(sourceUrl: String, targetSetId: String?, def: ModuleDef) { 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" val setId = targetSetId ?: "src_$sourceUrl"
if (isInfinite(def.type, def.layoutConfig)) { if (isInfinite(def.type, def.layoutConfig)) {
val hasInfinite = allModulesCache.value.any { if (allModulesCache.value.any {
it.customSetId == setId && isInfinite( it.customSetId == setId && isInfinite(
it.type, it.type,
it.layoutConfig it.layoutConfig
) )
} }) {
if (hasInfinite) {
viewModelScope.launch { viewModelScope.launch {
_effects.emit( _effects.emit(
HomepageEffect.ShowSnackbar( HomepageEffect.ShowSnackbar(
@@ -550,21 +528,11 @@ class HomepageViewModel(
return return
} }
} }
val id = ModuleDef.globalIdOf(sourceUrl, key, setId) val id = ModuleDef.globalIdOf(sourceUrl, key, setId)
val module = ModuleItem( val module = ModuleItem(
id = id, id = id, sourceUrl = sourceUrl, moduleKey = key, type = def.type, title = def.title,
sourceUrl = sourceUrl, args = def.args, layoutConfig = def.layoutConfig, url = def.url, isEnabled = true,
moduleKey = key, isUserCreated = true, customSetId = setId, syncedAt = System.currentTimeMillis()
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 { viewModelScope.launch {
val source = bookSourceRepository.getBookSource(sourceUrl) val source = bookSourceRepository.getBookSource(sourceUrl)
@@ -575,23 +543,37 @@ class HomepageViewModel(
} }
} }
fun getSourceExploreKinds(sourceUrl: String): List<Pair<String, String>> { fun getSourceExploreKinds(sourceUrl: String): List<Pair<String, String>> =
return _exploreKindsCache.value[sourceUrl].orEmpty() _exploreKindsCache.value[sourceUrl].orEmpty()
}
fun updateModule(globalId: String, def: ModuleDef) { fun updateModule(globalId: String, def: ModuleDef) {
viewModelScope.launch { viewModelScope.launch {
val existing = gateway.getById(globalId) ?: return@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( gateway.upsertAll(
listOf( listOf(
existing.copy( existing.copy(
customTitle = def.title.takeIf { it != existing.title }, customTitle = def.title.takeIf { it != existing.title }, type = def.type,
type = def.type, url = def.url, args = def.args, layoutConfig = def.layoutConfig,
url = def.url, isUserCreated = true, syncedAt = System.currentTimeMillis()
args = def.args,
layoutConfig = def.layoutConfig,
isUserCreated = true, // 标记为用户编辑,阻止 JSON 同步覆盖
syncedAt = System.currentTimeMillis(),
) )
) )
) )
@@ -601,17 +583,17 @@ class HomepageViewModel(
fun setModuleCustomSetTitle(globalId: String, customSetTitle: String?) { fun setModuleCustomSetTitle(globalId: String, customSetTitle: String?) {
viewModelScope.launch { viewModelScope.launch {
gateway.setCustomSetTitle(globalId, customSetTitle) gateway.setCustomSetTitle(
notifyConfigChanged() globalId,
customSetTitle
); notifyConfigChanged()
} }
} }
fun deleteModule(globalId: String) { fun deleteModule(globalId: String) {
viewModelScope.launch { viewModelScope.launch {
gateway.delete(globalId) gateway.delete(globalId); _moduleContentStates.update { it - globalId }
_moduleContentStates.update { it - globalId } loadJobs.remove(globalId)?.cancel(); _pendingEnabled.update { it - globalId }
loadJobs.remove(globalId)?.cancel()
_pendingEnabled.update { it - globalId }
_pendingUserModules.update { it.filter { m -> m.id != globalId } } _pendingUserModules.update { it.filter { m -> m.id != globalId } }
notifyConfigChanged() notifyConfigChanged()
} }
@@ -620,30 +602,23 @@ class HomepageViewModel(
fun reorderJoinedModules(orderedIds: List<String>) { fun reorderJoinedModules(orderedIds: List<String>) {
viewModelScope.launch { viewModelScope.launch {
val orders = orderedIds.mapIndexed { index, id -> id to index }.toMap() val orders = orderedIds.mapIndexed { index, id -> id to index }.toMap()
gateway.batchSetSortOrders(orders) gateway.batchSetSortOrders(orders); notifyConfigChanged()
notifyConfigChanged()
} }
} }
fun reorderCustomSets(orderedUrls: List<String>) { fun reorderCustomSets(orderedUrls: List<String>) {
viewModelScope.launch { viewModelScope.launch {
val orders = orderedUrls.mapIndexed { index, url -> val orders =
customSetIdFromUrl(url) to index orderedUrls.mapIndexed { index, url -> customSetIdFromUrl(url) to index }.toMap()
}.toMap() gateway.batchSetCustomSetSortOrders(orders); notifyConfigChanged()
gateway.batchSetCustomSetSortOrders(orders)
notifyConfigChanged()
} }
} }
/** 获取指定集内的模块(sourceUrl 可以是书源 URL 或 custom://xxx) */
fun getJoinedModules(sourceUrl: String): List<HomepageModuleManageUi> { fun getJoinedModules(sourceUrl: String): List<HomepageModuleManageUi> {
val isSet = isCustomSetUrl(sourceUrl) val isSet = isCustomSetUrl(sourceUrl);
val setId = if (isSet) customSetIdFromUrl(sourceUrl) else null val setId = if (isSet) customSetIdFromUrl(sourceUrl) else null
val dbModules = if (isSet) { val dbModules =
allModulesCache.value.filter { it.customSetId == setId } if (isSet) allModulesCache.value.filter { it.customSetId == setId } else allModulesCache.value.filter { it.sourceUrl == sourceUrl }
} else {
allModulesCache.value.filter { it.sourceUrl == sourceUrl }
}
val dbIds = dbModules.map { it.id }.toSet() val dbIds = dbModules.map { it.id }.toSet()
val pendingModules = _pendingUserModules.value.filter { pending -> val pendingModules = _pendingUserModules.value.filter { pending ->
val matches = val matches =
@@ -653,67 +628,70 @@ class HomepageViewModel(
return (dbModules + pendingModules).map { uiFromModule(it) } return (dbModules + pendingModules).map { uiFromModule(it) }
} }
/** 所有已添加的模块,按书源分组(用于自定义集添加模块) */ fun getAllModulesGroupedBySource(): Map<String, List<HomepageModuleManageUi>> =
fun getAllModulesGroupedBySource(): Map<String, List<HomepageModuleManageUi>> { allModulesCache.value.distinctBy { it.sourceUrl to it.moduleKey }.map { uiFromModule(it) }
return allModulesCache.value
.distinctBy { it.sourceUrl to it.moduleKey }
.map { uiFromModule(it) }
.groupBy { it.sourceUrl } .groupBy { it.sourceUrl }
}
fun getSourceName(sourceUrl: String): String { fun getSourceName(sourceUrl: String): String =
return _bookSourcesCache.value[sourceUrl]?.bookSourceName ?: sourceUrl _bookSourcesCache.value[sourceUrl]?.bookSourceName ?: sourceUrl
}
fun assignModuleToCustomSet(moduleId: String, customSetId: String?) { fun assignModuleToCustomSet(moduleId: String, customSetId: String?) {
viewModelScope.launch { viewModelScope.launch {
val existing = gateway.getById(moduleId) ?: return@launch val existing = gateway.getById(moduleId) ?: return@launch
if (customSetId == null) { if (customSetId == null) {
// 如果是取消分配,且它不是归属于书源默认集的,则直接删除该副本 if (existing.customSetId != "src_${existing.sourceUrl}") gateway.delete(moduleId)
if (existing.customSetId != "src_${existing.sourceUrl}") {
gateway.delete(moduleId)
}
} else { } 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 = val newId =
ModuleDef.globalIdOf(existing.sourceUrl, existing.moduleKey, customSetId) ModuleDef.globalIdOf(existing.sourceUrl, existing.moduleKey, customSetId)
val newModule = existing.copy( gateway.upsertAll(
listOf(
existing.copy(
id = newId, id = newId,
customSetId = customSetId, customSetId = customSetId,
isEnabled = true, // 分配到新集时默认开启 isEnabled = true,
isUserCreated = true // 标记为用户创建,避免被同步清理 isUserCreated = true
)
)
) )
gateway.upsertAll(listOf(newModule))
} }
notifyConfigChanged() notifyConfigChanged()
} }
} }
fun syncSourceModules(sourceUrl: String) { fun syncSourceModules(sourceUrl: String) {
viewModelScope.launch { viewModelScope.launch { resolveBookSource(sourceUrl)?.let { syncModulesFromSource(it) } }
resolveBookSource(sourceUrl)?.let { syncModulesFromSource(it) }
}
} }
/** 「书源模块」tab:仅 JSON,纯参考 */
fun getSourceModules( fun getSourceModules(
sourceUrl: String, sourceUrl: String,
targetSetId: String? = null targetSetId: String? = null
): List<HomepageModuleManageUi> { ): List<HomepageModuleManageUi> {
val source = resolveBookSource(sourceUrl) ?: return emptyList() val source = resolveBookSource(sourceUrl) ?: return emptyList()
val json = source.homepageModules ?: return emptyList() val json = source.homepageModules ?: return emptyList()
val jsonDefs = parseBookSourceModules(source, json) val jsonDefs = parseModuleDefs(source, json)
val effectiveSetId = targetSetId ?: "src_$sourceUrl" val effectiveSetId = targetSetId ?: "src_$sourceUrl"
val joinedKeys = allModulesCache.value val joinedKeys =
.filter { it.sourceUrl == sourceUrl && it.customSetId == effectiveSetId } allModulesCache.value.filter { it.sourceUrl == sourceUrl && it.customSetId == effectiveSetId }
.map { it.moduleKey }.toSet() .map { it.moduleKey }.toSet()
return jsonDefs.map { def -> return jsonDefs.map { def ->
val id = ModuleDef.globalIdOf(sourceUrl, def.key, effectiveSetId)
HomepageModuleManageUi( HomepageModuleManageUi(
id = id, id = ModuleDef.globalIdOf(sourceUrl, def.key, effectiveSetId),
sourceUrl = def.sourceUrl, sourceUrl = def.sourceUrl,
moduleKey = def.key, moduleKey = def.key,
title = def.title, title = def.title,
@@ -727,15 +705,13 @@ class HomepageViewModel(
} }
} }
/** 从书源模块「加入」→ 写入 DB,自动归属到该书源的集 */
/** 从发现页 Kind 创建一个 ButtonGroup 模块,args 存储选中 Kind 标题的 JSON 数组 */
fun addButtonGroupFromKinds( fun addButtonGroupFromKinds(
sourceUrl: String, sourceUrl: String,
targetSetId: String?, targetSetId: String?,
title: String, title: String,
kindTitles: List<String> kindTitles: List<String>
) { ) {
val key = kindTitles.firstOrNull() ?: title val key = kindTitles.firstOrNull() ?: title;
val setId = targetSetId ?: "src_$sourceUrl" val setId = targetSetId ?: "src_$sourceUrl"
val id = ModuleDef.globalIdOf(sourceUrl, key, setId) val id = ModuleDef.globalIdOf(sourceUrl, key, setId)
val module = ModuleItem( val module = ModuleItem(
@@ -759,52 +735,8 @@ class HomepageViewModel(
} }
} }
fun joinModule(sourceUrl: String, targetSetId: String?, def: ModuleDef) { fun joinModule(sourceUrl: String, targetSetId: String?, def: ModuleDef) =
val setId = targetSetId ?: "src_$sourceUrl" addCustomModule(sourceUrl, targetSetId, def)
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()
}
}
private fun uiFromModule(module: ModuleItem) = HomepageModuleManageUi( private fun uiFromModule(module: ModuleItem) = HomepageModuleManageUi(
id = module.id, id = module.id,
@@ -822,27 +754,19 @@ class HomepageViewModel(
) )
fun createCustomSet(name: String) { fun createCustomSet(name: String) {
viewModelScope.launch { viewModelScope.launch { gateway.createCustomSet(name); notifyConfigChanged() }
gateway.createCustomSet(name)
notifyConfigChanged()
}
} }
fun renameCustomSet(id: String, name: String) { fun renameCustomSet(id: String, name: String) {
viewModelScope.launch { viewModelScope.launch { gateway.renameCustomSet(id, name); notifyConfigChanged() }
gateway.renameCustomSet(id, name)
notifyConfigChanged()
} }
}
fun deleteCustomSet(id: String) { fun deleteCustomSet(id: String) {
viewModelScope.launch { 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) gateway.deleteCustomSet(id)
moduleIds.forEach { mid -> ids.forEach { mid ->
_moduleContentStates.update { it - mid } _moduleContentStates.update { it - mid }; loadJobs.remove(mid)
loadJobs.remove(mid)?.cancel() ?.cancel(); _pendingEnabled.update { it - mid }
_pendingEnabled.update { it - mid }
} }
notifyConfigChanged() notifyConfigChanged()
} }
@@ -853,12 +777,12 @@ class HomepageViewModel(
saveSearchBooksUseCase.save(book) saveSearchBooksUseCase.save(book)
_effects.emit( _effects.emit(
HomepageEffect.NavigateToBookInfo( HomepageEffect.NavigateToBookInfo(
name = book.name, book.name,
author = book.author, book.author,
bookUrl = book.bookUrl, book.bookUrl,
origin = book.origin, book.origin,
coverPath = book.coverUrl, book.coverUrl,
sharedCoverKey = sharedCoverKey sharedCoverKey
) )
) )
} }
@@ -866,14 +790,18 @@ class HomepageViewModel(
fun onModuleHeaderClick(sourceUrl: String, exploreUrl: String?, title: String?) { fun onModuleHeaderClick(sourceUrl: String, exploreUrl: String?, title: String?) {
viewModelScope.launch { viewModelScope.launch {
_effects.emit(HomepageEffect.NavigateToExploreShow(title, sourceUrl, exploreUrl)) _effects.emit(
HomepageEffect.NavigateToExploreShow(
title,
sourceUrl,
exploreUrl
)
)
} }
} }
private fun resolveBookSource(sourceUrl: String): BookSource? { private fun resolveBookSource(sourceUrl: String): BookSource? =
return _bookSourcesCache.value[sourceUrl] _bookSourcesCache.value[sourceUrl] ?: bookSourceRepository.getBookSourceSync(sourceUrl)
?: bookSourceRepository.getBookSourceSync(sourceUrl)
}
private suspend fun ensureModuleInDb( private suspend fun ensureModuleInDb(
sourceUrl: String, sourceUrl: String,
@@ -883,8 +811,7 @@ class HomepageViewModel(
) { ) {
if (gateway.getById(id) != null) return if (gateway.getById(id) != null) return
val source = resolveBookSource(sourceUrl) ?: return val source = resolveBookSource(sourceUrl) ?: return
val json = source.homepageModules ?: return val defs = parseModuleDefs(source, source.homepageModules ?: return)
val defs = parseBookSourceModules(source, json)
val def = defs.find { it.key == moduleKey } ?: return val def = defs.find { it.key == moduleKey } ?: return
gateway.upsertAll( gateway.upsertAll(
listOf( listOf(
@@ -906,10 +833,6 @@ class HomepageViewModel(
private fun notifyConfigChanged() { private fun notifyConfigChanged() {
_configVersion.update { it + 1 } _configVersion.update { it + 1 }
} }
private fun parseBookSourceModules(source: BookSource, json: String): List<ModuleDef> =
parseModuleDefs(source, json)
} }
private data class HomepageUiFlags( 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, style = LegadoTheme.typography.bodySmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 2, maxLines = 2,
minLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
@@ -77,7 +77,7 @@ fun AppModalBottomSheet(
} }
} }
}, },
insideMargin = DpSize(16.dp, 12.dp), insideMargin = DpSize(16.dp, 0.dp),
backgroundColor = sheetContainerColor, backgroundColor = sheetContainerColor,
dragHandleColor = sheetDragHandleColor, dragHandleColor = sheetDragHandleColor,
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
@@ -120,7 +120,7 @@ fun AppModalBottomSheet(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 24.dp) .padding(start = 16.dp, end = 16.dp, bottom = 0.dp)
.heightIn(max = maxHeight) .heightIn(max = maxHeight)
.animateContentSize() .animateContentSize()
.then(modifier) .then(modifier)
+1 -1
View File
@@ -1689,7 +1689,7 @@
<string name="homepage_tab_discover">发现</string> <string name="homepage_tab_discover">发现</string>
<string name="homepage_no_joined_modules">暂无已加入的模块</string> <string name="homepage_no_joined_modules">暂无已加入的模块</string>
<string name="homepage_standard_modules_sortable">标准模块 (可拖拽排序)</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_in_effect">当前生效</string>
<string name="homepage_blocked">已被屏蔽</string> <string name="homepage_blocked">已被屏蔽</string>
<string name="homepage_source_json_empty">该书源的 homepageModules JSON 为空</string> <string name="homepage_source_json_empty">该书源的 homepageModules JSON 为空</string>
+13 -49
View File
@@ -1,20 +1,20 @@
[versions] [versions]
accompanistWebview = "0.36.0" accompanistWebview = "0.36.0"
adaptive = "1.3.0-beta01" adaptive = "1.3.0-beta02"
adaptiveLayout = "1.3.0-beta01" adaptiveLayout = "1.3.0-beta02"
adaptiveNavigation = "1.3.0-beta01" adaptiveNavigation = "1.3.0-beta02"
animation = "1.11.1" animation = "1.11.2"
biometric = "1.4.0-alpha07" biometric = "1.4.0-alpha07"
coilCompose = "2.7.0" coilCompose = "2.7.0"
composeBom = "2026.05.00" composeBom = "2026.05.01"
constraintlayoutCompose = "1.1.1" constraintlayoutCompose = "1.1.1"
coreSplashscreen = "1.2.0" coreSplashscreen = "1.2.0"
datastorePreferences = "1.2.1" datastorePreferences = "1.2.1"
foundation = "1.11.1" foundation = "1.11.2"
kotlin = "2.3.21" kotlin = "2.3.21"
kotlinxSerialization = "1.11.0" kotlinxSerialization = "1.11.0"
kotlinxCoroutinesAndroid = "1.10.2" kotlinxCoroutinesAndroid = "1.11.0"
kotlinxSerializationJson = "1.11.0" kotlinxSerializationJson = "1.11.0"
kotlinxCollectionsImmutable = "0.4.0" kotlinxCollectionsImmutable = "0.4.0"
ksp = "2.3.6" ksp = "2.3.6"
@@ -45,11 +45,11 @@ jsoupxpath = "2.5.3"
coroutines = "1.10.2" coroutines = "1.10.2"
lifecycleViewmodelCompose = "2.10.0" lifecycleViewmodelCompose = "2.10.0"
liveeventbus = "1.8.14" liveeventbus = "1.8.14"
navigation3 = "1.1.1" navigation3 = "1.1.2"
markdownRenderer = "0.40.2" markdownRenderer = "0.40.2"
markwon = "4.6.2" markwon = "4.6.2"
material = "1.14.0-rc01" material = "1.14.0"
material3 = "1.5.0-alpha19" material3 = "1.5.0-alpha20"
material3IconsExtended = "1.7.8" material3IconsExtended = "1.7.8"
materialKolor = "4.1.1" materialKolor = "4.1.1"
media = "1.8.0" media = "1.8.0"
@@ -59,7 +59,7 @@ okhttp = "5.3.2"
palette = "1.0.0" palette = "1.0.0"
preference = "1.2.1" preference = "1.2.1"
protobufJavalite = "4.26.1" protobufJavalite = "4.26.1"
quickChineseTransfer = "0.2.16" quickChineseTransfer = "0.2.17"
room = "2.8.4" room = "2.8.4"
splitties = "3.0.0" splitties = "3.0.0"
rhino = "1.9.1" rhino = "1.9.1"
@@ -96,15 +96,10 @@ profileinstaller = "1.4.1"
[libraries] [libraries]
accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanistWebview" } 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-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
activity-ktx = { module = "androidx.activity:activity-ktx", 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.10.0" }
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-collection = { module = "androidx.collection:collection", version.ref = "collection" } androidx-collection = { module = "androidx.collection:collection", version.ref = "collection" }
androidx-compose-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" } 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" } 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-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" }
coil-gif = { module = "io.coil-kt:coil-gif", 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" } 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" } core-ktx = { module = "androidx.core:core-ktx", version.ref = "core" }
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version = "3.7.0" } 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" } 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-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" } androidx-junit = { module = "androidx.test.ext:junit", version = "1.3.0" }
hutool-crypto = { module = "cn.hutool:hutool-crypto", version.ref = "hutool" } 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-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" } libarchive = { module = "me.zhanghai.android.libarchive:library", version.ref = "libarchive" }
lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "lifecycle" } lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "lifecycle" }
lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", 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" } media-media = { module = "androidx.media:media", version.ref = "media" }
media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3" } media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3" }
media3-exoplayer = { module = "androidx.media3:media3-exoplayer", 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-core = { module = "io.noties.markwon:core", version.ref = "markwon" }
markwon-image-glide = { module = "io.noties.markwon:image-glide", 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-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" }
ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", 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" } preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preference" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } 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-glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" }
glide-okhttp= { module = "com.github.bumptech.glide:okhttp3-integration", 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-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-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" } glide-svg = { module = "com.github.qoqa:glide-svg", version = "4.0.2" }
androidsvg = { module = "com.caverock:androidsvg-aar", version = "1.4" } androidsvg = { module = "com.caverock:androidsvg-aar", version = "1.4" }
splitties-appctx = { module = "com.louiscad.splitties:splitties-appctx", version.ref = "splitties" } 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-systemservices = { module = "com.louiscad.splitties:splitties-systemservices", version.ref = "splitties" }
splitties-views = { module = "com.louiscad.splitties:splitties-views", 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-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" } 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-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" } liveeventbus = { module = "com.github.michaellee123:LiveEventBus", version.ref = "liveeventbus" }
material = { module = "com.google.android.material:material", version.ref = "material" } material = { module = "com.google.android.material:material", version.ref = "material" }
mozilla-rhino = { module = "org.mozilla:rhino", version.ref = "rhino" } 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" } 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" } 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-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-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-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" } miuix-ui-android = { module = "top.yukonga.miuix.kmp:miuix-ui-android", version.ref = "miuix" }
capsule = { module = "io.github.kyant0:capsule", version.ref = "capsule" } 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-library = { id = "com.android.library", version.ref = "agp" }
android-test = { id = "com.android.test", 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-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" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
google-services = { id = "com.google.gms.google-services", version = "4.4.4" } 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" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
room = { id = "androidx.room", version.ref = "room" } room = { id = "androidx.room", version.ref = "room" }
#protobuf = { id = "com.google.protobuf", version = "0.9.4" } download = { id = "de.undercouch.download", version = "5.7.0" }
download = { id = "de.undercouch.download", version = "5.6.0" }
baselineprofile = { id = "androidx.baselineprofile", version.ref = "baselineprofile" } baselineprofile = { id = "androidx.baselineprofile", version.ref = "baselineprofile" }