[优化] 支持发现页更多样式

This commit is contained in:
HapeLee
2026-04-14 01:00:52 +08:00
parent 9410e9e0b5
commit 49614f5ec6
10 changed files with 749 additions and 124 deletions
@@ -6,11 +6,44 @@ package io.legado.app.data.entities.rule
data class ExploreKind(
val title: String = "",
val url: String? = null,
val type: String = Type.url,
val action: String? = null,
val chars: Array<String?>? = null,
val default: String? = null,
var viewName: String? = null,
val style: FlexChildStyle? = null
) {
@Suppress("ConstPropertyName")
object Type {
const val url = "url"
const val text = "text"
const val button = "button"
const val toggle = "toggle"
const val select = "select"
}
fun style(): FlexChildStyle {
return style ?: FlexChildStyle.defaultStyle
}
}
override fun equals(other: Any?): Boolean {
if (other is ExploreKind) {
return other.title == title
&& other.type == type
&& other.url == url
&& other.action == action
&& other.default == default
}
return false
}
override fun hashCode(): Int {
var result = title.hashCode() + type.hashCode()
result = 31 * result + (url?.hashCode() ?: 0)
result = 31 * result + (action?.hashCode() ?: 0)
result = 31 * result + (default?.hashCode() ?: 0)
return result
}
}
@@ -0,0 +1,115 @@
package io.legado.app.domain.usecase
import androidx.appcompat.app.AppCompatActivity
import com.script.rhino.runScriptWithContext
import io.legado.app.constant.AppLog
import io.legado.app.data.dao.BookSourceDao
import io.legado.app.data.entities.BaseSource
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.ui.login.SourceLoginJsExtensions
import io.legado.app.utils.InfoMap
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
class ExploreKindUiUseCase(
private val bookSourceDao: BookSourceDao
) {
private val sourceCache: ConcurrentHashMap<String, BookSource?> = ConcurrentHashMap()
suspend fun warmUp(sourceUrl: String?) {
sourceUrl?.takeIf { it.isNotBlank() }?.let { getOrLoadBookSource(it) }
}
suspend fun resolveDisplayName(
kind: ExploreKind,
sourceUrl: String?,
infoMap: InfoMap?
): String {
val viewName = kind.viewName
if (viewName.isNullOrBlank()) return kind.title
parseLiteralViewName(viewName)?.let { return it }
val effectiveSourceUrl = sourceUrl ?: return kind.title
val effectiveInfoMap = infoMap ?: return kind.title
return runCatching {
evalUiJs(viewName, effectiveSourceUrl, effectiveInfoMap)
.takeUnless { it.isNullOrEmpty() } ?: "null"
}.getOrElse {
"err"
}
}
suspend fun executeAction(
action: String?,
title: String,
sourceUrl: String?,
infoMap: InfoMap?,
activity: AppCompatActivity?,
onRefreshKinds: () -> Unit
) {
val actionText = action?.takeIf { it.isNotBlank() } ?: return
val effectiveSourceUrl = sourceUrl ?: return
val effectiveInfoMap = infoMap ?: return
val source = getOrLoadBookSource(effectiveSourceUrl) ?: return
val sourceJsExtensions = SourceLoginJsExtensions(
activity = activity,
source = source,
callback = object : SourceLoginJsExtensions.Callback {
override fun upUiData(data: Map<String, String?>?) = Unit
override fun reUiView() = onRefreshKinds()
}
)
evalButtonClick(actionText, source, effectiveInfoMap, title, sourceJsExtensions)
}
private fun parseLiteralViewName(viewName: String?): String? {
if (viewName.isNullOrEmpty()) return null
return if (viewName.length in 3..19 && viewName.first() == '\'' && viewName.last() == '\'') {
viewName.substring(1, viewName.length - 1)
} else {
null
}
}
private suspend fun getOrLoadBookSource(sourceUrl: String): BookSource? {
sourceCache[sourceUrl]?.let { return it }
return withContext(IO) {
bookSourceDao.getBookSource(sourceUrl)
}?.also { source ->
sourceCache[sourceUrl] = source
}
}
private suspend fun evalUiJs(jsStr: String, sourceUrl: String, infoMap: InfoMap): String? {
val source = getOrLoadBookSource(sourceUrl) ?: return null
return runScriptWithContext {
source.evalJS(jsStr) {
put("infoMap", infoMap)
}?.toString()
}
}
private suspend fun evalButtonClick(
jsStr: String,
source: BaseSource?,
infoMap: InfoMap,
name: String,
java: SourceLoginJsExtensions
) {
val source = source ?: return
try {
runScriptWithContext {
source.evalJS(jsStr) {
put("java", java)
put("infoMap", infoMap)
}
}
} catch (e: Exception) {
AppLog.put("ExploreUI Button $name JavaScript error", e)
}
}
}
@@ -2,6 +2,7 @@ package io.legado.app.ui.book.explore
import android.annotation.SuppressLint
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
@@ -31,7 +32,6 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -66,6 +66,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -73,6 +74,7 @@ import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import io.legado.app.data.entities.SearchBook
import io.legado.app.domain.usecase.ExploreKindUiUseCase
import io.legado.app.model.BookShelfState
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeResolver
@@ -85,7 +87,8 @@ import io.legado.app.ui.widget.components.button.TopBarActionButton
import io.legado.app.ui.widget.components.button.TopBarNavigationButton
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.cover.Cover
import io.legado.app.ui.widget.components.explore.ExploreKindItem
import io.legado.app.ui.widget.components.explore.calculateExploreKindRows
import io.legado.app.ui.widget.components.explore.ExploreKindMultiTypeItem
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
@@ -95,6 +98,7 @@ import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@SuppressLint("LocalContextConfigurationRead", "ConfigurationScreenWidthHeight")
@OptIn(
@@ -131,6 +135,14 @@ fun ExploreShowScreen(
var showGridCountSheet by remember { mutableStateOf(false) }
val gridColumnCount by viewModel.gridCount.collectAsState()
val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
val context = LocalContext.current
val activity = context as? AppCompatActivity
val sourceUrl = remember(intent) { intent.getStringExtra("sourceUrl") }
val exploreKindUseCase: ExploreKindUiUseCase = koinInject()
LaunchedEffect(sourceUrl) {
exploreKindUseCase.warmUp(sourceUrl)
}
val isRefreshing by viewModel.isRefreshing.collectAsState()
val pullToRefreshState = rememberPullToRefreshState()
@@ -242,7 +254,7 @@ fun ExploreShowScreen(
SearchBarSection(
query = kindQuery,
backgroundColor = LegadoTheme.colorScheme.surfaceContainerHigh,
backgroundColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.5f),
onQueryChange = { kindQuery = it },
placeholder = "选择或搜索分类",
)
@@ -254,32 +266,48 @@ fun ExploreShowScreen(
(kind.url?.contains(kindQuery, ignoreCase = true) == true)
}
}
val kindRows = remember(filteredKinds) {
calculateExploreKindRows(filteredKinds, 6)
}
LazyVerticalGrid(
columns = GridCells.Fixed(3),
LazyColumn(
contentPadding = PaddingValues(vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.weight(1f, fill = false)
) {
itemsIndexed(
items = filteredKinds,
key = { index, kind -> "${kind.url ?: kind.title}_$index" },
span = { _, kind ->
val isClickable = !kind.url.isNullOrBlank()
if (isClickable) GridItemSpan(1) else GridItemSpan(3)
items(kindRows) { rowItems ->
Row(
modifier = Modifier
.fillMaxWidth()
.animateItem()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
rowItems.forEach { (kind, span) ->
ExploreKindMultiTypeItem(
modifier = Modifier
.weight(span.toFloat())
.animateItem(),
kind = kind,
sourceUrl = sourceUrl,
activity = activity,
onOpenUrl = { url ->
showKindSheet = false
viewModel.switchExploreUrl(kind.copy(url = url))
},
onRefreshKinds = viewModel::refreshKinds,
backgroundColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.5f),
isMiuix = isMiuix,
useCase = exploreKindUseCase
)
}
val totalSpan = rowItems.sumOf { it.second }
if (totalSpan < 6) {
Spacer(
modifier = Modifier.weight((6 - totalSpan).toFloat())
)
}
}
) { _, kind ->
ExploreKindItem(
modifier = Modifier.animateItem(),
kind = kind,
isClickable = !kind.url.isNullOrBlank(),
onClick = {
showKindSheet = false
viewModel.switchExploreUrl(kind)
},
isMiuix = isMiuix
)
}
}
}
@@ -56,6 +56,7 @@ class ExploreShowViewModel(
private val _isRefreshing = MutableStateFlow(false)
private val _errorMsg = MutableStateFlow<String?>(null)
private var bookSource: BookSource? = null
private var sourceUrl: String? = null
private var exploreUrl: String? = null
private var page = 1
private val _isEndStateFlow = MutableStateFlow(false)
@@ -110,13 +111,14 @@ class ExploreShowViewModel(
}
fun initData(intent: Intent) {
val sourceUrl = intent.getStringExtra("sourceUrl")
val incomingSourceUrl = intent.getStringExtra("sourceUrl")
sourceUrl = incomingSourceUrl
exploreUrl = intent.getStringExtra("exploreUrl")
viewModelScope.launch {
if (bookSource == null && sourceUrl != null) {
bookSource = repository.getBookSource(sourceUrl)
loadKinds(sourceUrl)
if (bookSource == null && incomingSourceUrl != null) {
bookSource = repository.getBookSource(incomingSourceUrl)
loadKinds(incomingSourceUrl)
}
loadMore(isRefresh = true)
}
@@ -128,6 +130,10 @@ class ExploreShowViewModel(
}
}
fun refreshKinds() {
sourceUrl?.let { loadKinds(it) }
}
fun switchExploreUrl(kind: ExploreKind) {
_selectedKindTitle.value = kind.title
exploreUrl = kind.url
@@ -221,4 +227,4 @@ class ExploreShowViewModel(
return BookShelfState.NOT_IN_SHELF
}
}
}
@@ -1,5 +1,6 @@
package io.legado.app.ui.main.explore
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
@@ -50,7 +51,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.domain.usecase.ExploreKindUiUseCase
import io.legado.app.ui.book.explore.ExploreShowActivity
import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.book.search.SearchScope
@@ -64,7 +65,8 @@ 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.card.TextCard
import io.legado.app.ui.widget.components.divider.PillHeaderDivider
import io.legado.app.ui.widget.components.explore.ExploreKindItem
import io.legado.app.ui.widget.components.explore.calculateExploreKindRows
import io.legado.app.ui.widget.components.explore.ExploreKindMultiTypeItem
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
import io.legado.app.ui.widget.components.list.ListScaffold
import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
@@ -75,8 +77,8 @@ import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.utils.startActivity
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import top.yukonga.miuix.kmp.theme.MiuixTheme
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
@@ -84,10 +86,12 @@ fun ExploreScreen(
viewModel: ExploreViewModel = koinViewModel()
) {
val context = LocalContext.current
val activity = context as? AppCompatActivity
val uiState by viewModel.uiState.collectAsState()
var sourceToDelete by remember { mutableStateOf<BookSourcePart?>(null) }
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val exploreKindUseCase: ExploreKindUiUseCase = koinInject()
// 自动滚动置顶
LaunchedEffect(uiState.expandedId) {
@@ -113,7 +117,7 @@ fun ExploreScreen(
var contentRowCount = 0
for (item in uiState.items) {
if (item.bookSourceUrl == expandedId) {
contentRowCount = calculateRows(uiState.exploreKinds, 6).size
contentRowCount = calculateExploreKindRows(uiState.exploreKinds, 6).size
break
}
headerIndex++
@@ -167,6 +171,11 @@ fun ExploreScreen(
val isExpanded = uiState.expandedId == item.bookSourceUrl
item(key = item.bookSourceUrl) {
if (isExpanded) {
LaunchedEffect(item.bookSourceUrl) {
exploreKindUseCase.warmUp(item.bookSourceUrl)
}
}
ExploreSourceHeader(
modifier = Modifier.animateItem(),
item = item,
@@ -197,7 +206,7 @@ fun ExploreScreen(
}
if (isExpanded) {
val rows = calculateRows(uiState.exploreKinds, 6)
val rows = calculateExploreKindRows(uiState.exploreKinds, 6)
itemsIndexed(
items = rows,
key = { index, _ -> "${item.bookSourceUrl}_$index" }
@@ -210,21 +219,21 @@ fun ExploreScreen(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
rowItems.forEach { (kind, span) ->
val isClickable = !kind.url.isNullOrBlank()
ExploreKindItem(
ExploreKindMultiTypeItem(
kind = kind,
isClickable = isClickable,
modifier = Modifier.weight(span.toFloat()),
onClick = {
if (isClickable) {
context.startActivity<ExploreShowActivity> {
putExtra("exploreName", kind.title)
putExtra("sourceUrl", item.bookSourceUrl)
putExtra("exploreUrl", kind.url)
}
sourceUrl = item.bookSourceUrl,
activity = activity,
onOpenUrl = { url ->
context.startActivity<ExploreShowActivity> {
putExtra("exploreName", kind.title)
putExtra("sourceUrl", item.bookSourceUrl)
putExtra("exploreUrl", url)
}
},
isMiuix = composeEngine
onRefreshKinds = { viewModel.refreshExploreKinds(item) },
modifier = Modifier.weight(span.toFloat()),
isMiuix = composeEngine,
useCase = exploreKindUseCase
)
}
@@ -248,11 +257,11 @@ fun ExploreScreen(
) { item ->
TextCard(
text = item.bookSourceName,
textStyle = LegadoTheme.typography.labelLarge,
textStyle = LegadoTheme.typography.labelMediumEmphasized,
backgroundColor = LegadoTheme.colorScheme.cardContainer,
contentColor = LegadoTheme.colorScheme.onCardContainer,
cornerRadius = 8.dp,
horizontalPadding = 8.dp,
cornerRadius = 12.dp,
horizontalPadding = 12.dp,
verticalPadding = 8.dp,
onClick = {
scope.launch {
@@ -281,40 +290,6 @@ fun ExploreScreen(
)
}
private fun calculateRows(
kinds: List<ExploreKind>,
maxSpan: Int
): List<List<Pair<ExploreKind, Int>>> {
val rows = mutableListOf<MutableList<Pair<ExploreKind, Int>>>()
var currentRow = mutableListOf<Pair<ExploreKind, Int>>()
var currentSpan = 0
kinds.forEach { kind ->
val style = kind.style()
val span = when {
style.layout_wrapBefore || style.layout_flexBasisPercent >= 1.0f -> maxSpan
style.layout_flexBasisPercent > 0 -> (maxSpan * style.layout_flexBasisPercent).roundToInt()
.coerceIn(1, maxSpan)
style.layout_flexGrow > 0f -> 3
else -> 2
}
if ((style.layout_wrapBefore && currentRow.isNotEmpty()) || (currentSpan + span > maxSpan)) {
rows.add(currentRow)
currentRow = mutableListOf()
currentSpan = 0
}
currentRow.add(kind to span)
currentSpan += span
if (currentSpan >= maxSpan) {
rows.add(currentRow)
currentRow = mutableListOf()
currentSpan = 0
}
}
if (currentRow.isNotEmpty()) rows.add(currentRow)
return rows
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ExploreSourceHeader(
@@ -70,6 +70,11 @@ fun SearchBarSection(
}
val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
val resolvedBackgroundColor = if (backgroundColor != Color.Unspecified) {
backgroundColor
} else {
if (isMiuix) MiuixTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.surfaceContainerLow
}
val modifier = modifier
.fillMaxWidth()
@@ -88,7 +93,7 @@ fun SearchBarSection(
backgroundColor = if (!isMiuix) {
Color.Transparent
} else {
MiuixTheme.colorScheme.surfaceContainer
resolvedBackgroundColor
}
)
}
@@ -101,7 +106,7 @@ fun SearchBarSection(
.fillMaxWidth()
.padding(bottom = 4.dp),
shape = RoundedCornerShape(32.dp),
color = backgroundColor
color = resolvedBackgroundColor
) {
searchTextField()
}
@@ -1,5 +1,6 @@
package io.legado.app.ui.widget.components.explore
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CardDefaults
@@ -8,6 +9,7 @@ import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -25,28 +27,11 @@ fun ExploreKindItem(
isClickable: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
isMiuix: Boolean
isMiuix: Boolean,
backgroundColor: androidx.compose.ui.graphics.Color = LegadoTheme.colorScheme.surfaceContainer,
displayText: String = kind.title,
trailingIcon: (@Composable () -> Unit)? = null
) {
val color = if (isMiuix)
MiuixTheme.colorScheme.surfaceContainer
else
MaterialTheme.colorScheme.secondaryContainer
val contentColor = if (isMiuix)
MiuixTheme.colorScheme.onSurface
else
MaterialTheme.colorScheme.secondary
val unClickBackColor = if (isMiuix)
MiuixTheme.colorScheme.surfaceContainer
else
MaterialTheme.colorScheme.surface
val unClickColor = if (isMiuix)
MiuixTheme.colorScheme.disabledOnSurface
else
MaterialTheme.colorScheme.primary
CompositionLocalProvider(
LocalMinimumInteractiveComponentSize provides Dp.Unspecified
) {
@@ -57,21 +42,29 @@ fun ExploreKindItem(
GlassCard(
onClick = onClick,
shape = shape,
containerColor = LegadoTheme.colorScheme.surfaceContainer.copy(alpha = 0.6f),
containerColor = backgroundColor,
contentColor = LegadoTheme.colorScheme.onSurface,
modifier = modifier
) {
KindText(kind)
KindText(
text = displayText,
isClickable = true,
trailingIcon = trailingIcon
)
}
} else {
GlassCard(
shape = shape,
containerColor = LegadoTheme.colorScheme.surfaceContainer.copy(alpha = 0.6f),
containerColor = backgroundColor,
contentColor = LegadoTheme.colorScheme.primary,
modifier = modifier,
border = CardDefaults.outlinedCardBorder()
) {
KindText(kind)
KindText(
text = displayText,
isClickable = false,
trailingIcon = trailingIcon
)
}
}
}
@@ -80,20 +73,34 @@ fun ExploreKindItem(
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun KindText(
kind: ExploreKind
text: String,
isClickable: Boolean,
trailingIcon: (@Composable () -> Unit)? = null
) {
AppText(
text = kind.title,
color = if (kind.url.isNullOrBlank())
LegadoTheme.colorScheme.primary
else
LegadoTheme.colorScheme.onSurface,
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
style = LegadoTheme.typography.labelMediumEmphasized,
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
.padding(vertical = 8.dp)
) {
AppText(
text = text,
color = if (isClickable) LegadoTheme.colorScheme.onSurface else LegadoTheme.colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.padding(end = if (trailingIcon == null) 0.dp else 18.dp),
style = LegadoTheme.typography.labelMediumEmphasized,
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
if (trailingIcon != null) {
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 8.dp)
) {
trailingIcon()
}
}
}
}
@@ -0,0 +1,68 @@
package io.legado.app.ui.widget.components.explore
import io.legado.app.data.entities.rule.ExploreKind
import kotlin.math.roundToInt
fun calculateExploreKindRows(
kinds: List<ExploreKind>,
maxSpan: Int
): List<List<Pair<ExploreKind, Int>>> {
val rows = mutableListOf<MutableList<Pair<ExploreKind, Int>>>()
var currentRow = mutableListOf<Pair<ExploreKind, Int>>()
var currentSpan = 0
fun fillCurrentRowTail() {
if (currentRow.isEmpty()) return
val remain = maxSpan - currentSpan
if (remain <= 0) return
val allSameSpan = currentRow.map { it.second }.distinct().size == 1
if (allSameSpan && currentRow.size > 1) {
val addEach = remain / currentRow.size
var extra = remain % currentRow.size
currentRow.indices.forEach { index ->
val (kind, span) = currentRow[index]
val add = addEach + if (extra > 0) {
extra -= 1
1
} else {
0
}
currentRow[index] = kind to (span + add)
}
} else {
val (lastKind, lastSpan) = currentRow.last()
currentRow[currentRow.lastIndex] = lastKind to (lastSpan + remain)
}
currentSpan += remain
}
kinds.forEach { kind ->
val style = kind.style()
val span = when {
style.layout_wrapBefore || style.layout_flexBasisPercent >= 1.0f -> maxSpan
style.layout_flexBasisPercent > 0 -> (maxSpan * style.layout_flexBasisPercent).roundToInt()
.coerceIn(1, maxSpan)
style.layout_flexGrow > 0f -> 3
else -> 2
}
if ((style.layout_wrapBefore && currentRow.isNotEmpty()) || (currentSpan + span > maxSpan)) {
fillCurrentRowTail()
rows.add(currentRow)
currentRow = mutableListOf()
currentSpan = 0
}
currentRow.add(kind to span)
currentSpan += span
if (currentSpan >= maxSpan) {
rows.add(currentRow)
currentRow = mutableListOf()
currentSpan = 0
}
}
if (currentRow.isNotEmpty()) {
fillCurrentRowTail()
rows.add(currentRow)
}
return rows
}
@@ -0,0 +1,333 @@
package io.legado.app.ui.widget.components.explore
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowRight
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.UnfoldMore
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import io.legado.app.App
import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.domain.usecase.ExploreKindUiUseCase
import io.legado.app.help.source.getExploreInfoMap
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.dialog.TextDialog
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.icon.AppIcon
import io.legado.app.utils.showDialogFragment
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.ArrowUpDown
@Composable
fun ExploreKindMultiTypeItem(
kind: ExploreKind,
sourceUrl: String?,
activity: AppCompatActivity?,
onOpenUrl: (String) -> Unit,
onRefreshKinds: () -> Unit,
modifier: Modifier = Modifier,
backgroundColor: Color = LegadoTheme.colorScheme.surfaceContainer,
isMiuix: Boolean,
useCase: ExploreKindUiUseCase
) {
val scope = rememberCoroutineScope()
val infoMap = remember(sourceUrl) { sourceUrl?.takeIf { it.isNotBlank() }?.let(::getExploreInfoMap) }
var displayName by remember(sourceUrl, kind.title, kind.viewName) { mutableStateOf(kind.title) }
LaunchedEffect(sourceUrl, kind.title, kind.viewName) {
displayName = useCase.resolveDisplayName(kind, sourceUrl, infoMap)
}
fun runAction(action: String?) {
scope.launch(IO) {
useCase.executeAction(
action = action,
title = kind.title,
sourceUrl = sourceUrl,
infoMap = infoMap,
activity = activity,
onRefreshKinds = onRefreshKinds
)
}
}
when (kind.type) {
ExploreKind.Type.url -> {
val url = kind.url?.takeIf { it.isNotBlank() }
ExploreKindItem(
kind = kind,
isClickable = !url.isNullOrBlank(),
onClick = {
if (url.isNullOrBlank()) return@ExploreKindItem
if (kind.title.startsWith("ERROR:")) {
activity?.showDialogFragment(TextDialog("ERROR", url))
} else {
onOpenUrl(url)
}
},
modifier = modifier,
backgroundColor = backgroundColor,
isMiuix = isMiuix,
displayText = displayName
)
}
ExploreKind.Type.button -> {
ExploreKindItem(
kind = kind,
isClickable = !kind.action.isNullOrBlank(),
onClick = { runAction(kind.action) },
modifier = modifier,
backgroundColor = backgroundColor,
isMiuix = isMiuix,
displayText = displayName,
trailingIcon = {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
AppIcon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
modifier = Modifier.height(14.dp),
tint = LegadoTheme.colorScheme.outlineVariant
)
}
}
)
}
ExploreKind.Type.text -> {
var value by remember(sourceUrl, kind.title) {
mutableStateOf(infoMap?.get(kind.title).orEmpty())
}
var actionJob by remember(sourceUrl, kind.title) { mutableStateOf<Job?>(null) }
ExploreKindCompactTextField(
value = value,
onValueChange = { newValue ->
value = newValue
infoMap?.let {
it[kind.title] = newValue
it.saveNow()
}
if (!kind.action.isNullOrBlank()) {
actionJob?.cancel()
actionJob = scope.launch {
delay(600)
runAction(kind.action)
}
}
},
placeholder = displayName,
modifier = modifier,
backgroundColor = backgroundColor,
isMiuix = isMiuix
)
}
ExploreKind.Type.toggle -> {
val chars = remember(kind.chars) {
kind.chars?.filterNotNull().takeUnless { it.isNullOrEmpty() } ?: listOf("chars", "is null")
}
val left = kind.style().layout_justifySelf != "right"
var char by remember(sourceUrl, kind.title, kind.default, kind.chars) {
mutableStateOf(
infoMap?.get(kind.title)
?.takeUnless { it.isEmpty() }
?: (kind.default ?: chars.first()).also {
infoMap?.let { map ->
map[kind.title] = it
map.saveNow()
}
}
)
}
val text = if (left) "$char$displayName" else "$displayName$char"
ExploreKindItem(
kind = kind,
isClickable = true,
onClick = {
val currentIndex = chars.indexOf(char)
val nextIndex = if (currentIndex < 0) 0 else (currentIndex + 1) % chars.size
char = chars.getOrElse(nextIndex) { "" }
infoMap?.let { map ->
map[kind.title] = char
map.saveNow()
}
runAction(kind.action)
},
modifier = modifier,
backgroundColor = backgroundColor,
isMiuix = isMiuix,
displayText = text,
trailingIcon = {
AppIcon(
imageVector = Icons.Default.Refresh,
contentDescription = null,
modifier = Modifier.height(14.dp),
tint = LegadoTheme.colorScheme.outlineVariant
)
}
)
}
ExploreKind.Type.select -> {
val chars = remember(kind.chars) {
kind.chars?.filterNotNull().takeUnless { it.isNullOrEmpty() } ?: listOf("chars", "is null")
}
var selected by remember(sourceUrl, kind.title, kind.default, kind.chars) {
mutableStateOf(
infoMap?.get(kind.title)
?.takeUnless { it.isEmpty() }
?: (kind.default ?: chars.first()).also {
infoMap?.let { map ->
map[kind.title] = it
map.saveNow()
}
}
)
}
var showSelector by remember(sourceUrl, kind.title) { mutableStateOf(false) }
Box(modifier = modifier) {
ExploreKindItem(
kind = kind,
isClickable = chars.isNotEmpty(),
onClick = { showSelector = true },
modifier = Modifier.fillMaxWidth(),
backgroundColor = backgroundColor,
isMiuix = isMiuix,
displayText = "$displayName $selected",
trailingIcon = {
AppIcon(
imageVector = Icons.Default.UnfoldMore,
contentDescription = null,
modifier = Modifier.height(14.dp),
tint = LegadoTheme.colorScheme.outlineVariant
)
}
)
RoundDropdownMenu(
expanded = showSelector,
onDismissRequest = { showSelector = false }
) {
chars.forEach { option ->
RoundDropdownMenuItem(
text = option,
onClick = {
showSelector = false
if (selected != option) {
selected = option
infoMap?.let { map ->
map[kind.title] = option
map.saveNow()
}
runAction(kind.action)
}
}
)
}
}
}
}
else -> {
ExploreKindItem(
kind = kind,
isClickable = false,
onClick = {},
modifier = modifier,
backgroundColor = backgroundColor,
isMiuix = isMiuix,
displayText = displayName
)
}
}
}
@Composable
private fun ExploreKindCompactTextField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier,
backgroundColor: Color = LegadoTheme.colorScheme.surfaceContainer,
isMiuix: Boolean
) {
val interactionSource = remember { MutableInteractionSource() }
val isFocused by interactionSource.collectIsFocusedAsState()
val shape = RoundedCornerShape(10.dp)
val borderColor = if (isFocused) {
LegadoTheme.colorScheme.primary
} else {
Color.Transparent
}
BasicTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
textStyle = LegadoTheme.typography.bodySmall.copy(color = LegadoTheme.colorScheme.onSurface),
cursorBrush = SolidColor(LegadoTheme.colorScheme.primary),
interactionSource = interactionSource,
modifier = modifier
.height(34.dp)
.clip(shape)
.background(backgroundColor)
.border(width = 1.dp, color = borderColor, shape = shape),
decorationBox = { innerTextField ->
Box(
modifier = Modifier
.fillMaxWidth()
.height(34.dp)
.padding(horizontal = 10.dp),
contentAlignment = androidx.compose.ui.Alignment.CenterStart
) {
if (value.isEmpty()) {
AppText(
text = placeholder,
color = LegadoTheme.colorScheme.outline,
style = LegadoTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
}
Box(modifier = Modifier.fillMaxWidth()) {
innerTextField()
}
}
}
)
}
@@ -0,0 +1,55 @@
package io.legado.app.utils
import androidx.annotation.Keep
import io.legado.app.help.CacheManager
/**
* 发现按钮信息
*/
@Keep
class InfoMap(val sourceUrl: String) : MutableMap<String, String> {
private var actualMap: MutableMap<String, String>
var needSave = false
private var saveTime = 0
init {
val cache = CacheManager.get("infoMap_$sourceUrl")
actualMap = GSON.fromJsonObject<MutableMap<String, String>>(cache).getOrNull() ?: mutableMapOf()
}
/**
* time 保存时间 单位为秒
*/
@JvmOverloads
fun save(time: Int = 0, need: Boolean = true) {
needSave = need
saveTime = time
}
fun saveNow() {
val json = GSON.toJson(actualMap)
CacheManager.put("infoMap_$sourceUrl", json, saveTime)
needSave = false
}
fun get(): MutableMap<String, String> {
return actualMap
}
fun set(value: Map<String, String>) {
actualMap = value.toMutableMap()
}
override fun get(key: String) = actualMap[key]
override fun put(key: String, value: String) = actualMap.put(key, value)
override fun remove(key: String) = actualMap.remove(key)
override fun putAll(from: Map<out String, String>) = actualMap.putAll(from)
override fun containsKey(key: String) = actualMap.containsKey(key)
override fun containsValue(value: String) = actualMap.containsValue(value)
override val size get() = actualMap.size
override val entries get() = actualMap.entries
override val keys get() = actualMap.keys
override val values get() = actualMap.values
override fun isEmpty() = actualMap.isEmpty()
override fun clear() = actualMap.clear()
}