Преглед изворни кода

[优化] 重写本地书籍界面

HapeLee пре 7 месеци
родитељ
комит
42dda827bc

+ 2 - 1
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(
 data class ImportBook(
     val file: FileDoc,
     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 name get() = file.name
     val isDir get() = file.isDir
     val isDir get() = file.isDir
     val size get() = file.size
     val size get() = file.size
     val lastModified get() = file.lastModified
     val lastModified get() = file.lastModified
+    val selectionId get() = file.toString()
 }
 }

+ 0 - 322
app/src/main/java/io/legado/app/ui/book/import/local/ImportBookActivity.kt

@@ -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<ImportBookViewModel>(),
-    PopupMenu.OnMenuItemClickListener,
-    ImportBookAdapter.CallBack,
-    SelectActionBar.CallBack {
-
-    override val viewModel by viewModels<ImportBookViewModel>()
-    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)
-        }
-    }
-
-}

+ 536 - 94
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
 package io.legado.app.ui.book.import.local
 
 
 import android.app.Application
 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.base.BaseViewModel
 import io.legado.app.constant.AppLog
 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.archiveFileRegex
 import io.legado.app.constant.AppPattern.bookFileRegex
 import io.legado.app.constant.AppPattern.bookFileRegex
 import io.legado.app.constant.PreferKey
 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.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.AlphanumComparator
+import io.legado.app.utils.ArchiveUtils
 import io.legado.app.utils.FileDoc
 import io.legado.app.utils.FileDoc
 import io.legado.app.utils.delete
 import io.legado.app.utils.delete
 import io.legado.app.utils.getPrefInt
 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.list
 import io.legado.app.utils.mapParallel
 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 io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.Dispatchers.Main
+import kotlinx.coroutines.Job
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
 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.collect
 import kotlinx.coroutines.flow.consumeAsFlow
 import kotlinx.coroutines.flow.consumeAsFlow
 import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.takeWhile
 import kotlinx.coroutines.flow.takeWhile
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import kotlinx.coroutines.withContext
-import java.util.Collections
+import java.io.File
+
+@Immutable
+data class ImportBookUiState(
+    override val items: List<ImportBook> = emptyList(),
+    override val selectedIds: Set<Any> = emptySet(),
+    override val searchKey: String = "",
+    val interaction: InteractionState = InteractionState(),
+    val pathNames: List<String> = emptyList(),
+    val canGoBack: Boolean = false,
+    val sort: Int = 0
+) : ListUiState<ImportBook> {
+    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<String>) : ImportBookEffect
+    data class ShowImportArchiveDialog(val fileDoc: FileDoc, val fileName: String) : ImportBookEffect
+    data class ShowToastRes(val resId: Int) : ImportBookEffect
+}
 
 
 class ImportBookViewModel(application: Application) : BaseViewModel(application) {
 class ImportBookViewModel(application: Application) : BaseViewModel(application) {
-    var rootDoc: FileDoc? = null
-    val subDocs = arrayListOf<FileDoc>()
-    var sort = context.getPrefInt(PreferKey.localBookImportSort)
-    var dataCallback: DataCallback? = null
-    var dataFlowStart: (() -> Unit)? = null
-    var filterKey: String? = null
-    val dataFlow = callbackFlow<List<ImportBook>> {
-
-        val list = Collections.synchronizedList(ArrayList<ImportBook>())
-
-        dataCallback = object : DataCallback {
-
-            override fun setItems(fileDocs: List<FileDoc>) {
-                list.clear()
-                fileDocs.mapTo(list) {
-                    ImportBook(it)
-                }
-                trySend(list)
-            }
 
 
-            override fun addItems(fileDocs: List<FileDoc>) {
-                fileDocs.mapTo(list) {
-                    ImportBook(it)
-                }
-                trySend(list)
-            }
+    private data class InternalState(
+        val rootDoc: FileDoc? = null,
+        val subDocs: List<FileDoc> = emptyList(),
+        val sourceDocs: List<FileDoc> = emptyList(),
+        val selectedIds: Set<String> = emptySet(),
+        val searchKey: String = "",
+        val sort: Int,
+        val interaction: InteractionState = InteractionState()
+    )
 
 
-            override fun clear() {
-                list.clear()
-                trySend(emptyList())
-            }
+    private val _state = MutableStateFlow(
+        InternalState(sort = context.getPrefInt(PreferKey.localBookImportSort))
+    )
+    private val _effects = MutableSharedFlow<ImportBookEffect>(extraBufferCapacity = 1)
+    val effects = _effects.asSharedFlow()
+
+    private var scanDocJob: Job? = null
 
 
-            override fun upAdapter() {
-                trySend(list)
+    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()
+                    )
+                )
             }
             }
+
+            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
+            )
+
+            is ImportBookIntent.ImportArchiveConfirmed -> addArchiveToBookShelf(
+                intent.fileDoc,
+                intent.fileName
+            )
         }
         }
