diff --git a/app/src/main/java/io/legado/app/ui/book/import/local/ImportBook.kt b/app/src/main/java/io/legado/app/ui/book/import/local/ImportBook.kt index 3fa74d5ab..75a8ffcfb 100644 --- a/app/src/main/java/io/legado/app/ui/book/import/local/ImportBook.kt +++ b/app/src/main/java/io/legado/app/ui/book/import/local/ImportBook.kt @@ -5,10 +5,11 @@ import io.legado.app.utils.FileDoc data class ImportBook( val file: FileDoc, - var isOnBookShelf: Boolean = !file.isDir && LocalBook.isOnBookShelf(file.name) + val isOnBookShelf: Boolean = !file.isDir && LocalBook.isOnBookShelf(file.name) ) { val name get() = file.name val isDir get() = file.isDir val size get() = file.size val lastModified get() = file.lastModified + val selectionId get() = file.toString() } diff --git a/app/src/main/java/io/legado/app/ui/book/import/local/ImportBookActivity.kt b/app/src/main/java/io/legado/app/ui/book/import/local/ImportBookActivity.kt deleted file mode 100644 index d303042b0..000000000 --- a/app/src/main/java/io/legado/app/ui/book/import/local/ImportBookActivity.kt +++ /dev/null @@ -1,322 +0,0 @@ -package io.legado.app.ui.book.import.local - -//import io.legado.app.lib.theme.backgroundColor -import android.annotation.SuppressLint -import android.net.Uri -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import androidx.activity.addCallback -import androidx.activity.viewModels -import androidx.appcompat.widget.PopupMenu -import androidx.core.net.toUri -import androidx.core.view.isVisible -import androidx.documentfile.provider.DocumentFile -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import io.legado.app.R -import io.legado.app.constant.PreferKey -import io.legado.app.data.appDb -import io.legado.app.databinding.DialogEditTextBinding -import io.legado.app.help.config.AppConfig -import io.legado.app.lib.dialogs.alert -import io.legado.app.lib.permission.Permissions -import io.legado.app.lib.permission.PermissionsCompat -import io.legado.app.ui.book.import.BaseImportBookActivity -import io.legado.app.ui.file.HandleFileContract -import io.legado.app.ui.widget.SelectActionBar -import io.legado.app.utils.ArchiveUtils -import io.legado.app.utils.FileDoc -import io.legado.app.utils.gone -import io.legado.app.utils.isContentScheme -import io.legado.app.utils.isUri -import io.legado.app.utils.launch -import io.legado.app.utils.putPrefInt -import io.legado.app.utils.visible -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.Dispatchers.Main -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.conflate -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File - -/** - * 导入本地书籍界面 - */ -class ImportBookActivity : BaseImportBookActivity(), - PopupMenu.OnMenuItemClickListener, - ImportBookAdapter.CallBack, - SelectActionBar.CallBack { - - override val viewModel by viewModels() - private val adapter by lazy { ImportBookAdapter(this, this) } - private var scanDocJob: Job? = null - - private val selectFolder = registerForActivityResult(HandleFileContract()) { - it.uri?.let { uri -> - AppConfig.importBookPath = uri.toString() - initRootDoc(true) - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - searchView.queryHint = getString(R.string.screen) + " • " + getString(R.string.local_book) - onBackPressedDispatcher.addCallback(this) { - if (!goBackDir()) { - finish() - } - } - lifecycleScope.launch { - initView() - initEvent() - if (setBookStorage() && AppConfig.importBookPath.isNullOrBlank()) { - AppConfig.importBookPath = AppConfig.defaultBookTreeUri - } - initData() - } - } - - override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.import_book, menu) - return super.onCompatCreateOptionsMenu(menu) - } - - override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { - menu.findItem(R.id.menu_sort_name)?.isChecked = viewModel.sort == 0 - menu.findItem(R.id.menu_sort_size)?.isChecked = viewModel.sort == 1 - menu.findItem(R.id.menu_sort_time)?.isChecked = viewModel.sort == 2 - return super.onMenuOpened(featureId, menu) - } - - override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.menu_select_folder -> selectFolder.launch() - R.id.menu_scan_folder -> scanFolder() - R.id.menu_import_file_name -> alertImportFileName() - R.id.menu_sort_name -> upSort(0) - R.id.menu_sort_size -> upSort(1) - R.id.menu_sort_time -> upSort(2) - } - return super.onCompatOptionsItemSelected(item) - } - - override fun onMenuItemClick(item: MenuItem?): Boolean { - when (item?.itemId) { - R.id.menu_del_selection -> viewModel.deleteDoc(adapter.selected) { - adapter.removeSelection() - } - } - return false - } - - override fun selectAll(selectAll: Boolean) { - adapter.selectAll(selectAll) - } - - override fun revertSelection() { - adapter.revertSelection() - } - - @SuppressLint("NotifyDataSetChanged") - override fun onClickSelectBarMainAction() { - viewModel.addToBookshelf(adapter.selected) { - adapter.selected.forEach { - it.isOnBookShelf = true - } - adapter.selected.clear() - adapter.notifyDataSetChanged() - } - } - - private fun initView() { - //binding.layTop.setBackgroundColor(backgroundColor) - binding.tvEmptyMsg.setText(R.string.empty_msg_import_book) - binding.recyclerView.layoutManager = LinearLayoutManager(this) - binding.recyclerView.adapter = adapter - binding.recyclerView.recycledViewPool.setMaxRecycledViews(0, 15) - binding.selectActionBar.setMainActionText(R.string.add_to_bookshelf) - binding.selectActionBar.inflateMenu(R.menu.import_book_sel) - binding.selectActionBar.setOnMenuItemClickListener(this) - binding.selectActionBar.setCallBack(this) - } - - private fun initEvent() { - binding.tvGoBack.setOnClickListener { - goBackDir() - } - } - - private fun initData() { - viewModel.dataFlowStart = { - initRootDoc() - } - lifecycleScope.launch { - viewModel.dataFlow.conflate().collect { docs -> - adapter.setItems(docs) - } - } - } - - private fun initRootDoc(changedFolder: Boolean = false) { - if (viewModel.rootDoc != null && !changedFolder) { - upPath() - } else { - val lastPath = AppConfig.importBookPath - if (lastPath.isNullOrBlank()) { - binding.tvEmptyMsg.visible() - selectFolder.launch() - } else { - val rootUri = if (lastPath.isUri()) { - lastPath.toUri() - } else { - Uri.fromFile(File(lastPath)) - } - when { - rootUri.isContentScheme() -> { - kotlin.runCatching { - val doc = DocumentFile.fromTreeUri(this, rootUri) - if (doc == null || doc.name.isNullOrEmpty()) { - binding.tvEmptyMsg.visible() - selectFolder.launch() - } else { - viewModel.subDocs.clear() - viewModel.rootDoc = FileDoc.fromDocumentFile(doc) - upDocs(viewModel.rootDoc!!) - } - }.onFailure { - binding.tvEmptyMsg.visible() - selectFolder.launch() - } - } - - else -> initRootPath(rootUri.path!!) - } - } - } - } - - private fun initRootPath(path: String) { - binding.tvEmptyMsg.visible() - PermissionsCompat.Builder() - .addPermissions(*Permissions.Group.STORAGE) - .rationale(R.string.tip_perm_request_storage) - .onGranted { - kotlin.runCatching { - viewModel.rootDoc = FileDoc.fromFile(File(path)) - viewModel.subDocs.clear() - upPath() - }.onFailure { - binding.tvEmptyMsg.visible() - selectFolder.launch() - } - } - .request() - } - - private fun upSort(sort: Int) { - viewModel.sort = sort - putPrefInt(PreferKey.localBookImportSort, sort) - if (scanDocJob?.isActive != true) { - viewModel.dataCallback?.upAdapter() - } - } - - @Synchronized - private fun upPath() { - binding.tvGoBack.isEnabled = viewModel.subDocs.isNotEmpty() - viewModel.rootDoc?.let { - scanDocJob?.cancel() - upDocs(it) - } - } - - private fun upDocs(rootDoc: FileDoc) { - binding.tvEmptyMsg.gone() - var path = rootDoc.name + File.separator - var lastDoc = rootDoc - for (doc in viewModel.subDocs) { - lastDoc = doc - path = path + doc.name + File.separator - } - binding.tvPath.text = path - adapter.selected.clear() - adapter.clearItems() - viewModel.loadDoc(lastDoc) - } - - /** - * 扫描当前文件夹及所有子文件夹 - */ - private fun scanFolder() { - viewModel.rootDoc?.let { doc -> - adapter.clearItems() - val lastDoc = viewModel.subDocs.lastOrNull() ?: doc - binding.refreshProgressBar.isVisible = true - scanDocJob?.cancel() - scanDocJob = lifecycleScope.launch(IO) { - viewModel.scanDoc(lastDoc) - withContext(Main) { - binding.refreshProgressBar.isVisible = false - } - } - } - } - - private fun alertImportFileName() { - alert(R.string.import_file_name) { - setMessage("""使用js处理文件名变量src,将书名作者分别赋值到变量name author""") - val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { - editView.hint = "js" - editView.setText(AppConfig.bookImportFileName) - } - customView { alertBinding.root } - okButton { - AppConfig.bookImportFileName = alertBinding.editView.text?.toString() - } - cancelButton() - } - } - - @Synchronized - override fun nextDoc(fileDoc: FileDoc) { - viewModel.subDocs.add(fileDoc) - upPath() - } - - @Synchronized - private fun goBackDir(): Boolean { - return if (viewModel.subDocs.isNotEmpty()) { - viewModel.subDocs.removeAt(viewModel.subDocs.lastIndex) - upPath() - true - } else { - false - } - } - - override fun onSearchTextChange(newText: String?) { - viewModel.updateCallBackFlow(newText) - } - - override fun upCountView() { - binding.selectActionBar.upCountView(adapter.selected.size, adapter.checkableCount) - } - - override fun startRead(fileDoc: FileDoc) { - if (!ArchiveUtils.isArchive(fileDoc.name)) { - appDb.bookDao.getBookByFileName(fileDoc.name)?.let { - val filePath = fileDoc.toString() - if (it.bookUrl != filePath) { - it.bookUrl = filePath - appDb.bookDao.insert(it) - } - startReadBook(it) - } - } else { - onArchiveFileClick(fileDoc) - } - } - -} diff --git a/app/src/main/java/io/legado/app/ui/book/import/local/ImportBookViewModel.kt b/app/src/main/java/io/legado/app/ui/book/import/local/ImportBookViewModel.kt index 2490ba91d..98ca0c715 100644 --- a/app/src/main/java/io/legado/app/ui/book/import/local/ImportBookViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/import/local/ImportBookViewModel.kt @@ -1,181 +1,623 @@ package io.legado.app.ui.book.import.local import android.app.Application +import android.net.Uri +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import androidx.compose.runtime.Immutable +import androidx.lifecycle.viewModelScope import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppLog +import io.legado.app.constant.AppPattern import io.legado.app.constant.AppPattern.archiveFileRegex import io.legado.app.constant.AppPattern.bookFileRegex import io.legado.app.constant.PreferKey +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.help.config.AppConfig import io.legado.app.model.localBook.LocalBook +import io.legado.app.ui.widget.components.list.InteractionState +import io.legado.app.ui.widget.components.list.ListUiState import io.legado.app.utils.AlphanumComparator +import io.legado.app.utils.ArchiveUtils import io.legado.app.utils.FileDoc import io.legado.app.utils.delete import io.legado.app.utils.getPrefInt +import io.legado.app.utils.isContentScheme +import io.legado.app.utils.isUri import io.legado.app.utils.list import io.legado.app.utils.mapParallel +import io.legado.app.utils.putPrefInt +import io.legado.app.utils.takePersistablePermissionSafely import io.legado.app.utils.toastOnUi +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.util.Collections +import java.io.File + +@Immutable +data class ImportBookUiState( + override val items: List = emptyList(), + override val selectedIds: Set = emptySet(), + override val searchKey: String = "", + val interaction: InteractionState = InteractionState(), + val pathNames: List = emptyList(), + val canGoBack: Boolean = false, + val sort: Int = 0 +) : ListUiState { + override val isSearch: Boolean get() = interaction.isSearchMode + override val isLoading: Boolean get() = interaction.isLoading +} + +enum class ImportFolderPickTarget { + DEFAULT_BOOK, + IMPORT_FOLDER +} + +sealed interface ImportBookIntent { + data object Initialize : ImportBookIntent + data object SelectFolderClick : ImportBookIntent + data class FolderPicked(val uri: Uri?, val target: ImportFolderPickTarget) : ImportBookIntent + data class SearchToggle(val enabled: Boolean) : ImportBookIntent + data class SearchQueryChange(val query: String) : ImportBookIntent + data class SortChange(val sort: Int) : ImportBookIntent + data object ScanFolder : ImportBookIntent + data object NavigateBack : ImportBookIntent + data class NavigateToLevel(val level: Int) : ImportBookIntent + data object SelectAll : ImportBookIntent + data object SelectInvert : ImportBookIntent + data object AddToBookshelf : ImportBookIntent + data object DeleteSelection : ImportBookIntent + data class ItemClick(val item: ImportBook) : ImportBookIntent + data class ArchiveEntrySelected(val fileDoc: FileDoc, val fileName: String) : ImportBookIntent + data class ImportArchiveConfirmed(val fileDoc: FileDoc, val fileName: String) : ImportBookIntent +} + +sealed interface ImportBookEffect { + data class RequestFolderPicker( + val target: ImportFolderPickTarget, + val initialUri: Uri? = null + ) : ImportBookEffect + + data class OpenBook(val book: Book) : ImportBookEffect + data class ShowArchiveEntries(val fileDoc: FileDoc, val fileNames: List) : ImportBookEffect + data class ShowImportArchiveDialog(val fileDoc: FileDoc, val fileName: String) : ImportBookEffect + data class ShowToastRes(val resId: Int) : ImportBookEffect +} class ImportBookViewModel(application: Application) : BaseViewModel(application) { - var rootDoc: FileDoc? = null - val subDocs = arrayListOf() - var sort = context.getPrefInt(PreferKey.localBookImportSort) - var dataCallback: DataCallback? = null - var dataFlowStart: (() -> Unit)? = null - var filterKey: String? = null - val dataFlow = callbackFlow> { - val list = Collections.synchronizedList(ArrayList()) + private data class InternalState( + val rootDoc: FileDoc? = null, + val subDocs: List = emptyList(), + val sourceDocs: List = emptyList(), + val selectedIds: Set = emptySet(), + val searchKey: String = "", + val sort: Int, + val interaction: InteractionState = InteractionState() + ) - dataCallback = object : DataCallback { + private val _state = MutableStateFlow( + InternalState(sort = context.getPrefInt(PreferKey.localBookImportSort)) + ) + private val _effects = MutableSharedFlow(extraBufferCapacity = 1) + val effects = _effects.asSharedFlow() - override fun setItems(fileDocs: List) { - list.clear() - fileDocs.mapTo(list) { - ImportBook(it) - } - trySend(list) + private var scanDocJob: Job? = null + + fun dispatch(intent: ImportBookIntent) { + when (intent) { + ImportBookIntent.Initialize -> initialize() + ImportBookIntent.SelectFolderClick -> { + _effects.tryEmit( + ImportBookEffect.RequestFolderPicker( + target = ImportFolderPickTarget.IMPORT_FOLDER, + initialUri = AppConfig.importBookPath + ?.takeIf { it.isUri() } + ?.toUri() + ) + ) } - override fun addItems(fileDocs: List) { - fileDocs.mapTo(list) { - ImportBook(it) - } - trySend(list) - } + is ImportBookIntent.FolderPicked -> onFolderPicked(intent.uri, intent.target) + is ImportBookIntent.SearchToggle -> setSearchMode(intent.enabled) + is ImportBookIntent.SearchQueryChange -> setSearchKey(intent.query) + is ImportBookIntent.SortChange -> setSort(intent.sort) + ImportBookIntent.ScanFolder -> scanCurrentDoc() + ImportBookIntent.NavigateBack -> navigateBack() + is ImportBookIntent.NavigateToLevel -> navigateToLevel(intent.level) + ImportBookIntent.SelectAll -> selectAllCheckable() + ImportBookIntent.SelectInvert -> invertSelection() + ImportBookIntent.AddToBookshelf -> addSelectedToBookshelf() + ImportBookIntent.DeleteSelection -> deleteSelectedDocs() + is ImportBookIntent.ItemClick -> onItemClick(intent.item) + is ImportBookIntent.ArchiveEntrySelected -> onArchiveEntrySelected( + intent.fileDoc, + intent.fileName + ) - override fun clear() { - list.clear() - trySend(emptyList()) - } + is ImportBookIntent.ImportArchiveConfirmed -> addArchiveToBookShelf( + intent.fileDoc, + intent.fileName + ) + } + } - override fun upAdapter() { - trySend(list) - } + val uiState = combine( + _state, + appDb.bookDao.flowLocal() + ) { state, localBooks -> + val localFileNames = localBooks.asSequence().map { it.originName }.toSet() + + val docs = state.sourceDocs.map { fileDoc -> + ImportBook( + file = fileDoc, + isOnBookShelf = !fileDoc.isDir && fileDoc.name in localFileNames + ) } - withContext(Main) { - dataFlowStart?.invoke() + val filteredDocs = if (state.searchKey.isBlank()) { + docs + } else { + docs.filter { it.name.contains(state.searchKey, ignoreCase = true) } } - awaitClose { - dataCallback = null - } - - }.map { docList -> - val docList = docList.toList() - val filterKey = filterKey - val skipFilter = filterKey.isNullOrBlank() - val comparator = when (sort) { + val comparator = when (state.sort) { 2 -> compareBy({ !it.isDir }, { -it.lastModified }) 1 -> compareBy({ !it.isDir }, { -it.size }) else -> compareBy { !it.isDir } } then compareBy(AlphanumComparator) { it.name } - docList.asSequence().filter { - skipFilter || it.name.contains(filterKey) - }.sortedWith(comparator).toList() - }.flowOn(IO) - fun addToBookshelf(bookList: HashSet, finally: () -> Unit) { - execute { - val fileUris = bookList.map { - it.file.uri + val sortedDocs = filteredDocs.sortedWith(comparator) + val checkableIds = sortedDocs + .asSequence() + .filter { !it.isDir && !it.isOnBookShelf } + .map { it.selectionId } + .toSet() + + val selectedIds = state.selectedIds.filterTo(hashSetOf()) { it in checkableIds } + val pathNames = state.rootDoc?.let { root -> + buildList { + add(root.name) + addAll(state.subDocs.map { it.name }) } - LocalBook.importFiles(fileUris) + } ?: emptyList() + + ImportBookUiState( + items = sortedDocs, + selectedIds = selectedIds, + searchKey = state.searchKey, + interaction = state.interaction, + pathNames = pathNames, + canGoBack = state.subDocs.isNotEmpty(), + sort = state.sort + ) + }.flowOn(Dispatchers.Default) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = ImportBookUiState() + ) + + fun hasRootDoc(): Boolean = _state.value.rootDoc != null + + private fun initialize() { + if (AppConfig.defaultBookTreeUri.isNullOrBlank()) { + _effects.tryEmit( + ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.DEFAULT_BOOK) + ) + return + } + if (AppConfig.importBookPath.isNullOrBlank()) { + AppConfig.importBookPath = AppConfig.defaultBookTreeUri + } + initRootDoc(changedFolder = false) + } + + private fun onFolderPicked(uri: Uri?, target: ImportFolderPickTarget) { + uri ?: return + uri.takePersistablePermissionSafely(context) + when (target) { + ImportFolderPickTarget.DEFAULT_BOOK -> { + AppConfig.defaultBookTreeUri = uri.toString() + if (AppConfig.importBookPath.isNullOrBlank()) { + AppConfig.importBookPath = AppConfig.defaultBookTreeUri + } + } + + ImportFolderPickTarget.IMPORT_FOLDER -> { + AppConfig.importBookPath = uri.toString() + } + } + initRootDoc(changedFolder = true) + } + + private fun initRootDoc(changedFolder: Boolean) { + if (hasRootDoc() && !changedFolder) { + reloadCurrentDoc() + return + } + val lastPath = AppConfig.importBookPath + if (lastPath.isNullOrBlank()) { + _effects.tryEmit( + ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.IMPORT_FOLDER) + ) + return + } + + val rootUri = if (lastPath.isUri()) { + lastPath.toUri() + } else { + Uri.fromFile(File(lastPath)) + } + + when { + rootUri.isContentScheme() -> { + kotlin.runCatching { + val doc = DocumentFile.fromTreeUri(context, rootUri) + if (doc == null || doc.name.isNullOrEmpty()) { + _effects.tryEmit( + ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.IMPORT_FOLDER) + ) + } else { + setRootDoc(FileDoc.fromDocumentFile(doc)) + } + }.onFailure { + _effects.tryEmit( + ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.IMPORT_FOLDER) + ) + } + } + + else -> { + kotlin.runCatching { + setRootDoc(FileDoc.fromFile(File(rootUri.path!!))) + }.onFailure { + _effects.tryEmit( + ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.IMPORT_FOLDER) + ) + } + } + } + } + + fun clearRoot() { + _state.update { + it.copy( + rootDoc = null, + subDocs = emptyList(), + sourceDocs = emptyList(), + selectedIds = emptySet() + ) + } + } + + fun setRootDoc(rootDoc: FileDoc) { + _state.update { + it.copy( + rootDoc = rootDoc, + subDocs = emptyList(), + selectedIds = emptySet() + ) + } + loadCurrentDoc() + } + + fun reloadCurrentDoc() { + loadCurrentDoc() + } + + fun navigateNext(fileDoc: FileDoc) { + _state.update { + it.copy( + subDocs = it.subDocs + fileDoc, + selectedIds = emptySet() + ) + } + loadCurrentDoc() + } + + fun navigateBack(): Boolean { + val hasSubDocs = _state.value.subDocs.isNotEmpty() + if (!hasSubDocs) return false + _state.update { + it.copy( + subDocs = it.subDocs.dropLast(1), + selectedIds = emptySet() + ) + } + loadCurrentDoc() + return true + } + + fun navigateToLevel(index: Int) { + val state = _state.value + val rootDoc = state.rootDoc ?: return + if (index < 0 || index > state.subDocs.size) return + + val newSubDocs = if (index == 0) { + emptyList() + } else { + state.subDocs.take(index) + } + _state.update { + it.copy( + subDocs = newSubDocs, + selectedIds = emptySet() + ) + } + loadDoc(newSubDocs.lastOrNull() ?: rootDoc) + } + + fun setSort(sort: Int) { + _state.update { it.copy(sort = sort) } + context.putPrefInt(PreferKey.localBookImportSort, sort) + } + + fun setSearchMode(isSearch: Boolean) { + _state.update { + it.copy( + interaction = it.interaction.copy(isSearchMode = isSearch), + searchKey = if (isSearch) it.searchKey else "" + ) + } + } + + fun setSearchKey(key: String) { + _state.update { it.copy(searchKey = key) } + } + + fun toggleSelection(id: String) { + _state.update { + val newSelected = if (id in it.selectedIds) { + it.selectedIds - id + } else { + it.selectedIds + id + } + it.copy(selectedIds = newSelected) + } + } + + fun selectAllCheckable() { + val selected = uiState.value.items + .asSequence() + .filter { !it.isDir && !it.isOnBookShelf } + .map { it.selectionId } + .toSet() + _state.update { it.copy(selectedIds = selected) } + } + + fun invertSelection() { + val checkableIds = uiState.value.items + .asSequence() + .filter { !it.isDir && !it.isOnBookShelf } + .map { it.selectionId } + .toSet() + _state.update { state -> + state.copy(selectedIds = checkableIds - state.selectedIds) + } + } + + fun clearSelection() { + _state.update { it.copy(selectedIds = emptySet()) } + } + + fun addSelectedToBookshelf() { + val selectedBooks = uiState.value.items + .filter { it.selectionId in uiState.value.selectedIds } + .filter { !it.isDir && !it.isOnBookShelf } + + if (selectedBooks.isEmpty()) return + + execute { + LocalBook.importFiles(selectedBooks.map { it.file.uri }) }.onError { context.toastOnUi("添加书架失败,请尝试重新选择文件夹") AppLog.put("添加书架失败\n${it.localizedMessage}", it) }.onSuccess { context.toastOnUi("添加书架成功") }.onFinally { - finally.invoke() + clearSelection() } } - fun deleteDoc(bookList: HashSet, finally: () -> Unit) { - execute { - bookList.forEach { - it.file.delete() + private fun onItemClick(item: ImportBook) { + when { + item.isDir -> navigateNext(item.file) + item.isOnBookShelf -> onImportedFileClick(item.file) + else -> toggleSelection(item.selectionId) + } + } + + private fun onImportedFileClick(fileDoc: FileDoc) { + if (!ArchiveUtils.isArchive(fileDoc.name)) { + appDb.bookDao.getBookByFileName(fileDoc.name)?.let { book -> + val filePath = fileDoc.toString() + if (book.bookUrl != filePath) { + book.bookUrl = filePath + appDb.bookDao.insert(book) + } + _effects.tryEmit(ImportBookEffect.OpenBook(book)) } - }.onFinally { - finally.invoke() + return + } + val fileNames = ArchiveUtils.getArchiveFilesName(fileDoc) { + it.matches(AppPattern.bookFileRegex) + } + when { + fileNames.isEmpty() -> _effects.tryEmit( + ImportBookEffect.ShowToastRes(io.legado.app.R.string.unsupport_archivefile_entry) + ) + + fileNames.size == 1 -> onArchiveEntrySelected(fileDoc, fileNames.first()) + else -> _effects.tryEmit(ImportBookEffect.ShowArchiveEntries(fileDoc, fileNames)) } } - fun loadDoc(fileDoc: FileDoc) { + private fun onArchiveEntrySelected(fileDoc: FileDoc, fileName: String) { + appDb.bookDao.getBookByFileName(fileName)?.let { + _effects.tryEmit(ImportBookEffect.OpenBook(it)) + } ?: _effects.tryEmit(ImportBookEffect.ShowImportArchiveDialog(fileDoc, fileName)) + } + + private fun addArchiveToBookShelf(fileDoc: FileDoc, fileName: String) { execute { - val docList = fileDoc.list { item -> + LocalBook.importArchiveFile(fileDoc.uri, fileName) { it.contains(fileName) } + .firstOrNull() + }.onSuccess { book -> + if (book != null) { + _effects.tryEmit(ImportBookEffect.OpenBook(book)) + } else { + _effects.tryEmit( + ImportBookEffect.ShowToastRes(io.legado.app.R.string.error) + ) + } + }.onError { + _effects.tryEmit( + ImportBookEffect.ShowToastRes(io.legado.app.R.string.error) + ) + } + } + + fun deleteSelectedDocs() { + val selectedIds = uiState.value.selectedIds.mapTo(hashSetOf()) { it.toString() } + if (selectedIds.isEmpty()) return + + execute { + _state.value.sourceDocs + .filter { it.toString() in selectedIds } + .forEach { it.delete() } + }.onFinally { + _state.update { state -> + state.copy( + sourceDocs = state.sourceDocs.filterNot { it.toString() in selectedIds }, + selectedIds = emptySet() + ) + } + } + } + + fun scanCurrentDoc() { + val current = currentDoc() ?: return + + scanDocJob?.cancel() + _state.update { + it.copy( + sourceDocs = emptyList(), + selectedIds = emptySet(), + interaction = it.interaction.copy(isLoading = true) + ) + } + + scanDocJob = viewModelScope.launch(IO) { + kotlin.runCatching { + scanDoc(current) + }.onSuccess { docs -> + _state.update { + it.copy( + sourceDocs = docs, + interaction = it.interaction.copy(isLoading = false) + ) + } + }.onFailure { + withContext(Main) { + context.toastOnUi("扫描文件夹出错\n${it.localizedMessage}") + } + _state.update { state -> + state.copy(interaction = state.interaction.copy(isLoading = false)) + } + } + } + } + + private fun loadCurrentDoc() { + currentDoc()?.let { loadDoc(it) } + } + + private fun currentDoc(state: InternalState = _state.value): FileDoc? { + return state.subDocs.lastOrNull() ?: state.rootDoc + } + + private fun loadDoc(fileDoc: FileDoc) { + scanDocJob?.cancel() + _state.update { + it.copy( + sourceDocs = emptyList(), + selectedIds = emptySet(), + interaction = it.interaction.copy(isLoading = true) + ) + } + + execute { + fileDoc.list { item -> when { item.name.startsWith(".") -> false item.isDir -> true else -> item.name.matches(bookFileRegex) || item.name.matches(archiveFileRegex) } + } ?: emptyList() + }.onSuccess { docs -> + _state.update { + it.copy( + sourceDocs = docs, + interaction = it.interaction.copy(isLoading = false) + ) } - dataCallback?.setItems(docList!!) }.onError { context.toastOnUi("获取文件列表出错\n${it.localizedMessage}") + _state.update { state -> + state.copy(interaction = state.interaction.copy(isLoading = false)) + } } } - suspend fun scanDoc(fileDoc: FileDoc) { - dataCallback?.clear() + private suspend fun scanDoc(fileDoc: FileDoc): List { val channel = Channel(UNLIMITED) var n = 1 channel.trySend(fileDoc) - val list = arrayListOf() + val docs = arrayListOf() + channel.consumeAsFlow() - .mapParallel(16) { fileDoc -> - fileDoc.list()!! - }.onEach { fileDocs -> + .mapParallel(16) { doc -> + doc.list() ?: emptyList() + } + .onEach { fileDocs -> n-- - list.clear() fileDocs.forEach { if (it.isDir) { n++ channel.trySend(it) - } else if (it.name.matches(bookFileRegex) - || it.name.matches(archiveFileRegex) - ) { - list.add(it) + } else if (it.name.matches(bookFileRegex) || it.name.matches(archiveFileRegex)) { + docs.add(it) } } - dataCallback?.addItems(list) - }.takeWhile { - n > 0 - }.catch { - context.toastOnUi("扫描文件夹出错\n${it.localizedMessage}") - }.collect() + } + .takeWhile { n > 0 } + .collect {} + + return docs } - fun updateCallBackFlow(filterKey: String?) { - this.filterKey = filterKey - dataCallback?.upAdapter() + override fun onCleared() { + scanDocJob?.cancel() + super.onCleared() } - - interface DataCallback { - - fun setItems(fileDocs: List) - - fun addItems(fileDocs: List) - - fun clear() - - fun upAdapter() - - } - -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookActivity.kt b/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookActivity.kt deleted file mode 100644 index db892b48d..000000000 --- a/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookActivity.kt +++ /dev/null @@ -1,26 +0,0 @@ -package io.legado.app.ui.book.import.remote - -import androidx.compose.runtime.Composable -import io.legado.app.base.BaseComposeActivity -import io.legado.app.ui.theme.AppTheme -import io.legado.app.utils.startActivityForBook - -/** - * 展示远程书籍 - */ -class RemoteBookActivity : BaseComposeActivity() { - @Composable - override fun Content() { - AppTheme { - RemoteBookScreen( - onBackClick = { finish() }, - startReadBook = { book -> - startActivityForBook(book) - }, - onArchiveFileClick = { }, - selectBookFolder = { } - ) - } - } - -} diff --git a/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookScreen.kt b/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookScreen.kt index 4fa0cc3e4..615d8f42e 100644 --- a/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookScreen.kt @@ -1,5 +1,7 @@ package io.legado.app.ui.book.import.remote +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.animateContentSize import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable @@ -39,18 +41,13 @@ import androidx.compose.material.icons.outlined.CloudSync import androidx.compose.material.icons.outlined.Description import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.RadioButton import androidx.compose.material3.TextButton import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState @@ -59,12 +56,11 @@ 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.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -75,30 +71,32 @@ import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.legado.app.R import io.legado.app.constant.AppConst -import io.legado.app.data.entities.Book import io.legado.app.data.entities.Server import io.legado.app.help.config.AppConfig +import io.legado.app.lib.dialogs.alert +import io.legado.app.lib.dialogs.selector import io.legado.app.model.remote.RemoteBook import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.widget.components.ActionItem +import io.legado.app.ui.widget.components.AppRadioButton +import io.legado.app.ui.widget.components.AppTextField import io.legado.app.ui.widget.components.EmptyMessageView import io.legado.app.ui.widget.components.SelectionActions import io.legado.app.ui.widget.components.alert.AppAlertDialog import io.legado.app.ui.widget.components.button.SmallIconButton import io.legado.app.ui.widget.components.button.SmallTonalIconButton import io.legado.app.ui.widget.components.card.GlassCard +import io.legado.app.ui.widget.components.card.SelectionItemCard import io.legado.app.ui.widget.components.card.TextCard import io.legado.app.ui.widget.components.list.ListScaffold 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.text.AppText -import io.legado.app.utils.ArchiveUtils import io.legado.app.utils.ConvertUtils import io.legado.app.utils.FileDoc -import io.legado.app.utils.find -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import io.legado.app.utils.isUri +import io.legado.app.utils.startActivityForBook +import io.legado.app.utils.toastOnUi import org.json.JSONObject import org.koin.androidx.compose.koinViewModel @@ -116,46 +114,17 @@ sealed class RemoteBookSheet { @Composable fun RemoteBookScreen( viewModel: RemoteBookViewModel = koinViewModel(), - onBackClick: () -> Unit, - startReadBook: (Book) -> Unit, - onArchiveFileClick: (FileDoc) -> Unit, - selectBookFolder: () -> Unit + onBackClick: () -> Unit ) { + val context = LocalContext.current val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val scope = rememberCoroutineScope() var dialogState by remember { mutableStateOf(null) } var showSheet by remember { mutableStateOf(null) } - - val startRead: (RemoteBook) -> Unit = - remember(viewModel, startReadBook, onArchiveFileClick, selectBookFolder) { - { remoteBook -> - scope.launch { - val downloadFileName = remoteBook.filename - if (!ArchiveUtils.isArchive(downloadFileName)) { - viewModel.getLocalBook(downloadFileName)?.let { - startReadBook(it) - } - } else { - val bookTreeUri = AppConfig.defaultBookTreeUri - if (bookTreeUri == null) { - selectBookFolder() - } else { - val downloadArchiveFileDoc = withContext(Dispatchers.IO) { - FileDoc.fromUri(bookTreeUri.toUri(), true) - .find(downloadFileName) - } - if (downloadArchiveFileDoc == null) { - dialogState = RemoteBookDialog.DownloadArchive(remoteBook) - } else { - onArchiveFileClick(downloadArchiveFileDoc) - } - } - } - } - } - } - - + val selectDocTree = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocumentTree() + ) { uri -> + viewModel.dispatch(RemoteBookIntent.BookFolderPicked(uri)) + } AppAlertDialog( data = dialogState as? RemoteBookDialog.DownloadArchive, @@ -166,7 +135,7 @@ fun RemoteBookScreen( }, confirmText = stringResource(android.R.string.ok), onConfirm = { state -> - scope.launch { viewModel.addToBookshelf(setOf(state.remoteBook)) } + viewModel.dispatch(RemoteBookIntent.AddBooks(setOf(state.remoteBook))) dialogState = null }, dismissText = stringResource(android.R.string.cancel), @@ -182,7 +151,7 @@ fun RemoteBookScreen( }, confirmText = stringResource(android.R.string.ok), onConfirm = { state -> - scope.launch { viewModel.addToBookshelf(setOf(state.remoteBook)) } + viewModel.dispatch(RemoteBookIntent.AddBooks(setOf(state.remoteBook))) dialogState = null }, dismissText = stringResource(android.R.string.cancel), @@ -191,7 +160,26 @@ fun RemoteBookScreen( AppModalBottomSheet( show = showSheet != null, - onDismissRequest = { showSheet = null } + onDismissRequest = { showSheet = null }, + title = when (val state = showSheet) { + is RemoteBookSheet.Servers -> stringResource(R.string.server_config) + is RemoteBookSheet.ServerConfig -> { + val actionText = + if (state.server == null) stringResource(R.string.add) + else stringResource(R.string.edit) + "$actionText ${stringResource(R.string.server_config)}" + } + else -> null + }, + endAction = if (showSheet is RemoteBookSheet.Servers) { + { + IconButton(onClick = { showSheet = RemoteBookSheet.ServerConfig(null) }) { + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add)) + } + } + } else { + null + } ) { when (val state = showSheet) { is RemoteBookSheet.Servers -> { @@ -199,14 +187,13 @@ fun RemoteBookScreen( servers = uiState.servers, selectedServerId = uiState.selectedServerId, onSelect = { - viewModel.selectServer(it) + viewModel.dispatch(RemoteBookIntent.SelectServer(it)) showSheet = null }, onEdit = { showSheet = RemoteBookSheet.ServerConfig(it) }, onDelete = { viewModel.deleteServer(it) }, - onAdd = { showSheet = RemoteBookSheet.ServerConfig(null) }, onDefault = { - viewModel.selectServer(AppConst.DEFAULT_WEBDAV_ID) + viewModel.dispatch(RemoteBookIntent.SelectServer(AppConst.DEFAULT_WEBDAV_ID)) showSheet = null } ) @@ -228,19 +215,54 @@ fun RemoteBookScreen( } LaunchedEffect(viewModel) { - viewModel.initData { viewModel.loadRemoteBookList() } - } + viewModel.dispatch(RemoteBookIntent.Initialize) + viewModel.effects.collect { effect -> + when (effect) { + is RemoteBookEffect.OpenBook -> context.startActivityForBook(effect.book) + is RemoteBookEffect.RequestBookFolderPicker -> { + selectDocTree.launch( + effect.initialUri + ?: AppConfig.defaultBookTreeUri?.takeIf { it.isUri() }?.toUri() + ) + } - LaunchedEffect(viewModel) { - viewModel.permissionDenialEvent.collect { selectBookFolder() } + is RemoteBookEffect.ShowArchiveEntries -> { + context.selector(R.string.start_read, effect.fileNames) { _, name, _ -> + viewModel.dispatch( + RemoteBookIntent.ArchiveEntrySelected(effect.fileDoc, name) + ) + } + } + + is RemoteBookEffect.ShowImportArchiveDialog -> { + context.alert(R.string.draw, R.string.no_book_found_bookshelf) { + okButton { + viewModel.dispatch( + RemoteBookIntent.ImportArchiveConfirmed( + effect.fileDoc, + effect.fileName + ) + ) + } + noButton() + } + } + + is RemoteBookEffect.ShowDownloadArchiveDialog -> { + dialogState = RemoteBookDialog.DownloadArchive(effect.remoteBook) + } + + is RemoteBookEffect.ShowToast -> context.toastOnUi(effect.message) + } + } } ListScaffold( title = "远程书籍", state = uiState, onBackClick = onBackClick, - onSearchToggle = { viewModel.setSearchMode(it) }, - onSearchQueryChange = { viewModel.setSearchKey(it) }, + onSearchToggle = { viewModel.dispatch(RemoteBookIntent.SearchToggle(it)) }, + onSearchQueryChange = { viewModel.dispatch(RemoteBookIntent.SearchChange(it)) }, searchPlaceholder = "搜索", topBarActions = { IconButton(onClick = { showSheet = RemoteBookSheet.Servers }) { @@ -251,7 +273,7 @@ fun RemoteBookScreen( RoundDropdownMenuItem( text = "按名称排序", onClick = { - viewModel.toggleSort(RemoteBookSort.Name) + viewModel.dispatch(RemoteBookIntent.SortToggle(RemoteBookSort.Name)) dismiss() }, trailingIcon = { @@ -263,7 +285,7 @@ fun RemoteBookScreen( RoundDropdownMenuItem( text = "按时间排序", onClick = { - viewModel.toggleSort(RemoteBookSort.Default) + viewModel.dispatch(RemoteBookIntent.SortToggle(RemoteBookSort.Default)) dismiss() }, trailingIcon = { @@ -277,13 +299,13 @@ fun RemoteBookScreen( PathNavigationBar( pathNames = uiState.pathNames, canGoBack = uiState.canGoBack, - onNavigateBack = { viewModel.navigateBack() }, - onNavigateToLevel = { viewModel.navigateToLevel(it) } + onNavigateBack = { viewModel.dispatch(RemoteBookIntent.NavigateBack) }, + onNavigateToLevel = { viewModel.dispatch(RemoteBookIntent.NavigateToLevel(it)) } ) }, selectionActions = SelectionActions( - onSelectAll = { viewModel.selectAllCheckable() }, - onSelectInvert = { viewModel.invertSelection() }, + onSelectAll = { viewModel.dispatch(RemoteBookIntent.SelectAll) }, + onSelectInvert = { viewModel.dispatch(RemoteBookIntent.SelectInvert) }, primaryAction = ActionItem( text = "添加至书架", icon = { Icon(Icons.Default.CloudDownload, null) }, @@ -292,7 +314,7 @@ fun RemoteBookScreen( .filter { it.id in uiState.selectedIds } .map { it.remoteBook } .toSet() - scope.launch { viewModel.addToBookshelf(selectedBooks) } + viewModel.dispatch(RemoteBookIntent.AddBooks(selectedBooks)) } ), secondaryActions = emptyList() @@ -306,7 +328,7 @@ fun RemoteBookScreen( .padding(paddingValues), isRefreshing = uiState.isLoading, state = pullToRefreshState, - onRefresh = { viewModel.refreshData() } + onRefresh = { viewModel.dispatch(RemoteBookIntent.Refresh) } ) { if (uiState.items.isEmpty()) { if (uiState.isLoading) { @@ -328,14 +350,10 @@ fun RemoteBookScreen( book = book, isSelected = itemUi.id in uiState.selectedIds, onClick = { - when { - book.isDir -> viewModel.navigateToDir(book) - book.isOnBookShelf -> startRead(book) - else -> viewModel.toggleSelection(itemUi.id) - } + viewModel.dispatch(RemoteBookIntent.OpenItem(book)) }, onAddClick = { remoteBook -> - scope.launch { viewModel.addToBookshelf(setOf(remoteBook)) } + viewModel.dispatch(RemoteBookIntent.AddBooks(setOf(remoteBook))) }, onUpdateClick = { remoteBook -> dialogState = RemoteBookDialog.ReImport(remoteBook) @@ -355,7 +373,6 @@ private fun ServersSheetContent( onSelect: (Long) -> Unit, onEdit: (Server) -> Unit, onDelete: (Server) -> Unit, - onAdd: () -> Unit, onDefault: () -> Unit ) { Column( @@ -364,22 +381,6 @@ private fun ServersSheetContent( .navigationBarsPadding() .padding(bottom = 8.dp) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - AppText( - text = stringResource(R.string.server_config), - style = LegadoTheme.typography.titleLarge - ) - IconButton(onClick = onAdd) { - Icon(Icons.Default.Add, contentDescription = "添加") - } - } - LazyColumn( modifier = Modifier .fillMaxWidth() @@ -418,70 +419,45 @@ private fun ServerItem( onEdit: (() -> Unit)? = null, onDelete: (() -> Unit)? = null ) { - val containerColor = if (isSelected) { - LegadoTheme.colorScheme.secondaryContainer - } else { - LegadoTheme.colorScheme.surface - } - - Card( - onClick = onClick, - modifier = modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - shape = MaterialTheme.shapes.medium, - colors = CardDefaults.cardColors( - containerColor = containerColor - ) - ) { - ListItem( - modifier = Modifier.animateContentSize(), - headlineContent = { - AppText( - text = name, - style = LegadoTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - leadingContent = { - RadioButton( - selected = isSelected, - onClick = null - ) - }, - supportingContent = url?.let { - { - AppText( - text = it, - style = LegadoTheme.typography.bodySmall, - color = LegadoTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - }, - trailingContent = if (onEdit != null || onDelete != null) { - { - Row { - onEdit?.let { - IconButton(onClick = it) { - Icon(Icons.Default.Edit, contentDescription = "编辑") - } - } - onDelete?.let { - IconButton(onClick = it) { - Icon(Icons.Default.Delete, contentDescription = "删除") - } - } + SelectionItemCard( + title = name, + subtitle = url, + isSelected = isSelected, + onToggleSelection = onClick, + leadingContent = { + AppRadioButton( + selected = isSelected, + onClick = null + ) + }, + trailingAction = if (onEdit != null || onDelete != null) { + { + Row { + onEdit?.let { + SmallIconButton( + onClick = it, + imageVector = Icons.Default.Edit, + contentDescription = "Edit" + ) + } + onDelete?.let { + SmallIconButton( + onClick = it, + imageVector = Icons.Default.Delete, + contentDescription = "Delete" + ) } } - } else null, - colors = ListItemDefaults.colors( - containerColor = Color.Transparent - ) - ) - } + } + } else { + null + }, + containerColor = LegadoTheme.colorScheme.surface, + selectedContainerColor = LegadoTheme.colorScheme.secondaryContainer, + modifier = modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) } @Composable @@ -503,40 +479,34 @@ private fun ServerConfigSheetContent( .navigationBarsPadding() .verticalScroll(rememberScrollState()) ) { - AppText( - text = if (server == null) "添加服务器" else "编辑服务器", - style = LegadoTheme.typography.titleLarge, - modifier = Modifier.padding(bottom = 16.dp) - ) - - OutlinedTextField( + AppTextField( value = name, onValueChange = { name = it }, - label = { AppText("名称") }, + label = "名称", modifier = Modifier.fillMaxWidth(), singleLine = true ) Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( + AppTextField( value = url, onValueChange = { url = it }, - label = { AppText("URL") }, + label = "URL", modifier = Modifier.fillMaxWidth(), singleLine = true ) Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( + AppTextField( value = username, onValueChange = { username = it }, - label = { AppText("用户名") }, + label = "用户名", modifier = Modifier.fillMaxWidth(), singleLine = true ) Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( + AppTextField( value = password, onValueChange = { password = it }, - label = { AppText("密码") }, + label = "密码", modifier = Modifier.fillMaxWidth(), singleLine = true, visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), @@ -644,7 +614,7 @@ private fun PathNavigationBar( if (canGoBack) { SmallTonalIconButton( onClick = onNavigateBack, - icon = Icons.AutoMirrored.Filled.ArrowBack, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回上级" ) } @@ -664,7 +634,7 @@ private fun RemoteBookItem( val containerColor = if (isSelected) { LegadoTheme.colorScheme.secondaryContainer } else { - LegadoTheme.colorScheme.surfaceContainerLow + LegadoTheme.colorScheme.surfaceContainer } GlassCard( @@ -751,7 +721,7 @@ private fun RemoteBookItem( onAddClick(book) } }, - icon = if (book.isOnBookShelf) + imageVector = if (book.isOnBookShelf) Icons.Outlined.CloudSync else Icons.Outlined.AddCircleOutline, @@ -760,4 +730,4 @@ private fun RemoteBookItem( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookViewModel.kt b/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookViewModel.kt index a3a071e38..585916343 100644 --- a/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookViewModel.kt @@ -1,6 +1,7 @@ package io.legado.app.ui.book.import.remote import android.app.Application +import androidx.core.net.toUri import androidx.compose.runtime.Immutable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope @@ -23,15 +24,18 @@ import io.legado.app.ui.widget.components.list.InteractionState import io.legado.app.ui.widget.components.list.ListUiState import io.legado.app.ui.widget.components.list.SelectableItem import io.legado.app.utils.AlphanumComparator +import io.legado.app.utils.ArchiveUtils import io.legado.app.utils.ConvertUtils +import io.legado.app.utils.FileDoc +import io.legado.app.utils.find +import io.legado.app.utils.takePersistablePermissionSafely import io.legado.app.utils.toastOnUi import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn @@ -72,6 +76,35 @@ data class RemoteBookUiState( val canGoBack: Boolean get() = navigation.canGoBack } +sealed interface RemoteBookIntent { + data object Initialize : RemoteBookIntent + data object Refresh : RemoteBookIntent + data class SearchToggle(val enabled: Boolean) : RemoteBookIntent + data class SearchChange(val query: String) : RemoteBookIntent + data class SortToggle(val sort: RemoteBookSort) : RemoteBookIntent + data object SelectAll : RemoteBookIntent + data object SelectInvert : RemoteBookIntent + data class ToggleSelection(val id: String) : RemoteBookIntent + data class OpenItem(val book: RemoteBook) : RemoteBookIntent + data object NavigateBack : RemoteBookIntent + data class NavigateToLevel(val level: Int) : RemoteBookIntent + data class NavigateToDir(val book: RemoteBook) : RemoteBookIntent + data class AddBooks(val books: Set) : RemoteBookIntent + data class SelectServer(val serverId: Long) : RemoteBookIntent + data class BookFolderPicked(val uri: android.net.Uri?) : RemoteBookIntent + data class ArchiveEntrySelected(val fileDoc: FileDoc, val fileName: String) : RemoteBookIntent + data class ImportArchiveConfirmed(val fileDoc: FileDoc, val fileName: String) : RemoteBookIntent +} + +sealed interface RemoteBookEffect { + data class RequestBookFolderPicker(val initialUri: android.net.Uri? = null) : RemoteBookEffect + data class OpenBook(val book: Book) : RemoteBookEffect + data class ShowArchiveEntries(val fileDoc: FileDoc, val fileNames: List) : RemoteBookEffect + data class ShowImportArchiveDialog(val fileDoc: FileDoc, val fileName: String) : RemoteBookEffect + data class ShowDownloadArchiveDialog(val remoteBook: RemoteBook) : RemoteBookEffect + data class ShowToast(val message: String) : RemoteBookEffect +} + class RemoteBookViewModel( application: Application, @Suppress("UNUSED_PARAMETER") @@ -93,6 +126,43 @@ class RemoteBookViewModel( ) private val _state = MutableStateFlow(InternalState()) + private val _effects = MutableSharedFlow(extraBufferCapacity = 1) + val effects = _effects.asSharedFlow() + + fun dispatch(intent: RemoteBookIntent) { + when (intent) { + RemoteBookIntent.Initialize -> initData { loadRemoteBookList() } + RemoteBookIntent.Refresh -> refreshData() + is RemoteBookIntent.SearchToggle -> setSearchMode(intent.enabled) + is RemoteBookIntent.SearchChange -> setSearchKey(intent.query) + is RemoteBookIntent.SortToggle -> toggleSort(intent.sort) + RemoteBookIntent.SelectAll -> selectAllCheckable() + RemoteBookIntent.SelectInvert -> invertSelection() + is RemoteBookIntent.ToggleSelection -> toggleSelection(intent.id) + is RemoteBookIntent.OpenItem -> onOpenItem(intent.book) + RemoteBookIntent.NavigateBack -> navigateBack() + is RemoteBookIntent.NavigateToLevel -> navigateToLevel(intent.level) + is RemoteBookIntent.NavigateToDir -> navigateToDir(intent.book) + is RemoteBookIntent.AddBooks -> addBooksToShelf(intent.books) + is RemoteBookIntent.SelectServer -> selectServer(intent.serverId) + is RemoteBookIntent.BookFolderPicked -> onBookFolderPicked(intent.uri) + is RemoteBookIntent.ArchiveEntrySelected -> onArchiveEntrySelected( + intent.fileDoc, + intent.fileName + ) + + is RemoteBookIntent.ImportArchiveConfirmed -> addArchiveToBookShelf( + intent.fileDoc, + intent.fileName + ) + } + } + + private fun onBookFolderPicked(uri: android.net.Uri?) { + uri ?: return + uri.takePersistablePermissionSafely(context) + AppConfig.defaultBookTreeUri = uri.toString() + } val uiState: StateFlow = combine( _state, @@ -139,13 +209,6 @@ class RemoteBookViewModel( initialValue = RemoteBookUiState() ) - private val _permissionDenialEvent = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - val permissionDenialEvent: SharedFlow = _permissionDenialEvent - fun initData(onSuccess: () -> Unit) { viewModelScope.launch { try { @@ -218,7 +281,7 @@ class RemoteBookViewModel( _state.update { it.copy(selectedIds = emptySet()) } Result.success(Unit) } catch (e: SecurityException) { - _permissionDenialEvent.emit(1) + _effects.emit(RemoteBookEffect.RequestBookFolderPicker()) Result.failure(e) } catch (e: Exception) { AppLog.put("导入出错\n${e.localizedMessage}", e) @@ -229,6 +292,12 @@ class RemoteBookViewModel( } } + private fun addBooksToShelf(remoteBooks: Set) { + viewModelScope.launch { + addToBookshelf(remoteBooks) + } + } + suspend fun getLocalBook(fileName: String): Book? { return withContext(Dispatchers.IO) { appDb.bookDao.getBookByFileName(fileName) @@ -331,6 +400,83 @@ class RemoteBookViewModel( loadRemoteBookList(currentPath) } + private fun onOpenItem(remoteBook: RemoteBook) { + if (remoteBook.isDir) { + navigateToDir(remoteBook) + return + } + if (!remoteBook.isOnBookShelf) { + toggleSelection(remoteBook.path) + return + } + viewModelScope.launch { + val downloadFileName = remoteBook.filename + if (!ArchiveUtils.isArchive(downloadFileName)) { + getLocalBook(downloadFileName)?.let { + _effects.emit(RemoteBookEffect.OpenBook(it)) + } + return@launch + } + + val bookTreeUri = AppConfig.defaultBookTreeUri + if (bookTreeUri == null) { + _effects.emit(RemoteBookEffect.RequestBookFolderPicker()) + return@launch + } + + val downloadArchiveFileDoc = withContext(Dispatchers.IO) { + FileDoc.fromUri(bookTreeUri.toUri(), true) + .find(downloadFileName) + } + if (downloadArchiveFileDoc == null) { + _effects.emit(RemoteBookEffect.ShowDownloadArchiveDialog(remoteBook)) + } else { + onArchiveFileClick(downloadArchiveFileDoc) + } + } + } + + private suspend fun onArchiveFileClick(fileDoc: FileDoc) { + val fileNames = ArchiveUtils.getArchiveFilesName(fileDoc) { + it.matches(io.legado.app.constant.AppPattern.bookFileRegex) + } + when { + fileNames.isEmpty() -> _effects.emit( + RemoteBookEffect.ShowToast(context.getString(io.legado.app.R.string.unsupport_archivefile_entry)) + ) + + fileNames.size == 1 -> onArchiveEntrySelected(fileDoc, fileNames.first()) + else -> _effects.emit(RemoteBookEffect.ShowArchiveEntries(fileDoc, fileNames)) + } + } + + private fun onArchiveEntrySelected(fileDoc: FileDoc, fileName: String) { + appDb.bookDao.getBookByFileName(fileName)?.let { book -> + viewModelScope.launch { _effects.emit(RemoteBookEffect.OpenBook(book)) } + } ?: viewModelScope.launch { + _effects.emit(RemoteBookEffect.ShowImportArchiveDialog(fileDoc, fileName)) + } + } + + private fun addArchiveToBookShelf(fileDoc: FileDoc, fileName: String) { + execute { + LocalBook.importArchiveFile(fileDoc.uri, fileName) { it.contains(fileName) } + .firstOrNull() + }.onSuccess { book -> + if (book != null) { + viewModelScope.launch { _effects.emit(RemoteBookEffect.OpenBook(book)) } + } else { + viewModelScope.launch { + _effects.emit(RemoteBookEffect.ShowToast(context.getString(io.legado.app.R.string.error))) + } + } + }.onError { + viewModelScope.launch { + _effects.emit(RemoteBookEffect.ShowToast(context.getString(io.legado.app.R.string.error))) + } + } + } + private fun sortBooks( books: List, sortKey: RemoteBookSort, diff --git a/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt b/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt index 9b9720aa7..e347208ce 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt @@ -139,8 +139,7 @@ fun BatchImportDialog( AppModalBottomSheet( show = show, - onDismissRequest = onDismissRequest, - containerColor = LegadoTheme.colorScheme.surfaceContainer + onDismissRequest = onDismissRequest ) { AppScaffold( modifier = Modifier @@ -242,7 +241,7 @@ fun ImportItemRow( SmallIconButton( onClick = onInfoClick, - icon = Icons.Default.Info, + imageVector = Icons.Default.Info, contentDescription = "详情" ) }