[优化] 性能优化
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
+63
-30
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user