+    }
+
+    val uiState = combine(
+        _state,
+        appDb.bookDao.flowLocal()
+    ) { state, localBooks ->
+        val localFileNames = localBooks.asSequence().map { it.originName }.toSet()
 
 
-        withContext(Main) {
-            dataFlowStart?.invoke()
+        val docs = state.sourceDocs.map { fileDoc ->
+            ImportBook(
+                file = fileDoc,
+                isOnBookShelf = !fileDoc.isDir && fileDoc.name in localFileNames
+            )
         }
         }
 
 
-        awaitClose {
-            dataCallback = null
+        val filteredDocs = if (state.searchKey.isBlank()) {
+            docs
+        } else {
+            docs.filter { it.name.contains(state.searchKey, ignoreCase = true) }
         }
         }
 
 
-    }.map { docList ->
-        val docList = docList.toList()
-        val filterKey = filterKey
-        val skipFilter = filterKey.isNullOrBlank()
-        val comparator = when (sort) {
+        val comparator = when (state.sort) {
             2 -> compareBy<ImportBook>({ !it.isDir }, { -it.lastModified })
             2 -> compareBy<ImportBook>({ !it.isDir }, { -it.lastModified })
             1 -> compareBy({ !it.isDir }, { -it.size })
             1 -> compareBy({ !it.isDir }, { -it.size })
             else -> compareBy { !it.isDir }
             else -> compareBy { !it.isDir }
         } then compareBy(AlphanumComparator) { it.name }
         } then compareBy(AlphanumComparator) { it.name }
-        docList.asSequence().filter {
-            skipFilter || it.name.contains(filterKey)
-        }.sortedWith(comparator).toList()
-    }.flowOn(IO)
 
 
-    fun addToBookshelf(bookList: HashSet<ImportBook>, 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 })
+            }
+        } ?: 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
             }
             }
