[优化] 性能优化

This commit is contained in:
HapeLee
2026-05-01 00:55:43 +08:00
parent e2d90d153c
commit 03d0528dd2
25 changed files with 1625 additions and 708 deletions
@@ -2,11 +2,12 @@ package io.legado.app.data.repository
import io.legado.app.data.AppDatabase
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.SearchBook
import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.help.source.SourceHelp
import io.legado.app.help.source.exploreKinds
import io.legado.app.model.webBook.WebBook
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -14,10 +15,14 @@ import kotlinx.coroutines.withContext
interface ExploreRepository {
fun getBookshelfItems(): Flow<List<SearchBook>>
fun getExploreGroups(): Flow<List<String>>
fun getExploreSources(query: String, selectedGroup: String): Flow<List<BookSourcePart>>
suspend fun getBookSource(url: String): BookSource?
suspend fun exploreBook(source: BookSource, url: String, page: Int): Result<List<SearchBook>>
suspend fun saveSearchBooks(books: List<SearchBook>)
suspend fun getSourceExploreKinds(sourceUrl: String): List<ExploreKind>
suspend fun topSource(bookSource: BookSourcePart)
suspend fun deleteSource(sourceUrl: String)
}
class ExploreRepositoryImpl(
@@ -41,6 +46,33 @@ class ExploreRepositoryImpl(
}
}
override fun getExploreGroups(): Flow<List<String>> {
return appDb.bookSourceDao.flowExploreGroups()
}
override fun getExploreSources(
query: String,
selectedGroup: String
): Flow<List<BookSourcePart>> {
return when {
query.isNotBlank() -> {
if (query.startsWith("group:")) {
appDb.bookSourceDao.flowGroupExplore(query.substringAfter("group:"))
} else {
appDb.bookSourceDao.flowExplore(query)
}
}
selectedGroup.isNotBlank() -> {
appDb.bookSourceDao.flowGroupExplore(selectedGroup)
}
else -> {
appDb.bookSourceDao.flowExplore()
}
}
}
override suspend fun getBookSource(url: String): BookSource? {
return appDb.bookSourceDao.getBookSource(url)
}
@@ -65,4 +97,13 @@ class ExploreRepositoryImpl(
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))
}
override suspend fun deleteSource(sourceUrl: String) {
SourceHelp.deleteBookSource(sourceUrl)
}
}
@@ -1,12 +1,13 @@
package io.legado.app.data.repository
import io.legado.app.data.appDb
import io.legado.app.data.dao.RssSourceDao
import io.legado.app.data.entities.RssSource
import io.legado.app.help.source.SourceHelp
import kotlinx.coroutines.flow.Flow
class RssRepository {
private val dao = appDb.rssSourceDao
class RssRepository(
private val dao: RssSourceDao
) {
fun getEnabledSources(): Flow<List<RssSource>> = dao.flowEnabled()
@@ -15,12 +16,42 @@ class RssRepository {
fun getEnabledSourcesByGroup(group: String): Flow<List<RssSource>> =
dao.flowEnabledByGroup(group)
fun getEnabledSources(searchKey: String, group: String): Flow<List<RssSource>> {
return when {
searchKey.isNotEmpty() -> dao.flowEnabled(searchKey)
group.isNotEmpty() -> dao.flowEnabledByGroup(group)
else -> dao.flowEnabled()
}
}
fun getEnabledGroups(): Flow<List<String>> = dao.flowEnabledGroups()
suspend fun updateSources(vararg sources: RssSource) {
dao.update(*sources)
}
suspend fun topSources(vararg sources: RssSource) {
val minOrder = dao.minOrder - 1
val sortedSources = sources.sortedBy { it.customOrder }
val updates = Array(sortedSources.size) { index ->
sortedSources[index].copy(customOrder = minOrder - index)
}
dao.update(*updates)
}
suspend fun bottomSources(vararg sources: RssSource) {
val maxOrder = dao.maxOrder + 1
val sortedSources = sources.sortedBy { it.customOrder }
val updates = Array(sortedSources.size) { index ->
sortedSources[index].copy(customOrder = maxOrder + index)
}
dao.update(*updates)
}
suspend fun disableSource(source: RssSource) {
dao.update(source.copy(enabled = false))
}
suspend fun deleteSources(sources: List<RssSource>) {
SourceHelp.deleteRssSources(sources)
}
@@ -21,6 +21,7 @@ import io.legado.app.data.repository.ExploreRepositoryImpl
import io.legado.app.data.repository.LocalBookRepository
import io.legado.app.data.repository.ReadRecordRepository
import io.legado.app.data.repository.RemoteBookRepository
import io.legado.app.data.repository.RssRepository
import io.legado.app.data.repository.SearchRepository
import io.legado.app.data.repository.SearchRepositoryImpl
import io.legado.app.data.repository.SearchContentRepository
@@ -142,6 +143,7 @@ val appModule = module {
single<ReadingProgressGateway> { WebDavReadingProgressRepository() }
single<BookDomainRepository> { BookDomainRepositoryImpl(get(), get()) }
single<ExploreRepository> { ExploreRepositoryImpl(get()) }
singleOf(::RssRepository)
single {
SearchRepositoryImpl(get())
}
@@ -56,7 +56,6 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.legado.app.R
import io.legado.app.ui.config.mainConfig.MainConfig
import io.legado.app.ui.main.bookshelf.BookshelfScreen
import io.legado.app.ui.main.bookshelf.BookshelfViewModel
import io.legado.app.ui.main.explore.ExploreScreen
@@ -100,9 +99,10 @@ fun MainScreen(
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val mainUiState by viewModel.uiState.collectAsStateWithLifecycle()
val bookshelfViewModel: BookshelfViewModel = koinViewModel()
val bookshelfUiState by bookshelfViewModel.uiState.collectAsStateWithLifecycle()
val bookshelfGroupState by bookshelfViewModel.groupSelectorState.collectAsStateWithLifecycle()
val hazeState = remember { HazeState() }
val floatingBarSurfaceColor = MaterialTheme.colorScheme.surface
@@ -110,45 +110,37 @@ fun MainScreen(
drawRect(floatingBarSurfaceColor)
drawContent()
}
val destinations = remember(MainConfig.showDiscovery, MainConfig.showRSS) {
MainDestination.mainDestinations.filter {
when (it) {
MainDestination.Explore -> MainConfig.showDiscovery
MainDestination.Rss -> MainConfig.showRSS
else -> true
}
}
}
val destinations = mainUiState.destinations
val initialPage = remember(destinations) {
val index = destinations.indexOfFirst { it.route == MainConfig.defaultHomePage }
val initialPage = remember(destinations, mainUiState.defaultHomePage) {
val index = destinations.indexOfFirst { it.route == mainUiState.defaultHomePage }
if (index != -1) index else 0
}
val pagerState = rememberPagerState(initialPage = initialPage) { destinations.size }
val labelVisibilityMode = MainConfig.labelVisibilityMode
LaunchedEffect(destinations) {
if (destinations.isNotEmpty() && pagerState.currentPage !in destinations.indices) {
pagerState.scrollToPage(destinations.lastIndex)
}
}
val labelVisibilityMode = mainUiState.labelVisibilityMode
val isUnlabeled = labelVisibilityMode == "unlabeled"
val useFloatingBottomBar =
!useRail && MainConfig.showBottomView && MainConfig.useFloatingBottomBar
!useRail && mainUiState.showBottomView && mainUiState.useFloatingBottomBar
val useLiquidGlass = useFloatingBottomBar &&
MainConfig.useFloatingBottomBarLiquidGlass &&
mainUiState.useFloatingBottomBarLiquidGlass &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
val alwaysShowLabel = labelVisibilityMode == "labeled"
val showLabel = !isUnlabeled
val navState = rememberWideNavigationRailState(
initialValue = if (MainConfig.navExtended)
initialValue = if (mainUiState.navExtended)
WideNavigationRailValue.Expanded
else
WideNavigationRailValue.Collapsed
)
LaunchedEffect(navState.currentValue) {
MainConfig.navExtended =
navState.currentValue == WideNavigationRailValue.Expanded
}
Row(modifier = Modifier.fillMaxSize()) {
if (useRail && MainConfig.showBottomView) {
if (useRail && mainUiState.showBottomView) {
WideNavigationRail(
state = navState,
header = {
@@ -159,8 +151,10 @@ fun MainScreen(
modifier = Modifier.padding(start = 24.dp),
onClick = {
coroutineScope.launch {
if (expanded) navState.collapse()
else navState.expand()
val targetExpanded = !expanded
if (targetExpanded) navState.expand()
else navState.collapse()
viewModel.setNavExtended(targetExpanded)
}
}
) {
@@ -185,7 +179,6 @@ fun MainScreen(
}
}
) {
val labelVisibilityMode = MainConfig.labelVisibilityMode
destinations.forEachIndexed { index, destination ->
val selected = pagerState.targetPage == index
var showGroupMenu by remember { mutableStateOf(false) }
@@ -224,7 +217,7 @@ fun MainScreen(
expanded = showGroupMenu,
onDismissRequest = { showGroupMenu = false }
) { dismiss ->
bookshelfUiState.groups.forEachIndexed { groupIndex, group ->
bookshelfGroupState.groups.forEachIndexed { groupIndex, group ->
RoundDropdownMenuItem(
text = group.groupName,
onClick = {
@@ -237,7 +230,7 @@ fun MainScreen(
}
},
trailingIcon = {
if (bookshelfUiState.selectedGroupIndex == groupIndex) {
if (bookshelfGroupState.selectedGroupIndex == groupIndex) {
Icon(
Icons.Default.Check,
null,
@@ -262,7 +255,7 @@ fun MainScreen(
AppScaffold(
modifier = Modifier.weight(1f),
bottomBar = {
if (!useRail && MainConfig.showBottomView) {
if (!useRail && mainUiState.showBottomView) {
if (useFloatingBottomBar) {
Box(modifier = Modifier.fillMaxWidth()) {
FloatingBottomBar(
@@ -365,7 +358,7 @@ fun MainScreen(
state = pagerState,
modifier = Modifier.fillMaxSize(),
userScrollEnabled = true,
beyondViewportPageCount = 3
beyondViewportPageCount = 1
) { page ->
val destination = destinations.getOrNull(page) ?: return@HorizontalPager
when (destination) {
@@ -394,7 +387,6 @@ fun MainScreen(
}
)
MainDestination.My -> MyScreen(
viewModel = koinViewModel(),
onOpenSettings = onOpenSettings,
onNavigate = { event ->
if (event == PrefClickEvent.OpenBookCacheManage) {
@@ -3,13 +3,25 @@ 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
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.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
class MainViewModel(
application: Application,
@@ -17,10 +29,37 @@ class MainViewModel(
private val webDavBackupUseCase: WebDavBackupUseCase
) : BaseViewModel(application) {
private val prefs = context.defaultSharedPreferences
private val mainPreferenceKeys = setOf(
PreferKey.showDiscovery,
PreferKey.showRss,
PreferKey.showBottomView,
PreferKey.useFloatingBottomBar,
PreferKey.useFloatingBottomBarLiquidGlass,
PreferKey.defaultHomePage,
PreferKey.labelVisibilityMode,
NAV_EXTENDED_KEY
)
private val preferenceListener =
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key in mainPreferenceKeys) {
_uiState.value = readMainUiState()
}
}
private val _uiState = MutableStateFlow(readMainUiState())
val uiState = _uiState.asStateFlow()
init {
prefs.registerOnSharedPreferenceChangeListener(preferenceListener)
deleteNotShelfBook()
}
override fun onCleared() {
prefs.unregisterOnSharedPreferenceChangeListener(preferenceListener)
super.onCleared()
}
fun upAllBookToc() {
FlowEventBus.post(EventBus.UP_ALL_BOOK_TOC, Unit)
}
@@ -45,6 +84,12 @@ class MainViewModel(
}
}
fun setNavExtended(expanded: Boolean) {
if (_uiState.value.navExtended == expanded) return
_uiState.update { it.copy(navExtended = expanded) }
MainConfig.navExtended = expanded
}
fun onPrefClickEvent(context: Context, event: PrefClickEvent) {
when (event) {
is PrefClickEvent.OpenUrl -> context.startActivity(
@@ -56,7 +101,11 @@ class MainViewModel(
is PrefClickEvent.CopyUrl -> context.sendToClip(event.url)
is PrefClickEvent.ShowMd -> {
// Handle showing MD dialog
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 -> {
@@ -76,3 +125,40 @@ class MainViewModel(
}
}
data class MainUiState(
val destinations: List<MainDestination> = MainDestination.mainDestinations,
val defaultHomePage: String = "bookshelf",
val showBottomView: Boolean = true,
val useFloatingBottomBar: Boolean = false,
val useFloatingBottomBarLiquidGlass: Boolean = false,
val labelVisibilityMode: String = "auto",
val navExtended: Boolean = false
)
private const val NAV_EXTENDED_KEY = "navExtended"
private fun MainViewModel.readMainUiState(): MainUiState {
val showDiscovery = context.getPrefBoolean(PreferKey.showDiscovery, true)
val showRss = context.getPrefBoolean(PreferKey.showRss, true)
val destinations = MainDestination.mainDestinations.filter {
when (it) {
MainDestination.Explore -> showDiscovery
MainDestination.Rss -> showRss
else -> true
}
}
return MainUiState(
destinations = destinations,
defaultHomePage = context.getPrefString(PreferKey.defaultHomePage, "bookshelf")
?: "bookshelf",
showBottomView = context.getPrefBoolean(PreferKey.showBottomView, true),
useFloatingBottomBar = context.getPrefBoolean(PreferKey.useFloatingBottomBar, false),
useFloatingBottomBarLiquidGlass = context.getPrefBoolean(
PreferKey.useFloatingBottomBarLiquidGlass,
false
),
labelVisibilityMode = context.getPrefString(PreferKey.labelVisibilityMode, "auto") ?: "auto",
navExtended = context.getPrefBoolean(NAV_EXTENDED_KEY, false)
)
}
@@ -56,10 +56,9 @@ import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -67,7 +66,6 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -85,7 +83,9 @@ import io.legado.app.ui.theme.adaptiveContentPadding
import io.legado.app.ui.theme.adaptiveContentPaddingBookshelf
import io.legado.app.ui.theme.adaptiveHorizontalPadding
import io.legado.app.ui.theme.adaptiveHorizontalPaddingTab
import io.legado.app.ui.widget.components.ActionItem
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.SelectionActions
import io.legado.app.ui.widget.components.button.SmallOutlinedIconToggleButton
import io.legado.app.ui.widget.components.topbar.TopBarActionButton
import io.legado.app.ui.widget.components.alert.AppAlertDialog
@@ -102,10 +102,6 @@ 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.tabRow.AppTabRow
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.utils.move
import io.legado.app.utils.readText
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@@ -126,21 +122,15 @@ fun BookshelfScreen(
onNavigateToLocalImport: () -> Unit,
onNavigateToCache: (Long) -> Unit
) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var showAddUrlDialog by remember { mutableStateOf(false) }
var showImportSheet by remember { mutableStateOf(false) }
var showExportSheet by remember { mutableStateOf(false) }
var showConfigSheet by remember { mutableStateOf(false) }
var showGroupManageSheet by remember { mutableStateOf(false) }
var showLogSheet by remember { mutableStateOf(false) }
var showGroupMenu by remember { mutableStateOf(false) }
var showGroupSelectSheet by remember { mutableStateOf(false) }
var showBatchDownloadConfirmDialog by remember { mutableStateOf(false) }
var isEditMode by remember { mutableStateOf(false) }
var selectedBookUrls by remember { mutableStateOf<Set<String>>(emptySet()) }
val activeOverlay = uiState.activeOverlay
val showGroupMenu = activeOverlay == BookshelfOverlay.GroupMenu
val isEditMode = uiState.isEditMode
val selectedBookUrls = uiState.selectedBookUrls
val isInFolderRoot = uiState.isInFolderRoot
val bookGroupStyle = uiState.bookGroupStyle
val clipboardManager = LocalClipboard.current
val snackbarHostState = remember { SnackbarHostState() }
@@ -173,14 +163,8 @@ fun BookshelfScreen(
contract = ActivityResultContracts.OpenDocument(),
onResult = { uri ->
uri?.let {
runCatching {
val text = it.readText(context)
val groupId =
uiState.groups.getOrNull(uiState.selectedGroupIndex)?.groupId ?: -1L
viewModel.importBookshelf(text, groupId)
}.onFailure {
context.toastOnUi(it.localizedMessage ?: "ERROR")
}
val groupId = uiState.groups.getOrNull(uiState.selectedGroupIndex)?.groupId ?: -1L
viewModel.importBookshelf(it, groupId)
}
}
)
@@ -192,10 +176,35 @@ fun BookshelfScreen(
}
)
if (uiState.groups.isEmpty()) {
ListScaffold(
title = uiState.title.ifEmpty { stringResource(R.string.bookshelf) },
subtitle = uiState.subtitle,
state = uiState,
showSearchAction = true,
onSearchToggle = { viewModel.setSearchMode(it) },
onSearchQueryChange = { viewModel.setSearchKey(it) },
snackbarHostState = snackbarHostState
) { paddingValues ->
EmptyMessage(
modifier = Modifier
.fillMaxSize()
.padding(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding()
),
messageResId = R.string.bookshelf_empty
)
}
return
}
val pagerState = rememberPagerState(
initialPage = uiState.selectedGroupIndex,
pageCount = { uiState.groups.size }
)
val latestGroups by rememberUpdatedState(uiState.groups)
val latestSelectedGroupId by rememberUpdatedState(uiState.selectedGroupId)
LaunchedEffect(uiState.groups, uiState.isSearch) {
if (!uiState.isSearch && uiState.groups.isNotEmpty()) {
@@ -209,14 +218,13 @@ fun BookshelfScreen(
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }
snapshotFlow { pagerState.settledPage }
.distinctUntilChanged()
.collect { page ->
if (uiState.groups.isNotEmpty() && page in uiState.groups.indices) {
val targetGroupId = uiState.groups[page].groupId
val currentGroupId =
uiState.groups.getOrNull(uiState.selectedGroupIndex)?.groupId
if (currentGroupId != targetGroupId) {
val groups = latestGroups
if (groups.isNotEmpty() && page in groups.indices) {
val targetGroupId = groups[page].groupId
if (latestSelectedGroupId != targetGroupId) {
viewModel.changeGroup(targetGroupId)
}
}
@@ -234,36 +242,18 @@ fun BookshelfScreen(
val isUsingStandaloneSearchGroup = uiState.isSearch &&
uiState.groups.none { it.groupId == currentGroupId }
val currentGroupBookCount = uiState.currentGroupBookCount
val allGroupsBookCount = uiState.allBooksCount
val bookGroupStyle = BookshelfConfig.bookGroupStyle
// 控制是否处于“文件夹列表”根视图,还是“文件夹内部”书籍视图
var isInFolderRoot by remember(bookGroupStyle) { mutableStateOf(bookGroupStyle == 2) }
val clearSelection = {
selectedBookUrls = emptySet()
viewModel.clearSelection()
}
val exitEditMode = {
isEditMode = false
clearSelection()
viewModel.exitEditMode()
}
val toggleEditMode = {
if (isEditMode) {
exitEditMode()
} else {
if (bookGroupStyle == 2 && isInFolderRoot) {
isInFolderRoot = false
}
isEditMode = true
clearSelection()
}
viewModel.toggleEditMode()
}
val toggleBookSelection: (String) -> Unit = { bookUrl ->
selectedBookUrls = if (selectedBookUrls.contains(bookUrl)) {
selectedBookUrls - bookUrl
} else {
selectedBookUrls + bookUrl
}
viewModel.toggleBookSelection(bookUrl)
}
LaunchedEffect(pagerState.currentPage, isInFolderRoot) {
@@ -271,8 +261,7 @@ fun BookshelfScreen(
}
LaunchedEffect(uiState.items) {
val visibleBookUrls = uiState.items.mapTo(hashSetOf()) { it.bookUrl }
selectedBookUrls = selectedBookUrls.intersect(visibleBookUrls)
viewModel.pruneSelectionToVisible(uiState.items)
}
BackHandler(enabled = isEditMode) {
@@ -283,36 +272,11 @@ fun BookshelfScreen(
}
}
val currentGroupName = uiState.allGroups.firstOrNull { it.groupId == currentGroupId }?.groupName
?: uiState.groups.getOrNull(pagerState.currentPage)?.groupName
val baseTitle = when {
uiState.isSearch && bookGroupStyle == 0 -> stringResource(R.string.bookshelf)
uiState.isSearch -> currentGroupName ?: stringResource(R.string.bookshelf)
bookGroupStyle == 1 -> currentGroupName ?: stringResource(R.string.bookshelf)
bookGroupStyle == 2 && uiState.groups.isNotEmpty() -> {
if (isInFolderRoot) stringResource(R.string.bookshelf)
else currentGroupName ?: stringResource(R.string.bookshelf)
}
else -> stringResource(R.string.bookshelf)
}
val title = if (isEditMode) {
stringResource(R.string.bookshelf)
} else if (uiState.upBooksCount > 0) {
"$baseTitle (${uiState.upBooksCount})"
} else {
baseTitle
}
val subtitle = if (isEditMode) {
"${allGroupsBookCount}"
} else {
null
}
val currentGroupName = uiState.currentGroupName
if (bookGroupStyle == 2 && !isInFolderRoot && !isEditMode) {
BackHandler {
isInFolderRoot = true
viewModel.setInFolderRoot(true)
}
}
@@ -337,8 +301,8 @@ fun BookshelfScreen(
}
ListScaffold(
title = title,
subtitle = subtitle,
title = uiState.title.ifEmpty { stringResource(R.string.bookshelf) },
subtitle = uiState.subtitle,
state = uiState,
showSearchAction = true,
onSearchToggle = { active ->
@@ -369,19 +333,14 @@ fun BookshelfScreen(
topBarActions = {
AnimatedVisibility(visible = isEditMode) {
TopBarActionButton(
onClick = {
selectedBookUrls = uiState.items.mapTo(hashSetOf()) { it.bookUrl }
},
onClick = { viewModel.selectAllVisible() },
imageVector = Icons.Default.SelectAll,
contentDescription = stringResource(R.string.select_all)
)
}
AnimatedVisibility(visible = isEditMode) {
TopBarActionButton(
onClick = {
val visibleBookUrls = uiState.items.mapTo(hashSetOf()) { it.bookUrl }
selectedBookUrls = visibleBookUrls - selectedBookUrls
},
onClick = { viewModel.invertVisibleSelection() },
imageVector = Icons.Default.Refresh,
contentDescription = stringResource(R.string.revert_selection)
)
@@ -390,7 +349,7 @@ fun BookshelfScreen(
TopBarActionButton(
onClick = {
if (selectedBookUrls.isNotEmpty()) {
showBatchDownloadConfirmDialog = true
viewModel.showOverlay(BookshelfOverlay.BatchDownloadConfirmDialog)
}
},
imageVector = Icons.Default.Download,
@@ -401,7 +360,7 @@ fun BookshelfScreen(
TopBarActionButton(
onClick = {
if (selectedBookUrls.isNotEmpty()) {
showGroupSelectSheet = true
viewModel.showOverlay(BookshelfOverlay.GroupSelectSheet)
}
},
imageVector = Icons.Default.Bookmarks,
@@ -428,17 +387,26 @@ fun BookshelfScreen(
)
RoundDropdownMenuItem(
text = stringResource(R.string.layout_setting),
onClick = { showConfigSheet = true; dismiss() },
onClick = {
viewModel.showOverlay(BookshelfOverlay.ConfigSheet)
dismiss()
},
leadingIcon = { Icon(Icons.Default.GridView, null) }
)
RoundDropdownMenuItem(
text = stringResource(R.string.group_manage),
onClick = { showGroupManageSheet = true; dismiss() },
onClick = {
viewModel.showOverlay(BookshelfOverlay.GroupManageSheet)
dismiss()
},
leadingIcon = { Icon(Icons.Default.Edit, null) }
)
RoundDropdownMenuItem(
text = stringResource(R.string.add_url),
onClick = { showAddUrlDialog = true; dismiss() },
onClick = {
viewModel.showOverlay(BookshelfOverlay.AddUrlDialog)
dismiss()
},
leadingIcon = { Icon(Icons.Default.Link, null) }
)
RoundDropdownMenuItem(
@@ -462,26 +430,58 @@ fun BookshelfScreen(
RoundDropdownMenuItem(
text = stringResource(R.string.export_bookshelf),
onClick = {
showExportSheet = true
viewModel.showOverlay(BookshelfOverlay.ExportSheet)
dismiss()
},
leadingIcon = { Icon(Icons.Default.UploadFile, null) }
)
RoundDropdownMenuItem(
text = stringResource(R.string.import_bookshelf),
onClick = { showImportSheet = true; dismiss() },
onClick = {
viewModel.showOverlay(BookshelfOverlay.ImportSheet)
dismiss()
},
leadingIcon = { Icon(Icons.Default.CloudDownload, null) }
)
RoundDropdownMenuItem(
text = stringResource(R.string.log),
onClick = {
showLogSheet = true
viewModel.showOverlay(BookshelfOverlay.LogSheet)
dismiss()
},
leadingIcon = { Icon(Icons.Default.History, null) }
)
}
} else null,
selectionActions = if (isEditMode) {
SelectionActions(
primaryAction = ActionItem(
text = stringResource(R.string.action_download),
icon = { Icon(Icons.Default.Download, contentDescription = null) },
onClick = {
if (selectedBookUrls.isNotEmpty()) {
viewModel.showOverlay(BookshelfOverlay.BatchDownloadConfirmDialog)
}
}
),
secondaryActions = listOf(
ActionItem(
text = stringResource(R.string.move_to_group),
icon = { Icon(Icons.Default.Bookmarks, contentDescription = null) },
onClick = {
if (selectedBookUrls.isNotEmpty()) {
viewModel.showOverlay(BookshelfOverlay.GroupSelectSheet)
}
}
)
),
onClearSelection = { viewModel.clearSelection() },
onSelectAll = { viewModel.selectAllVisible() },
onSelectInvert = { viewModel.invertVisibleSelection() }
)
} else {
null
},
snackbarHostState = snackbarHostState,
bottomContent = if (bookGroupStyle == 0) {
{
@@ -511,13 +511,19 @@ fun BookshelfScreen(
Box(modifier = Modifier) {
SmallOutlinedIconToggleButton(
checked = showGroupMenu,
onCheckedChange = { showGroupMenu = it },
onCheckedChange = {
if (it) {
viewModel.showOverlay(BookshelfOverlay.GroupMenu)
} else {
viewModel.dismissOverlay()
}
},
imageVector = Icons.AutoMirrored.Filled.FormatListBulleted,
contentDescription = stringResource(R.string.group_manage)
)
RoundDropdownMenu(
expanded = showGroupMenu,
onDismissRequest = { showGroupMenu = false }
onDismissRequest = { viewModel.dismissOverlay() }
) { dismiss ->
uiState.groups.forEachIndexed { index, group ->
RoundDropdownMenuItem(
@@ -606,7 +612,6 @@ fun BookshelfScreen(
}
} else null
) { paddingValues ->
var isRefreshing by remember { mutableStateOf(false) }
val pullToRefreshState = rememberPullToRefreshState()
val currentGroup = if (uiState.isSearch) {
uiState.allGroups.firstOrNull { it.groupId == currentGroupId }
@@ -620,15 +625,8 @@ fun BookshelfScreen(
.fillMaxSize()
.pullToRefresh(
state = pullToRefreshState,
isRefreshing = isRefreshing,
onRefresh = {
scope.launch {
isRefreshing = true
viewModel.upToc(uiState.items)
delay(1000)
isRefreshing = false
}
},
isRefreshing = uiState.isRefreshing,
onRefresh = { viewModel.refreshBooks(uiState.items) },
enabled = pullToRefreshEnabled
)
) {
@@ -675,9 +673,11 @@ fun BookshelfScreen(
titleMaxLines = BookshelfConfig.bookshelfTitleMaxLines,
onClick = {
scope.launch { pagerState.scrollToPage(index) }
isInFolderRoot = false
viewModel.setInFolderRoot(false)
},
onLongClick = { showGroupManageSheet = true }
onLongClick = {
viewModel.showOverlay(BookshelfOverlay.GroupManageSheet)
}
)
} else {
BookGroupItemGrid(
@@ -692,9 +692,11 @@ fun BookshelfScreen(
coverShadow = BookshelfConfig.bookshelfCoverShadow,
onClick = {
scope.launch { pagerState.scrollToPage(index) }
isInFolderRoot = false
viewModel.setInFolderRoot(false)
},
onLongClick = { showGroupManageSheet = true }
onLongClick = {
viewModel.showOverlay(BookshelfOverlay.GroupManageSheet)
}
)
}
}
@@ -712,7 +714,12 @@ fun BookshelfScreen(
selectedBookUrls = selectedBookUrls,
canReorderBooks = false,
onToggleBookSelection = { toggleBookSelection(it.bookUrl) },
onSaveBookOrder = {},
draggingBooks = null,
pendingSavedBooks = null,
onDragStarted = {},
onMoveBook = { _, _, _ -> },
onDragFinished = {},
onSyncDragState = { _, _ -> },
onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) },
onBookClick = onBookClick,
onBookLongClick = onBookLongClick
@@ -726,10 +733,16 @@ fun BookshelfScreen(
) { pageIndex ->
val group = uiState.groups.getOrNull(pageIndex)
if (group != null) {
val booksFlow = remember(group.groupId) {
viewModel.getBooksFlow(group.groupId)
val isSelectedGroup = group.groupId == uiState.selectedGroupId
val books = if (isSelectedGroup) {
uiState.items
} else {
emptyList()
}
val books by booksFlow.collectAsStateWithLifecycle(emptyList())
val canReorderBooks = isEditMode &&
!uiState.isSearch &&
group.getRealBookSort() == 3 &&
isSelectedGroup
BookshelfPage(
paddingValues = paddingValues,
books = books,
@@ -739,12 +752,33 @@ fun BookshelfScreen(
bookshelfLayoutList = bookshelfLayoutList,
isEditMode = isEditMode,
selectedBookUrls = selectedBookUrls,
canReorderBooks = isEditMode &&
!uiState.isSearch &&
group.getRealBookSort() == 3,
canReorderBooks = canReorderBooks,
onToggleBookSelection = { toggleBookSelection(it.bookUrl) },
onSaveBookOrder = { reorderedBooks ->
viewModel.saveBookOrder(reorderedBooks)
draggingBooks = if (isSelectedGroup) {
uiState.draggingBooks
} else {
null
},
pendingSavedBooks = if (isSelectedGroup) {
uiState.pendingSavedBooks
} else {
null
},
onDragStarted = {
if (isSelectedGroup) viewModel.startDraggingBooks(it)
},
onMoveBook = { from, to, currentBooks ->
if (isSelectedGroup) {
viewModel.moveDraggingBook(from, to, currentBooks)
}
},
onDragFinished = {
if (isSelectedGroup) viewModel.finishDraggingBooks()
},
onSyncDragState = { currentBooks, canReorder ->
if (isSelectedGroup) {
viewModel.syncDragState(currentBooks, canReorder)
}
},
onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) },
onBookClick = onBookClick,
@@ -782,11 +816,19 @@ fun BookshelfScreen(
)
Spacer(modifier = Modifier.width(8.dp))
AppText(
text = "已选${summary.selectedCount}",
text = stringResource(
R.string.bookshelf_selected_count,
summary.selectedCount
),
style = LegadoTheme.typography.labelSmallEmphasized
)
AppText(
text = " · ${summary.currentGroupTotalCount}",
text = " · ${
stringResource(
R.string.bookshelf_total_count,
summary.currentGroupTotalCount
)
}",
style = LegadoTheme.typography.labelSmallEmphasized
)
Spacer(modifier = Modifier.width(8.dp))
@@ -798,7 +840,9 @@ fun BookshelfScreen(
cornerRadius = 16.dp,
verticalPadding = 8.dp,
horizontalPadding = 12.dp,
onClick = { showGroupMenu = true }
onClick = {
viewModel.showOverlay(BookshelfOverlay.GroupMenu)
}
)
}
}
@@ -808,7 +852,7 @@ fun BookshelfScreen(
if (summary.showGroupName) {
RoundDropdownMenu(
expanded = showGroupMenu,
onDismissRequest = { showGroupMenu = false }
onDismissRequest = { viewModel.dismissOverlay() }
) { dismiss ->
uiState.groups.forEach { group ->
RoundDropdownMenuItem(
@@ -827,7 +871,7 @@ fun BookshelfScreen(
viewModel.changeGroup(group.groupId)
}
if (bookGroupStyle == 2) {
isInFolderRoot = false
viewModel.setInFolderRoot(false)
}
dismiss()
},
@@ -849,7 +893,7 @@ fun BookshelfScreen(
PullToRefreshDefaults.LoadingIndicator(
state = pullToRefreshState,
isRefreshing = isRefreshing,
isRefreshing = uiState.isRefreshing,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = paddingValues.calculateTopPadding())
@@ -858,82 +902,81 @@ fun BookshelfScreen(
}
BookshelfConfigSheet(
show = showConfigSheet,
onDismissRequest = { showConfigSheet = false }
show = activeOverlay == BookshelfOverlay.ConfigSheet,
onDismissRequest = { viewModel.dismissOverlay() }
)
GroupManageSheet(
show = showGroupManageSheet,
onDismissRequest = { showGroupManageSheet = false }
show = activeOverlay == BookshelfOverlay.GroupManageSheet,
onDismissRequest = { viewModel.dismissOverlay() }
)
GroupSelectSheet(
show = showGroupSelectSheet,
show = activeOverlay == BookshelfOverlay.GroupSelectSheet,
currentGroupId = 0L,
onDismissRequest = { showGroupSelectSheet = false },
onDismissRequest = { viewModel.dismissOverlay() },
onConfirm = { groupId ->
viewModel.moveBooksToGroup(selectedBookUrls, groupId)
showGroupSelectSheet = false
viewModel.dismissOverlay()
clearSelection()
}
)
SourceInputDialog(
show = showAddUrlDialog,
show = activeOverlay == BookshelfOverlay.AddUrlDialog,
title = stringResource(R.string.add_book_url),
onDismissRequest = { showAddUrlDialog = false },
onDismissRequest = { viewModel.dismissOverlay() },
onConfirm = { url ->
viewModel.addBookByUrl(url)
showAddUrlDialog = false
viewModel.dismissOverlay()
}
)
FilePickerSheet(
show = showImportSheet,
onDismissRequest = { showImportSheet = false },
show = activeOverlay == BookshelfOverlay.ImportSheet,
onDismissRequest = { viewModel.dismissOverlay() },
title = stringResource(R.string.import_bookshelf),
onSelectSysFile = { types ->
importLauncher.launch(types)
showImportSheet = false
viewModel.dismissOverlay()
},
onManualInput = {
showAddUrlDialog = true
showImportSheet = false
viewModel.showOverlay(BookshelfOverlay.AddUrlDialog)
},
allowExtensions = arrayOf("json", "txt")
)
FilePickerSheet(
show = showExportSheet,
onDismissRequest = { showExportSheet = false },
show = activeOverlay == BookshelfOverlay.ExportSheet,
onDismissRequest = { viewModel.dismissOverlay() },
title = stringResource(R.string.export_bookshelf),
onSelectSysDir = {
showExportSheet = false
viewModel.dismissOverlay()
exportLauncher.launch("bookshelf.json")
},
onUpload = {
showExportSheet = false
viewModel.dismissOverlay()
viewModel.uploadBookshelf(uiState.items)
}
)
AppLogSheet(
show = showLogSheet,
onDismissRequest = { showLogSheet = false }
show = activeOverlay == BookshelfOverlay.LogSheet,
onDismissRequest = { viewModel.dismissOverlay() }
)
AppAlertDialog(
show = showBatchDownloadConfirmDialog,
onDismissRequest = { showBatchDownloadConfirmDialog = false },
show = activeOverlay == BookshelfOverlay.BatchDownloadConfirmDialog,
onDismissRequest = { viewModel.dismissOverlay() },
title = stringResource(R.string.draw),
text = stringResource(R.string.sure_cache_book),
confirmText = stringResource(android.R.string.ok),
onConfirm = {
showBatchDownloadConfirmDialog = false
viewModel.dismissOverlay()
viewModel.downloadBooks(selectedBookUrls)
},
dismissText = stringResource(android.R.string.cancel),
onDismiss = { showBatchDownloadConfirmDialog = false }
onDismiss = { viewModel.dismissOverlay() }
)
if (uiState.isLoading) {
@@ -979,7 +1022,12 @@ fun BookshelfPage(
selectedBookUrls: Set<String>,
canReorderBooks: Boolean,
onToggleBookSelection: (BookShelfItem) -> Unit,
onSaveBookOrder: (books: List<BookShelfItem>) -> Unit,
draggingBooks: List<BookShelfItem>?,
pendingSavedBooks: List<BookShelfItem>?,
onDragStarted: (List<BookShelfItem>) -> Unit,
onMoveBook: (fromIndex: Int, toIndex: Int, currentBooks: List<BookShelfItem>) -> Unit,
onDragFinished: () -> Unit,
onSyncDragState: (books: List<BookShelfItem>, canReorderBooks: Boolean) -> Unit,
onGlobalSearch: () -> Unit,
onBookClick: (BookShelfItem) -> Unit,
onBookLongClick: (BookShelfItem) -> Unit
@@ -993,8 +1041,8 @@ fun BookshelfPage(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding()
),
message = "没有书籍,尝试全局搜索",
buttonText = "全局搜索",
message = stringResource(R.string.bookshelf_empty_global_search),
buttonText = stringResource(R.string.global_search),
onButtonClick = onGlobalSearch
)
} else {
@@ -1018,36 +1066,20 @@ fun BookshelfPage(
val gridContentHorizontalPadding = totalHorizontalPadding / 2
val gridInnerHorizontalPadding = totalHorizontalPadding / 2
val hapticFeedback = LocalHapticFeedback.current
var draggingBooks by remember { mutableStateOf<List<BookShelfItem>?>(null) }
var pendingSavedBooks by remember { mutableStateOf<List<BookShelfItem>?>(null) }
val displayBooks = draggingBooks ?: pendingSavedBooks ?: books
LaunchedEffect(books, pendingSavedBooks, canReorderBooks) {
if (!canReorderBooks) {
draggingBooks = null
pendingSavedBooks = null
return@LaunchedEffect
}
val pending = pendingSavedBooks ?: return@LaunchedEffect
if (books.map { it.bookUrl } == pending.map { it.bookUrl }) {
pendingSavedBooks = null
}
onSyncDragState(books, canReorderBooks)
}
val gridState = rememberLazyGridState()
val reorderableState = rememberReorderableLazyGridState(gridState) { from, to ->
if (canReorderBooks) {
draggingBooks = displayBooks.toMutableList().apply {
move(from.index, to.index)
}
onMoveBook(from.index, to.index, displayBooks)
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
}
LaunchedEffect(reorderableState.isAnyItemDragging) {
if (!reorderableState.isAnyItemDragging) {
draggingBooks?.let { reorderedBooks ->
pendingSavedBooks = reorderedBooks
onSaveBookOrder(reorderedBooks)
draggingBooks = null
}
onDragFinished()
}
}
FastScrollLazyVerticalGrid(
@@ -1077,7 +1109,7 @@ fun BookshelfPage(
if (canReorderBooks) {
Modifier.longPressDraggableHandle(
onDragStarted = {
draggingBooks = displayBooks
onDragStarted(displayBooks)
hapticFeedback.performHapticFeedback(
HapticFeedbackType.GestureThresholdActivate
)
@@ -3,6 +3,24 @@ package io.legado.app.ui.main.bookshelf
import io.legado.app.data.entities.BookGroup
import io.legado.app.ui.widget.components.list.ListUiState
data class BookshelfGroupSelectorState(
val groups: List<BookGroup> = emptyList(),
val selectedGroupIndex: Int = 0,
val selectedGroupId: Long = BookGroup.IdAll
)
sealed interface BookshelfOverlay {
data object AddUrlDialog : BookshelfOverlay
data object ImportSheet : BookshelfOverlay
data object ExportSheet : BookshelfOverlay
data object ConfigSheet : BookshelfOverlay
data object GroupManageSheet : BookshelfOverlay
data object LogSheet : BookshelfOverlay
data object GroupMenu : BookshelfOverlay
data object GroupSelectSheet : BookshelfOverlay
data object BatchDownloadConfirmDialog : BookshelfOverlay
}
data class BookshelfUiState(
override val items: List<BookShelfItem> = emptyList(),
override val selectedIds: Set<Any> = emptySet(),
@@ -19,5 +37,16 @@ data class BookshelfUiState(
val selectedGroupId: Long = BookGroup.IdAll,
val loadingText: String? = null,
val upBooksCount: Int = 0,
val updatingBooks: Set<String> = emptySet()
val updatingBooks: Set<String> = emptySet(),
val activeOverlay: BookshelfOverlay? = null,
val isEditMode: Boolean = false,
val selectedBookUrls: Set<String> = emptySet(),
val isInFolderRoot: Boolean = false,
val isRefreshing: Boolean = false,
val bookGroupStyle: Int = 0,
val title: String = "",
val subtitle: String? = null,
val currentGroupName: String? = null,
val draggingBooks: List<BookShelfItem>? = null,
val pendingSavedBooks: List<BookShelfItem>? = null
) : ListUiState<BookShelfItem>
@@ -48,19 +48,20 @@ import io.legado.app.utils.eventBus.FlowEventBus
import io.legado.app.utils.fromJsonArray
import io.legado.app.utils.isAbsUrl
import io.legado.app.utils.isJsonArray
import io.legado.app.utils.move
import io.legado.app.utils.onEachParallel
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.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -71,7 +72,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
@@ -86,7 +86,6 @@ import java.io.FileOutputStream
import java.io.OutputStreamWriter
import java.util.LinkedList
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import kotlin.math.max
import kotlin.math.min
@@ -97,18 +96,23 @@ class BookshelfViewModel(
private val batchCacheDownloadUseCase: BatchCacheDownloadUseCase,
private val updateBooksGroupUseCase: UpdateBooksGroupUseCase
) : BaseViewModel(application) {
var addBookJob: Coroutine<*>? = null
private var addBookJob: Coroutine<*>? = null
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)
private val selectedBookUrlsFlow = MutableStateFlow<Set<String>>(emptySet())
private val isInFolderRootFlow = MutableStateFlow(BookshelfConfig.bookGroupStyle == 2)
private val isRefreshingFlow = MutableStateFlow(false)
private val bookGroupStyleFlow = MutableStateFlow(BookshelfConfig.bookGroupStyle)
private val draggingBooksFlow = MutableStateFlow<List<BookShelfItem>?>(null)
private val pendingSavedBooksFlow = MutableStateFlow<List<BookShelfItem>?>(null)
// 更新相关
private var threadCount = AppConfig.threadCount
private var poolSize = threadCount
private var upTocPool = Executors.newFixedThreadPool(poolSize).asCoroutineDispatcher()
private val waitUpTocBooks = LinkedList<String>()
private val onUpTocBooks = ConcurrentHashMap.newKeySet<String>()
private val updatingBooksFlow = MutableStateFlow<Set<String>>(emptySet())
@@ -119,6 +123,13 @@ class BookshelfViewModel(
val scrollTrigger = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
private val updateConcurrency: Int
get() = AppConfig.threadCount.coerceIn(1, AppConst.MAX_THREAD)
@OptIn(ExperimentalCoroutinesApi::class)
private val updateDispatcher: CoroutineDispatcher
get() = Dispatchers.IO.limitedParallelism(updateConcurrency)
protected val _eventChannel = Channel<BaseRuleEvent>()
val events = _eventChannel.receiveAsFlow()
@@ -138,79 +149,36 @@ class BookshelfViewModel(
val allBookCount: Int
)
val groupSelectorState: StateFlow<BookshelfGroupSelectorState> = combine(
groupsFlow,
groupIdFlow
) { groups, selectedGroupId ->
BookshelfGroupSelectorState(
groups = groups,
selectedGroupIndex = groups.indexOfFirst { it.groupId == selectedGroupId }
.coerceAtLeast(0),
selectedGroupId = selectedGroupId
)
}.distinctUntilChanged()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), BookshelfGroupSelectorState())
@OptIn(ExperimentalCoroutinesApi::class)
val booksFlow = combine(groupIdFlow, refreshTrigger) { groupId, _ -> groupId }
.flatMapLatest { groupId ->
appDb.bookDao.flowBookShelfByGroup(groupId).map { list ->
combine(
appDb.bookDao.flowBookShelfByGroup(groupId),
groupsFlow
) { list, groups ->
sortBooks(
list,
groupsFlow.value.find { it.groupId == groupId })
groups.find { it.groupId == groupId }
)
}
}.distinctUntilChanged().flowOn(Dispatchers.Default)
private val groupPreviewsFlow =
combine(groupsFlow, allBooksFlow, refreshTrigger) { groups, allBooks, _ ->
if (BookshelfConfig.bookGroupStyle in 2..3) {
val previews = HashMap<Long, List<BookShelfItem>>(groups.size)
val counts = HashMap<Long, Int>(groups.size)
groups.forEach { group ->
val groupBooks = when (group.groupId) {
BookGroup.IdRoot -> {
val sumUserGroupIds =
groups.filter { it.groupId > 0 }.sumOf { it.groupId }
allBooks.filter { book ->
(book.type and BookType.text) > 0 &&
(book.type and BookType.local) == 0 &&
(sumUserGroupIds and book.group) == 0L
}
}
BookGroup.IdAll -> allBooks
BookGroup.IdLocal -> allBooks.filter { (it.type and BookType.local) > 0 }
BookGroup.IdAudio -> allBooks.filter { (it.type and BookType.audio) > 0 }
BookGroup.IdNetNone -> {
val sumUserGroupIds =
groups.filter { it.groupId > 0 }.sumOf { it.groupId }
allBooks.filter { book ->
(book.type and BookType.audio) == 0 &&
(book.type and BookType.local) == 0 &&
(sumUserGroupIds and book.group) == 0L
}
}
BookGroup.IdLocalNone -> {
val sumUserGroupIds =
groups.filter { it.groupId > 0 }.sumOf { it.groupId }
allBooks.filter { book ->
(book.type and BookType.local) > 0 &&
(sumUserGroupIds and book.group) == 0L
}
}
BookGroup.IdManga -> allBooks.filter { (it.type and BookType.image) > 0 }
BookGroup.IdText -> allBooks.filter { (it.type and BookType.text) > 0 }
BookGroup.IdError -> allBooks.filter { (it.type and BookType.updateError) > 0 }
BookGroup.IdUnread -> allBooks.filter { it.durChapterIndex == 0 && it.durChapterPos == 0 }
BookGroup.IdReading -> allBooks.filter { it.totalChapterNum > 0 && it.durChapterIndex > 0 && it.durChapterIndex < it.totalChapterNum - 1 }
BookGroup.IdReadFinished -> allBooks.filter { it.totalChapterNum > 0 && it.durChapterIndex >= it.totalChapterNum - 1 }
else -> allBooks.filter { (it.group and group.groupId) != 0L }
}
counts[group.groupId] = groupBooks.size
val sortedBooks = sortBooks(groupBooks, group)
val booksWithCover = sortedBooks.filter { it.getDisplayCover() != null }
val result = if (booksWithCover.size >= 4) {
booksWithCover.take(4)
} else {
(booksWithCover + sortedBooks.filter { it.getDisplayCover() == null }).take(
4
)
}
previews[group.groupId] = result
}
GroupPreviewState(previews, counts, allBooks.size)
} else {
GroupPreviewState(emptyMap(), emptyMap(), allBooks.size)
}
combine(groupsFlow, allBooksFlow, refreshTrigger, bookGroupStyleFlow) { groups, allBooks, _, bookGroupStyle ->
buildGroupPreviewState(groups, allBooks, bookGroupStyle)
}.distinctUntilChanged().flowOn(Dispatchers.Default)
private val coreInternalStateFlow = combine(
@@ -239,36 +207,130 @@ class BookshelfViewModel(
val upBooksCount: Int
)
val uiState: StateFlow<BookshelfUiState> = combine(
data class BookshelfInteractionState(
val activeOverlay: BookshelfOverlay?,
val isEditMode: Boolean,
val selectedBookUrls: Set<String>,
val isInFolderRoot: Boolean,
val isRefreshing: Boolean,
val bookGroupStyle: Int,
val draggingBooks: List<BookShelfItem>?,
val pendingSavedBooks: List<BookShelfItem>?
)
private val editStateFlow = combine(
activeOverlayFlow,
isEditModeFlow,
selectedBookUrlsFlow,
isInFolderRootFlow
) { activeOverlay, isEditMode, selectedBookUrls, isInFolderRoot ->
EditState(activeOverlay, isEditMode, selectedBookUrls, isInFolderRoot)
}
private data class EditState(
val activeOverlay: BookshelfOverlay?,
val isEditMode: Boolean,
val selectedBookUrls: Set<String>,
val isInFolderRoot: Boolean
)
private val interactionStateFlow = combine(
editStateFlow,
isRefreshingFlow,
bookGroupStyleFlow,
draggingBooksFlow,
pendingSavedBooksFlow
) { editState, isRefreshing, bookGroupStyle, draggingBooks, pendingSavedBooks ->
BookshelfInteractionState(
activeOverlay = editState.activeOverlay,
isEditMode = editState.isEditMode,
selectedBookUrls = editState.selectedBookUrls,
isInFolderRoot = editState.isInFolderRoot,
isRefreshing = isRefreshing,
bookGroupStyle = bookGroupStyle,
draggingBooks = draggingBooks,
pendingSavedBooks = pendingSavedBooks
)
}
private val dataStateFlow = combine(
booksFlow,
groupsFlow,
allGroupsFlow,
groupPreviewsFlow,
internalStateFlow
) { books, groups, allGroups, previews, internal ->
BookshelfDataState(books, groups, allGroups, previews, internal)
}
private data class BookshelfDataState(
val books: List<BookShelfItem>,
val groups: List<BookGroup>,
val allGroups: List<BookGroup>,
val previews: GroupPreviewState,
val internal: InternalState
)
val uiState: StateFlow<BookshelfUiState> = combine(
dataStateFlow,
interactionStateFlow
) { data, interaction ->
val books = data.books
val groups = data.groups
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 selectedGroupIndex = groups.indexOfFirst { it.groupId == internal.groupId }
.coerceAtLeast(0)
val currentGroupName = allGroups.firstOrNull { it.groupId == internal.groupId }?.groupName
?: groups.getOrNull(selectedGroupIndex)?.groupName
val selectedIds = interaction.selectedBookUrls.mapTo(linkedSetOf<Any>()) { it }
val title = buildTitle(
bookGroupStyle = interaction.bookGroupStyle,
isInFolderRoot = interaction.isInFolderRoot,
isEditMode = interaction.isEditMode,
isSearchMode = internal.isSearchMode,
currentGroupName = currentGroupName,
upBooksCount = internal.upBooksCount
)
BookshelfUiState(
items = filteredBooks,
selectedIds = selectedIds,
groups = groups,
allGroups = allGroups,
groupPreviews = previews.previews,
groupBookCounts = previews.counts,
currentGroupBookCount = books.size,
allBooksCount = previews.allBookCount,
selectedGroupIndex = groups.indexOfFirst { it.groupId == internal.groupId }
.coerceAtLeast(0),
selectedGroupIndex = selectedGroupIndex,
selectedGroupId = internal.groupId,
searchKey = internal.searchKey,
isSearch = internal.isSearchMode,
isLoading = internal.loadingText != null,
loadingText = internal.loadingText,
upBooksCount = internal.upBooksCount,
updatingBooks = internal.updatingBooks
updatingBooks = internal.updatingBooks,
activeOverlay = interaction.activeOverlay,
isEditMode = interaction.isEditMode,
selectedBookUrls = interaction.selectedBookUrls,
isInFolderRoot = interaction.isInFolderRoot,
isRefreshing = interaction.isRefreshing,
bookGroupStyle = interaction.bookGroupStyle,
title = title,
subtitle = if (interaction.isEditMode) {
context.getString(R.string.bookshelf_total_count, previews.allBookCount)
} else {
null
},
currentGroupName = currentGroupName,
draggingBooks = interaction.draggingBooks,
pendingSavedBooks = interaction.pendingSavedBooks
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), BookshelfUiState())
@@ -292,7 +354,12 @@ class BookshelfViewModel(
snapshotFlow { BookshelfConfig.bookshelfSortOrder }.collect { refresh() }
}
viewModelScope.launch {
snapshotFlow { BookshelfConfig.bookGroupStyle }.collect { refresh() }
snapshotFlow { BookshelfConfig.bookGroupStyle }
.distinctUntilChanged()
.collect { style ->
updateBookGroupStyle(style)
refresh()
}
}
viewModelScope.launch {
snapshotFlow { BookshelfConfig.showWaitUpCount }.collect { postUpBooksCount() }
@@ -303,11 +370,6 @@ class BookshelfViewModel(
}
}
override fun onCleared() {
super.onCleared()
upTocPool.close()
}
private fun sortBooks(list: List<BookShelfItem>, group: BookGroup?): List<BookShelfItem> {
val bookSort = group?.getRealBookSort() ?: BookshelfConfig.bookshelfSort
val isDescending = BookshelfConfig.bookshelfSortOrder == 1
@@ -342,29 +404,126 @@ class BookshelfViewModel(
}
}
@OptIn(ExperimentalCoroutinesApi::class)
fun getBooksFlow(groupId: Long): Flow<List<BookShelfItem>> {
return combine(
appDb.bookDao.flowBookShelfByGroup(groupId),
searchKeyFlow,
searchModeFlow,
groupsFlow,
refreshTrigger
) { books, searchKey, isSearchMode, groups, _ ->
val group = groups.find { it.groupId == groupId }
val filtered = if (!isSearchMode || searchKey.isBlank()) {
books
private fun buildTitle(
bookGroupStyle: Int,
isInFolderRoot: Boolean,
isEditMode: Boolean,
isSearchMode: Boolean,
currentGroupName: String?,
upBooksCount: Int
): String {
val bookshelfTitle = context.getString(R.string.bookshelf)
val baseTitle = when {
isSearchMode && bookGroupStyle == 0 -> bookshelfTitle
isSearchMode -> currentGroupName ?: bookshelfTitle
bookGroupStyle == 1 -> currentGroupName ?: bookshelfTitle
bookGroupStyle == 2 -> if (isInFolderRoot) {
bookshelfTitle
} else {
books.filter { it.matchesSearchKey(searchKey) }
currentGroupName ?: bookshelfTitle
}
sortBooks(filtered, group)
}.distinctUntilChanged().flowOn(Dispatchers.Default)
else -> bookshelfTitle
}
return when {
isEditMode -> bookshelfTitle
upBooksCount > 0 -> "$baseTitle ($upBooksCount)"
else -> baseTitle
}
}
private fun buildGroupPreviewState(
groups: List<BookGroup>,
allBooks: List<BookShelfItem>,
bookGroupStyle: Int
): GroupPreviewState {
if (bookGroupStyle !in 2..3) {
return GroupPreviewState(emptyMap(), emptyMap(), allBooks.size)
}
val buckets = HashMap<Long, MutableList<BookShelfItem>>(groups.size)
groups.forEach { group ->
buckets[group.groupId] = ArrayList()
}
val userGroups = groups.filter { it.groupId > 0 }
val sumUserGroupIds = userGroups.sumOf { it.groupId }
fun add(groupId: Long, book: BookShelfItem) {
buckets[groupId]?.add(book)
}
allBooks.forEach { book ->
add(BookGroup.IdAll, book)
if (book.isRootGroupBook(sumUserGroupIds)) add(BookGroup.IdRoot, book)
if (book.isLocal) add(BookGroup.IdLocal, book)
if (book.isAudio) add(BookGroup.IdAudio, book)
if (book.isNetNoneGroupBook(sumUserGroupIds)) add(BookGroup.IdNetNone, book)
if (book.isLocalNoneGroupBook(sumUserGroupIds)) add(BookGroup.IdLocalNone, book)
if (book.isImage) add(BookGroup.IdManga, book)
if ((book.type and BookType.text) > 0) add(BookGroup.IdText, book)
if ((book.type and BookType.updateError) > 0) add(BookGroup.IdError, book)
if (book.durChapterIndex == 0 && book.durChapterPos == 0) {
add(BookGroup.IdUnread, book)
}
if (book.totalChapterNum > 0 &&
book.durChapterIndex > 0 &&
book.durChapterIndex < book.totalChapterNum - 1
) {
add(BookGroup.IdReading, book)
}
if (book.totalChapterNum > 0 && book.durChapterIndex >= book.totalChapterNum - 1) {
add(BookGroup.IdReadFinished, book)
}
userGroups.forEach { group ->
if ((book.group and group.groupId) != 0L) {
add(group.groupId, book)
}
}
}
val previews = HashMap<Long, List<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))
}
return GroupPreviewState(previews, counts, allBooks.size)
}
private fun BookShelfItem.isRootGroupBook(sumUserGroupIds: Long): Boolean {
return (type and BookType.text) > 0 &&
(type and BookType.local) == 0 &&
(sumUserGroupIds and group) == 0L
}
private fun BookShelfItem.isNetNoneGroupBook(sumUserGroupIds: Long): Boolean {
return (type and BookType.audio) == 0 &&
(type and BookType.local) == 0 &&
(sumUserGroupIds and group) == 0L
}
private fun BookShelfItem.isLocalNoneGroupBook(sumUserGroupIds: Long): Boolean {
return (type and BookType.local) > 0 &&
(sumUserGroupIds and group) == 0L
}
private fun buildGroupPreview(sortedBooks: List<BookShelfItem>): List<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)
}
}
fun changeGroup(groupId: Long) {
if (groupIdFlow.value != groupId) {
groupIdFlow.value = groupId
BookshelfConfig.saveTabPosition = groupId
clearSelection()
clearDragState()
}
}
@@ -377,12 +536,86 @@ class BookshelfViewModel(
if (!active) {
searchKeyFlow.value = ""
}
clearSelection()
}
fun refresh() {
refreshTrigger.value++
}
fun showOverlay(overlay: BookshelfOverlay) {
activeOverlayFlow.value = overlay
}
fun dismissOverlay() {
activeOverlayFlow.value = null
}
fun toggleEditMode() {
if (isEditModeFlow.value) {
exitEditMode()
return
}
if (bookGroupStyleFlow.value == 2 && isInFolderRootFlow.value) {
isInFolderRootFlow.value = false
}
isEditModeFlow.value = true
clearSelection()
}
fun exitEditMode() {
isEditModeFlow.value = false
clearSelection()
clearDragState()
}
fun clearSelection() {
selectedBookUrlsFlow.value = emptySet()
}
fun selectAllVisible() {
selectedBookUrlsFlow.value = uiState.value.items.mapTo(hashSetOf()) { it.bookUrl }
}
fun invertVisibleSelection() {
val visibleBookUrls = uiState.value.items.mapTo(hashSetOf()) { it.bookUrl }
selectedBookUrlsFlow.value = visibleBookUrls - selectedBookUrlsFlow.value
}
fun toggleBookSelection(bookUrl: String) {
selectedBookUrlsFlow.value = if (selectedBookUrlsFlow.value.contains(bookUrl)) {
selectedBookUrlsFlow.value - bookUrl
} else {
selectedBookUrlsFlow.value + bookUrl
}
}
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
clearSelection()
clearDragState()
}
}
private fun updateBookGroupStyle(bookGroupStyle: Int) {
val previousStyle = bookGroupStyleFlow.value
if (previousStyle == bookGroupStyle) return
bookGroupStyleFlow.value = bookGroupStyle
if (bookGroupStyle == 2 && previousStyle != 2) {
isInFolderRootFlow.value = true
} else if (bookGroupStyle != 2) {
isInFolderRootFlow.value = false
}
clearSelection()
clearDragState()
}
fun moveBooksToGroup(bookUrls: Set<String>, groupId: Long) {
if (bookUrls.isEmpty()) return
execute {
@@ -429,6 +662,48 @@ class BookshelfViewModel(
}
}
fun refreshBooks(books: List<BookShelfItem>) {
if (isRefreshingFlow.value) return
isRefreshingFlow.value = true
enqueueTocUpdate(books, resetRefreshWhenIdle = true)
}
fun startDraggingBooks(books: List<BookShelfItem>) {
draggingBooksFlow.value = books
}
fun moveDraggingBook(fromIndex: Int, toIndex: Int, fallbackBooks: List<BookShelfItem>) {
if (fromIndex == toIndex) return
val sourceBooks = draggingBooksFlow.value ?: fallbackBooks
if (fromIndex !in sourceBooks.indices || toIndex !in sourceBooks.indices) return
draggingBooksFlow.value = sourceBooks.toMutableList().apply {
move(fromIndex, toIndex)
}
}
fun finishDraggingBooks() {
val reorderedBooks = draggingBooksFlow.value ?: return
pendingSavedBooksFlow.value = reorderedBooks
draggingBooksFlow.value = null
saveBookOrder(reorderedBooks)
}
fun syncDragState(books: List<BookShelfItem>, canReorderBooks: Boolean) {
if (!canReorderBooks) {
clearDragState()
return
}
val pending = pendingSavedBooksFlow.value ?: return
if (books.map { it.bookUrl } == pending.map { it.bookUrl }) {
pendingSavedBooksFlow.value = null
}
}
private fun clearDragState() {
draggingBooksFlow.value = null
pendingSavedBooksFlow.value = null
}
fun gotoTop() {
scrollTrigger.tryEmit(Unit)
}
@@ -441,10 +716,25 @@ class BookshelfViewModel(
}
fun upToc(books: List<BookShelfItem>) {
execute(context = upTocPool) {
enqueueTocUpdate(books, resetRefreshWhenIdle = false)
}
private fun enqueueTocUpdate(
books: List<BookShelfItem>,
resetRefreshWhenIdle: Boolean
) {
execute(context = updateDispatcher) {
val bookUrls = books.filter { !it.isLocal && it.canUpdate }.map { it.bookUrl }
val fullBooks = bookUrls.mapNotNull { appDb.bookDao.getBook(it) }
addToWaitUp(fullBooks)
}.onError {
if (resetRefreshWhenIdle) {
isRefreshingFlow.value = false
}
}.onFinally {
if (resetRefreshWhenIdle) {
completeRefreshIfIdle()
}
}
}
@@ -462,14 +752,13 @@ class BookshelfViewModel(
}
private fun startUpTocJob() {
upPool()
postUpBooksCount()
upTocJob = viewModelScope.launch(upTocPool) {
upTocJob = viewModelScope.launch(updateDispatcher) {
flow {
while (true) {
emit(waitUpTocBooks.poll() ?: break)
}
}.onEachParallel(threadCount) {
}.onEachParallel(updateConcurrency) {
onUpTocBooks.add(it)
updatingBooksFlow.value = onUpTocBooks.toSet()
postEvent(EventBus.UP_BOOKSHELF, it)
@@ -483,6 +772,8 @@ class BookshelfViewModel(
upTocJob = null
if (waitUpTocBooks.isNotEmpty()) {
startUpTocJob()
} else {
completeRefreshIfIdle()
}
if (it == null && cacheBookJob == null && !CacheBookService.isRun) {
cacheBook()
@@ -493,13 +784,11 @@ class BookshelfViewModel(
}
}
private fun upPool() {
threadCount = AppConfig.threadCount
val newPoolSize = threadCount
if (poolSize == newPoolSize) return
poolSize = newPoolSize
upTocPool.close()
upTocPool = Executors.newFixedThreadPool(poolSize).asCoroutineDispatcher()
@Synchronized
private fun completeRefreshIfIdle() {
if (upTocJob == null && waitUpTocBooks.isEmpty() && onUpTocBooks.isEmpty()) {
isRefreshingFlow.value = false
}
}
private suspend fun updateToc(bookUrl: String) {
@@ -576,14 +865,14 @@ class BookshelfViewModel(
eventListenerSource.clear()
if (AppConfig.preDownloadNum == 0) return
cacheBookJob?.cancel()
cacheBookJob = viewModelScope.launch(upTocPool) {
cacheBookJob = viewModelScope.launch(updateDispatcher) {
launch {
while (isActive && CacheBook.isRun) {
CacheBook.setWorkingState(waitUpTocBooks.isEmpty() && onUpTocBooks.isEmpty())
delay(1000)
}
}
CacheBook.startProcessJob(upTocPool)
CacheBook.startProcessJob(updateDispatcher)
}
}
@@ -768,6 +1057,16 @@ class BookshelfViewModel(
}
}
fun importBookshelf(uri: Uri, groupId: Long) {
execute {
uri.readText(context)
}.onSuccess {
importBookshelf(it, groupId)
}.onError {
context.toastOnUi(it.localizedMessage ?: "ERROR")
}
}
private fun importBookshelfByJson(json: String, groupId: Long) {
loadingTextFlow.value = "导入中..."
execute {
@@ -15,7 +15,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Login
@@ -35,7 +35,6 @@ import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -49,6 +48,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.ui.widget.components.explore.ExploreKindUiUseCase
@@ -64,7 +64,6 @@ import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.divider.PillHeaderDivider
import io.legado.app.ui.widget.components.explore.calculateExploreKindRows
import io.legado.app.ui.widget.components.explore.ExploreKindMultiTypeItem
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
@@ -75,6 +74,7 @@ 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.utils.startActivity
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@@ -88,47 +88,59 @@ fun ExploreScreen(
) {
val context = LocalContext.current
val activity = context as? AppCompatActivity
val uiState by viewModel.uiState.collectAsState()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var sourceToDelete by remember { mutableStateOf<BookSourcePart?>(null) }
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val exploreKindUseCase: ExploreKindUiUseCase = koinInject()
// 自动滚动置顶
LaunchedEffect(uiState.expandedId) {
uiState.expandedId?.let { id ->
var realIndex = 0
for (item in uiState.items) {
if (item.bookSourceUrl == id) break
realIndex++
}
if (realIndex >= 0) {
listState.animateScrollToItem(realIndex)
LaunchedEffect(viewModel, activity, exploreKindUseCase) {
viewModel.effects.collect { effect ->
when (effect) {
is ExploreEffect.ExecuteKindAction -> {
exploreKindUseCase.executeAction(
action = effect.kind.action,
title = effect.kind.title,
sourceUrl = effect.sourceUrl,
activity = activity,
onRefreshKinds = { viewModel.refreshExploreKinds(effect.sourceUrl) }
)
}
}
}
}
val stickyHeaderSource by remember {
derivedStateOf {
val expandedId = uiState.expandedId ?: return@derivedStateOf null
val expandedSource =
uiState.items.find { it.bookSourceUrl == expandedId } ?: return@derivedStateOf null
var headerIndex = 0
var contentRowCount = 0
for (item in uiState.items) {
if (item.bookSourceUrl == expandedId) {
contentRowCount = calculateExploreKindRows(uiState.exploreKinds, 6).size
break
val expandedHeader = remember(uiState.expandedId, uiState.listItems) {
val expandedId = uiState.expandedId ?: return@remember null
val headerIndex = uiState.listItems.indexOfFirst {
it is ExploreListItem.Header && it.source.bookSourceUrl == expandedId
}
val headerItem = uiState.listItems.getOrNull(headerIndex) as? ExploreListItem.Header
if (headerItem != null) {
ExpandedExploreHeader(
source = headerItem.source,
headerIndex = headerIndex,
contentRowCount = uiState.listItems.count {
it is ExploreListItem.KindRow && it.sourceUrl == expandedId
}
headerIndex++
}
)
} else {
null
}
}
val lastContentIndex = headerIndex + contentRowCount
LaunchedEffect(expandedHeader?.headerIndex) {
expandedHeader?.let { listState.animateScrollToItem(it.headerIndex) }
}
val stickyHeaderSource by remember(expandedHeader) {
derivedStateOf {
val header = expandedHeader ?: return@derivedStateOf null
val lastContentIndex = header.headerIndex + header.contentRowCount
val firstVisible = listState.firstVisibleItemIndex
if (firstVisible in (headerIndex + 1)..lastContentIndex) {
expandedSource
if (firstVisible in (header.headerIndex + 1)..lastContentIndex) {
header.source
} else {
null
}
@@ -181,15 +193,14 @@ fun ExploreScreen(
bottom = 120.dp
)
) {
uiState.items.forEach { item ->
val isExpanded = uiState.expandedId == item.bookSourceUrl
item(key = item.bookSourceUrl) {
if (isExpanded) {
LaunchedEffect(item.bookSourceUrl) {
exploreKindUseCase.warmUp(item.bookSourceUrl)
}
}
items(
items = uiState.listItems,
key = { it.key }
) { listItem ->
when (listItem) {
is ExploreListItem.Header -> {
val item = listItem.source
val isExpanded = uiState.expandedId == item.bookSourceUrl
ExploreSourceHeader(
modifier = Modifier.animateItem(),
item = item,
@@ -217,14 +228,9 @@ fun ExploreScreen(
onDelete = { sourceToDelete = item },
isMiuix = composeEngine
)
}
}
if (isExpanded) {
val rows = calculateExploreKindRows(uiState.exploreKinds, 6)
itemsIndexed(
items = rows,
key = { index, _ -> "${item.bookSourceUrl}_$index" }
) { _, rowItems ->
is ExploreListItem.KindRow -> {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -232,22 +238,27 @@ fun ExploreScreen(
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
rowItems.forEach { (kind, span) ->
listItem.rowItems.forEach { (kind, span) ->
ExploreKindMultiTypeItem(
kind = kind,
sourceUrl = item.bookSourceUrl,
activity = activity,
sourceUrl = listItem.sourceUrl,
onOpenUrl = { url ->
onOpenExploreShow(kind.title, item.bookSourceUrl, url)
onOpenExploreShow(kind.title, listItem.sourceUrl, url)
},
onRefreshKinds = { viewModel.refreshExploreKinds(item) },
modifier = Modifier.weight(span.toFloat()),
isMiuix = composeEngine,
useCase = exploreKindUseCase
displayNameOverride = uiState.kindDisplayNames[kind.title],
valueOverride = uiState.kindValues[kind.title],
onValueChange = { value ->
viewModel.updateKindValue(listItem.sourceUrl, kind, value)
},
onRunAction = {
viewModel.requestKindAction(listItem.sourceUrl, kind)
}
)
}
val totalSpan = rowItems.sumOf { it.second }
val totalSpan = listItem.rowItems.sumOf { it.second }
if (totalSpan < 6) {
Spacer(
modifier = Modifier.weight((6 - totalSpan).toFloat())
@@ -298,6 +309,12 @@ fun ExploreScreen(
)
}
private data class ExpandedExploreHeader(
val source: BookSourcePart,
val headerIndex: Int,
val contentRowCount: Int
)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ExploreSourceHeader(
@@ -3,26 +3,41 @@ package io.legado.app.ui.main.explore
import android.app.Application
import androidx.lifecycle.viewModelScope
import io.legado.app.base.BaseViewModel
import io.legado.app.data.appDb
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.help.source.SourceHelp
import io.legado.app.data.repository.ExploreRepository
import io.legado.app.help.source.clearExploreKindsCache
import io.legado.app.help.source.exploreKinds
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.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class ExploreViewModel(application: Application) : BaseViewModel(application) {
class ExploreViewModel(
application: Application,
private val exploreRepository: ExploreRepository,
private val exploreKindUseCase: ExploreKindUiUseCase
) : BaseViewModel(application) {
private val _uiState = MutableStateFlow(ExploreUiState())
val uiState = _uiState.asStateFlow()
val uiState: StateFlow<ExploreUiState> = _uiState
.map { state -> state.copy(listItems = buildExploreListItems(state)) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ExploreUiState())
private val _effects = MutableSharedFlow<ExploreEffect>(extraBufferCapacity = 8)
val effects = _effects.asSharedFlow()
private var exploreJob: Job? = null
private var kindsJob: Job? = null
@@ -34,9 +49,11 @@ class ExploreViewModel(application: Application) : BaseViewModel(application) {
private fun observeGroups() {
viewModelScope.launch {
appDb.bookSourceDao.flowExploreGroups().collectLatest { groups ->
_uiState.update { it.copy(groups = groups) }
}
exploreRepository.getExploreGroups()
.flowOn(IO)
.collectLatest { groups ->
_uiState.update { it.copy(groups = groups) }
}
}
}
@@ -64,28 +81,11 @@ class ExploreViewModel(application: Application) : BaseViewModel(application) {
val query = state.searchKey
val selectedGroup = state.selectedGroup
val flow = when {
query.isNotBlank() -> {
if (query.startsWith("group:")) {
val key = query.substringAfter("group:")
appDb.bookSourceDao.flowGroupExplore(key)
} else {
appDb.bookSourceDao.flowExplore(query)
}
exploreRepository.getExploreSources(query, selectedGroup)
.flowOn(IO)
.collectLatest { items ->
_uiState.update { it.copy(items = items) }
}
selectedGroup.isNotBlank() -> {
appDb.bookSourceDao.flowGroupExplore(selectedGroup)
}
else -> {
appDb.bookSourceDao.flowExplore()
}
}
flow.flowOn(IO).collectLatest { items ->
_uiState.update { it.copy(items = items) }
}
}
}
@@ -96,6 +96,8 @@ class ExploreViewModel(application: Application) : BaseViewModel(application) {
it.copy(
expandedId = newExpandedId,
exploreKinds = emptyList(),
kindDisplayNames = emptyMap(),
kindValues = emptyMap(),
loadingKinds = newExpandedId != null
)
}
@@ -110,9 +112,24 @@ class ExploreViewModel(application: Application) : BaseViewModel(application) {
kindsJob = viewModelScope.launch(IO) {
try {
val kinds = source.exploreKinds()
exploreKindUseCase.warmUp(source.bookSourceUrl)
val infoMap = getExploreInfoMap(source.bookSourceUrl)
val displayNames = kinds.associate { kind ->
kind.title to exploreKindUseCase.resolveDisplayName(
kind = kind,
sourceUrl = source.bookSourceUrl,
infoMap = infoMap
)
}
val values = buildKindValues(kinds, source.bookSourceUrl)
_uiState.update {
if (it.expandedId == source.bookSourceUrl) {
it.copy(exploreKinds = kinds, loadingKinds = false)
it.copy(
exploreKinds = kinds,
kindDisplayNames = displayNames,
kindValues = values,
loadingKinds = false
)
} else it
}
} catch (e: Exception) {
@@ -132,15 +149,34 @@ class ExploreViewModel(application: Application) : BaseViewModel(application) {
fun topSource(bookSource: BookSourcePart) {
execute {
val minXh = appDb.bookSourceDao.minOrder
bookSource.customOrder = minXh - 1
appDb.bookSourceDao.upOrder(bookSource)
exploreRepository.topSource(bookSource)
}
}
fun refreshExploreKinds(sourceUrl: String) {
val source = _uiState.value.items.firstOrNull { it.bookSourceUrl == sourceUrl } ?: return
refreshExploreKinds(source)
}
fun updateKindValue(sourceUrl: String, kind: ExploreKind, value: String) {
_uiState.update { state ->
state.copy(kindValues = state.kindValues + (kind.title to value))
}
viewModelScope.launch(IO) {
getExploreInfoMap(sourceUrl).apply {
this[kind.title] = value
saveNow()
}
}
}
fun requestKindAction(sourceUrl: String, kind: ExploreKind) {
_effects.tryEmit(ExploreEffect.ExecuteKindAction(sourceUrl, kind))
}
fun deleteSource(source: BookSourcePart) {
execute {
SourceHelp.deleteBookSource(source.bookSourceUrl)
exploreRepository.deleteSource(source.bookSourceUrl)
}
}
@@ -154,7 +190,94 @@ class ExploreViewModel(application: Application) : BaseViewModel(application) {
val selectedGroup: String = "",
val expandedId: String? = null,
val exploreKinds: List<ExploreKind> = emptyList(),
val loadingKinds: Boolean = false
val kindDisplayNames: Map<String, String> = emptyMap(),
val kindValues: Map<String, String> = emptyMap(),
val loadingKinds: Boolean = false,
val listItems: List<ExploreListItem> = emptyList()
) : ListUiState<BookSourcePart>
private fun buildExploreListItems(state: ExploreUiState): List<ExploreListItem> {
if (state.items.isEmpty()) return emptyList()
val expandedId = state.expandedId
val kindRows = if (expandedId != null) {
calculateExploreKindRows(state.exploreKinds, 6)
} else {
emptyList()
}
return buildList {
state.items.forEach { source ->
add(ExploreListItem.Header(source))
if (source.bookSourceUrl == expandedId) {
kindRows.forEachIndexed { index, row ->
add(
ExploreListItem.KindRow(
sourceUrl = source.bookSourceUrl,
rowIndex = index,
rowItems = row
)
)
}
}
}
}
}
private fun buildKindValues(
kinds: List<ExploreKind>,
sourceUrl: String
): Map<String, String> {
val infoMap = getExploreInfoMap(sourceUrl)
var shouldSave = false
val values = HashMap<String, String>()
kinds.forEach { kind ->
when (kind.type) {
ExploreKind.Type.text -> {
values[kind.title] = infoMap[kind.title].orEmpty()
}
ExploreKind.Type.toggle,
ExploreKind.Type.select -> {
val chars = kind.chars
?.filterNotNull()
?.takeIf { it.isNotEmpty() }
?: listOf("chars", "is null")
val value = infoMap[kind.title]
?.takeUnless { it.isEmpty() }
?: (kind.default ?: chars.first()).also {
infoMap[kind.title] = it
shouldSave = true
}
values[kind.title] = value
}
}
}
if (shouldSave) {
infoMap.saveNow()
}
return values
}
}
sealed interface ExploreListItem {
val key: String
data class Header(val source: BookSourcePart) : ExploreListItem {
override val key: String = source.bookSourceUrl
}
data class KindRow(
val sourceUrl: String,
val rowIndex: Int,
val rowItems: List<Pair<ExploreKind, Int>>
) : ExploreListItem {
override val key: String = "${sourceUrl}_$rowIndex"
}
}
sealed interface ExploreEffect {
data class ExecuteKindAction(
val sourceUrl: String,
val kind: ExploreKind
) : ExploreEffect
}
@@ -3,8 +3,11 @@ package io.legado.app.ui.main.my
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
@@ -14,7 +17,6 @@ import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.automirrored.filled.HelpOutline
@@ -36,12 +38,12 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R
import io.legado.app.ui.about.AboutActivity
import io.legado.app.ui.book.bookmark.AllBookmarkActivity
@@ -59,17 +61,18 @@ import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun MyScreen(
viewModel: MyViewModel,
viewModel: MyViewModel = koinViewModel(),
onOpenSettings: () -> Unit,
onNavigate: (PrefClickEvent) -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
AppScaffold(
@@ -81,7 +84,14 @@ fun MyScreen(
title = stringResource(R.string.my),
actions = {
IconButton(
onClick = { onNavigate(PrefClickEvent.ShowMd("appHelp", "xxx")) }
onClick = {
onNavigate(
PrefClickEvent.ShowMd(
title = "",
path = "appHelp"
)
)
}
) {Icon(
Icons.AutoMirrored.Filled.HelpOutline, null)
}
@@ -90,119 +100,123 @@ fun MyScreen(
)
}
) { padding ->
LazyColumn(
modifier = Modifier,
contentPadding = adaptiveContentPadding(
top = padding.calculateTopPadding(),
bottom = 120.dp
)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(
adaptiveContentPadding(
top = padding.calculateTopPadding(),
bottom = 120.dp
)
)
) {
item {
SplicedColumnGroup(
title = ""
) {
WebServiceSettingBlock(
uiState = uiState,
viewModel = viewModel,
onNavigate = onNavigate
)
}
SplicedColumnGroup(
title = ""
) {
WebServiceSettingBlock(
uiState = uiState,
onToggleWebService = {
viewModel.onEvent(PrefClickEvent.ToggleWebService)
},
onNavigate = onNavigate
)
}
SplicedColumnGroup(
title = stringResource(R.string.rule_segment),
) {
ClickableSettingItem(
title = stringResource(R.string.book_source_manage),
description = stringResource(R.string.book_source_manage_desc),
imageVector = Icons.Default.Source,
onClick = {
onNavigate(
PrefClickEvent.StartActivity(BookSourceActivity::class.java)
)
}
)
ClickableSettingItem(
title = stringResource(R.string.replace_purify),
imageVector = Icons.Default.FindReplace,
onClick = {
onNavigate(
PrefClickEvent.StartActivity(ReplaceRuleActivity::class.java)
)
}
)
ClickableSettingItem(
title = stringResource(R.string.txt_toc_rule),
imageVector = Icons.AutoMirrored.Filled.Rule,
onClick = {
onNavigate(
PrefClickEvent.StartActivity(TxtTocRuleActivity::class.java)
)
}
)
ClickableSettingItem(
title = stringResource(R.string.dict_rule),
imageVector = Icons.AutoMirrored.Filled.LibraryBooks,
onClick = {
onNavigate(
PrefClickEvent.StartActivity(DictRuleActivity::class.java)
)
}
)
}
SplicedColumnGroup(
title = stringResource(R.string.rule_segment),
) {
ClickableSettingItem(
title = stringResource(R.string.book_source_manage),
description = stringResource(R.string.book_source_manage_desc),
imageVector = Icons.Default.Source,
onClick = {
onNavigate(
PrefClickEvent.StartActivity(BookSourceActivity::class.java)
)
}
)
ClickableSettingItem(
title = stringResource(R.string.replace_purify),
imageVector = Icons.Default.FindReplace,
onClick = {
onNavigate(
PrefClickEvent.StartActivity(ReplaceRuleActivity::class.java)
)
}
)
ClickableSettingItem(
title = stringResource(R.string.txt_toc_rule),
imageVector = Icons.AutoMirrored.Filled.Rule,
onClick = {
onNavigate(
PrefClickEvent.StartActivity(TxtTocRuleActivity::class.java)
)
}
)
ClickableSettingItem(
title = stringResource(R.string.dict_rule),
imageVector = Icons.AutoMirrored.Filled.LibraryBooks,
onClick = {
onNavigate(
PrefClickEvent.StartActivity(DictRuleActivity::class.java)
)
}
)
}
SplicedColumnGroup(
title = stringResource(R.string.other)
) {
ClickableSettingItem(
title = stringResource(R.string.setting),
imageVector = Icons.Default.Settings,
onClick = {
onOpenSettings()
}
)
ClickableSettingItem(
title = stringResource(R.string.bookmark),
imageVector = Icons.Default.Bookmark,
onClick = {
onNavigate(PrefClickEvent.StartActivity(AllBookmarkActivity::class.java))
}
)
ClickableSettingItem(
title = stringResource(R.string.read_record),
imageVector = Icons.Default.History,
onClick = {
onNavigate(PrefClickEvent.StartActivity(ReadRecordActivity::class.java))
}
)
ClickableSettingItem(
title = "缓存管理",
imageVector = Icons.Default.Download,
onClick = {
onNavigate(PrefClickEvent.OpenBookCacheManage)
}
)
ClickableSettingItem(
title = stringResource(R.string.file_manage),
imageVector = Icons.Default.Folder,
onClick = {
onNavigate(PrefClickEvent.StartActivity(FileManageActivity::class.java))
}
)
ClickableSettingItem(
title = stringResource(R.string.about),
imageVector = Icons.Default.Info,
onClick = {
onNavigate(PrefClickEvent.StartActivity(AboutActivity::class.java))
}
)
ClickableSettingItem(
title = stringResource(R.string.exit),
imageVector = Icons.AutoMirrored.Filled.ExitToApp,
onClick = {
onNavigate(PrefClickEvent.ExitApp)
}
)
}
SplicedColumnGroup(
title = stringResource(R.string.other)
) {
ClickableSettingItem(
title = stringResource(R.string.setting),
imageVector = Icons.Default.Settings,
onClick = {
onOpenSettings()
}
)
ClickableSettingItem(
title = stringResource(R.string.bookmark),
imageVector = Icons.Default.Bookmark,
onClick = {
onNavigate(PrefClickEvent.StartActivity(AllBookmarkActivity::class.java))
}
)
ClickableSettingItem(
title = stringResource(R.string.read_record),
imageVector = Icons.Default.History,
onClick = {
onNavigate(PrefClickEvent.StartActivity(ReadRecordActivity::class.java))
}
)
ClickableSettingItem(
title = stringResource(R.string.cache_management),
imageVector = Icons.Default.Download,
onClick = {
onNavigate(PrefClickEvent.OpenBookCacheManage)
}
)
ClickableSettingItem(
title = stringResource(R.string.file_manage),
imageVector = Icons.Default.Folder,
onClick = {
onNavigate(PrefClickEvent.StartActivity(FileManageActivity::class.java))
}
)
ClickableSettingItem(
title = stringResource(R.string.about),
imageVector = Icons.Default.Info,
onClick = {
onNavigate(PrefClickEvent.StartActivity(AboutActivity::class.java))
}
)
ClickableSettingItem(
title = stringResource(R.string.exit),
imageVector = Icons.AutoMirrored.Filled.ExitToApp,
onClick = {
onNavigate(PrefClickEvent.ExitApp)
}
)
}
}
}
@@ -212,7 +226,7 @@ fun MyScreen(
@Composable
fun WebServiceSettingBlock(
uiState: MyUiState,
viewModel: MyViewModel,
onToggleWebService: () -> Unit,
onNavigate: (PrefClickEvent) -> Unit
) {
Column(modifier = Modifier.fillMaxWidth()) {
@@ -225,9 +239,7 @@ fun WebServiceSettingBlock(
},
imageVector = Icons.Default.Web,
checked = uiState.isWebServiceRun,
onCheckedChange = {
viewModel.onEvent(PrefClickEvent.ToggleWebService)
}
onCheckedChange = { onToggleWebService() }
)
AnimatedVisibility(
@@ -242,7 +254,7 @@ fun WebServiceSettingBlock(
horizontalArrangement = Arrangement.End
) {
SmallTextButton(
text = "复制地址",
text = stringResource(R.string.copy_url),
imageVector = Icons.Default.ContentCopy,
onClick = {
onNavigate(PrefClickEvent.CopyUrl(uiState.webServiceAddress))
@@ -252,7 +264,7 @@ fun WebServiceSettingBlock(
Spacer(modifier = Modifier.width(12.dp))
SmallTextButton(
text = "浏览器打开",
text = stringResource(R.string.open_in_browser),
imageVector = Icons.Default.OpenInBrowser,
onClick = {
onNavigate(PrefClickEvent.OpenUrl(uiState.webServiceAddress))
@@ -1,8 +1,8 @@
package io.legado.app.ui.main.my
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import io.legado.app.base.BaseViewModel
import io.legado.app.constant.EventBus
import io.legado.app.service.WebService
import io.legado.app.utils.eventBus.FlowEventBus
@@ -29,7 +29,7 @@ sealed class PrefClickEvent {
class MyViewModel(
application: Application
) : AndroidViewModel(application) {
) : BaseViewModel(application) {
private val _uiState = MutableStateFlow(
MyUiState(
@@ -59,9 +59,9 @@ class MyViewModel(
val currentIsRun = _uiState.value.isWebServiceRun
if (!currentIsRun) {
WebService.start(getApplication())
WebService.start(context)
} else {
WebService.stop(getApplication())
WebService.stop(context)
_uiState.update { it.copy(isWebServiceRun = false, webServiceAddress = "") }
}
@@ -32,7 +32,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -45,6 +45,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R
import io.legado.app.data.entities.RssSource
import io.legado.app.ui.login.SourceLoginActivity
@@ -66,6 +67,7 @@ import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.utils.openUrl
import io.legado.app.utils.startActivity
import kotlinx.coroutines.flow.collectLatest
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@@ -76,25 +78,24 @@ fun RssScreen(
onOpenRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit
) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsState()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var sourceToDelete by remember { mutableStateOf<RssSource?>(null) }
val openRss: (RssSource) -> Unit = { rssSource ->
if (rssSource.singleUrl) {
viewModel.getSingleUrl(rssSource) { url ->
if (url.startsWith("http", true)) {
onOpenRead(
rssSource.sourceName,
url,
null,
null
)
} else {
context.openUrl(url)
LaunchedEffect(viewModel) {
viewModel.effects.collectLatest { effect ->
when (effect) {
is RssEffect.OpenSort -> {
onOpenSort(effect.sourceUrl, effect.sortUrl, effect.key)
}
is RssEffect.OpenRead -> {
onOpenRead(effect.title, effect.origin, effect.link, effect.openUrl)
}
is RssEffect.OpenExternalUrl -> {
context.openUrl(effect.url)
}
}
} else {
onOpenSort(rssSource.sourceUrl, null, null)
}
}
@@ -114,7 +115,7 @@ fun RssScreen(
ListScaffold(
title = stringResource(R.string.rss),
state = uiState,
subtitle = uiState.group.ifEmpty { "全部" },
subtitle = uiState.group.ifEmpty { stringResource(R.string.all) },
onBackClick = null,
onSearchToggle = { viewModel.toggleSearchVisible(it) },
onSearchQueryChange = { viewModel.search(it) },
@@ -172,7 +173,7 @@ fun RssScreen(
RssSourceGridItem(
modifier = Modifier.animateItem(),
source = source,
onClick = { openRss(source) },
onClick = { viewModel.openSource(source) },
onTop = { viewModel.topSource(source) },
onEdit = { edit(source) },
onDelete = { sourceToDelete = source },
@@ -4,28 +4,34 @@ import android.app.Application
import androidx.lifecycle.viewModelScope
import com.script.rhino.runScriptWithContext
import io.legado.app.base.BaseViewModel
import io.legado.app.data.appDb
import io.legado.app.data.entities.RssSource
import io.legado.app.help.source.SourceHelp
import io.legado.app.data.repository.RssRepository
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class RssViewModel(application: Application) : BaseViewModel(application) {
class RssViewModel(
application: Application,
private val rssRepository: RssRepository
) : BaseViewModel(application) {
private val _uiState = MutableStateFlow(RssUiState())
val uiState = _uiState.asStateFlow()
private val searchKeyFlow = MutableStateFlow("")
private val groupFlow = MutableStateFlow("")
private val _effects = MutableSharedFlow<RssEffect>(extraBufferCapacity = 8)
val effects = _effects.asSharedFlow()
init {
initGroupData()
@@ -34,7 +40,7 @@ class RssViewModel(application: Application) : BaseViewModel(application) {
private fun initGroupData() {
viewModelScope.launch {
appDb.rssSourceDao.flowEnabledGroups()
rssRepository.getEnabledGroups()
.flowOn(IO)
.collect { groups ->
_uiState.update { state -> state.copy(groups = groups) }
@@ -45,17 +51,13 @@ class RssViewModel(application: Application) : BaseViewModel(application) {
@OptIn(ExperimentalCoroutinesApi::class)
private fun initRssData() {
combine(
_uiState.map { it.searchKey }.distinctUntilChanged(),
_uiState.map { it.group }.distinctUntilChanged()
searchKeyFlow,
groupFlow
) { searchKey, group ->
searchKey to group
}
.flatMapLatest { (searchKey, group) ->
when {
searchKey.isNotEmpty() -> appDb.rssSourceDao.flowEnabled(searchKey)
group.isNotEmpty() -> appDb.rssSourceDao.flowEnabledByGroup(group)
else -> appDb.rssSourceDao.flowEnabled()
}
rssRepository.getEnabledSources(searchKey, group)
}
.flowOn(IO)
.onEach { sources ->
@@ -65,14 +67,20 @@ class RssViewModel(application: Application) : BaseViewModel(application) {
}
fun search(key: String) {
searchKeyFlow.value = key
_uiState.update { it.copy(searchKey = key, isSearch = key.isNotEmpty()) }
}
fun setGroup(group: String) {
groupFlow.value = group
searchKeyFlow.value = ""
_uiState.update { it.copy(group = group, searchKey = "", isSearch = false) }
}
fun toggleSearchVisible(visible: Boolean) {
if (!visible) {
searchKeyFlow.value = ""
}
_uiState.update {
it.copy(isSearch = visible, searchKey = if (visible) it.searchKey else "")
}
@@ -80,70 +88,96 @@ class RssViewModel(application: Application) : BaseViewModel(application) {
fun topSource(vararg sources: RssSource) {
execute {
sources.sortBy { it.customOrder }
val minOrder = appDb.rssSourceDao.minOrder - 1
val array = Array(sources.size) {
sources[it].copy(customOrder = minOrder - it)
}
appDb.rssSourceDao.update(*array)
rssRepository.topSources(*sources)
}
}
fun bottomSource(vararg sources: RssSource) {
execute {
sources.sortBy { it.customOrder }
val maxOrder = appDb.rssSourceDao.maxOrder + 1
val array = Array(sources.size) {
sources[it].copy(customOrder = maxOrder + it)
}
appDb.rssSourceDao.update(*array)
rssRepository.bottomSources(*sources)
}
}
fun del(vararg rssSource: RssSource) {
execute {
SourceHelp.deleteRssSources(rssSource.toList())
rssRepository.deleteSources(rssSource.toList())
}
}
fun disable(rssSource: RssSource) {
execute {
rssSource.enabled = false
appDb.rssSourceDao.update(rssSource)
rssRepository.disableSource(rssSource)
}
}
fun getSingleUrl(rssSource: RssSource, onSuccess: (url: String) -> Unit) {
fun openSource(rssSource: RssSource) {
if (!rssSource.singleUrl) {
_effects.tryEmit(RssEffect.OpenSort(rssSource.sourceUrl, null, null))
return
}
execute {
var sortUrl = rssSource.sortUrl
if (!sortUrl.isNullOrBlank()) {
if (sortUrl.startsWith("<js>", false)
|| sortUrl.startsWith("@js:", false)
) {
val jsStr = if (sortUrl.startsWith("@")) {
sortUrl.substring(4)
} else {
sortUrl.substring(4, sortUrl.lastIndexOf("<"))
}
val result = runScriptWithContext {
rssSource.evalJS(jsStr)?.toString()
}
if (!result.isNullOrBlank()) {
sortUrl = result
}
}
if (sortUrl.contains("::")) {
return@execute sortUrl.split("::")[1]
} else {
return@execute sortUrl
}
}
rssSource.sourceUrl
resolveSingleUrl(rssSource)
}.timeout(10000)
.onSuccess {
onSuccess.invoke(it)
.onSuccess { url ->
if (url.startsWith("http", true)) {
_effects.tryEmit(
RssEffect.OpenRead(
title = rssSource.sourceName,
origin = url,
link = null,
openUrl = null
)
)
} else {
_effects.tryEmit(RssEffect.OpenExternalUrl(url))
}
}.onError {
context.toastOnUi(it.localizedMessage)
}
}
private suspend fun resolveSingleUrl(rssSource: RssSource): String {
var sortUrl = rssSource.sortUrl
if (!sortUrl.isNullOrBlank()) {
if (sortUrl.startsWith("<js>", false)
|| sortUrl.startsWith("@js:", false)
) {
val jsStr = if (sortUrl.startsWith("@")) {
sortUrl.substring(4)
} else {
sortUrl.substring(4, sortUrl.lastIndexOf("<"))
}
val result = runScriptWithContext {
rssSource.evalJS(jsStr)?.toString()
}
if (!result.isNullOrBlank()) {
sortUrl = result
}
}
return if (sortUrl.contains("::")) {
sortUrl.split("::")[1]
} else {
sortUrl
}
}
return rssSource.sourceUrl
}
}
sealed interface RssEffect {
data class OpenSort(
val sourceUrl: String,
val sortUrl: String?,
val key: String?
) : RssEffect
data class OpenRead(
val title: String?,
val origin: String,
val link: String?,
val openUrl: String?
) : RssEffect
data class OpenExternalUrl(val url: String) : RssEffect
}
@@ -57,32 +57,57 @@ import top.yukonga.miuix.kmp.icon.basic.ArrowUpDown
fun ExploreKindMultiTypeItem(
kind: ExploreKind,
sourceUrl: String?,
activity: AppCompatActivity?,
activity: AppCompatActivity? = null,
onOpenUrl: (String) -> Unit,
onRefreshKinds: () -> Unit,
onRefreshKinds: () -> Unit = {},
modifier: Modifier = Modifier,
backgroundColor: Color = LegadoTheme.colorScheme.surfaceContainer,
isMiuix: Boolean,
useCase: ExploreKindUiUseCase
displayNameOverride: String? = null,
valueOverride: String? = null,
onValueChange: ((String) -> Unit)? = null,
onRunAction: (() -> Unit)? = null,
useCase: ExploreKindUiUseCase? = null
) {
val scope = rememberCoroutineScope()
val infoMap = remember(sourceUrl) { sourceUrl?.takeIf { it.isNotBlank() }?.let(::getExploreInfoMap) }
val infoMap = remember(sourceUrl, useCase) {
if (useCase == null) null else sourceUrl?.takeIf { it.isNotBlank() }?.let(::getExploreInfoMap)
}
var displayName by remember(sourceUrl, kind.title, kind.viewName) { mutableStateOf(kind.title) }
LaunchedEffect(sourceUrl, kind.title, kind.viewName) {
displayName = useCase.resolveDisplayName(kind, sourceUrl, infoMap)
LaunchedEffect(displayNameOverride, sourceUrl, kind.title, kind.viewName, useCase) {
displayName = displayNameOverride
?: useCase?.resolveDisplayName(kind, sourceUrl, infoMap)
?: kind.title
}
fun runAction(action: String?) {
scope.launch(IO) {
useCase.executeAction(
action = action,
title = kind.title,
sourceUrl = sourceUrl,
infoMap = infoMap,
activity = activity,
onRefreshKinds = onRefreshKinds
)
if (action.isNullOrBlank()) return
if (onRunAction != null) {
onRunAction()
} else {
val useCase = useCase ?: return
scope.launch(IO) {
useCase.executeAction(
action = action,
title = kind.title,
sourceUrl = sourceUrl,
infoMap = infoMap,
activity = activity,
onRefreshKinds = onRefreshKinds
)
}
}
}
fun updateValue(value: String) {
if (onValueChange != null) {
onValueChange(value)
} else {
infoMap?.let {
it[kind.title] = value
it.saveNow()
}
}
}
@@ -131,17 +156,19 @@ fun ExploreKindMultiTypeItem(
ExploreKind.Type.text -> {
var value by remember(sourceUrl, kind.title) {
mutableStateOf(infoMap?.get(kind.title).orEmpty())
mutableStateOf(valueOverride ?: infoMap?.get(kind.title).orEmpty())
}
LaunchedEffect(valueOverride) {
if (valueOverride != null) {
value = valueOverride
}
}
var actionJob by remember(sourceUrl, kind.title) { mutableStateOf<Job?>(null) }
ExploreKindCompactTextField(
value = value,
onValueChange = { newValue ->
value = newValue
infoMap?.let {
it[kind.title] = newValue
it.saveNow()
}
updateValue(newValue)
if (!kind.action.isNullOrBlank()) {
actionJob?.cancel()
actionJob = scope.launch {
@@ -164,7 +191,8 @@ fun ExploreKindMultiTypeItem(
val left = kind.style().layout_justifySelf != "right"
var char by remember(sourceUrl, kind.title, kind.default, kind.chars) {
mutableStateOf(
infoMap?.get(kind.title)
valueOverride
?: infoMap?.get(kind.title)
?.takeUnless { it.isEmpty() }
?: (kind.default ?: chars.first()).also {
infoMap?.let { map ->
@@ -174,6 +202,11 @@ fun ExploreKindMultiTypeItem(
}
)
}
LaunchedEffect(valueOverride) {
if (valueOverride != null) {
char = valueOverride
}
}
val text = if (left) "$char$displayName" else "$displayName$char"
ExploreKindItem(
kind = kind,
@@ -182,10 +215,7 @@ fun ExploreKindMultiTypeItem(
val currentIndex = chars.indexOf(char)
val nextIndex = if (currentIndex < 0) 0 else (currentIndex + 1) % chars.size
char = chars.getOrElse(nextIndex) { "" }
infoMap?.let { map ->
map[kind.title] = char
map.saveNow()
}
updateValue(char)
runAction(kind.action)
},
modifier = modifier,
@@ -209,7 +239,8 @@ fun ExploreKindMultiTypeItem(
}
var selected by remember(sourceUrl, kind.title, kind.default, kind.chars) {
mutableStateOf(
infoMap?.get(kind.title)
valueOverride
?: infoMap?.get(kind.title)
?.takeUnless { it.isEmpty() }
?: (kind.default ?: chars.first()).also {
infoMap?.let { map ->
@@ -219,6 +250,11 @@ fun ExploreKindMultiTypeItem(
}
)
}
LaunchedEffect(valueOverride) {
if (valueOverride != null) {
selected = valueOverride
}
}
var showSelector by remember(sourceUrl, kind.title) { mutableStateOf(false) }
Box(modifier = modifier) {
ExploreKindItem(
@@ -249,10 +285,7 @@ fun ExploreKindMultiTypeItem(
showSelector = false
if (selected != option) {
selected = option
infoMap?.let { map ->
map[kind.title] = option
map.saveNow()
}
updateValue(option)
runAction(kind.action)
}
}
@@ -7,6 +7,7 @@ import io.legado.app.data.dao.BookSourceDao
import io.legado.app.data.entities.BaseSource
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.help.source.getExploreInfoMap
import io.legado.app.ui.login.SourceLoginJsExtensions
import io.legado.app.utils.InfoMap
import kotlinx.coroutines.Dispatchers.IO
@@ -43,6 +44,18 @@ class ExploreKindUiUseCase(
}
}
suspend fun executeAction(
action: String?,
title: String,
sourceUrl: String?,
activity: AppCompatActivity?,
onRefreshKinds: () -> Unit
) {
val effectiveSourceUrl = sourceUrl ?: return
val infoMap = getExploreInfoMap(effectiveSourceUrl)
executeAction(action, title, effectiveSourceUrl, infoMap, activity, onRefreshKinds)
}
suspend fun executeAction(
action: String?,
title: String,