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 029f2091d..01b3f3580 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 @@ -1,8 +1,14 @@ package io.legado.app.ui.book.explore import android.content.Intent +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.Crossfade +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement @@ -19,9 +25,11 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn 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.rememberLazyGridState @@ -36,6 +44,7 @@ import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.GridView +import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.outlined.FilterAlt import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -48,12 +57,14 @@ import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -75,6 +86,7 @@ import androidx.compose.ui.unit.sp import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.rule.ExploreKind import io.legado.app.model.BookShelfState +import io.legado.app.ui.widget.components.AnimatedTextButton import io.legado.app.ui.widget.components.AnimatedTextLine import io.legado.app.ui.widget.components.Cover import io.legado.app.ui.widget.components.EmptyMessageView @@ -95,6 +107,8 @@ fun ExploreShowScreen( } val books by viewModel.uiBooks.collectAsState() + val isBookEnd by viewModel.isRawDataEmptyAndEnd.collectAsState() + val shouldTriggerAutoLoad by viewModel.shouldTriggerAutoLoad.collectAsState() val kinds by viewModel.kinds.collectAsState() val isLoading by viewModel.isLoading.collectAsState() val errorMsg by viewModel.errorMsg.collectAsState() @@ -106,6 +120,7 @@ fun ExploreShowScreen( var showKindSheet by remember { mutableStateOf(false) } val layoutState by viewModel.layoutState.collectAsState() val isGridMode = layoutState == 1 + val shouldLoadMore = remember { derivedStateOf { val totalItems: Int @@ -117,7 +132,7 @@ fun ExploreShowScreen( totalItems = listState.layoutInfo.totalItemsCount lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 } - totalItems > 0 && lastVisibleIndex >= totalItems - 3 + totalItems > 0 && lastVisibleIndex >= totalItems - 6 } } @@ -127,10 +142,29 @@ fun ExploreShowScreen( } } - if (showKindSheet) { - val scrollState = rememberScrollState() // 记住滚动状态 + LaunchedEffect(shouldTriggerAutoLoad) { + if (shouldTriggerAutoLoad) { + viewModel.loadMore() + } + } + LaunchedEffect(isGridMode) { + if (isGridMode) { + if (listState.firstVisibleItemIndex > 0) { + gridState.scrollToItem(listState.firstVisibleItemIndex) + } + } else { + if (gridState.firstVisibleItemIndex > 0) { + listState.scrollToItem(gridState.firstVisibleItemIndex) + } + } + } + + if (showKindSheet) { + val scrollState = rememberScrollState() + val sheetState = rememberModalBottomSheetState() ModalBottomSheet( + sheetState = sheetState, onDismissRequest = { showKindSheet = false } ) { Column( @@ -142,13 +176,10 @@ fun ExploreShowScreen( Text( "选择分类", style = MaterialTheme.typography.titleMedium, - modifier = Modifier - .padding(bottom = 8.dp) + modifier = Modifier.padding(bottom = 8.dp) ) - FlowRow( - modifier = Modifier.fillMaxWidth() - ) { + FlowRow(modifier = Modifier.fillMaxWidth()) { kinds.forEach { kind -> KindListItem( kind = kind, @@ -184,7 +215,7 @@ fun ExploreShowScreen( .fillMaxSize() .padding(paddingValues) ) { - if (!isLoading && books.isEmpty() && errorMsg == null) { + if (!isLoading && isBookEnd && errorMsg == null) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { EmptyMessageView(message = "暂无书籍") } @@ -199,12 +230,7 @@ fun ExploreShowScreen( state = gridState, modifier = Modifier.fillMaxSize(), columns = GridCells.Adaptive(minSize = 90.dp), - contentPadding = PaddingValues( - start = 12.dp, - end = 12.dp, - top = 8.dp, - bottom = 12.dp - ), + contentPadding = PaddingValues(12.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -221,7 +247,7 @@ fun ExploreShowScreen( ) } - item { + item(span = { GridItemSpan(maxLineSpan) }) { LoadMoreFooter( isLoading = isLoading, errorMsg = errorMsg, @@ -376,25 +402,44 @@ fun ExploreBookItem( onClick: () -> Unit, modifier: Modifier = Modifier ) { + + val badge: (@Composable RowScope.() -> Unit)? + = when (shelfState) { + + BookShelfState.IN_SHELF -> { + { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "已在书架", + modifier = Modifier.size(12.dp) + ) + } + } + + BookShelfState.SAME_NAME_AUTHOR -> { + { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = "同名书籍", + modifier = Modifier.size(12.dp) + ) + + } + } + + BookShelfState.NOT_IN_SHELF -> null + } + Row( modifier = modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(horizontal = 16.dp, vertical = 8.dp) ) { - Column { - Cover(path = book.coverUrl) - - if (shelfState != BookShelfState.NOT_IN_SHELF) { - Spacer(modifier = Modifier.height(4.dp)) - BookshelfStatusBadge( - shelfState = shelfState, - modifier = Modifier - .width(48.dp) - ) - } - } + Cover( + path = book.coverUrl, + badgeContent = badge) Spacer(modifier = Modifier.width(8.dp)) @@ -514,35 +559,6 @@ fun ExploreBookGridItem( } } -@Composable -fun BookshelfStatusBadge( - shelfState: BookShelfState, - modifier: Modifier = Modifier -) { - val text = when (shelfState) { - BookShelfState.IN_SHELF -> "已在书架" - BookShelfState.SAME_NAME_AUTHOR -> "同名书籍" - else -> null - } - - if (text != null) { - Surface( - modifier = modifier, - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f), - contentColor = MaterialTheme.colorScheme.onTertiaryContainer, - ) { - Text( - text = text, - style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold, fontSize = 9.sp), - textAlign = TextAlign.Center, - modifier = Modifier - .padding(horizontal = 4.dp, vertical = 2.dp) - ) - } - } -} - // 简单的标签组件 @Composable fun TagChip(text: String) { @@ -572,23 +588,35 @@ fun LoadMoreFooter( .padding(32.dp), contentAlignment = Alignment.Center ) { - when { - isLoading -> LoadingIndicator() - errorMsg != null -> { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("加载失败: $errorMsg", color = Color.Red) - TextButton(onClick = onRetry) { Text("重试") } - } - } + Column(horizontalAlignment = Alignment.CenterHorizontally) { - else -> { + AnimatedContent( + targetState = when { + isLoading -> "加载中…" + errorMsg != null -> "加载失败: $errorMsg" + else -> "已经到底了~" + }, + label = "FooterTextChange" + ) { text -> Text( - text = "已经到底了~", - color = Color.Gray, + text = text, + color = when { + errorMsg != null -> Color.Red + else -> Color.Gray + }, style = MaterialTheme.typography.bodySmall ) } + + Spacer(modifier = Modifier.height(8.dp)) + + AnimatedTextButton( + isLoading = isLoading, + onClick = onRetry, + text = if (errorMsg != null) "重试" else "再试一次", + modifier = Modifier.padding(top = 4.dp) + ) } } -} +} \ No newline at end of file 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 7b5447bbf..81f93c924 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 @@ -1,6 +1,7 @@ package io.legado.app.ui.book.explore import android.content.Intent +import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.legado.app.data.entities.BookSource @@ -52,7 +53,7 @@ class ExploreShowViewModel( private var bookSource: BookSource? = null private var exploreUrl: String? = null private var page = 1 - private var isEnd = false + private val _isEndStateFlow = MutableStateFlow(false) private val _bookshelf = MutableStateFlow>(emptySet()) private val _kinds = MutableStateFlow>(emptyList()) val kinds = _kinds.asStateFlow() @@ -71,6 +72,21 @@ class ExploreShowViewModel( } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + val isRawDataEmptyAndEnd = combine( + _rawBooks, + _isEndStateFlow + ) { rawBooks, isEndValue -> + rawBooks.isEmpty() && isEndValue + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + val shouldTriggerAutoLoad = combine( + _isLoading, + uiBooks, + _rawBooks, + _isEndStateFlow + ) { loading, uiBooks, rawBooks, isEnd -> + !loading && uiBooks.isEmpty() && rawBooks.isNotEmpty() && !isEnd + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) val isLoading = _isLoading.asStateFlow() val errorMsg = _errorMsg.asStateFlow() @@ -107,6 +123,7 @@ class ExploreShowViewModel( fun switchExploreUrl(kind: ExploreKind) { _selectedKindTitle.value = kind.title exploreUrl = kind.url + _isEndStateFlow.value = false loadMore(isRefresh = true) } @@ -122,7 +139,7 @@ class ExploreShowViewModel( } fun loadMore(isRefresh: Boolean = false) { - if (_isLoading.value || (isEnd && !isRefresh)) return + if (_isLoading.value || (_isEndStateFlow.value && !isRefresh)) return viewModelScope.launch { _isLoading.value = true @@ -139,20 +156,26 @@ class ExploreShowViewModel( if (isRefresh) { page = 1 - isEnd = false + _isEndStateFlow.value = false _rawBooks.value = emptyList() } repository.exploreBook(source, url, page) .onSuccess { newBooks -> if (newBooks.isEmpty()) { - isEnd = true + _isEndStateFlow.value = true } else { repository.saveSearchBooks(newBooks) val currentList = if (page == 1) emptyList() else _rawBooks.value - val combined = (currentList + newBooks).distinctBy { it.bookUrl } - _rawBooks.value = combined + if (page == 1) { + _rawBooks.value = newBooks.distinctBy { it.bookUrl } + } else { + val existingUrls = currentList.map { it.bookUrl }.toSet() + val uniqueNewBooks = newBooks.filter { it.bookUrl !in existingUrls } + _rawBooks.value = currentList + uniqueNewBooks + } page++ + _isEndStateFlow.value = false } } .onFailure { diff --git a/app/src/main/java/io/legado/app/ui/widget/components/AnimatedTextButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/AnimatedTextButton.kt new file mode 100644 index 000000000..c13fb970c --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/AnimatedTextButton.kt @@ -0,0 +1,45 @@ +package io.legado.app.ui.widget.components + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun AnimatedTextButton( + isLoading: Boolean, + onClick: () -> Unit, + text: String, + modifier: Modifier = Modifier +) { + TextButton( + onClick = onClick, + enabled = !isLoading, + modifier = modifier.animateContentSize() + ) { + AnimatedContent( + targetState = isLoading, + contentAlignment = Alignment.Center, + transitionSpec = { + fadeIn(tween(200)) togetherWith fadeOut(tween(200)) + }, + label = "ButtonLoading" + ) { loading -> + if (loading) { + LoadingIndicator() + } else { + Text(text) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/Cover.kt b/app/src/main/java/io/legado/app/ui/widget/components/Cover.kt index 7e36b20e6..9a9f74ccf 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/Cover.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/Cover.kt @@ -81,14 +81,14 @@ fun Cover( Surface( modifier = Modifier .align(Alignment.TopEnd) - .padding(4.dp), + .padding(2.dp), shape = RoundedCornerShape(4.dp), color = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer, tonalElevation = 2.dp ) { Row( - modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + modifier = Modifier.padding(2.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, content = badgeContent