[优化] 性能优化

This commit is contained in:
HapeLee
2026-05-01 12:26:35 +08:00
parent 03d0528dd2
commit f309e03b23
32 changed files with 971 additions and 313 deletions
@@ -76,6 +76,7 @@ interface BookDao {
bookUrl,
name,
author,
origin,
originName,
coverUrl,
customCoverUrl,
@@ -109,6 +110,7 @@ interface BookDao {
bookUrl,
name,
author,
origin,
originName,
coverUrl,
customCoverUrl,
@@ -139,6 +141,7 @@ interface BookDao {
bookUrl,
name,
author,
origin,
originName,
coverUrl,
customCoverUrl,
@@ -169,6 +172,7 @@ interface BookDao {
bookUrl,
name,
author,
origin,
originName,
coverUrl,
customCoverUrl,
@@ -204,6 +208,7 @@ interface BookDao {
bookUrl,
name,
author,
origin,
originName,
coverUrl,
customCoverUrl,
@@ -240,6 +245,7 @@ interface BookDao {
bookUrl,
name,
author,
origin,
originName,
coverUrl,
customCoverUrl,
@@ -271,6 +277,7 @@ interface BookDao {
bookUrl,
name,
author,
origin,
originName,
coverUrl,
customCoverUrl,
@@ -303,6 +310,7 @@ interface BookDao {
bookUrl,
name,
author,
origin,
originName,
coverUrl,
customCoverUrl,
@@ -333,6 +341,7 @@ interface BookDao {
bookUrl,
name,
author,
origin,
originName,
coverUrl,
customCoverUrl,
@@ -364,6 +373,7 @@ interface BookDao {
bookUrl,
name,
author,
origin,
originName,
coverUrl,
customCoverUrl,
@@ -394,6 +404,7 @@ interface BookDao {
bookUrl,
name,
author,
origin,
originName,
coverUrl,
customCoverUrl,
@@ -424,6 +435,7 @@ interface BookDao {
bookUrl,
name,
author,
origin,
originName,
coverUrl,
customCoverUrl,
@@ -454,6 +466,7 @@ interface BookDao {
bookUrl,
name,
author,
origin,
originName,
coverUrl,
customCoverUrl,
@@ -484,6 +497,7 @@ interface BookDao {
bookUrl,
name,
author,
origin,
originName,
coverUrl,
customCoverUrl,
@@ -42,7 +42,6 @@ sealed interface BookInfoSheet {
}
sealed interface BookInfoDialog {
data object AddToShelfOnBack : BookInfoDialog
data class DeleteBook(val isLocal: Boolean) : BookInfoDialog
data class EditRemark(val remark: String?) : BookInfoDialog
data class PhotoPreview(val path: String) : BookInfoDialog
@@ -60,7 +59,6 @@ data class BookInfoWebFile(
}
sealed interface BookInfoIntent {
data object BackPressed : BookInfoIntent
data object DismissSheet : BookInfoIntent
data object DismissDialog : BookInfoIntent
data object DismissAppLogSheet : BookInfoIntent
@@ -77,7 +75,6 @@ sealed interface BookInfoIntent {
data object ChangeSourceClick : BookInfoIntent
data object ReadRecordClick : BookInfoIntent
data object RemarkClick : BookInfoIntent
data object ConfirmBackAddToShelf : BookInfoIntent
data class ConfirmDelete(val deleteOriginal: Boolean) : BookInfoIntent
data class UpdateRemark(val remark: String) : BookInfoIntent
data class SelectGroup(val groupId: Long) : BookInfoIntent
@@ -5,6 +5,9 @@ import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -33,6 +36,7 @@ import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.startActivity
import kotlinx.coroutines.flow.collectLatest
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun BookInfoRouteScreen(
bookUrl: String,
@@ -40,6 +44,9 @@ fun BookInfoRouteScreen(
onBack: () -> Unit,
onFinish: (resultCode: Int?, afterTransition: Boolean) -> Unit,
onOpenSearch: (String) -> Unit,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKey: String? = null,
onRegisterVariableSetter: (((String, String?) -> Unit)?) -> Unit = {}
) {
val context = LocalContext.current
@@ -156,6 +163,9 @@ fun BookInfoRouteScreen(
state = viewModel.uiState.collectAsStateWithLifecycle().value,
onIntent = viewModel::onIntent,
onBack = onBack,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey,
)
}
@@ -1,6 +1,8 @@
package io.legado.app.ui.book.info
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
@@ -109,6 +111,9 @@ fun BookInfoScreen(
state: BookInfoUiState,
onIntent: (BookInfoIntent) -> Unit,
onBack: () -> Unit,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKey: String? = null,
) {
val bookColorTheme = rememberBookInfoColorTheme(state.book)
@@ -117,16 +122,22 @@ fun BookInfoScreen(
state = state,
onIntent = onIntent,
onBack = onBack,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
@Composable
private fun BookInfoScreenContent(
state: BookInfoUiState,
onIntent: (BookInfoIntent) -> Unit,
onBack: () -> Unit,
sharedTransitionScope: SharedTransitionScope?,
animatedVisibilityScope: AnimatedVisibilityScope?,
sharedCoverKey: String?,
) {
val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
val scrollBehavior = if (isMiuix) {
@@ -138,8 +149,6 @@ private fun BookInfoScreenContent(
val pullState = rememberPullToRefreshState()
var showMenu by rememberSaveable { mutableStateOf(false) }
BackHandler { onIntent(BookInfoIntent.BackPressed) }
AppScaffold(
modifier = Modifier
.fillMaxSize()
@@ -150,7 +159,7 @@ private fun BookInfoScreenContent(
showMenu = showMenu,
onShowMenuChange = { showMenu = it },
onMenuAction = { onIntent(BookInfoIntent.MenuAction(it)) },
onBackPressed = { onIntent(BookInfoIntent.BackPressed) },
onBackPressed = onBack,
scrollBehavior = scrollBehavior,
)
},
@@ -211,6 +220,9 @@ private fun BookInfoScreenContent(
onAuthorClick = { onIntent(BookInfoIntent.AuthorClick(it)) },
onBookNameClick = { onIntent(BookInfoIntent.BookNameClick(it)) },
onOriginClick = { onIntent(BookInfoIntent.OriginClick) },
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey,
)
}
item {
@@ -313,7 +325,7 @@ private fun BookInfoScreenContent(
)
}
BookInfoDialogs(state = state, onIntent = onIntent, onBack = onBack)
BookInfoDialogs(state = state, onIntent = onIntent)
}
@Composable
@@ -391,14 +403,27 @@ private fun BookInfoTransparentTopAppBar(
@Composable
private fun rememberBookInfoColorTheme(book: Book?): ThemeOverrideState? {
val imageLoader = koinInject<ImageLoader>()
val coverPath = book?.getDisplayCover()
val sourceOrigin = book?.origin
var shouldExtractColor by remember(book?.bookUrl) { mutableStateOf(false) }
LaunchedEffect(book?.bookUrl) {
shouldExtractColor = false
if (book != null) {
delay(520)
shouldExtractColor = true
}
}
val coverPath = if (shouldExtractColor) book?.getDisplayCover() else null
val sourceOrigin = if (shouldExtractColor) book?.origin else null
val loadOnlyWifi = CoverConfig.loadCoverOnlyWifi
val requestKey = remember(coverPath, sourceOrigin, loadOnlyWifi) {
listOf(coverPath, sourceOrigin, loadOnlyWifi)
}
val seedColor = rememberImageSeedColor(
imageLoader = imageLoader,
data = coverPath,
requestKey = listOf(coverPath, sourceOrigin, loadOnlyWifi),
requestKey = requestKey,
) {
setParameter("sourceOrigin", sourceOrigin)
setParameter("loadOnlyWifi", loadOnlyWifi)
@@ -449,6 +474,16 @@ private fun BookInfoBackdrop(book: Book) {
val loadOnlyWifi = CoverConfig.loadCoverOnlyWifi
val context = LocalContext.current
val imageLoader = koinInject<ImageLoader>()
var showBackdropImage by remember(cover) { mutableStateOf(false) }
LaunchedEffect(cover) {
showBackdropImage = false
if (!cover.isNullOrBlank()) {
delay(520)
showBackdropImage = true
}
}
val backdropRequest = remember(cover, sourceOrigin, loadOnlyWifi, context) {
buildCoverImageRequest(
context = context,
@@ -464,14 +499,15 @@ private fun BookInfoBackdrop(book: Book) {
0.42f
)
Box(modifier = Modifier.fillMaxSize()) {
if (!cover.isNullOrBlank()) {
if (!cover.isNullOrBlank() && showBackdropImage) {
AsyncImage(
model = backdropRequest,
imageLoader = imageLoader,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.blur(32.dp),
.fillMaxWidth()
.height(360.dp)
.blur(24.dp),
contentScale = ContentScale.Crop,
)
}
@@ -600,6 +636,9 @@ private fun BookInfoHeader(
onAuthorClick: (Boolean) -> Unit,
onBookNameClick: (Boolean) -> Unit,
onOriginClick: () -> Unit,
sharedTransitionScope: SharedTransitionScope?,
animatedVisibilityScope: AnimatedVisibilityScope?,
sharedCoverKey: String?,
) {
Column(
modifier = Modifier
@@ -631,12 +670,25 @@ private fun BookInfoHeader(
.width(112.dp)
.combinedClickable(onClick = onCoverClick, onLongClick = onCoverLongClick)
) {
val coverModifier = with(sharedTransitionScope) {
if (this != null && animatedVisibilityScope != null && sharedCoverKey != null) {
Modifier
.width(112.dp)
.sharedElement(
sharedContentState = rememberSharedContentState(sharedCoverKey),
animatedVisibilityScope = animatedVisibilityScope,
)
} else {
Modifier.width(112.dp)
}
}
CoilBookCover(
name = book.name,
author = book.author,
path = book.getDisplayCover(),
sourceOrigin = book.origin,
modifier = Modifier.width(112.dp)
modifier = coverModifier,
showLoadingPlaceholder = sharedCoverKey == null,
)
}
Column(
@@ -895,25 +947,11 @@ private fun BookInfoSummary(
private fun BookInfoDialogs(
state: BookInfoUiState,
onIntent: (BookInfoIntent) -> Unit,
onBack: () -> Unit,
) {
val dialog = state.dialog
var deleteOriginal by remember(dialog, state.deleteOriginal) { mutableStateOf(state.deleteOriginal) }
var remarkText by remember(dialog) { mutableStateOf((dialog as? BookInfoDialog.EditRemark)?.remark.orEmpty()) }
if (dialog is BookInfoDialog.AddToShelfOnBack) {
AppAlertDialog(
show = true,
onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) },
title = stringResource(R.string.add_to_bookshelf),
text = stringResource(R.string.check_add_bookshelf, state.book?.name.orEmpty()),
confirmText = stringResource(android.R.string.ok),
onConfirm = { onIntent(BookInfoIntent.ConfirmBackAddToShelf) },
dismissText = stringResource(android.R.string.cancel),
onDismiss = onBack,
)
}
if (dialog is BookInfoDialog.DeleteBook) {
AppAlertDialog(
show = true,
@@ -118,17 +118,22 @@ class BookInfoViewModel(
clearReadRecordObserve()
_uiState.value = BookInfoUiState()
execute {
appDb.bookDao.getBook(bookUrl)?.let {
val book = appDb.bookDao.getBook(bookUrl)?.let {
inBookshelf = !it.isNotShelf
return@execute it
}
appDb.searchBookDao.getSearchBook(bookUrl)?.toBook()?.let {
it
} ?: appDb.searchBookDao.getSearchBook(bookUrl)?.toBook()?.let {
inBookshelf = false
return@execute it
it
} ?: throw NoStackTraceException("未找到书籍")
val source = if (book.isLocal) {
null
} else {
appDb.bookSourceDao.getBookSource(book.origin)
}
throw NoStackTraceException("未找到书籍")
book to source
}.onSuccess {
upBook(it)
upBook(it.first, it.second)
}.onError {
context.toastOnUi(it.localizedMessage ?: "未找到书籍")
emitEffect(BookInfoEffect.Finish(afterTransition = true))
@@ -137,7 +142,6 @@ class BookInfoViewModel(
fun onIntent(intent: BookInfoIntent) {
when (intent) {
BookInfoIntent.BackPressed -> onBackPressed()
BookInfoIntent.DismissSheet -> dismissSheet()
BookInfoIntent.DismissDialog -> dismissDialog()
is BookInfoIntent.MenuAction -> handleMenuAction(intent.action)
@@ -159,13 +163,6 @@ class BookInfoViewModel(
BookInfoIntent.ChangeSourceClick -> setSheet(BookInfoSheet.SourcePicker)
BookInfoIntent.ReadRecordClick -> setSheet(BookInfoSheet.ReadRecord)
BookInfoIntent.RemarkClick -> showDialog(BookInfoDialog.EditRemark(currentBook?.remark))
BookInfoIntent.ConfirmBackAddToShelf -> {
dismissDialog()
addToBookshelf {
emitEffect(BookInfoEffect.Finish(afterTransition = true))
}
}
is BookInfoIntent.ConfirmDelete -> {
dismissDialog()
deleteBook(intent.deleteOriginal)
@@ -246,7 +243,17 @@ class BookInfoViewModel(
fun onInfoEdited() {
currentBook?.bookUrl?.let { bookUrl ->
appDb.bookDao.getBook(bookUrl)?.let { upBook(it) }
execute {
val book = appDb.bookDao.getBook(bookUrl) ?: return@execute null
val source = if (book.isLocal) {
null
} else {
appDb.bookSourceDao.getBookSource(book.origin)
}
book to source
}.onSuccess {
it?.let { (book, source) -> upBook(book, source) }
}
}
}
@@ -651,18 +658,17 @@ class BookInfoViewModel(
}
}
private fun upBook(book: Book) {
private fun upBook(book: Book, source: BookSource?) {
currentBook = book
currentChapterList = emptyList()
currentWebFiles = emptyList()
currentKindLabels = emptyList()
currentGroupNames = null
currentHasCustomGroup = false
bookSource = source
syncUiState(isTocLoading = true)
refreshMeta(book)
upCoverByRule(book)
bookSource = if (book.isLocal) null else appDb.bookSourceDao.getBookSource(book.origin)
syncUiState(isTocLoading = true)
if (book.tocUrl.isEmpty() && !book.isLocal) {
loadBookInfo(book, runPreUpdateJs = inBookshelf)
} else {
@@ -801,14 +807,6 @@ class BookInfoViewModel(
}
}
private fun onBackPressed() {
if (!inBookshelf && AppConfig.showAddToShelfAlert && currentBook != null) {
showDialog(BookInfoDialog.AddToShelfOnBack)
} else {
emitEffect(BookInfoEffect.Finish(afterTransition = true))
}
}
private fun onReadClick() {
val book = currentBook ?: return
if (book.isWebFile) {
@@ -0,0 +1,3 @@
package io.legado.app.ui.main
fun bookCoverSharedElementKey(bookUrl: String): String = "book-cover:$bookUrl"
@@ -6,6 +6,8 @@ import android.content.res.Configuration
import android.os.Bundle
import android.text.format.DateUtils
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
@@ -24,6 +26,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.LocalNavAnimatedContentScope
import androidx.navigation3.ui.NavDisplay
import io.legado.app.BuildConfig
import io.legado.app.R
@@ -340,6 +343,7 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
routeEvents.tryEmit(resolveStartRoute(intent))
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
override fun Content() {
val orientation = resources.configuration.orientation
@@ -362,9 +366,10 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
}
}
NavDisplay(
backStack = backStack,
transitionSpec = {
SharedTransitionLayout {
NavDisplay(
backStack = backStack,
transitionSpec = {
(slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Start,
animationSpec = tween(
@@ -441,7 +446,7 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
finish()
}
},
entryProvider = entryProvider {
entryProvider = entryProvider {
entry<MainRouteHome> {
MainScreen(
useRail = useRail,
@@ -508,7 +513,9 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
openUrl = openUrl
)
)
}
},
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = LocalNavAnimatedContentScope.current,
)
}
@@ -671,6 +678,9 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
onOpenSearch = { keyword ->
navigateToRoute(backStack, MainRouteSearch(key = keyword))
},
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = LocalNavAnimatedContentScope.current,
sharedCoverKey = bookCoverSharedElementKey(route.bookUrl),
onRegisterVariableSetter = { setter ->
bookInfoVariableSetter = setter
}
@@ -695,8 +705,9 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
}
)
}
}
)
}
)
}
}
private fun navigateToRoute(backStack: MutableList<NavKey>, route: NavKey) {
@@ -2,6 +2,7 @@ package io.legado.app.ui.main
import androidx.annotation.StringRes
import io.legado.app.R
import kotlinx.collections.immutable.persistentListOf
sealed class MainDestination(
val route: String,
@@ -28,6 +29,6 @@ sealed class MainDestination(
)
companion object {
val mainDestinations = listOf(Bookshelf, Explore, Rss, My)
val mainDestinations = persistentListOf<MainDestination>(Bookshelf, Explore, Rss, My)
}
}
@@ -1,6 +1,13 @@
package io.legado.app.ui.main
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.activity.ComponentActivity
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
@@ -74,13 +81,18 @@ import io.legado.app.ui.widget.components.icon.AppIcons
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.utils.sendToClip
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.startActivityForBook
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.androidx.compose.koinViewModel
@OptIn(
ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class,
ExperimentalFoundationApi::class
ExperimentalFoundationApi::class, ExperimentalSharedTransitionApi::class
)
@Composable
fun MainScreen(
@@ -95,14 +107,46 @@ fun MainScreen(
onNavigateToBookInfo: (name: String, author: String, bookUrl: String) -> Unit,
onNavigateToExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
onNavigateToRssSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit,
onNavigateToRssRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit
onNavigateToRssRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val mainUiState by viewModel.uiState.collectAsStateWithLifecycle()
val bookshelfViewModel: BookshelfViewModel = koinViewModel()
val bookshelfGroupState by bookshelfViewModel.groupSelectorState.collectAsStateWithLifecycle()
LaunchedEffect(viewModel, context) {
viewModel.effects.collect { effect ->
when (effect) {
is MainEffect.OpenUrl -> {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(effect.url))
)
}
is MainEffect.CopyUrl -> context.sendToClip(effect.url)
is MainEffect.ShowMarkdown -> {
val activity = context as? AppCompatActivity ?: return@collect
val title = effect.title.ifBlank { context.getString(R.string.help) }
val mdText = withContext(Dispatchers.IO) {
context.assets
.open("web/help/md/${effect.path}.md")
.bufferedReader()
.use { it.readText() }
}
activity.showDialogFragment(TextDialog(title, mdText, TextDialog.Mode.MD))
}
is MainEffect.StartActivity -> {
context.startActivity(Intent(context, effect.destination).apply {
effect.configTag?.let { putExtra("configTag", it) }
})
}
MainEffect.ExitApp -> (context as? ComponentActivity)?.finish()
}
}
}
val hazeState = remember { HazeState() }
val floatingBarSurfaceColor = MaterialTheme.colorScheme.surface
@@ -213,34 +257,15 @@ fun MainScreen(
)
if (destination == MainDestination.Bookshelf && showGroupMenu) {
RoundDropdownMenu(
BookshelfRailGroupMenu(
expanded = showGroupMenu,
onDismissRequest = { showGroupMenu = false }
) { dismiss ->
bookshelfGroupState.groups.forEachIndexed { groupIndex, group ->
RoundDropdownMenuItem(
text = group.groupName,
onClick = {
coroutineScope.launch {
if (pagerState.currentPage != index) {
pagerState.scrollToPage(index)
}
bookshelfViewModel.changeGroup(group.groupId)
dismiss()
}
},
trailingIcon = {
if (bookshelfGroupState.selectedGroupIndex == groupIndex) {
Icon(
Icons.Default.Check,
null,
modifier = Modifier.size(18.dp)
)
}
}
)
onDismissRequest = { showGroupMenu = false },
onBeforeSelectGroup = {
if (pagerState.currentPage != index) {
pagerState.scrollToPage(index)
}
}
}
)
}
}
},
@@ -372,7 +397,9 @@ fun MainScreen(
onNavigateToSearch = { query -> onNavigateToSearch(query) },
onNavigateToRemoteImport = onNavigateToRemoteImport,
onNavigateToLocalImport = onNavigateToLocalImport,
onNavigateToCache = onNavigateToCache
onNavigateToCache = onNavigateToCache,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
MainDestination.Explore -> ExploreScreen(
@@ -392,7 +419,7 @@ fun MainScreen(
if (event == PrefClickEvent.OpenBookCacheManage) {
onNavigateToBookCacheManage()
} else {
viewModel.onPrefClickEvent(context, event)
viewModel.onPrefClickEvent(event)
}
}
)
@@ -403,6 +430,44 @@ fun MainScreen(
}
}
@Composable
private fun BookshelfRailGroupMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
onBeforeSelectGroup: suspend () -> Unit,
viewModel: BookshelfViewModel = koinViewModel()
) {
val groupState by viewModel.groupSelectorState.collectAsStateWithLifecycle()
val coroutineScope = rememberCoroutineScope()
RoundDropdownMenu(
expanded = expanded,
onDismissRequest = onDismissRequest
) { dismiss ->
groupState.groups.forEachIndexed { groupIndex, group ->
RoundDropdownMenuItem(
text = group.groupName,
onClick = {
coroutineScope.launch {
onBeforeSelectGroup()
viewModel.changeGroup(group.groupId)
dismiss()
}
},
trailingIcon = {
if (groupState.selectedGroupIndex == groupIndex) {
Icon(
Icons.Default.Check,
null,
modifier = Modifier.size(18.dp)
)
}
}
)
}
}
}
@Composable
private fun NavigationIcon(
destination: MainDestination,
@@ -1,10 +1,7 @@
package io.legado.app.ui.main
import android.app.Application
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import androidx.appcompat.app.AppCompatActivity
import io.legado.app.base.BaseViewModel
import io.legado.app.constant.PreferKey
import io.legado.app.constant.EventBus
@@ -12,16 +9,17 @@ import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase
import io.legado.app.domain.usecase.WebDavBackupUseCase
import io.legado.app.ui.config.mainConfig.MainConfig
import io.legado.app.ui.main.my.PrefClickEvent
import io.legado.app.ui.widget.dialog.TextDialog
import io.legado.app.utils.defaultSharedPreferences
import io.legado.app.utils.eventBus.FlowEventBus
import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.getPrefString
import io.legado.app.utils.sendToClip
import io.legado.app.utils.showDialogFragment
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
class MainViewModel(
application: Application,
@@ -49,6 +47,8 @@ class MainViewModel(
private val _uiState = MutableStateFlow(readMainUiState())
val uiState = _uiState.asStateFlow()
private val _effects = MutableSharedFlow<MainEffect>(extraBufferCapacity = 8)
val effects = _effects.asSharedFlow()
init {
prefs.registerOnSharedPreferenceChangeListener(preferenceListener)
@@ -90,35 +90,27 @@ class MainViewModel(
MainConfig.navExtended = expanded
}
fun onPrefClickEvent(context: Context, event: PrefClickEvent) {
fun onPrefClickEvent(event: PrefClickEvent) {
when (event) {
is PrefClickEvent.OpenUrl -> context.startActivity(
Intent(
Intent.ACTION_VIEW,
android.net.Uri.parse(event.url)
is PrefClickEvent.OpenUrl -> _effects.tryEmit(MainEffect.OpenUrl(event.url))
is PrefClickEvent.CopyUrl -> _effects.tryEmit(MainEffect.CopyUrl(event.url))
is PrefClickEvent.ShowMd -> _effects.tryEmit(
MainEffect.ShowMarkdown(
title = event.title,
path = event.path
)
)
is PrefClickEvent.CopyUrl -> context.sendToClip(event.url)
is PrefClickEvent.ShowMd -> {
if (context is AppCompatActivity) {
val title = event.title.ifBlank { context.getString(io.legado.app.R.string.help) }
val mdText = String(context.assets.open("web/help/md/${event.path}.md").readBytes())
context.showDialogFragment(TextDialog(title, mdText, TextDialog.Mode.MD))
}
}
is PrefClickEvent.StartActivity -> {
context.startActivity(Intent(context, event.destination).apply {
event.configTag?.let { putExtra("configTag", it) }
})
_effects.tryEmit(
MainEffect.StartActivity(
destination = event.destination,
configTag = event.configTag
)
)
}
PrefClickEvent.ExitApp -> {
if (context is androidx.activity.ComponentActivity) {
context.finish()
}
}
PrefClickEvent.ExitApp -> _effects.tryEmit(MainEffect.ExitApp)
else -> Unit
}
@@ -126,8 +118,20 @@ class MainViewModel(
}
sealed interface MainEffect {
data class OpenUrl(val url: String) : MainEffect
data class CopyUrl(val url: String) : MainEffect
data class ShowMarkdown(val title: String, val path: String) : MainEffect
data class StartActivity(
val destination: Class<*>,
val configTag: String? = null
) : MainEffect
data object ExitApp : MainEffect
}
data class MainUiState(
val destinations: List<MainDestination> = MainDestination.mainDestinations,
val destinations: ImmutableList<MainDestination> = MainDestination.mainDestinations,
val defaultHomePage: String = "bookshelf",
val showBottomView: Boolean = true,
val useFloatingBottomBar: Boolean = false,
@@ -147,7 +151,7 @@ private fun MainViewModel.readMainUiState(): MainUiState {
MainDestination.Rss -> showRss
else -> true
}
}
}.toImmutableList()
return MainUiState(
destinations = destinations,
defaultHomePage = context.getPrefString(PreferKey.defaultHomePage, "bookshelf")
@@ -1,5 +1,8 @@
package io.legado.app.ui.main.bookshelf
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
@@ -389,6 +392,7 @@ fun BookGroupItemList(
)
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun BookItem(
book: BookShelfItem,
@@ -404,6 +408,9 @@ fun BookItem(
coverShadow: Boolean = false,
isSearchMode: Boolean = false,
searchKey: String = "",
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKey: String? = null,
onClick: () -> Unit,
onLongClick: (() -> Unit)?
) {
@@ -447,15 +454,28 @@ fun BookItem(
}
} else null,
cover = { modifier ->
val coverModifier = with(sharedTransitionScope) {
if (this != null && animatedVisibilityScope != null && sharedCoverKey != null) {
Modifier.fillMaxWidth().sharedElement(
sharedContentState = rememberSharedContentState(sharedCoverKey),
animatedVisibilityScope = animatedVisibilityScope,
)
} else {
Modifier.fillMaxWidth()
}
}
BookshelfCover(
name = book.name,
author = book.author,
path = book.getDisplayCover(),
isUpdating = isUpdating,
modifier = modifier,
coverModifier = coverModifier,
sourceOrigin = book.origin,
badgeText = if (layoutMode != 0) unreadText else null,
showBadgeDot = BookshelfConfig.showUnread && BookshelfConfig.showUnreadNew && book.isNew,
leftBottomText = matchedSourceLabel ?: bookTypeLabel
leftBottomText = matchedSourceLabel ?: bookTypeLabel,
showLoadingPlaceholder = sharedCoverKey == null,
)
},
title = book.name,
@@ -10,6 +10,7 @@ data class BookShelfItem(
val bookUrl: String,
val name: String,
val author: String,
val origin: String,
val originName: String,
val coverUrl: String?,
val customCoverUrl: String?,
@@ -44,6 +45,7 @@ data class BookShelfItem(
fun BookShelfItem.toLightBook() = Book(
bookUrl = bookUrl,
origin = origin,
originName = originName,
name = name,
author = author,
@@ -7,6 +7,9 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.clickable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
@@ -77,6 +80,7 @@ import io.legado.app.data.entities.BookGroup
import io.legado.app.ui.about.AppLogSheet
import io.legado.app.ui.book.info.GroupSelectSheet
import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
import io.legado.app.ui.main.bookCoverSharedElementKey
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.theme.adaptiveContentPadding
@@ -110,7 +114,7 @@ import sh.calvin.reorderable.rememberReorderableLazyGridState
@OptIn(
ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class,
ExperimentalMaterial3ExpressiveApi::class
ExperimentalMaterial3ExpressiveApi::class, ExperimentalSharedTransitionApi::class
)
@Composable
fun BookshelfScreen(
@@ -120,7 +124,9 @@ fun BookshelfScreen(
onNavigateToSearch: (String) -> Unit,
onNavigateToRemoteImport: () -> Unit,
onNavigateToLocalImport: () -> Unit,
onNavigateToCache: (Long) -> Unit
onNavigateToCache: (Long) -> Unit,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
@@ -260,10 +266,6 @@ fun BookshelfScreen(
clearSelection()
}
LaunchedEffect(uiState.items) {
viewModel.pruneSelectionToVisible(uiState.items)
}
BackHandler(enabled = isEditMode) {
if (selectedBookUrls.isNotEmpty()) {
clearSelection()
@@ -722,7 +724,9 @@ fun BookshelfScreen(
onSyncDragState = { _, _ -> },
onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) },
onBookClick = onBookClick,
onBookLongClick = onBookLongClick
onBookLongClick = onBookLongClick,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
} else {
HorizontalPager(
@@ -741,7 +745,8 @@ fun BookshelfScreen(
}
val canReorderBooks = isEditMode &&
!uiState.isSearch &&
group.getRealBookSort() == 3 &&
(group.bookSort.takeIf { it >= 0 }
?: uiState.bookshelfSort) == 3 &&
isSelectedGroup
BookshelfPage(
paddingValues = paddingValues,
@@ -782,7 +787,9 @@ fun BookshelfScreen(
},
onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) },
onBookClick = onBookClick,
onBookLongClick = onBookLongClick
onBookLongClick = onBookLongClick,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
}
}
@@ -1030,7 +1037,9 @@ fun BookshelfPage(
onSyncDragState: (books: List<BookShelfItem>, canReorderBooks: Boolean) -> Unit,
onGlobalSearch: () -> Unit,
onBookClick: (BookShelfItem) -> Unit,
onBookLongClick: (BookShelfItem) -> Unit
onBookLongClick: (BookShelfItem) -> Unit,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) {
if (books.isEmpty()) {
if (uiState.isSearch) {
@@ -1135,6 +1144,9 @@ fun BookshelfPage(
coverShadow = BookshelfConfig.bookshelfCoverShadow,
isSearchMode = uiState.isSearch,
searchKey = uiState.searchKey,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl),
onClick = {
if (isEditMode) {
onToggleBookSelection(book)
@@ -2,9 +2,15 @@ package io.legado.app.ui.main.bookshelf
import io.legado.app.data.entities.BookGroup
import io.legado.app.ui.widget.components.list.ListUiState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.persistentSetOf
data class BookshelfGroupSelectorState(
val groups: List<BookGroup> = emptyList(),
val groups: ImmutableList<BookGroup> = persistentListOf(),
val selectedGroupIndex: Int = 0,
val selectedGroupId: Long = BookGroup.IdAll
)
@@ -22,31 +28,33 @@ sealed interface BookshelfOverlay {
}
data class BookshelfUiState(
override val items: List<BookShelfItem> = emptyList(),
override val selectedIds: Set<Any> = emptySet(),
override val items: ImmutableList<BookShelfItem> = persistentListOf(),
override val selectedIds: ImmutableSet<Any> = persistentSetOf(),
override val searchKey: String = "",
override val isSearch: Boolean = false,
override val isLoading: Boolean = false,
val groups: List<BookGroup> = emptyList(),
val allGroups: List<BookGroup> = emptyList(),
val groupPreviews: Map<Long, List<BookShelfItem>> = emptyMap(),
val groupBookCounts: Map<Long, Int> = emptyMap(),
val groups: ImmutableList<BookGroup> = persistentListOf(),
val allGroups: ImmutableList<BookGroup> = persistentListOf(),
val groupPreviews: ImmutableMap<Long, ImmutableList<BookShelfItem>> = persistentMapOf(),
val groupBookCounts: ImmutableMap<Long, Int> = persistentMapOf(),
val currentGroupBookCount: Int = 0,
val allBooksCount: Int = 0,
val selectedGroupIndex: Int = 0,
val selectedGroupId: Long = BookGroup.IdAll,
val loadingText: String? = null,
val upBooksCount: Int = 0,
val updatingBooks: Set<String> = emptySet(),
val updatingBooks: ImmutableSet<String> = persistentSetOf(),
val activeOverlay: BookshelfOverlay? = null,
val isEditMode: Boolean = false,
val selectedBookUrls: Set<String> = emptySet(),
val selectedBookUrls: ImmutableSet<String> = persistentSetOf(),
val isInFolderRoot: Boolean = false,
val isRefreshing: Boolean = false,
val bookGroupStyle: Int = 0,
val bookshelfSort: Int = 0,
val bookshelfSortOrder: Int = 1,
val title: String = "",
val subtitle: String? = null,
val currentGroupName: String? = null,
val draggingBooks: List<BookShelfItem>? = null,
val pendingSavedBooks: List<BookShelfItem>? = null
val draggingBooks: ImmutableList<BookShelfItem>? = null,
val pendingSavedBooks: ImmutableList<BookShelfItem>? = null
) : ListUiState<BookShelfItem>
@@ -1,6 +1,5 @@
package io.legado.app.ui.main.bookshelf
import kotlinx.coroutines.flow.flow
import android.app.Application
import android.net.Uri
import androidx.compose.runtime.snapshotFlow
@@ -54,11 +53,17 @@ import io.legado.app.utils.postEvent
import io.legado.app.utils.printOnDebug
import io.legado.app.utils.readText
import io.legado.app.utils.toastOnUi
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
@@ -71,9 +76,8 @@ import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.isActive
@@ -101,7 +105,6 @@ class BookshelfViewModel(
private val groupIdFlow = MutableStateFlow(BookshelfConfig.saveTabPosition)
private val searchKeyFlow = MutableStateFlow("")
private val searchModeFlow = MutableStateFlow(false)
private val refreshTrigger = MutableStateFlow(0)
private val loadingTextFlow = MutableStateFlow<String?>(null)
private val activeOverlayFlow = MutableStateFlow<BookshelfOverlay?>(null)
private val isEditModeFlow = MutableStateFlow(false)
@@ -112,7 +115,23 @@ class BookshelfViewModel(
private val draggingBooksFlow = MutableStateFlow<List<BookShelfItem>?>(null)
private val pendingSavedBooksFlow = MutableStateFlow<List<BookShelfItem>?>(null)
private data class BookshelfSortConfig(
val sort: Int,
val sortOrder: Int
)
private fun readSortConfig() = BookshelfSortConfig(
sort = BookshelfConfig.bookshelfSort,
sortOrder = BookshelfConfig.bookshelfSortOrder
)
private val sortConfigFlow: StateFlow<BookshelfSortConfig> = snapshotFlow {
readSortConfig()
}.distinctUntilChanged()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), readSortConfig())
// 更新相关
private val updateQueueLock = Any()
private val waitUpTocBooks = LinkedList<String>()
private val onUpTocBooks = ConcurrentHashMap.newKeySet<String>()
private val updatingBooksFlow = MutableStateFlow<Set<String>>(emptySet())
@@ -144,8 +163,8 @@ class BookshelfViewModel(
.flowOn(Dispatchers.Default)
private data class GroupPreviewState(
val previews: Map<Long, List<BookShelfItem>>,
val counts: Map<Long, Int>,
val previews: ImmutableMap<Long, ImmutableList<BookShelfItem>>,
val counts: ImmutableMap<Long, Int>,
val allBookCount: Int
)
@@ -154,7 +173,7 @@ class BookshelfViewModel(
groupIdFlow
) { groups, selectedGroupId ->
BookshelfGroupSelectorState(
groups = groups,
groups = groups.toImmutableList(),
selectedGroupIndex = groups.indexOfFirst { it.groupId == selectedGroupId }
.coerceAtLeast(0),
selectedGroupId = selectedGroupId
@@ -163,22 +182,45 @@ class BookshelfViewModel(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), BookshelfGroupSelectorState())
@OptIn(ExperimentalCoroutinesApi::class)
val booksFlow = combine(groupIdFlow, refreshTrigger) { groupId, _ -> groupId }
val booksFlow = groupIdFlow
.flatMapLatest { groupId ->
combine(
appDb.bookDao.flowBookShelfByGroup(groupId),
groupsFlow
) { list, groups ->
groupsFlow,
sortConfigFlow
) { list, groups, sortConfig ->
sortBooks(
list,
groups.find { it.groupId == groupId }
groups.find { it.groupId == groupId },
sortConfig
)
}
}.distinctUntilChanged().flowOn(Dispatchers.Default)
private val visibleBooksFlow = combine(
booksFlow,
searchKeyFlow,
searchModeFlow
) { books, searchKey, isSearchMode ->
filterBooks(books, searchKey, isSearchMode)
}.distinctUntilChanged()
private val selectedVisibleBookUrlsFlow = combine(
selectedBookUrlsFlow,
visibleBooksFlow
) { selectedBookUrls, visibleBooks ->
val visibleBookUrls = visibleBooks.mapTo(hashSetOf()) { it.bookUrl }
selectedBookUrls.intersect(visibleBookUrls)
}.distinctUntilChanged()
private val groupPreviewsFlow =
combine(groupsFlow, allBooksFlow, refreshTrigger, bookGroupStyleFlow) { groups, allBooks, _, bookGroupStyle ->
buildGroupPreviewState(groups, allBooks, bookGroupStyle)
combine(
groupsFlow,
allBooksFlow,
bookGroupStyleFlow,
sortConfigFlow
) { groups, allBooks, bookGroupStyle, sortConfig ->
buildGroupPreviewState(groups, allBooks, bookGroupStyle, sortConfig)
}.distinctUntilChanged().flowOn(Dispatchers.Default)
private val coreInternalStateFlow = combine(
@@ -188,23 +230,36 @@ class BookshelfViewModel(
loadingTextFlow,
updatingBooksFlow
) { groupId, searchKey, isSearchMode, loadingText, updatingBooks ->
InternalState(groupId, searchKey, isSearchMode, loadingText, updatingBooks, 0)
InternalState(
groupId = groupId,
searchKey = searchKey,
isSearchMode = isSearchMode,
loadingText = loadingText,
updatingBooks = updatingBooks,
upBooksCount = 0,
sortConfig = readSortConfig()
)
}
private val internalStateFlow = combine(
coreInternalStateFlow,
upBooksCountFlow
) { core, upBooksCount ->
core.copy(upBooksCount = upBooksCount)
upBooksCountFlow,
sortConfigFlow
) { core, upBooksCount, sortConfig ->
core.copy(
upBooksCount = upBooksCount,
sortConfig = sortConfig
)
}
data class InternalState(
private data class InternalState(
val groupId: Long,
val searchKey: String,
val isSearchMode: Boolean,
val loadingText: String?,
val updatingBooks: Set<String>,
val upBooksCount: Int
val upBooksCount: Int,
val sortConfig: BookshelfSortConfig
)
data class BookshelfInteractionState(
@@ -221,7 +276,7 @@ class BookshelfViewModel(
private val editStateFlow = combine(
activeOverlayFlow,
isEditModeFlow,
selectedBookUrlsFlow,
selectedVisibleBookUrlsFlow,
isInFolderRootFlow
) { activeOverlay, isEditMode, selectedBookUrls, isInFolderRoot ->
EditState(activeOverlay, isEditMode, selectedBookUrls, isInFolderRoot)
@@ -280,11 +335,7 @@ class BookshelfViewModel(
val allGroups = data.allGroups
val previews = data.previews
val internal = data.internal
val filteredBooks = if (!internal.isSearchMode || internal.searchKey.isBlank()) {
books
} else {
books.filter { it.matchesSearchKey(internal.searchKey) }
}
val filteredBooks = filterBooks(books, internal.searchKey, internal.isSearchMode)
val selectedGroupIndex = groups.indexOfFirst { it.groupId == internal.groupId }
.coerceAtLeast(0)
val currentGroupName = allGroups.firstOrNull { it.groupId == internal.groupId }?.groupName
@@ -300,10 +351,10 @@ class BookshelfViewModel(
)
BookshelfUiState(
items = filteredBooks,
selectedIds = selectedIds,
groups = groups,
allGroups = allGroups,
items = filteredBooks.toImmutableList(),
selectedIds = selectedIds.toImmutableSet(),
groups = groups.toImmutableList(),
allGroups = allGroups.toImmutableList(),
groupPreviews = previews.previews,
groupBookCounts = previews.counts,
currentGroupBookCount = books.size,
@@ -315,13 +366,15 @@ class BookshelfViewModel(
isLoading = internal.loadingText != null,
loadingText = internal.loadingText,
upBooksCount = internal.upBooksCount,
updatingBooks = internal.updatingBooks,
updatingBooks = internal.updatingBooks.toImmutableSet(),
activeOverlay = interaction.activeOverlay,
isEditMode = interaction.isEditMode,
selectedBookUrls = interaction.selectedBookUrls,
selectedBookUrls = interaction.selectedBookUrls.toImmutableSet(),
isInFolderRoot = interaction.isInFolderRoot,
isRefreshing = interaction.isRefreshing,
bookGroupStyle = interaction.bookGroupStyle,
bookshelfSort = internal.sortConfig.sort,
bookshelfSortOrder = internal.sortConfig.sortOrder,
title = title,
subtitle = if (interaction.isEditMode) {
context.getString(R.string.bookshelf_total_count, previews.allBookCount)
@@ -329,8 +382,8 @@ class BookshelfViewModel(
null
},
currentGroupName = currentGroupName,
draggingBooks = interaction.draggingBooks,
pendingSavedBooks = interaction.pendingSavedBooks
draggingBooks = interaction.draggingBooks?.toImmutableList(),
pendingSavedBooks = interaction.pendingSavedBooks?.toImmutableList()
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), BookshelfUiState())
@@ -340,29 +393,19 @@ class BookshelfViewModel(
upAllBookToc()
}
}
viewModelScope.launch {
FlowEventBus.with<Unit>(EventBus.BOOKSHELF_REFRESH).collect {
refresh()
}
}
// 监听排序配置变化,触发刷新
viewModelScope.launch {
snapshotFlow { BookshelfConfig.bookshelfSort }.collect { refresh() }
}
viewModelScope.launch {
snapshotFlow { BookshelfConfig.bookshelfSortOrder }.collect { refresh() }
}
viewModelScope.launch {
snapshotFlow { BookshelfConfig.bookGroupStyle }
.distinctUntilChanged()
.collect { style ->
updateBookGroupStyle(style)
refresh()
}
}
viewModelScope.launch {
snapshotFlow { BookshelfConfig.showWaitUpCount }.collect { postUpBooksCount() }
snapshotFlow { BookshelfConfig.showWaitUpCount }
.distinctUntilChanged()
.collect {
postUpBooksCount()
}
}
if (BookshelfConfig.autoRefreshBook) {
@@ -370,9 +413,29 @@ class BookshelfViewModel(
}
}
private fun sortBooks(list: List<BookShelfItem>, group: BookGroup?): List<BookShelfItem> {
val bookSort = group?.getRealBookSort() ?: BookshelfConfig.bookshelfSort
val isDescending = BookshelfConfig.bookshelfSortOrder == 1
private fun filterBooks(
books: List<BookShelfItem>,
searchKey: String,
isSearchMode: Boolean
): List<BookShelfItem> {
return if (!isSearchMode || searchKey.isBlank()) {
books
} else {
books.filter { it.matchesSearchKey(searchKey) }
}
}
private fun sortBooks(
list: List<BookShelfItem>,
group: BookGroup?,
sortConfig: BookshelfSortConfig
): List<BookShelfItem> {
val bookSort = if (group != null && group.bookSort >= 0) {
group.bookSort
} else {
sortConfig.sort
}
val isDescending = sortConfig.sortOrder == 1
return when (bookSort) {
1 -> if (isDescending) list.sortedByDescending { it.latestChapterTime }
@@ -435,10 +498,11 @@ class BookshelfViewModel(
private fun buildGroupPreviewState(
groups: List<BookGroup>,
allBooks: List<BookShelfItem>,
bookGroupStyle: Int
bookGroupStyle: Int,
sortConfig: BookshelfSortConfig
): GroupPreviewState {
if (bookGroupStyle !in 2..3) {
return GroupPreviewState(emptyMap(), emptyMap(), allBooks.size)
return GroupPreviewState(persistentMapOf(), persistentMapOf(), allBooks.size)
}
val buckets = HashMap<Long, MutableList<BookShelfItem>>(groups.size)
@@ -482,14 +546,14 @@ class BookshelfViewModel(
}
}
val previews = HashMap<Long, List<BookShelfItem>>(groups.size)
val previews = HashMap<Long, ImmutableList<BookShelfItem>>(groups.size)
val counts = HashMap<Long, Int>(groups.size)
groups.forEach { group ->
val groupBooks = buckets[group.groupId].orEmpty()
counts[group.groupId] = groupBooks.size
previews[group.groupId] = buildGroupPreview(sortBooks(groupBooks, group))
previews[group.groupId] = buildGroupPreview(sortBooks(groupBooks, group, sortConfig))
}
return GroupPreviewState(previews, counts, allBooks.size)
return GroupPreviewState(previews.toImmutableMap(), counts.toImmutableMap(), allBooks.size)
}
private fun BookShelfItem.isRootGroupBook(sumUserGroupIds: Long): Boolean {
@@ -509,13 +573,13 @@ class BookshelfViewModel(
(sumUserGroupIds and group) == 0L
}
private fun buildGroupPreview(sortedBooks: List<BookShelfItem>): List<BookShelfItem> {
private fun buildGroupPreview(sortedBooks: List<BookShelfItem>): ImmutableList<BookShelfItem> {
val booksWithCover = sortedBooks.filter { it.getDisplayCover() != null }
return if (booksWithCover.size >= 4) {
booksWithCover.take(4)
} else {
(booksWithCover + sortedBooks.filter { it.getDisplayCover() == null }).take(4)
}
}.toImmutableList()
}
fun changeGroup(groupId: Long) {
@@ -539,10 +603,6 @@ class BookshelfViewModel(
clearSelection()
}
fun refresh() {
refreshTrigger.value++
}
fun showOverlay(overlay: BookshelfOverlay) {
activeOverlayFlow.value = overlay
}
@@ -590,11 +650,6 @@ class BookshelfViewModel(
}
}
fun pruneSelectionToVisible(books: List<BookShelfItem>) {
val visibleBookUrls = books.mapTo(hashSetOf()) { it.bookUrl }
selectedBookUrlsFlow.value = selectedBookUrlsFlow.value.intersect(visibleBookUrls)
}
fun setInFolderRoot(isInFolderRoot: Boolean) {
if (isInFolderRootFlow.value != isInFolderRoot) {
isInFolderRootFlow.value = isInFolderRoot
@@ -738,55 +793,95 @@ class BookshelfViewModel(
}
}
@Synchronized
private fun addToWaitUp(books: List<Book>) {
books.forEach { book ->
if (!waitUpTocBooks.contains(book.bookUrl) && !onUpTocBooks.contains(book.bookUrl)) {
waitUpTocBooks.add(book.bookUrl)
synchronized(updateQueueLock) {
books.forEach { book ->
if (!waitUpTocBooks.contains(book.bookUrl) &&
!onUpTocBooks.contains(book.bookUrl)
) {
waitUpTocBooks.add(book.bookUrl)
}
}
if (upTocJob == null && waitUpTocBooks.isNotEmpty()) {
startUpTocJobLocked()
}
}
postUpBooksCount()
if (upTocJob == null) {
startUpTocJob()
}
}
private fun startUpTocJob() {
postUpBooksCount()
private fun startUpTocJobLocked() {
upTocJob = viewModelScope.launch(updateDispatcher) {
var completedWithoutFlowError = true
flow {
while (true) {
emit(waitUpTocBooks.poll() ?: break)
emit(pollWaitUpBookUrl() ?: break)
}
}.onEachParallel(updateConcurrency) {
onUpTocBooks.add(it)
updatingBooksFlow.value = onUpTocBooks.toSet()
postEvent(EventBus.UP_BOOKSHELF, it)
updateToc(it)
}.onEach {
onUpTocBooks.remove(it)
updatingBooksFlow.value = onUpTocBooks.toSet()
postEvent(EventBus.UP_BOOKSHELF, it)
postUpBooksCount()
}.onCompletion {
upTocJob = null
if (waitUpTocBooks.isNotEmpty()) {
startUpTocJob()
} else {
completeRefreshIfIdle()
}
if (it == null && cacheBookJob == null && !CacheBookService.isRun) {
cacheBook()
markBookUpdateStarted(it)
try {
postEvent(EventBus.UP_BOOKSHELF, it)
updateToc(it)
} finally {
markBookUpdateFinished(it)
}
}.catch {
completedWithoutFlowError = false
AppLog.put("更新目录出错\n${it.localizedMessage}", it)
}.collect()
finishUpTocJob(completedWithoutFlowError)
}
postUpBooksCount()
}
private fun pollWaitUpBookUrl(): String? = synchronized(updateQueueLock) {
waitUpTocBooks.poll()
}
private fun markBookUpdateStarted(bookUrl: String) {
synchronized(updateQueueLock) {
onUpTocBooks.add(bookUrl)
}
updatingBooksFlow.value = onUpTocBooksSnapshot()
}
private fun markBookUpdateFinished(bookUrl: String) {
synchronized(updateQueueLock) {
onUpTocBooks.remove(bookUrl)
}
updatingBooksFlow.value = onUpTocBooksSnapshot()
postEvent(EventBus.UP_BOOKSHELF, bookUrl)
postUpBooksCount()
}
private fun onUpTocBooksSnapshot(): Set<String> = synchronized(updateQueueLock) {
onUpTocBooks.toSet()
}
private fun finishUpTocJob(completedWithoutFlowError: Boolean) {
val restarted = synchronized(updateQueueLock) {
upTocJob = null
if (waitUpTocBooks.isNotEmpty()) {
startUpTocJobLocked()
true
} else {
false
}
}
if (!restarted) {
completeRefreshIfIdle()
}
if (!restarted && completedWithoutFlowError && cacheBookJob == null && !CacheBookService.isRun) {
cacheBook()
}
}
@Synchronized
private fun completeRefreshIfIdle() {
if (upTocJob == null && waitUpTocBooks.isEmpty() && onUpTocBooks.isEmpty()) {
val isIdle = synchronized(updateQueueLock) {
upTocJob == null && waitUpTocBooks.isEmpty() && onUpTocBooks.isEmpty()
}
if (isIdle) {
isRefreshingFlow.value = false
}
}
@@ -841,8 +936,13 @@ class BookshelfViewModel(
}
private fun postUpBooksCount() {
val count =
if (BookshelfConfig.showWaitUpCount) waitUpTocBooks.size + onUpTocBooks.size else 0
val count = if (BookshelfConfig.showWaitUpCount) {
synchronized(updateQueueLock) {
waitUpTocBooks.size + onUpTocBooks.size
}
} else {
0
}
upBooksCountFlow.value = count
}
@@ -868,7 +968,7 @@ class BookshelfViewModel(
cacheBookJob = viewModelScope.launch(updateDispatcher) {
launch {
while (isActive && CacheBook.isRun) {
CacheBook.setWorkingState(waitUpTocBooks.isEmpty() && onUpTocBooks.isEmpty())
CacheBook.setWorkingState(isUpdateQueueIdle())
delay(1000)
}
}
@@ -876,6 +976,10 @@ class BookshelfViewModel(
}
}
private fun isUpdateQueueIdle(): Boolean = synchronized(updateQueueLock) {
waitUpTocBooks.isEmpty() && onUpTocBooks.isEmpty()
}
fun addBookByUrl(bookUrls: String) {
var successCount = 0
loadingTextFlow.value = "添加中..."
@@ -41,6 +41,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
@@ -89,7 +90,10 @@ fun ExploreScreen(
val context = LocalContext.current
val activity = context as? AppCompatActivity
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var sourceToDelete by remember { mutableStateOf<BookSourcePart?>(null) }
var sourceToDeleteUrl by rememberSaveable { mutableStateOf<String?>(null) }
val sourceToDelete = remember(sourceToDeleteUrl, uiState.items) {
uiState.items.firstOrNull { it.bookSourceUrl == sourceToDeleteUrl }
}
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val exploreKindUseCase: ExploreKindUiUseCase = koinInject()
@@ -195,7 +199,13 @@ fun ExploreScreen(
) {
items(
items = uiState.listItems,
key = { it.key }
key = { it.key },
contentType = {
when (it) {
is ExploreListItem.Header -> "source-header"
is ExploreListItem.KindRow -> "kind-row"
}
}
) { listItem ->
when (listItem) {
is ExploreListItem.Header -> {
@@ -225,7 +235,7 @@ fun ExploreScreen(
}
},
onRefresh = { viewModel.refreshExploreKinds(item) },
onDelete = { sourceToDelete = item },
onDelete = { sourceToDeleteUrl = item.bookSourceUrl },
isMiuix = composeEngine
)
}
@@ -297,15 +307,15 @@ fun ExploreScreen(
AppAlertDialog(
data = sourceToDelete,
onDismissRequest = { sourceToDelete = null },
onDismissRequest = { sourceToDeleteUrl = null },
title = stringResource(R.string.sure_del),
confirmText = stringResource(android.R.string.ok),
onConfirm = { source ->
viewModel.deleteSource(source)
sourceToDelete = null
sourceToDeleteUrl = null
},
dismissText = stringResource(android.R.string.cancel),
onDismiss = { sourceToDelete = null },
onDismiss = { sourceToDeleteUrl = null },
)
}
@@ -12,7 +12,17 @@ import io.legado.app.help.source.getExploreInfoMap
import io.legado.app.ui.widget.components.explore.ExploreKindUiUseCase
import io.legado.app.ui.widget.components.explore.calculateExploreKindRows
import io.legado.app.ui.widget.components.list.ListUiState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -20,6 +30,10 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@@ -39,7 +53,8 @@ class ExploreViewModel(
private val _effects = MutableSharedFlow<ExploreEffect>(extraBufferCapacity = 8)
val effects = _effects.asSharedFlow()
private var exploreJob: Job? = null
private val searchKeyFlow = MutableStateFlow("")
private val groupFlow = MutableStateFlow("")
private var kindsJob: Job? = null
init {
@@ -52,19 +67,19 @@ class ExploreViewModel(
exploreRepository.getExploreGroups()
.flowOn(IO)
.collectLatest { groups ->
_uiState.update { it.copy(groups = groups) }
_uiState.update { it.copy(groups = groups.toImmutableList()) }
}
}
}
fun search(key: String) {
searchKeyFlow.value = key
_uiState.update { it.copy(searchKey = key, expandedId = null) }
observeExplore()
}
fun setGroup(group: String) {
groupFlow.value = group
_uiState.update { it.copy(selectedGroup = group, expandedId = null) }
observeExplore()
}
fun toggleSearchVisible(visible: Boolean) {
@@ -74,17 +89,23 @@ class ExploreViewModel(
}
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
private fun observeExplore() {
exploreJob?.cancel()
exploreJob = viewModelScope.launch {
val state = _uiState.value
val query = state.searchKey
val selectedGroup = state.selectedGroup
exploreRepository.getExploreSources(query, selectedGroup)
viewModelScope.launch {
combine(
searchKeyFlow
.debounce(250)
.distinctUntilChanged(),
groupFlow
) { query, selectedGroup ->
query to selectedGroup
}
.flatMapLatest { (query, selectedGroup) ->
exploreRepository.getExploreSources(query, selectedGroup)
}
.flowOn(IO)
.collectLatest { items ->
_uiState.update { it.copy(items = items) }
_uiState.update { it.copy(items = items.toImmutableList()) }
}
}
}
@@ -95,9 +116,9 @@ class ExploreViewModel(
_uiState.update {
it.copy(
expandedId = newExpandedId,
exploreKinds = emptyList(),
kindDisplayNames = emptyMap(),
kindValues = emptyMap(),
exploreKinds = persistentListOf(),
kindDisplayNames = persistentMapOf(),
kindValues = persistentMapOf(),
loadingKinds = newExpandedId != null
)
}
@@ -125,9 +146,9 @@ class ExploreViewModel(
_uiState.update {
if (it.expandedId == source.bookSourceUrl) {
it.copy(
exploreKinds = kinds,
kindDisplayNames = displayNames,
kindValues = values,
exploreKinds = kinds.toImmutableList(),
kindDisplayNames = displayNames.toImmutableMap(),
kindValues = values.toImmutableMap(),
loadingKinds = false
)
} else it
@@ -160,7 +181,7 @@ class ExploreViewModel(
fun updateKindValue(sourceUrl: String, kind: ExploreKind, value: String) {
_uiState.update { state ->
state.copy(kindValues = state.kindValues + (kind.title to value))
state.copy(kindValues = (state.kindValues + (kind.title to value)).toImmutableMap())
}
viewModelScope.launch(IO) {
getExploreInfoMap(sourceUrl).apply {
@@ -181,23 +202,23 @@ class ExploreViewModel(
}
data class ExploreUiState(
override val items: List<BookSourcePart> = emptyList(),
override val selectedIds: Set<String> = emptySet(),
override val items: ImmutableList<BookSourcePart> = persistentListOf(),
override val selectedIds: ImmutableSet<String> = persistentSetOf(),
override val searchKey: String = "",
override val isSearch: Boolean = false,
override val isLoading: Boolean = false,
val groups: List<String> = emptyList(),
val groups: ImmutableList<String> = persistentListOf(),
val selectedGroup: String = "",
val expandedId: String? = null,
val exploreKinds: List<ExploreKind> = emptyList(),
val kindDisplayNames: Map<String, String> = emptyMap(),
val kindValues: Map<String, String> = emptyMap(),
val exploreKinds: ImmutableList<ExploreKind> = persistentListOf(),
val kindDisplayNames: ImmutableMap<String, String> = persistentMapOf(),
val kindValues: ImmutableMap<String, String> = persistentMapOf(),
val loadingKinds: Boolean = false,
val listItems: List<ExploreListItem> = emptyList()
val listItems: ImmutableList<ExploreListItem> = persistentListOf()
) : ListUiState<BookSourcePart>
private fun buildExploreListItems(state: ExploreUiState): List<ExploreListItem> {
if (state.items.isEmpty()) return emptyList()
private fun buildExploreListItems(state: ExploreUiState): ImmutableList<ExploreListItem> {
if (state.items.isEmpty()) return persistentListOf()
val expandedId = state.expandedId
val kindRows = if (expandedId != null) {
calculateExploreKindRows(state.exploreKinds, 6)
@@ -213,13 +234,13 @@ class ExploreViewModel(
ExploreListItem.KindRow(
sourceUrl = source.bookSourceUrl,
rowIndex = index,
rowItems = row
rowItems = row.toImmutableList()
)
)
}
}
}
}
}.toImmutableList()
}
private fun buildKindValues(
@@ -269,7 +290,7 @@ sealed interface ExploreListItem {
data class KindRow(
val sourceUrl: String,
val rowIndex: Int,
val rowItems: List<Pair<ExploreKind, Int>>
val rowItems: ImmutableList<Pair<ExploreKind, Int>>
) : ExploreListItem {
override val key: String = "${sourceUrl}_$rowIndex"
}
@@ -36,7 +36,9 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -79,21 +81,27 @@ fun RssScreen(
) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var sourceToDelete by remember { mutableStateOf<RssSource?>(null) }
var sourceToDeleteUrl by rememberSaveable { mutableStateOf<String?>(null) }
val sourceToDelete = remember(sourceToDeleteUrl, uiState.items) {
uiState.items.firstOrNull { it.sourceUrl == sourceToDeleteUrl }
}
val currentContext by rememberUpdatedState(context)
val currentOnOpenSort by rememberUpdatedState(onOpenSort)
val currentOnOpenRead by rememberUpdatedState(onOpenRead)
LaunchedEffect(viewModel) {
viewModel.effects.collectLatest { effect ->
when (effect) {
is RssEffect.OpenSort -> {
onOpenSort(effect.sourceUrl, effect.sortUrl, effect.key)
currentOnOpenSort(effect.sourceUrl, effect.sortUrl, effect.key)
}
is RssEffect.OpenRead -> {
onOpenRead(effect.title, effect.origin, effect.link, effect.openUrl)
currentOnOpenRead(effect.title, effect.origin, effect.link, effect.openUrl)
}
is RssEffect.OpenExternalUrl -> {
context.openUrl(effect.url)
currentContext.openUrl(effect.url)
}
}
}
@@ -176,7 +184,7 @@ fun RssScreen(
onClick = { viewModel.openSource(source) },
onTop = { viewModel.topSource(source) },
onEdit = { edit(source) },
onDelete = { sourceToDelete = source },
onDelete = { sourceToDeleteUrl = source.sourceUrl },
onDisable = { viewModel.disable(source) },
onLogin = { login(source) }
)
@@ -186,15 +194,15 @@ fun RssScreen(
AppAlertDialog(
data = sourceToDelete,
onDismissRequest = { sourceToDelete = null },
onDismissRequest = { sourceToDeleteUrl = null },
title = stringResource(R.string.draw),
confirmText = stringResource(R.string.yes),
onConfirm = { source ->
viewModel.del(source)
sourceToDelete = null
sourceToDeleteUrl = null
},
dismissText = stringResource(R.string.no),
onDismiss = { sourceToDelete = null }
onDismiss = { sourceToDeleteUrl = null }
)
}
@@ -2,13 +2,17 @@ package io.legado.app.ui.main.rss
import io.legado.app.data.entities.RssSource
import io.legado.app.ui.widget.components.list.ListUiState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
data class RssUiState(
override val items: List<RssSource> = emptyList(),
override val selectedIds: Set<String> = emptySet(),
override val items: ImmutableList<RssSource> = persistentListOf(),
override val selectedIds: ImmutableSet<String> = persistentSetOf(),
override val searchKey: String = "",
override val isSearch: Boolean = false,
override val isLoading: Boolean = false,
val groups: List<String> = emptyList(),
val groups: ImmutableList<String> = persistentListOf(),
val group: String = ""
) : ListUiState<RssSource>
@@ -7,6 +7,7 @@ import io.legado.app.base.BaseViewModel
import io.legado.app.data.entities.RssSource
import io.legado.app.data.repository.RssRepository
import io.legado.app.utils.toastOnUi
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -43,7 +44,7 @@ class RssViewModel(
rssRepository.getEnabledGroups()
.flowOn(IO)
.collect { groups ->
_uiState.update { state -> state.copy(groups = groups) }
_uiState.update { state -> state.copy(groups = groups.toImmutableList()) }
}
}
}
@@ -61,7 +62,7 @@ class RssViewModel(
}
.flowOn(IO)
.onEach { sources ->
_uiState.update { state -> state.copy(items = sources) }
_uiState.update { state -> state.copy(items = sources.toImmutableList()) }
}
.launchIn(viewModelScope)
}
@@ -20,21 +20,24 @@ fun BookshelfCover(
author: String?,
path: String?,
modifier: Modifier = Modifier,
coverModifier: Modifier = Modifier.fillMaxWidth(),
isUpdating: Boolean = false,
badgeText: String? = null,
showBadgeDot: Boolean = false,
leftBottomText: String? = null,
sourceOrigin: String? = null,
onLoadFinish: (() -> Unit)? = null
onLoadFinish: (() -> Unit)? = null,
showLoadingPlaceholder: Boolean = true,
) {
Box(modifier = modifier) {
CoilBookCover(
name = name,
author = author,
path = path,
modifier = Modifier.fillMaxWidth(),
modifier = coverModifier,
sourceOrigin = sourceOrigin,
onLoadFinish = onLoadFinish
onLoadFinish = onLoadFinish,
showLoadingPlaceholder = showLoadingPlaceholder,
)
if (!badgeText.isNullOrEmpty()) {
@@ -41,6 +41,7 @@ import coil.compose.AsyncImage
import io.legado.app.ui.config.coverConfig.CoverConfig
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.card.NormalCard
import kotlinx.coroutines.delay
import org.koin.compose.koinInject
import io.legado.app.model.BookCover as BookCoverModel
@@ -52,7 +53,8 @@ fun CoilBookCover(
modifier: Modifier = Modifier.width(64.dp),
sourceOrigin: String? = null,
onLoadFinish: (() -> Unit)? = null,
ignoreUseDefaultCover: Boolean = false
ignoreUseDefaultCover: Boolean = false,
showLoadingPlaceholder: Boolean = true,
) {
val context = LocalContext.current
val isNight = isSystemInDarkTheme()
@@ -75,9 +77,23 @@ fun CoilBookCover(
var isOnlineCoverLoaded by remember(path) { mutableStateOf(false) }
var isFinalPathLoaded by remember(path) { mutableStateOf(false) }
var isFinalPathLoadFinished by remember(path) { mutableStateOf(false) }
var isPlaceholderAllowed by remember(path, showLoadingPlaceholder) {
mutableStateOf(showLoadingPlaceholder)
}
LaunchedEffect(finalPath) {
isOnlineCoverLoaded = false
isFinalPathLoaded = false
isFinalPathLoadFinished = finalPath == null
}
LaunchedEffect(finalPath, showLoadingPlaceholder) {
if (showLoadingPlaceholder) {
isPlaceholderAllowed = true
} else {
isPlaceholderAllowed = false
delay(600)
isPlaceholderAllowed = true
}
}
NormalCard(
@@ -133,10 +149,12 @@ fun CoilBookCover(
onSuccess = {
isOnlineCoverLoaded = true
isFinalPathLoaded = true
isFinalPathLoadFinished = true
onLoadFinish?.invoke()
},
onError = {
isOnlineCoverLoaded = false
isFinalPathLoadFinished = true
onLoadFinish?.invoke()
}
)
@@ -146,7 +164,9 @@ fun CoilBookCover(
}
}
if (!hasCustomDefault && !isOnlineCoverLoaded) {
val showPlaceholder = isPlaceholderAllowed && (showLoadingPlaceholder || isFinalPathLoadFinished)
if (!hasCustomDefault && !isOnlineCoverLoaded && showPlaceholder) {
Icon(
Icons.Default.Book,
contentDescription = null,
@@ -157,7 +177,7 @@ fun CoilBookCover(
)
}
if (!isOnlineCoverLoaded) {
if (!isOnlineCoverLoaded && showPlaceholder) {
CoverTextOverlay(
name = name,
author = author,