diff --git a/.claude/settings.local.json b/.claude/settings.local.json index bb32e0455..81c835ab0 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -49,7 +49,8 @@ "PowerShell(.\\\\gradlew.bat test 2>&1 | Select-Object -Last 10)", "Bash(npx skills *)", "Bash(npx *)", - "PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin 2>&1 | Select-Object -Last 20)" + "PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin 2>&1 | Select-Object -Last 20)", + "Bash(cmd /c \"gradlew.bat :app:compileAppDebugKotlin\")" ] } } diff --git a/app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt b/app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt index 044644a91..2fa505f47 100644 --- a/app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt @@ -172,6 +172,16 @@ interface BookSourceDao { ) fun getEnabledPartByGroup(group: String): List + @Query( + """select * from book_sources_part + where bookSourceGroup = :group + or bookSourceGroup like :group || ',%' + or bookSourceGroup like '%,' || :group + or bookSourceGroup like '%,' || :group || ',%' + order by customOrder asc""" + ) + fun getPartByGroup(group: String): List + @Query( """select * from book_sources where bookUrlPattern != 'NONE' and bookSourceType = :type order by customOrder asc""" diff --git a/app/src/main/java/io/legado/app/data/repository/BookGroupRepository.kt b/app/src/main/java/io/legado/app/data/repository/BookGroupRepository.kt index 8d32407e7..60cf604c8 100644 --- a/app/src/main/java/io/legado/app/data/repository/BookGroupRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/BookGroupRepository.kt @@ -11,6 +11,10 @@ class BookGroupRepository(private val bookGroupDao: BookGroupDao) { return bookGroupDao.flowAll() } + fun flowSelect(): Flow> { + return bookGroupDao.flowSelect() + } + fun flowShow(): Flow> { return bookGroupDao.show.asFlow() } diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt index 21bf22a95..881a5f20a 100644 --- a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt @@ -1,14 +1,42 @@ package io.legado.app.ui.book.changesource import android.app.Application +import io.legado.app.data.appDb +import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.SearchBook +import io.legado.app.data.repository.SearchRepository +import io.legado.app.ui.book.search.SearchScope +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update -class ChangeBookSourceComposeViewModel(application: Application) : - ChangeBookSourceViewModel(application) { +class ChangeBookSourceComposeViewModel( + application: Application, + private val searchRepository: SearchRepository, +) : ChangeBookSourceViewModel(application) { - val searchGroup: String - get() = ChangeSourceConfig.searchGroup + val enabledGroups = searchRepository.enabledGroups + val enabledSources = searchRepository.enabledSources + + val searchScope = SearchScope(ChangeSourceConfig.searchScope) + + data class ScopeUiState( + val isAll: Boolean, + val isSource: Boolean, + val displayNames: List, + val sourceUrls: List + ) + + private val _scopeUiState = MutableStateFlow( + ScopeUiState( + isAll = searchScope.isAll(), + isSource = searchScope.isSource(), + displayNames = searchScope.displayNames, + sourceUrls = searchScope.sourceUrls + ) + ) + val scopeUiState = _scopeUiState.asStateFlow() val checkAuthor: Boolean get() = ChangeSourceConfig.checkAuthor @@ -22,12 +50,6 @@ class ChangeBookSourceComposeViewModel(application: Application) : val loadWordCount: Boolean get() = ChangeSourceConfig.loadWordCount - fun onSearchGroupSelected(group: String) { - if (ChangeSourceConfig.searchGroup == group) return - ChangeSourceConfig.searchGroup = group - if (refresh()) startSearch() - } - fun onCheckAuthorChange(enabled: Boolean) { if (ChangeSourceConfig.checkAuthor == enabled) return ChangeSourceConfig.checkAuthor = enabled @@ -62,4 +84,60 @@ class ChangeBookSourceComposeViewModel(application: Application) : val currentScore = ObservableSourceConfig.getBookScore(searchBook) setBookScore(searchBook, if (currentScore > 0) 0 else 1) } + + fun selectAllScope() { + searchScope.update("") + saveScope() + } + + fun toggleScopeGroup(groupName: String) { + if (searchScope.isSource()) { + searchScope.update("") + } + val selected = searchScope.displayNames.toMutableSet() + if (selected.contains(groupName)) { + selected.remove(groupName) + } else { + selected.add(groupName) + } + searchScope.update(selected.toList()) + saveScope() + } + + fun toggleScopeSource(source: BookSourcePart) { + val selectedUrls = if (searchScope.isSource()) { + searchScope.sourceUrls.toMutableSet() + } else { + mutableSetOf() + } + + if (selectedUrls.contains(source.bookSourceUrl)) { + selectedUrls.remove(source.bookSourceUrl) + } else { + selectedUrls.add(source.bookSourceUrl) + } + + if (selectedUrls.isEmpty()) { + searchScope.update("") + } else { + val selectedSources = appDb.bookSourceDao.allEnabledPart.filter { + selectedUrls.contains(it.bookSourceUrl) + } + searchScope.updateSources(selectedSources) + } + saveScope() + } + + private fun saveScope() { + ChangeSourceConfig.searchScope = searchScope.toString() + _scopeUiState.update { + ScopeUiState( + isAll = searchScope.isAll(), + isSource = searchScope.isSource(), + displayNames = searchScope.displayNames, + sourceUrls = searchScope.sourceUrls + ) + } + refresh() + } } diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt index 20b766dc3..561b0153c 100644 --- a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt @@ -38,6 +38,7 @@ import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -97,6 +98,8 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a .thenBy { it.originOrder } } private var task: Job? = null + private var isPaused = false + private var wasSearching = false val bookMap = ConcurrentHashMap() val searchDataFlow = callbackFlow { @@ -119,13 +122,14 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a } - getDbSearchBooks().let { - searchBooks.clear() - searchBooks.addAll(it) - trySend(arrayOf(searchBooks)) - } - if (searchBooks.isEmpty()) { + getDbSearchBooks().let { + searchBooks.addAll(it) + } + } + trySend(arrayOf(searchBooks)) + + if (searchBooks.isEmpty() && !_isSearching.value) { startSearch() } @@ -207,18 +211,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a bookMap.clear() tocMapChapterCount = 0 _changeSourceProgress.value = 0 to "" - val searchGroup = AppConfig.searchGroup - if (searchGroup.isBlank()) { - bookSourceParts.addAll(appDb.bookSourceDao.allEnabledPart) - } else { - val sources = appDb.bookSourceDao.getEnabledPartByGroup(searchGroup) - if (sources.isEmpty()) { - AppConfig.searchGroup = "" - bookSourceParts.addAll(appDb.bookSourceDao.allEnabledPart) - } else { - bookSourceParts.addAll(sources) - } - } + bookSourceParts.addAll(io.legado.app.ui.book.search.SearchScope(ChangeSourceConfig.searchScope).getBookSourceParts()) initSearchPool() search() } @@ -250,6 +243,9 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a searchStateData.postValue(true) _isSearching.value = true }.mapParallel(threadCount) { + while (isPaused) { + kotlinx.coroutines.delay(100) + } try { withTimeout(60000L) { search(it) @@ -465,6 +461,17 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a searchPool?.close() searchStateData.postValue(false) _isSearching.value = false + wasSearching = false + } + + fun pause() { + isPaused = true + wasSearching = _isSearching.value + } + + fun resume() { + isPaused = false + wasSearching = false } fun getToc( diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceConfig.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceConfig.kt index 7058dbdcd..993ba412e 100644 --- a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceConfig.kt +++ b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceConfig.kt @@ -1,6 +1,7 @@ package io.legado.app.ui.book.changesource import io.legado.app.constant.PreferKey +import io.legado.app.domain.usecase.ChangeSourceMigrationOptions import io.legado.app.ui.config.prefDelegate object ChangeSourceConfig { @@ -10,6 +11,11 @@ object ChangeSourceConfig { defaultValue = "" ) + var searchScope by prefDelegate( + key = "changeSourceSearchScope", + defaultValue = "" + ) + var checkAuthor by prefDelegate( key = PreferKey.changeSourceCheckAuthor, defaultValue = false @@ -29,4 +35,61 @@ object ChangeSourceConfig { key = PreferKey.changeSourceLoadWordCount, defaultValue = false ) + + var migrateChapters by prefDelegate( + key = "migrateChapters", + defaultValue = true + ) + var migrateReadingProgress by prefDelegate( + key = "migrateReadingProgress", + defaultValue = true + ) + var migrateGroup by prefDelegate( + key = "migrateGroup", + defaultValue = true + ) + var migrateCover by prefDelegate( + key = "migrateCover", + defaultValue = true + ) + var migrateCategory by prefDelegate( + key = "migrateCategory", + defaultValue = true + ) + var migrateRemark by prefDelegate( + key = "migrateRemark", + defaultValue = true + ) + var migrateReadConfig by prefDelegate( + key = "migrateReadConfig", + defaultValue = true + ) + var deleteDownloadedChapters by prefDelegate( + key = "deleteDownloadedChapters", + defaultValue = false + ) + + fun getMigrationOptions(): ChangeSourceMigrationOptions { + return ChangeSourceMigrationOptions( + migrateChapters = migrateChapters, + migrateReadingProgress = migrateReadingProgress, + migrateGroup = migrateGroup, + migrateCover = migrateCover, + migrateCategory = migrateCategory, + migrateRemark = migrateRemark, + migrateReadConfig = migrateReadConfig, + deleteDownloadedChapters = deleteDownloadedChapters, + ) + } + + fun setMigrationOptions(options: ChangeSourceMigrationOptions) { + migrateChapters = options.migrateChapters + migrateReadingProgress = options.migrateReadingProgress + migrateGroup = options.migrateGroup + migrateCover = options.migrateCover + migrateCategory = options.migrateCategory + migrateRemark = options.migrateRemark + migrateReadConfig = options.migrateReadConfig + deleteDownloadedChapters = options.deleteDownloadedChapters + } } diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceMigrationOptionsSheet.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceMigrationOptionsSheet.kt index edfba5a67..ec10ae6b3 100644 --- a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceMigrationOptionsSheet.kt +++ b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceMigrationOptionsSheet.kt @@ -30,13 +30,27 @@ fun ChangeSourceMigrationOptionsSheet( onDismissRequest: () -> Unit, onConfirm: (ChangeSourceMigrationOptions) -> Unit, ) { - var migrateReadingProgress by rememberSaveable(show) { mutableStateOf(true) } - var migrateGroup by rememberSaveable(show) { mutableStateOf(true) } - var migrateCover by rememberSaveable(show) { mutableStateOf(true) } - var migrateCategory by rememberSaveable(show) { mutableStateOf(true) } - var migrateRemark by rememberSaveable(show) { mutableStateOf(true) } - var migrateReadConfig by rememberSaveable(show) { mutableStateOf(true) } - var deleteDownloadedChapters by rememberSaveable(show) { mutableStateOf(false) } + var migrateReadingProgress by rememberSaveable(show) { + mutableStateOf(ChangeSourceConfig.migrateReadingProgress) + } + var migrateGroup by rememberSaveable(show) { + mutableStateOf(ChangeSourceConfig.migrateGroup) + } + var migrateCover by rememberSaveable(show) { + mutableStateOf(ChangeSourceConfig.migrateCover) + } + var migrateCategory by rememberSaveable(show) { + mutableStateOf(ChangeSourceConfig.migrateCategory) + } + var migrateRemark by rememberSaveable(show) { + mutableStateOf(ChangeSourceConfig.migrateRemark) + } + var migrateReadConfig by rememberSaveable(show) { + mutableStateOf(ChangeSourceConfig.migrateReadConfig) + } + var deleteDownloadedChapters by rememberSaveable(show) { + mutableStateOf(ChangeSourceConfig.deleteDownloadedChapters) + } AppModalBottomSheet( show = show, diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt index cd1eeb7c1..848ef18bd 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt @@ -69,6 +69,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.ImageLoader import coil.compose.AsyncImage import io.legado.app.R @@ -282,12 +283,16 @@ private fun BookInfoScreenContent( onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) }, onSelect = { onIntent(BookInfoIntent.SelectCover(it)) }, ) - BookInfoSheet.GroupPicker -> GroupSelectSheet( - show = currentSheet == BookInfoSheet.GroupPicker, - currentGroupId = state.book?.group ?: 0L, - onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) }, - onConfirm = { onIntent(BookInfoIntent.SelectGroup(it)) }, - ) + BookInfoSheet.GroupPicker -> { + val groups by koinInject().flowSelect().collectAsStateWithLifecycle(initialValue = emptyList()) + GroupSelectSheet( + show = currentSheet == BookInfoSheet.GroupPicker, + groups = groups, + currentGroupId = state.book?.group ?: 0L, + onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) }, + onConfirm = { onIntent(BookInfoIntent.SelectGroup(it)) }, + ) + } BookInfoSheet.SourcePicker -> state.book?.let { book -> ChangeSourceSheet( show = currentSheet == BookInfoSheet.SourcePicker, diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt index 9b7537745..20ad26891 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.MoreVert @@ -36,6 +37,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -45,19 +47,22 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.legado.app.R -import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookGroup import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.SearchBook import io.legado.app.domain.usecase.ChangeSourceMigrationOptions -import io.legado.app.help.book.isSameNameAuthor import io.legado.app.ui.book.changecover.ChangeCoverViewModel import io.legado.app.ui.book.changesource.ChangeBookSourceComposeViewModel +import io.legado.app.ui.book.changesource.ChangeSourceConfig import io.legado.app.ui.book.changesource.ChangeSourceMigrationOptionsSheet import io.legado.app.ui.book.group.GroupEditSheet import io.legado.app.ui.book.source.edit.BookSourceEditActivity @@ -65,6 +70,7 @@ import io.legado.app.ui.book.source.manage.BookSourceActivity import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.widget.components.AppLinearProgressIndicator import io.legado.app.ui.widget.components.AppTextField +import io.legado.app.ui.widget.components.EmptyMessage import io.legado.app.ui.widget.components.alert.AppAlertDialog import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow import io.legado.app.ui.widget.components.button.MediumIconButton @@ -76,6 +82,7 @@ import io.legado.app.ui.widget.components.cover.CoilBookCover 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.modalBottomSheet.AppModalBottomSheet +import io.legado.app.ui.widget.components.tabRow.AppTabRow import io.legado.app.ui.widget.components.text.AppText import io.legado.app.utils.StartActivityContract import io.legado.app.utils.startActivity @@ -118,11 +125,11 @@ fun WebFileSheet( @Composable fun GroupSelectSheet( show: Boolean, + groups: List, currentGroupId: Long, onDismissRequest: () -> Unit, onConfirm: (Long) -> Unit, ) { - val groups by appDb.bookGroupDao.flowSelect().collectAsStateWithLifecycle(initialValue = emptyList()) var selectedGroupId by remember(currentGroupId) { mutableLongStateOf(currentGroupId) } var editingGroup by remember { mutableStateOf(null) } @@ -138,7 +145,7 @@ fun GroupSelectSheet( verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(groups, key = { it.groupId }) { group -> - val isSelected = selectedGroupId and group.groupId > 0 + val isSelected = (selectedGroupId and group.groupId) != 0L SelectionItemCard( title = group.groupName, isSelected = isSelected, @@ -194,7 +201,7 @@ fun ChangeCoverSheet( onSelect: (String) -> Unit, viewModel: ChangeCoverViewModel = koinViewModel(key = "cover-$name-$author"), ) { - val items by viewModel.dataFlow.collectAsStateWithLifecycle(initialValue = emptyList()) + val items by viewModel.dataFlow.collectAsStateWithLifecycle(initialValue = emptyList()) val isSearching by viewModel.isSearching.collectAsStateWithLifecycle() LaunchedEffect(name, author) { @@ -244,22 +251,25 @@ fun ChangeSourceSheet( viewModel: ChangeBookSourceComposeViewModel = koinViewModel(key = "source-${oldBook.bookUrl}"), ) { val context = LocalContext.current - val items by viewModel.searchDataFlow.collectAsStateWithLifecycle(initialValue = emptyList()) + val lifecycleOwner = LocalLifecycleOwner.current + val items by viewModel.searchDataFlow.collectAsStateWithLifecycle(initialValue = emptyList()) val isSearching by viewModel.isSearching.collectAsStateWithLifecycle() val progress by viewModel.changeSourceProgress.collectAsStateWithLifecycle() - val groups by appDb.bookSourceDao.flowEnabledGroups().collectAsStateWithLifecycle(initialValue = emptyList()) + val groups by viewModel.enabledGroups.collectAsStateWithLifecycle(initialValue = emptyList()) + val enabledSources by viewModel.enabledSources.collectAsStateWithLifecycle(initialValue = emptyList()) + val scopeState by viewModel.scopeUiState.collectAsStateWithLifecycle() var searchQuery by rememberSaveable { mutableStateOf("") } - val selectedGroup = viewModel.searchGroup val checkAuthor = viewModel.checkAuthor val loadInfo = viewModel.loadInfo val loadToc = viewModel.loadToc val loadWordCount = viewModel.loadWordCount var actionBook by remember { mutableStateOf(null) } var mismatchBook by remember { mutableStateOf(null) } - var pendingMigration by remember { mutableStateOf(null) } + var showMigrationOptions by remember { mutableStateOf(false) } var loadingAction by remember { mutableStateOf(false) } var showOptionsMenu by rememberSaveable { mutableStateOf(false) } - var showFilterMenu by rememberSaveable { mutableStateOf(false) } + var showFilterSheet by rememberSaveable { mutableStateOf(false) } + var scopeSheetTab by rememberSaveable { mutableIntStateOf(0) } val bookAddedToShelfText = stringResource(R.string.book_added_to_shelf) val editSourceResult = rememberLauncherForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) { @@ -270,6 +280,21 @@ fun ChangeSourceSheet( LaunchedEffect(oldBook.bookUrl) { viewModel.initData(oldBook.name, oldBook.author, oldBook, false) } + + DisposableEffect(lifecycleOwner, viewModel) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> viewModel.resume() + Lifecycle.Event.ON_PAUSE -> viewModel.pause() + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + DisposableEffect(oldBook.bookUrl) { onDispose { viewModel.stopSearch() @@ -323,10 +348,17 @@ fun ChangeSourceSheet( dismiss() } ) + RoundDropdownMenuItem( + text = stringResource(R.string.book_source_manage), + onClick = { + context.startActivity() + dismiss() + } + ) } } MediumIconButton( - onClick = { context.startActivity() }, + onClick = { showMigrationOptions = true }, imageVector = Icons.Outlined.Settings ) } @@ -337,35 +369,10 @@ fun ChangeSourceSheet( onClick = { viewModel.startOrStopSearch() }, imageVector = if (isSearching) Icons.Default.PauseCircleOutline else Icons.Default.Refresh, ) - Box { - MediumIconButton( - onClick = { showFilterMenu = true }, - imageVector = Icons.Default.FilterList - ) - RoundDropdownMenu( - expanded = showFilterMenu, - onDismissRequest = { showFilterMenu = false } - ) { dismiss -> - RoundDropdownMenuItem( - text = stringResource(R.string.all_source), - isSelected = selectedGroup.isBlank(), - onClick = { - viewModel.onSearchGroupSelected("") - dismiss() - } - ) - groups.forEach { group -> - RoundDropdownMenuItem( - text = group, - isSelected = selectedGroup == group, - onClick = { - viewModel.onSearchGroupSelected(group) - dismiss() - } - ) - } - } - } + MediumIconButton( + onClick = { showFilterSheet = true }, + imageVector = Icons.Default.FilterList + ) } } ) { @@ -389,95 +396,116 @@ fun ChangeSourceSheet( ) Spacer(modifier = Modifier.height(12.dp)) } - LazyColumn(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { - items(items, key = { it.bookUrl + it.origin }) { item -> - val bookScore by remember(item.origin, item.name, item.author) { - viewModel.bookScoreFlow(item) - }.collectAsStateWithLifecycle() - SelectionItemCard( - title = item.originName, - containerColor = LegadoTheme.colorScheme.onSheetContent, - selectedContainerColor = LegadoTheme.colorScheme.primaryContainer.copy(alpha = 0.32f), - leadingContent = { - MediumIconButton( - onClick = { - viewModel.onBookScoreClick(item) - }, - imageVector = Icons.Default.PushPin, - tint = if (bookScore > 0) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.outline, - contentDescription = null - ) - }, - supportingContent = { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - AppText( - text = item.author, - style = LegadoTheme.typography.labelLargeEmphasized - ) - AppText( - text = item.getDisplayLastChapterTitle(), - style = LegadoTheme.typography.labelMediumEmphasized - ) - item.chapterWordCountText?.takeIf { loadWordCount }?.let { - AppText( - text = it, - style = LegadoTheme.typography.labelSmallEmphasized, - color = LegadoTheme.colorScheme.primary - ) - } - } - }, - isSelected = item.bookUrl == oldBook.bookUrl, - onToggleSelection = { - if (item.bookUrl != oldBook.bookUrl) { - if (!item.sameBookTypeLocal(oldBook.type)) mismatchBook = item else actionBook = item - } - }, - dropdownContent = { onDismiss: () -> Unit -> - RoundDropdownMenuItem( - text = stringResource(R.string.to_top), - onClick = { - viewModel.topSource(item) - onDismiss() - } - ) - RoundDropdownMenuItem( - text = "置底", - onClick = { - viewModel.bottomSource(item) - onDismiss() - } - ) - RoundDropdownMenuItem( - text = stringResource(R.string.edit), - onClick = { - onDismiss() - editSourceResult.launch { putExtra("sourceUrl", item.origin) } - } - ) - RoundDropdownMenuItem( - text = "禁用", - onClick = { - viewModel.disableSource(item) - onDismiss() - } - ) - RoundDropdownMenuItem( - text = stringResource(R.string.delete), - color = LegadoTheme.colorScheme.error, - onClick = { - viewModel.del(item) - if (oldBook.bookUrl == item.bookUrl) { - viewModel.autoChangeSource(oldBook.type) { book, toc, source -> - pendingMigration = PendingSourceMigration(source, book, toc) - } - } - onDismiss() - } - ) - } + + if (items.isEmpty()) { + Box( + modifier = Modifier.fillMaxWidth().padding(vertical = 40.dp), + contentAlignment = Alignment.Center + ) { + EmptyMessage( + message = stringResource(R.string.search_empty) ) } + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(items, key = { it.bookUrl + it.origin }) { item -> + val bookScore by remember(item.origin, item.name, item.author) { + viewModel.bookScoreFlow(item) + }.collectAsStateWithLifecycle() + SelectionItemCard( + title = item.originName, + containerColor = LegadoTheme.colorScheme.onSheetContent, + selectedContainerColor = LegadoTheme.colorScheme.primaryContainer.copy(alpha = 0.32f), + leadingContent = { + MediumIconButton( + onClick = { + viewModel.onBookScoreClick(item) + }, + imageVector = Icons.Default.PushPin, + tint = if (bookScore > 0) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.outline, + contentDescription = null + ) + }, + supportingContent = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + AppText( + text = item.author, + style = LegadoTheme.typography.labelLargeEmphasized + ) + AppText( + text = item.getDisplayLastChapterTitle(), + style = LegadoTheme.typography.labelMediumEmphasized + ) + item.chapterWordCountText?.takeIf { loadWordCount }?.let { + AppText( + text = it, + style = LegadoTheme.typography.labelSmallEmphasized, + color = LegadoTheme.colorScheme.primary + ) + } + } + }, + isSelected = item.bookUrl == oldBook.bookUrl, + onToggleSelection = { + if (item.bookUrl != oldBook.bookUrl) { + if (!item.sameBookTypeLocal(oldBook.type)) mismatchBook = item else actionBook = + item + } + }, + dropdownContent = { onDismiss: () -> Unit -> + RoundDropdownMenuItem( + text = stringResource(R.string.to_top), + onClick = { + viewModel.topSource(item) + onDismiss() + } + ) + RoundDropdownMenuItem( + text = "置底", + onClick = { + viewModel.bottomSource(item) + onDismiss() + } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.edit), + onClick = { + onDismiss() + editSourceResult.launch { putExtra("sourceUrl", item.origin) } + } + ) + RoundDropdownMenuItem( + text = "禁用", + onClick = { + viewModel.disableSource(item) + onDismiss() + } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.delete), + color = LegadoTheme.colorScheme.error, + onClick = { + viewModel.del(item) + if (oldBook.bookUrl == item.bookUrl) { + viewModel.autoChangeSource(oldBook.type) { book, toc, source -> + onReplace( + source, + book, + toc, + ChangeSourceConfig.getMigrationOptions() + ) + } + } + onDismiss() + } + ) + } + ) + } + } } Spacer(modifier = Modifier.height(16.dp)) } @@ -485,19 +513,24 @@ fun ChangeSourceSheet( val performAction: (SearchBook, Boolean) -> Unit = { searchBook, replace -> loadingAction = true val book = viewModel.bookMap[searchBook.primaryStr()] ?: searchBook.toBook() - viewModel.getToc(book, { toc, source -> - loadingAction = false - if (replace) { - pendingMigration = PendingSourceMigration(source, book, toc) - } else { - onAddAsNew(book, toc) - context.toastOnUi(bookAddedToShelfText) + viewModel.getToc( + book, + onSuccess = { toc, source -> + loadingAction = false + if (replace) { + onReplace(source, book, toc, ChangeSourceConfig.getMigrationOptions()) + onDismissRequest() + } else { + onAddAsNew(book, toc) + context.toastOnUi(bookAddedToShelfText) + } + actionBook = null + }, + onError = { + loadingAction = false + context.toastOnUi(if (replace) "换源失败" else "添加书籍失败") } - actionBook = null - }, { - loadingAction = false - context.toastOnUi(if (replace) "换源失败" else "添加书籍失败") - }) + ) } AppAlertDialog( @@ -531,30 +564,103 @@ fun ChangeSourceSheet( } } ) - val migration = pendingMigration ChangeSourceMigrationOptionsSheet( - show = migration != null, + show = showMigrationOptions, title = "换源选项", - subtitle = migration?.let { - val sameNameAuthor = oldBook.isSameNameAuthor(it.book) - if (sameNameAuthor && oldBook.origin != it.book.origin) { - "检测到书名、作者相同但书源不同,可选择本次要迁移的数据。" - } else { - "选择本次替换当前书籍时要迁移的数据。" - } - }, - onDismissRequest = { pendingMigration = null }, + onDismissRequest = { showMigrationOptions = false }, onConfirm = { options -> - val pending = pendingMigration ?: return@ChangeSourceMigrationOptionsSheet - onReplace(pending.source, pending.book, pending.toc, options) - pendingMigration = null - onDismissRequest() + ChangeSourceConfig.setMigrationOptions(options) + showMigrationOptions = false } ) + + AppModalBottomSheet( + show = showFilterSheet, + onDismissRequest = { showFilterSheet = false }, + title = stringResource(R.string.search_select_group), + endAction = { + MediumIconButton( + onClick = { + viewModel.startSearch() + showFilterSheet = false + }, + imageVector = Icons.Default.Check + ) + } + ) { + Column { + SelectionItemCard( + title = stringResource(R.string.all_source), + isSelected = scopeState.isAll, + containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f), + inSelectionMode = true, + onToggleSelection = { + viewModel.selectAllScope() + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + AppTabRow( + tabTitles = listOf( + stringResource(R.string.group), + stringResource(R.string.book_source), + ), + selectedTabIndex = scopeSheetTab, + onTabSelected = { scopeSheetTab = it }, + ) + Spacer(modifier = Modifier.height(6.dp)) + + if (scopeSheetTab == 0) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(groups, key = { it }) { + val selected = !scopeState.isSource && scopeState.displayNames.contains(it) + SelectionItemCard( + title = it, + isSelected = selected, + containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f), + inSelectionMode = true, + onToggleSelection = { + viewModel.toggleScopeGroup(it) + } + ) + } + } + } else { + if (enabledSources.isEmpty()) { + Text( + text = stringResource(R.string.search_empty), + style = LegadoTheme.typography.bodyMedium, + color = LegadoTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + ) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(enabledSources, key = { it.bookSourceUrl }) { + val selected = scopeState.sourceUrls.contains(it.bookSourceUrl) + SelectionItemCard( + title = it.bookSourceName, + subtitle = it.bookSourceGroup?.takeIf { group -> group.isNotBlank() }, + containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f), + isSelected = selected, + inSelectionMode = true, + onToggleSelection = { + viewModel.toggleScopeSource(it) + } + ) + } + } + } + } + + Spacer(modifier = Modifier.height(20.dp)) + } + } } -private data class PendingSourceMigration( - val source: BookSource, - val book: Book, - val toc: List, -) diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt index 55f34ce0c..00575e1c9 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt @@ -20,6 +20,7 @@ import io.legado.app.domain.usecase.ChangeBookSourceUseCase import io.legado.app.domain.usecase.ChangeSourceMigrationOptions import io.legado.app.data.repository.ReadRecordRepository import io.legado.app.data.repository.RemoteBookRepository +import io.legado.app.data.repository.BookGroupRepository import io.legado.app.domain.usecase.ClearBookCacheUseCase import io.legado.app.exception.NoBooksDirException import io.legado.app.exception.NoStackTraceException @@ -68,9 +69,12 @@ class BookInfoViewModel( private val remoteBookRepository: RemoteBookRepository, private val readRecordRepository: ReadRecordRepository, private val changeBookSourceUseCase: ChangeBookSourceUseCase, - private val clearBookCacheUseCase: ClearBookCacheUseCase + private val clearBookCacheUseCase: ClearBookCacheUseCase, + private val bookGroupRepository: BookGroupRepository, ) : BaseViewModel(application) { + val allGroups = bookGroupRepository.flowAll() + private val _uiState = MutableStateFlow(BookInfoUiState()) val uiState = _uiState.asStateFlow() diff --git a/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt b/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt index 6b4eade87..15f7ab4b0 100644 --- a/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt @@ -72,6 +72,7 @@ import io.legado.app.R import io.legado.app.constant.IntentAction import io.legado.app.data.appDb import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookGroup import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSourcePart import io.legado.app.domain.usecase.BatchChangeSourcePreviewItem @@ -1056,6 +1057,7 @@ private fun BookshelfManageScreen( GroupSelectSheet( show = showGroupSelectSheet, + groups = state.groupList, currentGroupId = groupPickerCurrentGroupId, onDismissRequest = { showGroupSelectSheet = false }, onConfirm = { groupId -> diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt index f0a22618b..602bc5b59 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -103,7 +104,7 @@ fun SearchScreen( val listState = rememberLazyListState() val lifecycleOwner = LocalLifecycleOwner.current var queryInput by rememberSaveable { mutableStateOf(state.query) } - var scopeSheetTab by rememberSaveable { mutableStateOf(0) } + var scopeSheetTab by rememberSaveable { mutableIntStateOf(0) } var ignoreNextDebouncedQuery by rememberSaveable { mutableStateOf(null) } val showSuggestionPanel = state.showSuggestions val latestQuery by rememberUpdatedState(state.query) @@ -259,6 +260,13 @@ fun SearchScreen( ) }, actions = { + TopBarActionButton( + onClick = { + viewModel.onIntent(SearchIntent.OpenSourceManage) + }, + imageVector = AppIcons.Settings, + contentDescription = stringResource(R.string.book_source_manage) + ) TopBarAnimatedActionButton( checked = state.isPrecisionSearch, onCheckedChange = { checked -> @@ -289,13 +297,6 @@ fun SearchScreen( activeText = stringResource(R.string.screen), inactiveText = stringResource(R.string.screen), ) - TopBarActionButton( - onClick = { - viewModel.onIntent(SearchIntent.OpenSourceManage) - }, - imageVector = AppIcons.Settings, - contentDescription = stringResource(R.string.book_source_manage) - ) }, scrollBehavior = scrollBehavior ) diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt index 4eb5f78ca..43e866b1d 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt @@ -968,8 +968,11 @@ private fun BookshelfOverlays( onDismissRequest = { viewModel.dismissOverlay() } ) + val groups by viewModel.allGroupsFlow.collectAsStateWithLifecycle() + GroupSelectSheet( show = activeOverlay == BookshelfOverlay.GroupSelectSheet, + groups = groups, currentGroupId = 0L, onDismissRequest = { viewModel.dismissOverlay() }, onConfirm = { groupId ->