This commit is contained in:
HapeLee
2026-05-29 01:06:00 +08:00
parent 4c0a2256c2
commit d350f7fcb3
8 changed files with 278 additions and 225 deletions
@@ -19,7 +19,6 @@ interface ExploreRepository {
fun getExploreGroups(): Flow<List<String>>
fun getExploreSources(query: String, selectedGroup: String): Flow<List<BookSourcePart>>
suspend fun getBookSource(sourceUrl: String): BookSource?
suspend fun saveSearchBooks(books: List<SearchBook>)
suspend fun getSourceExploreKinds(sourceUrl: String): List<ExploreKind>
suspend fun topSource(bookSource: BookSourcePart)
suspend fun deleteSource(sourceUrl: String)
@@ -91,10 +90,6 @@ class ExploreRepositoryImpl(
return@withContext source?.exploreKinds() ?: emptyList()
}
override suspend fun saveSearchBooks(books: List<SearchBook>) {
appDb.searchBookDao.insert(*books.toTypedArray())
}
override suspend fun topSource(bookSource: BookSourcePart) {
val minOrder = appDb.bookSourceDao.minOrder
appDb.bookSourceDao.upOrder(bookSource.copy(customOrder = minOrder - 1))
@@ -1,20 +1,20 @@
package io.legado.app.domain.usecase
import io.legado.app.constant.BookType
import io.legado.app.data.appDb
import io.legado.app.data.entities.SearchBook
import io.legado.app.data.repository.BookRepository
import io.legado.app.help.book.removeType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class AddToBookshelfUseCase {
class AddToBookshelfUseCase(
private val bookRepository: BookRepository,
) {
suspend fun execute(book: SearchBook) = withContext(Dispatchers.IO) {
suspend fun execute(book: SearchBook) {
val b = book.toBook()
b.removeType(BookType.notShelf)
if (b.order == 0) {
b.order = appDb.bookDao.minOrder - 1
b.order = bookRepository.getMinOrder() - 1
}
b.save()
bookRepository.insert(b)
}
}
@@ -36,7 +36,7 @@ sealed interface ExploreShowSheet {
sealed interface ExploreShowIntent {
data class InitData(
val sourceUrl: String?,
val sourceUrl: String,
val exploreUrl: String?,
) : ExploreShowIntent
@@ -135,30 +135,38 @@ fun ExploreShowScreen(
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
val isGridMode = state.layoutState == 1
val hazeState = remember { HazeState() }
val shouldLoadMoreList = remember {
val showLoadMoreFooter = !state.isRefreshing &&
(state.isLoading || state.errorMsg != null || state.isEnd)
val canLoadMore = state.books.isNotEmpty() &&
!state.isLoading &&
!state.isRefreshing &&
!state.isEnd &&
state.errorMsg == null
val shouldLoadMore by remember(isGridMode) {
derivedStateOf {
val total = listState.layoutInfo.totalItemsCount
val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
total > 0 && last >= total - 3
if (isGridMode) {
val total = gridState.layoutInfo.totalItemsCount
val last = gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
total > 0 && last >= total - 1
} else {
val total = listState.layoutInfo.totalItemsCount
val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
total > 0 && last >= total - 3
}
}
}
val shouldLoadMoreGrid = remember {
derivedStateOf {
val total = gridState.layoutInfo.totalItemsCount
val last = gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
total > 0 && last >= total - 1
LaunchedEffect(
shouldLoadMore,
isGridMode,
canLoadMore,
state.books.size,
) {
if (shouldLoadMore && canLoadMore) {
viewModel.onIntent(ExploreShowIntent.LoadMore)
}
}
LaunchedEffect(shouldLoadMoreList.value, isGridMode) {
if (!isGridMode && shouldLoadMoreList.value) viewModel.onIntent(ExploreShowIntent.LoadMore)
}
LaunchedEffect(shouldLoadMoreGrid.value, isGridMode) {
if (isGridMode && shouldLoadMoreGrid.value) viewModel.onIntent(ExploreShowIntent.LoadMore)
}
LaunchedEffect(isGridMode) {
if (isGridMode) {
if (listState.firstVisibleItemIndex > 0) {
@@ -325,14 +333,14 @@ fun ExploreShowScreen(
)
}
item(span = { GridItemSpan(maxLineSpan) }) {
LoadMoreFooter(
isLoading = state.isLoading && !state.isRefreshing,
errorMsg = state.errorMsg,
isEnd = state.isEnd,
onRetry = { viewModel.onIntent(ExploreShowIntent.LoadMore) },
onLoadMore = { viewModel.onIntent(ExploreShowIntent.ForceLoadNext) }
)
if (showLoadMoreFooter) {
item(span = { GridItemSpan(maxLineSpan) }) {
ExploreShowLoadMoreFooter(
state = state,
onRetry = { viewModel.onIntent(ExploreShowIntent.LoadMore) },
onLoadMore = { viewModel.onIntent(ExploreShowIntent.ForceLoadNext) },
)
}
}
}
} else {
@@ -376,14 +384,14 @@ fun ExploreShowScreen(
)
}
item {
LoadMoreFooter(
isLoading = state.isLoading && !state.isRefreshing,
errorMsg = state.errorMsg,
isEnd = state.isEnd,
onRetry = { viewModel.onIntent(ExploreShowIntent.LoadMore) },
onLoadMore = { viewModel.onIntent(ExploreShowIntent.ForceLoadNext) }
)
if (showLoadMoreFooter) {
item {
ExploreShowLoadMoreFooter(
state = state,
onRetry = { viewModel.onIntent(ExploreShowIntent.LoadMore) },
onLoadMore = { viewModel.onIntent(ExploreShowIntent.ForceLoadNext) },
)
}
}
}
}
@@ -410,6 +418,22 @@ fun ExploreShowScreen(
)
}
@Composable
private fun ExploreShowLoadMoreFooter(
state: ExploreShowUiState,
onRetry: () -> Unit,
onLoadMore: () -> Unit,
) {
LoadMoreFooter(
isLoading = state.isLoading,
errorMsg = state.errorMsg,
isEnd = state.isEnd,
onRetry = onRetry,
onLoadMore = onLoadMore,
autoLoad = false,
)
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun ExploreBookItem(
@@ -2,7 +2,6 @@ package io.legado.app.ui.book.explore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.SearchBook
import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.data.repository.ExploreRepository
@@ -25,6 +24,25 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import splitties.init.appCtx
private data class ExploreShowLoadState(
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val isEnd: Boolean = false,
val errorMsg: String? = null,
)
private data class ExploreShowKindState(
val kinds: List<ExploreKind> = emptyList(),
val selectedKindTitle: String? = null,
)
private data class ExploreShowDisplayState(
val sourceUrl: String? = null,
val layoutState: Int,
val gridCount: Int,
val sheet: ExploreShowSheet = ExploreShowSheet.None,
)
class ExploreShowViewModel(
private val repository: ExploreRepository,
private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase,
@@ -35,15 +53,18 @@ class ExploreShowViewModel(
private val _rawBooks = MutableStateFlow<List<SearchBook>>(emptyList())
private val _bookshelf = MutableStateFlow<Set<BookShelfKey>>(emptySet())
private val _isLoading = MutableStateFlow(false)
private val _isEnd = MutableStateFlow(false)
private val _errorMsg = MutableStateFlow<String?>(null)
private val _kinds = MutableStateFlow<List<ExploreKind>>(emptyList())
private val _selectedKindTitle = MutableStateFlow<String?>(null)
private val _loadState = MutableStateFlow(ExploreShowLoadState())
private val _kindState = MutableStateFlow(ExploreShowKindState())
private val _displayState = MutableStateFlow(
ExploreShowDisplayState(
layoutState = AppConfig.exploreLayoutState,
gridCount = appCtx.exploreLayoutGrid,
)
)
private var bookSource: BookSource? = null
private var sourceUrl: String? = null
private var exploreUrl: String? = null
private var initialized = false
private var page = 1
private var autoPageCount = 0
@@ -54,8 +75,8 @@ class ExploreShowViewModel(
private val _uiState = MutableStateFlow(
ExploreShowUiState(
layoutState = AppConfig.exploreLayoutState,
gridCount = appCtx.exploreLayoutGrid,
layoutState = _displayState.value.layoutState,
gridCount = _displayState.value.gridCount,
)
)
val uiState = _uiState.asStateFlow()
@@ -77,8 +98,8 @@ class ExploreShowViewModel(
is ExploreShowIntent.SwitchKind -> switchKind(intent.kind)
ExploreShowIntent.ToggleLayout -> toggleLayout()
is ExploreShowIntent.SaveGridCount -> saveGridCount(intent.count)
is ExploreShowIntent.ShowSheet -> _uiState.update { it.copy(sheet = intent.sheet) }
ExploreShowIntent.DismissSheet -> _uiState.update { it.copy(sheet = ExploreShowSheet.None) }
is ExploreShowIntent.ShowSheet -> _displayState.update { it.copy(sheet = intent.sheet) }
ExploreShowIntent.DismissSheet -> _displayState.update { it.copy(sheet = ExploreShowSheet.None) }
is ExploreShowIntent.OpenBook -> emitEffect(
ExploreShowEffect.OpenBookInfo(
name = intent.book.name,
@@ -111,21 +132,10 @@ class ExploreShowViewModel(
combine(
_rawBooks,
_bookshelf,
_isLoading,
_isEnd,
_errorMsg,
_kinds,
_selectedKindTitle,
) { values ->
@Suppress("UNCHECKED_CAST")
val rawBooks = values[0] as List<SearchBook>
val bookshelf = values[1] as Set<BookShelfKey>
val isLoading = values[2] as Boolean
val isEnd = values[3] as Boolean
val errorMsg = values[4] as String?
val kinds = values[5] as List<ExploreKind>
val selectedKindTitle = values[6] as String?
_loadState,
_kindState,
_displayState,
) { rawBooks, bookshelf, loadState, kindState, displayState ->
val books = rawBooks.map { item ->
ExploreBookItemUi(
book = item,
@@ -139,154 +149,154 @@ class ExploreShowViewModel(
}
ExploreShowUiState(
sourceUrl = sourceUrl,
sourceUrl = displayState.sourceUrl,
books = books.toImmutableList(),
kinds = kinds.toImmutableList(),
selectedKindTitle = selectedKindTitle,
layoutState = _uiState.value.layoutState,
gridCount = _uiState.value.gridCount,
isLoading = isLoading,
isRefreshing = isLoading && page == 1,
isEnd = isEnd,
errorMsg = errorMsg,
sheet = _uiState.value.sheet,
kinds = kindState.kinds.toImmutableList(),
selectedKindTitle = kindState.selectedKindTitle,
layoutState = displayState.layoutState,
gridCount = displayState.gridCount,
isLoading = loadState.isLoading,
isRefreshing = loadState.isRefreshing,
isEnd = loadState.isEnd,
errorMsg = loadState.errorMsg,
sheet = displayState.sheet,
)
}.collect { newState ->
val oldState = _uiState.value
_uiState.value = newState
if (newState.books.isEmpty() && _rawBooks.value.isNotEmpty() && !newState.isEnd && !newState.isLoading) {
loadMore()
}
}
}
}
private fun initData(incomingSourceUrl: String?, incomingExploreUrl: String?) {
if (sourceUrl == incomingSourceUrl && exploreUrl == incomingExploreUrl && bookSource != null) {
private fun initData(incomingSourceUrl: String, incomingExploreUrl: String?) {
if (initialized && sourceUrl == incomingSourceUrl && exploreUrl == incomingExploreUrl) {
return
}
initialized = true
sourceUrl = incomingSourceUrl
exploreUrl = incomingExploreUrl
page = 1
autoPageCount = 0
bookSource = null
_rawBooks.value = emptyList()
_isEnd.value = false
_errorMsg.value = null
_selectedKindTitle.value = null
_kinds.value = emptyList()
viewModelScope.launch {
if (bookSource == null && incomingSourceUrl != null) {
bookSource = repository.getBookSource(incomingSourceUrl)
}
if (exploreUrl == null && bookSource != null) {
loadKinds(incomingSourceUrl!!)
}
loadMore(isRefresh = true)
_loadState.value = ExploreShowLoadState()
_kindState.value = ExploreShowKindState()
_displayState.update {
it.copy(
sourceUrl = incomingSourceUrl,
sheet = ExploreShowSheet.None,
)
}
if (incomingExploreUrl == null) {
viewModelScope.launch {
loadKinds(incomingSourceUrl)
}
}
loadMore(isRefresh = true)
}
private fun loadKinds(sourceUrl: String) {
viewModelScope.launch {
_kinds.value = repository.getSourceExploreKinds(sourceUrl)
}
private suspend fun loadKinds(sourceUrl: String) {
_kindState.update { it.copy(kinds = repository.getSourceExploreKinds(sourceUrl)) }
}
private fun switchKind(kind: ExploreKind) {
_selectedKindTitle.value = kind.title
_kindState.update { it.copy(selectedKindTitle = kind.title) }
exploreUrl = kind.url
_isEnd.value = false
_loadState.update { it.copy(isEnd = false) }
autoPageCount = 0
loadMore(isRefresh = true)
}
private fun toggleLayout() {
_uiState.update {
val newState = if (it.layoutState == 0) 1 else 0
AppConfig.exploreLayoutState = newState
it.copy(layoutState = newState)
_displayState.update {
val layoutState = if (it.layoutState == 0) 1 else 0
AppConfig.exploreLayoutState = layoutState
it.copy(layoutState = layoutState)
}
}
private fun saveGridCount(count: Int) {
appCtx.exploreLayoutGrid = count
_uiState.update { it.copy(gridCount = count) }
_displayState.update { it.copy(gridCount = count) }
}
private fun loadMore(isRefresh: Boolean = false, forceLoad: Boolean = false) {
val source = bookSource
val url = exploreUrl ?: source?.exploreUrl
if (source == null || url == null || _isLoading.value || (_isEnd.value && !isRefresh && !forceLoad)) return
val source = sourceUrl
val url = exploreUrl
val loadState = _loadState.value
if (source == null || loadState.isLoading || (loadState.isEnd && !isRefresh && !forceLoad)) return
_loadState.update {
it.copy(
isLoading = true,
isRefreshing = isRefresh,
isEnd = if (isRefresh || forceLoad) false else it.isEnd,
errorMsg = null,
)
}
viewModelScope.launch {
_isLoading.value = true
_errorMsg.value = null
if (isRefresh) {
page = 1
_isEnd.value = false
autoPageCount = 0
_rawBooks.value = emptyList()
}
if (forceLoad) {
autoPageCount = 0
_isEnd.value = false
}
fetchPage(source, url)
}
}
private suspend fun fetchPage(source: BookSource, url: String) {
private suspend fun fetchPage(sourceUrl: String, url: String?) {
kotlin.runCatching {
exploreBooksUseCase.execute(source.bookSourceUrl, url, args = null, page)
exploreBooksUseCase.execute(sourceUrl, url, args = null, page)
}.onSuccess { result ->
if (result.books.isEmpty()) {
page++
autoPageCount++
if (autoPageCount >= MAX_AUTO_PAGES) {
_isEnd.value = true
_isLoading.value = false
} else {
delay(AUTO_PAGE_DELAY_MS)
fetchPage(source, url)
}
} else {
val currentList = _rawBooks.value
val existingUrls = currentList.map { it.bookUrl }.toSet()
val uniqueNewBooks = result.books
.filter { it.bookUrl !in existingUrls }
.distinctBy { it.bookUrl }
if (result.books.isNotEmpty()) {
saveSearchBooksUseCase.save(result.books)
val currentList = _rawBooks.value
val existingUrls = currentList.map { it.bookUrl }.toSet()
val uniqueNewBooks = result.books
.filter { it.bookUrl !in existingUrls }
.distinctBy { it.bookUrl }
if (uniqueNewBooks.isEmpty()) {
page++
autoPageCount++
if (autoPageCount >= MAX_AUTO_PAGES) {
_isEnd.value = true
_isLoading.value = false
} else {
delay(AUTO_PAGE_DELAY_MS)
fetchPage(source, url)
}
} else {
_rawBooks.value = currentList + uniqueNewBooks
page++
autoPageCount = 0
_isEnd.value = false
_isLoading.value = false
}
}
}.onFailure {
_errorMsg.value = it.stackTraceStr
_isLoading.value = false
if (uniqueNewBooks.isEmpty()) {
fetchNextAutoPageOrFinish(sourceUrl, url)
} else {
_rawBooks.value = currentList + uniqueNewBooks
page++
autoPageCount = 0
_loadState.update { it.copy(isEnd = false) }
finishLoading()
}
}.onFailure { throwable ->
_loadState.update { it.copy(errorMsg = throwable.stackTraceStr) }
finishLoading()
}
}
private suspend fun fetchNextAutoPageOrFinish(sourceUrl: String, url: String?) {
page++
autoPageCount++
if (autoPageCount >= MAX_AUTO_PAGES) {
_loadState.update { it.copy(isEnd = true) }
finishLoading()
} else {
delay(AUTO_PAGE_DELAY_MS)
fetchPage(sourceUrl, url)
}
}
private fun finishLoading() {
_loadState.update {
it.copy(
isLoading = false,
isRefreshing = false,
)
}
}
@@ -899,6 +899,7 @@ private fun ExpandedSourceSheet(
title = sourceName,
) {
val listState = rememberLazyListState()
val showLoadMoreFooter = isLoading || errorMsg != null || isEnd
val shouldLoadMore by remember {
derivedStateOf {
@@ -935,13 +936,16 @@ private fun ExpandedSourceSheet(
)
}
item {
LoadMoreFooter(
isLoading = isLoading,
errorMsg = errorMsg,
isEnd = isEnd,
onRetry = onLoadMore,
)
if (showLoadMoreFooter) {
item {
LoadMoreFooter(
isLoading = isLoading,
errorMsg = errorMsg,
isEnd = isEnd,
onRetry = onLoadMore,
autoLoad = false,
)
}
}
}
}
@@ -135,6 +135,8 @@ fun RssArticlesPage(
modifier = modifier.fillMaxSize(),
topPadding = paddingValues.calculateTopPadding()
) {
val showLoadMoreFooter = !loadState.isRefreshing &&
(loadState.isLoadingMore || loadState.errorMessage != null || !loadState.hasMore)
when (layout) {
RssArticleLayout.List, RssArticleLayout.LargeCard -> {
val listState = rememberLazyListState()
@@ -160,13 +162,16 @@ fun RssArticlesPage(
onClick = onRead
)
}
item {
LoadMoreFooter(
isLoading = loadState.isRefreshing || loadState.isLoadingMore,
errorMsg = loadState.errorMessage,
isEnd = !loadState.hasMore,
onRetry = { rssSource?.let(viewModel::loadMore) }
)
if (showLoadMoreFooter) {
item {
LoadMoreFooter(
isLoading = loadState.isLoadingMore,
errorMsg = loadState.errorMessage,
isEnd = !loadState.hasMore,
onRetry = { rssSource?.let(viewModel::loadMore) },
autoLoad = false
)
}
}
}
}
@@ -197,13 +202,16 @@ fun RssArticlesPage(
onClick = onRead
)
}
item(span = { GridItemSpan(maxLineSpan) }) {
LoadMoreFooter(
isLoading = loadState.isRefreshing || loadState.isLoadingMore,
errorMsg = loadState.errorMessage,
isEnd = !loadState.hasMore,
onRetry = { rssSource?.let(viewModel::loadMore) }
)
if (showLoadMoreFooter) {
item(span = { GridItemSpan(maxLineSpan) }) {
LoadMoreFooter(
isLoading = loadState.isLoadingMore,
errorMsg = loadState.errorMessage,
isEnd = !loadState.hasMore,
onRetry = { rssSource?.let(viewModel::loadMore) },
autoLoad = false
)
}
}
}
}
@@ -234,13 +242,16 @@ fun RssArticlesPage(
onClick = onRead
)
}
item(span = StaggeredGridItemSpan.FullLine) {
LoadMoreFooter(
isLoading = loadState.isRefreshing || loadState.isLoadingMore,
errorMsg = loadState.errorMessage,
isEnd = !loadState.hasMore,
onRetry = { rssSource?.let(viewModel::loadMore) }
)
if (showLoadMoreFooter) {
item(span = StaggeredGridItemSpan.FullLine) {
LoadMoreFooter(
isLoading = loadState.isLoadingMore,
errorMsg = loadState.errorMessage,
isEnd = !loadState.hasMore,
onRetry = { rssSource?.let(viewModel::loadMore) },
autoLoad = false
)
}
}
}
}
@@ -44,16 +44,23 @@ fun LoadMoreFooter(
isEnd: Boolean,
onRetry: () -> Unit,
onLoadMore: (() -> Unit)? = null,
autoLoad: Boolean = true,
) {
val context = LocalContext.current
var showFullError by remember { mutableStateOf<String?>(null) }
LaunchedEffect(isLoading, errorMsg, isEnd) {
if (!isLoading && errorMsg == null && !isEnd) {
onRetry()
if (autoLoad) {
LaunchedEffect(isLoading, errorMsg, isEnd) {
if (!isLoading && errorMsg == null && !isEnd) {
onRetry()
}
}
}
if (autoLoad && !isLoading && errorMsg == null && !isEnd) {
return
}
AppAlertDialog(
data = showFullError,
onDismissRequest = { showFullError = null },
@@ -270,39 +277,41 @@ fun LoadMoreFooter(
}
else -> {
GlassCard(
modifier = Modifier
.fillMaxWidth(),
containerColor = LegadoTheme.colorScheme.surfaceContainer,
onClick = onRetry
) {
Column(
modifier = Modifier.fillMaxWidth()
if (!autoLoad) {
GlassCard(
modifier = Modifier
.fillMaxWidth(),
containerColor = LegadoTheme.colorScheme.surfaceContainer,
onClick = onRetry
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
all = 16.dp
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
Column(
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
all = 16.dp
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
AppIcon(
imageVector = Icons.Outlined.Info,
contentDescription = null,
tint = LegadoTheme.colorScheme.onSurface
)
AppIcon(
imageVector = Icons.Outlined.Info,
contentDescription = null,
tint = LegadoTheme.colorScheme.onSurface
)
AppText(
text = "加载更多",
color = LegadoTheme.colorScheme.onSurface,
style = LegadoTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
AppText(
text = "加载更多",
color = LegadoTheme.colorScheme.onSurface,
style = LegadoTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}