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

This commit is contained in:
HapeLee committed 2026-04-14 01:00:52 +08:00
1 parent 9410e9e0b5
commit 49614f5ec6
10 files changed
+749 -124

No files matched your search

@@ -6,11 +6,44 @@ package io.legado.app.data.entities.rule
data class ExploreKind( data class ExploreKind(
val title: String = "", val title: String = "",
val url: String? = null, 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 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 { fun style(): FlexChildStyle {
return style ?: FlexChildStyle.defaultStyle 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.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade 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.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items 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.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState 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.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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.HazeState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.SearchBook
import io.legado.app.domain.usecase.ExploreKindUiUseCase
import io.legado.app.model.BookShelfState import io.legado.app.model.BookShelfState
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeResolver 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.button.TopBarNavigationButton
import io.legado.app.ui.widget.components.card.TextCard 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.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.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
@@ -95,6 +98,7 @@ import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@SuppressLint("LocalContextConfigurationRead", "ConfigurationScreenWidthHeight") @SuppressLint("LocalContextConfigurationRead", "ConfigurationScreenWidthHeight")
@OptIn( @OptIn(
@@ -131,6 +135,14 @@ fun ExploreShowScreen(
var showGridCountSheet by remember { mutableStateOf(false) } var showGridCountSheet by remember { mutableStateOf(false) }
val gridColumnCount by viewModel.gridCount.collectAsState() val gridColumnCount by viewModel.gridCount.collectAsState()
val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine) 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 isRefreshing by viewModel.isRefreshing.collectAsState()
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
@@ -242,7 +254,7 @@ fun ExploreShowScreen(
SearchBarSection( SearchBarSection(
query = kindQuery, query = kindQuery,
backgroundColor = LegadoTheme.colorScheme.surfaceContainerHigh, backgroundColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.5f),
onQueryChange = { kindQuery = it }, onQueryChange = { kindQuery = it },
placeholder = "选择或搜索分类", placeholder = "选择或搜索分类",
) )
@@ -254,32 +266,48 @@ fun ExploreShowScreen(
(kind.url?.contains(kindQuery, ignoreCase = true) == true) (kind.url?.contains(kindQuery, ignoreCase = true) == true)
} }
} }
val kindRows = remember(filteredKinds) {
calculateExploreKindRows(filteredKinds, 6)
}
LazyVerticalGrid( LazyColumn(
columns = GridCells.Fixed(3),
contentPadding = PaddingValues(vertical = 16.dp), contentPadding = PaddingValues(vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.weight(1f, fill = false) modifier = Modifier.weight(1f, fill = false)
) { ) {
itemsIndexed( items(kindRows) { rowItems ->
items = filteredKinds, Row(
key = { index, kind -> "${kind.url ?: kind.title}_$index" }, modifier = Modifier
span = { _, kind -> .fillMaxWidth()
val isClickable = !kind.url.isNullOrBlank() .animateItem()
if (isClickable) GridItemSpan(1) else GridItemSpan(3) .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 _isRefreshing = MutableStateFlow(false)
private val _errorMsg = MutableStateFlow<String?>(null) private val _errorMsg = MutableStateFlow<String?>(null)
private var bookSource: BookSource? = null private var bookSource: BookSource? = null
private var sourceUrl: String? = null
private var exploreUrl: String? = null private var exploreUrl: String? = null
private var page = 1 private var page = 1
private val _isEndStateFlow = MutableStateFlow(false) private val _isEndStateFlow = MutableStateFlow(false)
@@ -110,13 +111,14 @@ class ExploreShowViewModel(
} }
fun initData(intent: Intent) { fun initData(intent: Intent) {
val sourceUrl = intent.getStringExtra("sourceUrl") val incomingSourceUrl = intent.getStringExtra("sourceUrl")
sourceUrl = incomingSourceUrl
exploreUrl = intent.getStringExtra("exploreUrl") exploreUrl = intent.getStringExtra("exploreUrl")
viewModelScope.launch { viewModelScope.launch {
if (bookSource == null && sourceUrl != null) { if (bookSource == null && incomingSourceUrl != null) {
bookSource = repository.getBookSource(sourceUrl) bookSource = repository.getBookSource(incomingSourceUrl)
loadKinds(sourceUrl) loadKinds(incomingSourceUrl)
} }
loadMore(isRefresh = true) loadMore(isRefresh = true)
} }
@@ -128,6 +130,10 @@ class ExploreShowViewModel(
} }
} }
fun refreshKinds() {
sourceUrl?.let { loadKinds(it) }
}
fun switchExploreUrl(kind: ExploreKind) { fun switchExploreUrl(kind: ExploreKind) {
_selectedKindTitle.value = kind.title _selectedKindTitle.value = kind.title
exploreUrl = kind.url exploreUrl = kind.url
@@ -221,4 +227,4 @@ class ExploreShowViewModel(
return BookShelfState.NOT_IN_SHELF return BookShelfState.NOT_IN_SHELF
} }
} }
@@ -1,5 +1,6 @@
package io.legado.app.ui.main.explore package io.legado.app.ui.main.explore
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
@@ -50,7 +51,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.legado.app.R import io.legado.app.R
import io.legado.app.data.entities.BookSourcePart 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.explore.ExploreShowActivity
import io.legado.app.ui.book.search.SearchActivity import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.book.search.SearchScope 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.GlassCard
import io.legado.app.ui.widget.components.card.TextCard 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.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.lazylist.FastScrollLazyColumn
import io.legado.app.ui.widget.components.list.ListScaffold import io.legado.app.ui.widget.components.list.ListScaffold
import io.legado.app.ui.widget.components.list.TopFloatingStickyItem 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 io.legado.app.utils.startActivity
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.MiuixTheme
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
@@ -84,10 +86,12 @@ fun ExploreScreen(
viewModel: ExploreViewModel = koinViewModel() viewModel: ExploreViewModel = koinViewModel()
) { ) {
val context = LocalContext.current val context = LocalContext.current
val activity = context as? AppCompatActivity
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
var sourceToDelete by remember { mutableStateOf<BookSourcePart?>(null) } var sourceToDelete by remember { mutableStateOf<BookSourcePart?>(null) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val exploreKindUseCase: ExploreKindUiUseCase = koinInject()
// 自动滚动置顶 // 自动滚动置顶
LaunchedEffect(uiState.expandedId) { LaunchedEffect(uiState.expandedId) {
@@ -113,7 +117,7 @@ fun ExploreScreen(
var contentRowCount = 0 var contentRowCount = 0
for (item in uiState.items) { for (item in uiState.items) {
if (item.bookSourceUrl == expandedId) { if (item.bookSourceUrl == expandedId) {
contentRowCount = calculateRows(uiState.exploreKinds, 6).size contentRowCount = calculateExploreKindRows(uiState.exploreKinds, 6).size
break break
} }
headerIndex++ headerIndex++
@@ -167,6 +171,11 @@ fun ExploreScreen(
val isExpanded = uiState.expandedId == item.bookSourceUrl val isExpanded = uiState.expandedId == item.bookSourceUrl
item(key = item.bookSourceUrl) { item(key = item.bookSourceUrl) {
if (isExpanded) {
LaunchedEffect(item.bookSourceUrl) {
exploreKindUseCase.warmUp(item.bookSourceUrl)
}
}
ExploreSourceHeader( ExploreSourceHeader(
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
item = item, item = item,
@@ -197,7 +206,7 @@ fun ExploreScreen(
} }
if (isExpanded) { if (isExpanded) {
val rows = calculateRows(uiState.exploreKinds, 6) val rows = calculateExploreKindRows(uiState.exploreKinds, 6)
itemsIndexed( itemsIndexed(
items = rows, items = rows,
key = { index, _ -> "${item.bookSourceUrl}_$index" } key = { index, _ -> "${item.bookSourceUrl}_$index" }
@@ -210,21 +219,21 @@ fun ExploreScreen(
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
rowItems.forEach { (kind, span) -> rowItems.forEach { (kind, span) ->
val isClickable = !kind.url.isNullOrBlank() ExploreKindMultiTypeItem(
ExploreKindItem(
kind = kind, kind = kind,
isClickable = isClickable, sourceUrl = item.bookSourceUrl,
modifier = Modifier.weight(span.toFloat()), activity = activity,
onClick = { onOpenUrl = { url ->
if (isClickable) { context.startActivity<ExploreShowActivity> {
context.startActivity<ExploreShowActivity> { putExtra("exploreName", kind.title)
putExtra("exploreName", kind.title) putExtra("sourceUrl", item.bookSourceUrl)
putExtra("sourceUrl", item.bookSourceUrl) putExtra("exploreUrl", url)
putExtra("exploreUrl", kind.url)
}
} }
}, },
isMiuix = composeEngine onRefreshKinds = { viewModel.refreshExploreKinds(item) },
modifier = Modifier.weight(span.toFloat()),
isMiuix = composeEngine,
useCase = exploreKindUseCase
) )
} }
@@ -248,11 +257,11 @@ fun ExploreScreen(
) { item -> ) { item ->
TextCard( TextCard(
text = item.bookSourceName, text = item.bookSourceName,
textStyle = LegadoTheme.typography.labelLarge, textStyle = LegadoTheme.typography.labelMediumEmphasized,
backgroundColor = LegadoTheme.colorScheme.cardContainer, backgroundColor = LegadoTheme.colorScheme.cardContainer,
contentColor = LegadoTheme.colorScheme.onCardContainer, contentColor = LegadoTheme.colorScheme.onCardContainer,
cornerRadius = 8.dp, cornerRadius = 12.dp,
horizontalPadding = 8.dp, horizontalPadding = 12.dp,
verticalPadding = 8.dp, verticalPadding = 8.dp,
onClick = { onClick = {
scope.launch { 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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ExploreSourceHeader( fun ExploreSourceHeader(
@@ -70,6 +70,11 @@ fun SearchBarSection(
} }
val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine) 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 val modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@@ -88,7 +93,7 @@ fun SearchBarSection(
backgroundColor = if (!isMiuix) { backgroundColor = if (!isMiuix) {
Color.Transparent Color.Transparent
} else { } else {
MiuixTheme.colorScheme.surfaceContainer resolvedBackgroundColor
} }
) )
} }
@@ -101,7 +106,7 @@ fun SearchBarSection(
.fillMaxWidth() .fillMaxWidth()
.padding(bottom = 4.dp), .padding(bottom = 4.dp),
shape = RoundedCornerShape(32.dp), shape = RoundedCornerShape(32.dp),
color = backgroundColor color = resolvedBackgroundColor
) { ) {
searchTextField() searchTextField()
} }
@@ -1,5 +1,6 @@
package io.legado.app.ui.widget.components.explore 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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -8,6 +9,7 @@ import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -25,28 +27,11 @@ fun ExploreKindItem(
isClickable: Boolean, isClickable: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, 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( CompositionLocalProvider(
LocalMinimumInteractiveComponentSize provides Dp.Unspecified LocalMinimumInteractiveComponentSize provides Dp.Unspecified
) { ) {
@@ -57,21 +42,29 @@ fun ExploreKindItem(
GlassCard( GlassCard(
onClick = onClick, onClick = onClick,
shape = shape, shape = shape,
containerColor = LegadoTheme.colorScheme.surfaceContainer.copy(alpha = 0.6f), containerColor = backgroundColor,
contentColor = LegadoTheme.colorScheme.onSurface, contentColor = LegadoTheme.colorScheme.onSurface,
modifier = modifier modifier = modifier
) { ) {
KindText(kind) KindText(
text = displayText,
isClickable = true,
trailingIcon = trailingIcon
)
} }
} else { } else {
GlassCard( GlassCard(
shape = shape, shape = shape,
containerColor = LegadoTheme.colorScheme.surfaceContainer.copy(alpha = 0.6f), containerColor = backgroundColor,
contentColor = LegadoTheme.colorScheme.primary, contentColor = LegadoTheme.colorScheme.primary,
modifier = modifier, modifier = modifier,
border = CardDefaults.outlinedCardBorder() border = CardDefaults.outlinedCardBorder()
) { ) {
KindText(kind) KindText(
text = displayText,
isClickable = false,
trailingIcon = trailingIcon
)
} }
} }
} }
@@ -80,20 +73,34 @@ fun ExploreKindItem(
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
private fun KindText( private fun KindText(
kind: ExploreKind text: String,
isClickable: Boolean,
trailingIcon: (@Composable () -> Unit)? = null
) { ) {
AppText( Box(
text = kind.title,
color = if (kind.url.isNullOrBlank())
LegadoTheme.colorScheme.primary
else
LegadoTheme.colorScheme.onSurface,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp), .padding(vertical = 8.dp)
style = LegadoTheme.typography.labelMediumEmphasized, ) {
textAlign = TextAlign.Center, AppText(
overflow = TextOverflow.Ellipsis, text = text,
maxLines = 1 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()
}