-            LocalBook.importFiles(fileUris)
+            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 {
         }.onError {
             context.toastOnUi("添加书架失败,请尝试重新选择文件夹")
             context.toastOnUi("添加书架失败,请尝试重新选择文件夹")
             AppLog.put("添加书架失败\n${it.localizedMessage}", it)
             AppLog.put("添加书架失败\n${it.localizedMessage}", it)
         }.onSuccess {
         }.onSuccess {
             context.toastOnUi("添加书架成功")
             context.toastOnUi("添加书架成功")
         }.onFinally {
         }.onFinally {
-            finally.invoke()
+            clearSelection()
+        }
+    }
+
+    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))
+            }
+            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 deleteDoc(bookList: HashSet<ImportBook>, finally: () -> Unit) {
+    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 {
         execute {
-            bookList.forEach {
-                it.file.delete()
+            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 {
         }.onFinally {
-            finally.invoke()
+            _state.update { state ->
+                state.copy(
+                    sourceDocs = state.sourceDocs.filterNot { it.toString() in selectedIds },
+                    selectedIds = emptySet()
+                )
+            }
         }
         }
     }
     }
 
 
-    fun loadDoc(fileDoc: FileDoc) {
+    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 {
         execute {
-            val docList = fileDoc.list { item ->
+            fileDoc.list { item ->
                 when {
                 when {
                     item.name.startsWith(".") -> false
                     item.name.startsWith(".") -> false
                     item.isDir -> true
                     item.isDir -> true
                     else -> item.name.matches(bookFileRegex) || item.name.matches(archiveFileRegex)
                     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 {
         }.onError {
             context.toastOnUi("获取文件列表出错\n${it.localizedMessage}")
             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<FileDoc> {
         val channel = Channel<FileDoc>(UNLIMITED)
         val channel = Channel<FileDoc>(UNLIMITED)
         var n = 1
         var n = 1
         channel.trySend(fileDoc)
         channel.trySend(fileDoc)
-        val list = arrayListOf<FileDoc>()
+        val docs = arrayListOf<FileDoc>()
+
         channel.consumeAsFlow()
         channel.consumeAsFlow()
-            .mapParallel(16) { fileDoc ->
-                fileDoc.list()!!
-            }.onEach { fileDocs ->
+            .mapParallel(16) { doc ->
+                doc.list() ?: emptyList<FileDoc>()
+            }
+            .onEach { fileDocs ->
                 n--
                 n--
-                list.clear()
                 fileDocs.forEach {
                 fileDocs.forEach {
                     if (it.isDir) {
                     if (it.isDir) {
                         n++
                         n++
                         channel.trySend(it)
                         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 {}
 
 
-    fun updateCallBackFlow(filterKey: String?) {
-        this.filterKey = filterKey
-        dataCallback?.upAdapter()
+        return docs
     }
     }
 
 
-    interface DataCallback {
-
-        fun setItems(fileDocs: List<FileDoc>)
-
-        fun addItems(fileDocs: List<FileDoc>)
-
-        fun clear()
-
-        fun upAdapter()
-
+    override fun onCleared() {
+        scanDocJob?.cancel()
+        super.onCleared()
     }
     }
-
-}
+}

+ 0 - 26
app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookActivity.kt

@@ -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 = { }
-            )
-        }
-    }
-
-}

+ 142 - 172
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
 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.animation.animateContentSize
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.clickable
 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.material.icons.outlined.Description
 import androidx.compose.material3.AlertDialog
 import androidx.compose.material3.AlertDialog
 import androidx.compose.material3.Button
 import androidx.compose.material3.Button
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
 import androidx.compose.material3.CircularProgressIndicator
 import androidx.compose.material3.CircularProgressIndicator
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.Icon
 import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
 import androidx.compose.material3.IconButton
-import androidx.compose.material3.ListItem
-import androidx.compose.material3.ListItemDefaults
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.OutlinedButton
 import androidx.compose.material3.OutlinedButton
 import androidx.compose.material3.OutlinedTextField
 import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.RadioButton
 import androidx.compose.material3.TextButton
 import androidx.compose.material3.TextButton
 import androidx.compose.material3.pulltorefresh.PullToRefreshBox
 import androidx.compose.material3.pulltorefresh.PullToRefreshBox
 import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
 import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
@@ -59,12 +56,11 @@ import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
 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.res.stringResource
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.input.PasswordVisualTransformation
 import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -75,30 +71,32 @@ import androidx.core.net.toUri
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.constant.AppConst
 import io.legado.app.constant.AppConst
-import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.Server
 import io.legado.app.data.entities.Server
 import io.legado.app.help.config.AppConfig
 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.model.remote.RemoteBook
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.ActionItem
 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.EmptyMessageView
 import io.legado.app.ui.widget.components.SelectionActions
 import io.legado.app.ui.widget.components.SelectionActions
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 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.SmallIconButton
 import io.legado.app.ui.widget.components.button.SmallTonalIconButton
 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.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.card.TextCard
 import io.legado.app.ui.widget.components.list.ListScaffold
 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.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.text.AppText
 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.ConvertUtils
 import io.legado.app.utils.FileDoc
 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.json.JSONObject
 import org.koin.androidx.compose.koinViewModel
 import org.koin.androidx.compose.koinViewModel
 
 
@@ -116,46 +114,17 @@ sealed class RemoteBookSheet {
 @Composable
 @Composable
 fun RemoteBookScreen(
 fun RemoteBookScreen(
     viewModel: RemoteBookViewModel = koinViewModel(),
     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 uiState by viewModel.uiState.collectAsStateWithLifecycle()
-    val scope = rememberCoroutineScope()
     var dialogState by remember { mutableStateOf<RemoteBookDialog?>(null) }
     var dialogState by remember { mutableStateOf<RemoteBookDialog?>(null) }
     var showSheet by remember { mutableStateOf<RemoteBookSheet?>(null) }
     var showSheet by remember { mutableStateOf<RemoteBookSheet?>(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(
     AppAlertDialog(
         data = dialogState as? RemoteBookDialog.DownloadArchive,
         data = dialogState as? RemoteBookDialog.DownloadArchive,
@@ -166,7 +135,7 @@ fun RemoteBookScreen(
         },
         },
         confirmText = stringResource(android.R.string.ok),
         confirmText = stringResource(android.R.string.ok),
         onConfirm = { state ->
         onConfirm = { state ->
-            scope.launch { viewModel.addToBookshelf(setOf(state.remoteBook)) }
+            viewModel.dispatch(RemoteBookIntent.AddBooks(setOf(state.remoteBook)))
             dialogState = null
             dialogState = null
         },
         },
         dismissText = stringResource(android.R.string.cancel),
         dismissText = stringResource(android.R.string.cancel),
@@ -182,7 +151,7 @@ fun RemoteBookScreen(
         },
         },
         confirmText = stringResource(android.R.string.ok),
         confirmText = stringResource(android.R.string.ok),
         onConfirm = { state ->
         onConfirm = { state ->
-            scope.launch { viewModel.addToBookshelf(setOf(state.remoteBook)) }
+            viewModel.dispatch(RemoteBookIntent.AddBooks(setOf(state.remoteBook)))
             dialogState = null
             dialogState = null
         },
         },
         dismissText = stringResource(android.R.string.cancel),
         dismissText = stringResource(android.R.string.cancel),
@@ -191,7 +160,26 @@ fun RemoteBookScreen(
 
 
     AppModalBottomSheet(
     AppModalBottomSheet(
         show = showSheet != null,
         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) {
         when (val state = showSheet) {
             is RemoteBookSheet.Servers -> {
             is RemoteBookSheet.Servers -> {
@@ -199,14 +187,13 @@ fun RemoteBookScreen(
                     servers = uiState.servers,
                     servers = uiState.servers,
                     selectedServerId = uiState.selectedServerId,
                     selectedServerId = uiState.selectedServerId,
                     onSelect = {
                     onSelect = {
-                        viewModel.selectServer(it)
+                        viewModel.dispatch(RemoteBookIntent.SelectServer(it))
                         showSheet = null
                         showSheet = null
                     },
                     },
                     onEdit = { showSheet = RemoteBookSheet.ServerConfig(it) },
                     onEdit = { showSheet = RemoteBookSheet.ServerConfig(it) },
                     onDelete = { viewModel.deleteServer(it) },
                     onDelete = { viewModel.deleteServer(it) },
-                    onAdd = { showSheet = RemoteBookSheet.ServerConfig(null) },
                     onDefault = {
                     onDefault = {
-                        viewModel.selectServer(AppConst.DEFAULT_WEBDAV_ID)
+                        viewModel.dispatch(RemoteBookIntent.SelectServer(AppConst.DEFAULT_WEBDAV_ID))
                         showSheet = null
                         showSheet = null
                     }
                     }
                 )
                 )
@@ -228,19 +215,54 @@ fun RemoteBookScreen(
     }
     }
 
 
     LaunchedEffect(viewModel) {
     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(
     ListScaffold(
         title = "远程书籍",
         title = "远程书籍",
         state = uiState,
         state = uiState,
         onBackClick = onBackClick,
         onBackClick = onBackClick,
-        onSearchToggle = { viewModel.setSearchMode(it) },
-        onSearchQueryChange = { viewModel.setSearchKey(it) },
+        onSearchToggle = { viewModel.dispatch(RemoteBookIntent.SearchToggle(it)) },
+        onSearchQueryChange = { viewModel.dispatch(RemoteBookIntent.SearchChange(it)) },
         searchPlaceholder = "搜索",
         searchPlaceholder = "搜索",
         topBarActions = {
         topBarActions = {
             IconButton(onClick = { showSheet = RemoteBookSheet.Servers }) {
             IconButton(onClick = { showSheet = RemoteBookSheet.Servers }) {
@@ -251,7 +273,7 @@ fun RemoteBookScreen(
             RoundDropdownMenuItem(
             RoundDropdownMenuItem(
                 text = "按名称排序",
                 text = "按名称排序",
                 onClick = {
                 onClick = {
-                    viewModel.toggleSort(RemoteBookSort.Name)
+                    viewModel.dispatch(RemoteBookIntent.SortToggle(RemoteBookSort.Name))
                     dismiss()
                     dismiss()
                 },
                 },
                 trailingIcon = {
                 trailingIcon = {
@@ -263,7 +285,7 @@ fun RemoteBookScreen(
             RoundDropdownMenuItem(
             RoundDropdownMenuItem(
                 text = "按时间排序",
                 text = "按时间排序",
                 onClick = {
                 onClick = {
-                    viewModel.toggleSort(RemoteBookSort.Default)
+                    viewModel.dispatch(RemoteBookIntent.SortToggle(RemoteBookSort.Default))
                     dismiss()
                     dismiss()
                 },
                 },
                 trailingIcon = {
                 trailingIcon = {
@@ -277,13 +299,13 @@ fun RemoteBookScreen(
             PathNavigationBar(
             PathNavigationBar(
                 pathNames = uiState.pathNames,
                 pathNames = uiState.pathNames,
                 canGoBack = uiState.canGoBack,
                 canGoBack = uiState.canGoBack,
-                onNavigateBack = { viewModel.navigateBack() },
-                onNavigateToLevel = { viewModel.navigateToLevel(it) }
+                onNavigateBack = { viewModel.dispatch(RemoteBookIntent.NavigateBack) },
+                onNavigateToLevel = { viewModel.dispatch(RemoteBookIntent.NavigateToLevel(it)) }
             )
             )
         },
         },
         selectionActions = SelectionActions(
         selectionActions = SelectionActions(
-            onSelectAll = { viewModel.selectAllCheckable() },
-            onSelectInvert = { viewModel.invertSelection() },
+            onSelectAll = { viewModel.dispatch(RemoteBookIntent.SelectAll) },
+            onSelectInvert = { viewModel.dispatch(RemoteBookIntent.SelectInvert) },
             primaryAction = ActionItem(
             primaryAction = ActionItem(
                 text = "添加至书架",
                 text = "添加至书架",
                 icon = { Icon(Icons.Default.CloudDownload, null) },
                 icon = { Icon(Icons.Default.CloudDownload, null) },
@@ -292,7 +314,7 @@ fun RemoteBookScreen(
                         .filter { it.id in uiState.selectedIds }
                         .filter { it.id in uiState.selectedIds }
                         .map { it.remoteBook }
                         .map { it.remoteBook }
                         .toSet()
                         .toSet()
-                    scope.launch { viewModel.addToBookshelf(selectedBooks) }
+                    viewModel.dispatch(RemoteBookIntent.AddBooks(selectedBooks))
                 }
                 }
             ),
             ),
             secondaryActions = emptyList()
             secondaryActions = emptyList()
@@ -306,7 +328,7 @@ fun RemoteBookScreen(
                 .padding(paddingValues),
                 .padding(paddingValues),
             isRefreshing = uiState.isLoading,
             isRefreshing = uiState.isLoading,
             state = pullToRefreshState,
             state = pullToRefreshState,
-            onRefresh = { viewModel.refreshData() }
+            onRefresh = { viewModel.dispatch(RemoteBookIntent.Refresh) }
         ) {
         ) {
             if (uiState.items.isEmpty()) {
             if (uiState.items.isEmpty()) {
                 if (uiState.isLoading) {
                 if (uiState.isLoading) {
@@ -328,14 +350,10 @@ fun RemoteBookScreen(
                             book = book,
                             book = book,
                             isSelected = itemUi.id in uiState.selectedIds,
                             isSelected = itemUi.id in uiState.selectedIds,
                             onClick = {
                             onClick = {
-                                when {
-                                    book.isDir -> viewModel.navigateToDir(book)
-                                    book.isOnBookShelf -> startRead(book)
-                                    else -> viewModel.toggleSelection(itemUi.id)
-                                }
+                                viewModel.dispatch(RemoteBookIntent.OpenItem(book))
                             },
                             },
                             onAddClick = { remoteBook ->
                             onAddClick = { remoteBook ->
-                                scope.launch { viewModel.addToBookshelf(setOf(remoteBook)) }
+                                viewModel.dispatch(RemoteBookIntent.AddBooks(setOf(remoteBook)))
                             },
                             },
                             onUpdateClick = { remoteBook ->
                             onUpdateClick = { remoteBook ->
                                 dialogState = RemoteBookDialog.ReImport(remoteBook)
                                 dialogState = RemoteBookDialog.ReImport(remoteBook)
@@ -355,7 +373,6 @@ private fun ServersSheetContent(
     onSelect: (Long) -> Unit,
     onSelect: (Long) -> Unit,
     onEdit: (Server) -> Unit,
     onEdit: (Server) -> Unit,
     onDelete: (Server) -> Unit,
     onDelete: (Server) -> Unit,
-    onAdd: () -> Unit,
     onDefault: () -> Unit
     onDefault: () -> Unit
 ) {
 ) {
     Column(
     Column(
@@ -364,22 +381,6 @@ private fun ServersSheetContent(
             .navigationBarsPadding()
             .navigationBarsPadding()
             .padding(bottom = 8.dp)
             .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(
         LazyColumn(
             modifier = Modifier
             modifier = Modifier
                 .fillMaxWidth()
                 .fillMaxWidth()
@@ -418,70 +419,45 @@ private fun ServerItem(
     onEdit: (() -> Unit)? = null,
     onEdit: (() -> Unit)? = null,
     onDelete: (() -> 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
 @Composable
@@ -503,40 +479,34 @@ private fun ServerConfigSheetContent(
             .navigationBarsPadding()
             .navigationBarsPadding()
             .verticalScroll(rememberScrollState())
             .verticalScroll(rememberScrollState())
     ) {
     ) {
-        AppText(
-            text = if (server == null) "添加服务器" else "编辑服务器",
-            style = LegadoTheme.typography.titleLarge,
-            modifier = Modifier.padding(bottom = 16.dp)
-        )
-
-        OutlinedTextField(
+        AppTextField(
             value = name,
             value = name,
             onValueChange = { name = it },
             onValueChange = { name = it },
-            label = { AppText("名称") },
+            label = "名称",
             modifier = Modifier.fillMaxWidth(),
             modifier = Modifier.fillMaxWidth(),
             singleLine = true
             singleLine = true
         )
         )
         Spacer(modifier = Modifier.height(8.dp))
         Spacer(modifier = Modifier.height(8.dp))
-        OutlinedTextField(
+        AppTextField(
             value = url,
             value = url,
             onValueChange = { url = it },
             onValueChange = { url = it },
-            label = { AppText("URL") },
+            label = "URL",
             modifier = Modifier.fillMaxWidth(),
             modifier = Modifier.fillMaxWidth(),
             singleLine = true
             singleLine = true
         )
         )
         Spacer(modifier = Modifier.height(8.dp))
         Spacer(modifier = Modifier.height(8.dp))
-        OutlinedTextField(
+        AppTextField(
             value = username,
             value = username,
             onValueChange = { username = it },
             onValueChange = { username = it },
-            label = { AppText("用户名") },
+            label = "用户名",
             modifier = Modifier.fillMaxWidth(),
             modifier = Modifier.fillMaxWidth(),
             singleLine = true
             singleLine = true
         )
         )
         Spacer(modifier = Modifier.height(8.dp))
         Spacer(modifier = Modifier.height(8.dp))
-        OutlinedTextField(
+        AppTextField(
             value = password,
             value = password,
             onValueChange = { password = it },
             onValueChange = { password = it },
-            label = { AppText("密码") },
+            label = "密码",
             modifier = Modifier.fillMaxWidth(),
             modifier = Modifier.fillMaxWidth(),
             singleLine = true,
             singleLine = true,
             visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
             visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
@@ -644,7 +614,7 @@ private fun PathNavigationBar(
         if (canGoBack) {
         if (canGoBack) {
             SmallTonalIconButton(
             SmallTonalIconButton(
                 onClick = onNavigateBack,
                 onClick = onNavigateBack,
-                icon = Icons.AutoMirrored.Filled.ArrowBack,
+                imageVector = Icons.AutoMirrored.Filled.ArrowBack,
                 contentDescription = "返回上级"
                 contentDescription = "返回上级"
             )
             )
         }
         }
@@ -664,7 +634,7 @@ private fun RemoteBookItem(
     val containerColor = if (isSelected) {
     val containerColor = if (isSelected) {
         LegadoTheme.colorScheme.secondaryContainer
         LegadoTheme.colorScheme.secondaryContainer
     } else {
     } else {
-        LegadoTheme.colorScheme.surfaceContainerLow
+        LegadoTheme.colorScheme.surfaceContainer
     }
     }
 
 
     GlassCard(
     GlassCard(
@@ -751,7 +721,7 @@ private fun RemoteBookItem(
                             onAddClick(book)
                             onAddClick(book)
                         }
                         }
                     },
                     },
-                    icon = if (book.isOnBookShelf)
+                    imageVector = if (book.isOnBookShelf)
                         Icons.Outlined.CloudSync
                         Icons.Outlined.CloudSync
                     else
                     else
                         Icons.Outlined.AddCircleOutline,
                         Icons.Outlined.AddCircleOutline,
@@ -760,4 +730,4 @@ private fun RemoteBookItem(
             }
             }
         }
         }
     }
     }
-}
+}

+ 156 - 10
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
 package io.legado.app.ui.book.import.remote
 
 
 import android.app.Application
 import android.app.Application
+import androidx.core.net.toUri
 import androidx.compose.runtime.Immutable
 import androidx.compose.runtime.Immutable
 import androidx.lifecycle.SavedStateHandle
 import androidx.lifecycle.SavedStateHandle
 import androidx.lifecycle.viewModelScope
 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.ListUiState
 import io.legado.app.ui.widget.components.list.SelectableItem
 import io.legado.app.ui.widget.components.list.SelectableItem
 import io.legado.app.utils.AlphanumComparator
 import io.legado.app.utils.AlphanumComparator
+import io.legado.app.utils.ArchiveUtils
 import io.legado.app.utils.ConvertUtils
 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 io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.channels.BufferOverflow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.SharedFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.stateIn
@@ -72,6 +76,35 @@ data class RemoteBookUiState(
     val canGoBack: Boolean get() = navigation.canGoBack
     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<RemoteBook>) : 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<String>) : 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(
 class RemoteBookViewModel(
     application: Application,
     application: Application,
     @Suppress("UNUSED_PARAMETER")
     @Suppress("UNUSED_PARAMETER")
@@ -93,6 +126,43 @@ class RemoteBookViewModel(
     )
     )
 
 
     private val _state = MutableStateFlow(InternalState())
     private val _state = MutableStateFlow(InternalState())
+    private val _effects = MutableSharedFlow<RemoteBookEffect>(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<RemoteBookUiState> = combine(
     val uiState: StateFlow<RemoteBookUiState> = combine(
         _state,
         _state,
@@ -139,13 +209,6 @@ class RemoteBookViewModel(
             initialValue = RemoteBookUiState()
             initialValue = RemoteBookUiState()
         )
         )
 
 
-    private val _permissionDenialEvent = MutableSharedFlow<Int>(
-        replay = 0,
-        extraBufferCapacity = 1,
-        onBufferOverflow = BufferOverflow.DROP_OLDEST
-    )
-    val permissionDenialEvent: SharedFlow<Int> = _permissionDenialEvent
-
     fun initData(onSuccess: () -> Unit) {
     fun initData(onSuccess: () -> Unit) {
         viewModelScope.launch {
         viewModelScope.launch {
             try {
             try {
@@ -218,7 +281,7 @@ class RemoteBookViewModel(
             _state.update { it.copy(selectedIds = emptySet()) }
             _state.update { it.copy(selectedIds = emptySet()) }
             Result.success(Unit)
             Result.success(Unit)
         } catch (e: SecurityException) {
         } catch (e: SecurityException) {
-            _permissionDenialEvent.emit(1)
+            _effects.emit(RemoteBookEffect.RequestBookFolderPicker())
             Result.failure(e)
             Result.failure(e)
         } catch (e: Exception) {
         } catch (e: Exception) {
             AppLog.put("导入出错\n${e.localizedMessage}", e)
             AppLog.put("导入出错\n${e.localizedMessage}", e)
@@ -229,6 +292,12 @@ class RemoteBookViewModel(
         }
         }
     }
     }
 
 
+    private fun addBooksToShelf(remoteBooks: Set<RemoteBook>) {
+        viewModelScope.launch {
+            addToBookshelf(remoteBooks)
+        }
+    }
+
     suspend fun getLocalBook(fileName: String): Book? {
     suspend fun getLocalBook(fileName: String): Book? {
         return withContext(Dispatchers.IO) {
         return withContext(Dispatchers.IO) {
             appDb.bookDao.getBookByFileName(fileName)
             appDb.bookDao.getBookByFileName(fileName)
@@ -331,6 +400,83 @@ class RemoteBookViewModel(
         loadRemoteBookList(currentPath)
         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(
     private fun sortBooks(
         books: List<RemoteBook>,
         books: List<RemoteBook>,
         sortKey: RemoteBookSort,
         sortKey: RemoteBookSort,

+ 2 - 3
app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt

@@ -139,8 +139,7 @@ fun <T> BatchImportDialog(
 
 
     AppModalBottomSheet(
     AppModalBottomSheet(
         show = show,
         show = show,
-        onDismissRequest = onDismissRequest,
-        containerColor = LegadoTheme.colorScheme.surfaceContainer
+        onDismissRequest = onDismissRequest
     ) {
     ) {
         AppScaffold(
         AppScaffold(
             modifier = Modifier
             modifier = Modifier
@@ -242,7 +241,7 @@ fun ImportItemRow(
 
 
             SmallIconButton(
             SmallIconButton(
                 onClick = onInfoClick,
                 onClick = onInfoClick,
-                icon = Icons.Default.Info,
+                imageVector = Icons.Default.Info,
                 contentDescription = "详情"
                 contentDescription = "详情"
             )
             )
         }
         }