diff --git a/app/src/main/java/io/legado/app/data/entities/rule/ExploreKind.kt b/app/src/main/java/io/legado/app/data/entities/rule/ExploreKind.kt index 802fd31d8..c82f50b54 100644 --- a/app/src/main/java/io/legado/app/data/entities/rule/ExploreKind.kt +++ b/app/src/main/java/io/legado/app/data/entities/rule/ExploreKind.kt @@ -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? = 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 } -} \ No newline at end of file + 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 + } + +} diff --git a/app/src/main/java/io/legado/app/domain/usecase/ExploreKindUiUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/ExploreKindUiUseCase.kt new file mode 100644 index 000000000..80058761a --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/usecase/ExploreKindUiUseCase.kt @@ -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 = 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?) = 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) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt index 645a74161..229275c88 100644 --- a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt @@ -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 - ) } } } diff --git a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt index f9b566099..ab4368beb 100644 --- a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt @@ -56,6 +56,7 @@ class ExploreShowViewModel( private val _isRefreshing = MutableStateFlow(false) private val _errorMsg = MutableStateFlow(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 } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt b/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt index 846e9fe4d..36cc2fe0e 100644 --- a/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt @@ -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(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 { - putExtra("exploreName", kind.title) - putExtra("sourceUrl", item.bookSourceUrl) - putExtra("exploreUrl", kind.url) - } + sourceUrl = item.bookSourceUrl, + activity = activity, + onOpenUrl = { url -> + context.startActivity { + 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, - maxSpan: Int -): List>> { - val rows = mutableListOf>>() - var currentRow = mutableListOf>() - 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( diff --git a/app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt b/app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt index 809e78bfd..fa437c5bf 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt @@ -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() } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindItem.kt index 36b7377cc..ea4c12de4 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindItem.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindItem.kt @@ -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() + } + } + } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindLayout.kt b/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindLayout.kt new file mode 100644 index 000000000..232fc327f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindLayout.kt @@ -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, + maxSpan: Int +): List>> { + val rows = mutableListOf>>() + var currentRow = mutableListOf>() + 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 +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindMultiTypeItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindMultiTypeItem.kt new file mode 100644 index 000000000..d53a4b6fc --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindMultiTypeItem.kt @@ -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(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() + } + } + } + ) +} diff --git a/app/src/main/java/io/legado/app/utils/InfoMap.kt b/app/src/main/java/io/legado/app/utils/InfoMap.kt new file mode 100644 index 000000000..ea47ef64c --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/InfoMap.kt @@ -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 { + private var actualMap: MutableMap + var needSave = false + private var saveTime = 0 + + init { + val cache = CacheManager.get("infoMap_$sourceUrl") + actualMap = GSON.fromJsonObject>(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 { + return actualMap + } + + fun set(value: Map) { + 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) = 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() +}