[优化] 重写本地书籍界面
This commit is contained in:
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
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>())
|
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()
|
||||||
|
)
|
||||||
|
|
||||||
dataCallback = object : DataCallback {
|
private val _state = MutableStateFlow(
|
||||||
|
InternalState(sort = context.getPrefInt(PreferKey.localBookImportSort))
|
||||||
|
)
|
||||||
|
private val _effects = MutableSharedFlow<ImportBookEffect>(extraBufferCapacity = 1)
|
||||||
|
val effects = _effects.asSharedFlow()
|
||||||
|
|
||||||
override fun setItems(fileDocs: List<FileDoc>) {
|
private var scanDocJob: Job? = null
|
||||||
list.clear()
|
|
||||||
fileDocs.mapTo(list) {
|
fun dispatch(intent: ImportBookIntent) {
|
||||||
ImportBook(it)
|
when (intent) {
|
||||||
}
|
ImportBookIntent.Initialize -> initialize()
|
||||||
trySend(list)
|
ImportBookIntent.SelectFolderClick -> {
|
||||||
|
_effects.tryEmit(
|
||||||
|
ImportBookEffect.RequestFolderPicker(
|
||||||
|
target = ImportFolderPickTarget.IMPORT_FOLDER,
|
||||||
|
initialUri = AppConfig.importBookPath
|
||||||
|
?.takeIf { it.isUri() }
|
||||||
|
?.toUri()
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun addItems(fileDocs: List<FileDoc>) {
|
is ImportBookIntent.FolderPicked -> onFolderPicked(intent.uri, intent.target)
|
||||||
fileDocs.mapTo(list) {
|
is ImportBookIntent.SearchToggle -> setSearchMode(intent.enabled)
|
||||||
ImportBook(it)
|
is ImportBookIntent.SearchQueryChange -> setSearchKey(intent.query)
|
||||||
}
|
is ImportBookIntent.SortChange -> setSort(intent.sort)
|
||||||
trySend(list)
|
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() {
|
is ImportBookIntent.ImportArchiveConfirmed -> addArchiveToBookShelf(
|
||||||
list.clear()
|
intent.fileDoc,
|
||||||
trySend(emptyList())
|
intent.fileName
|
||||||
}
|
)
|
||||||
|
|
||||||
override fun upAdapter() {
|
|
||||||
trySend(list)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
withContext(Main) {
|
val uiState = combine(
|
||||||
dataFlowStart?.invoke()
|
_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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
awaitClose {
|
val filteredDocs = if (state.searchKey.isBlank()) {
|
||||||
dataCallback = null
|
docs
|
||||||
|
} else {
|
||||||
|
docs.filter { it.name.contains(state.searchKey, ignoreCase = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
}.map { docList ->
|
val comparator = when (state.sort) {
|
||||||
val docList = docList.toList()
|
|
||||||
val filterKey = filterKey
|
|
||||||
val skipFilter = filterKey.isNullOrBlank()
|
|
||||||
val comparator = when (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) {
|
val sortedDocs = filteredDocs.sortedWith(comparator)
|
||||||
execute {
|
val checkableIds = sortedDocs
|
||||||
val fileUris = bookList.map {
|
.asSequence()
|
||||||
it.file.uri
|
.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 {
|
}.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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteDoc(bookList: HashSet<ImportBook>, finally: () -> Unit) {
|
private fun onItemClick(item: ImportBook) {
|
||||||
execute {
|
when {
|
||||||
bookList.forEach {
|
item.isDir -> navigateNext(item.file)
|
||||||
it.file.delete()
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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) {
|
private suspend fun scanDoc(fileDoc: FileDoc): List<FileDoc> {
|
||||||
dataCallback?.clear()
|
|
||||||
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 ->
|
.mapParallel(16) { doc ->
|
||||||
fileDoc.list()!!
|
doc.list() ?: emptyList<FileDoc>()
|
||||||
}.onEach { fileDocs ->
|
}
|
||||||
|
.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)
|
} else if (it.name.matches(bookFileRegex) || it.name.matches(archiveFileRegex)) {
|
||||||
|| it.name.matches(archiveFileRegex)
|
docs.add(it)
|
||||||
) {
|
|
||||||
list.add(it)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dataCallback?.addItems(list)
|
}
|
||||||
}.takeWhile {
|
.takeWhile { n > 0 }
|
||||||
n > 0
|
.collect {}
|
||||||
}.catch {
|
|
||||||
context.toastOnUi("扫描文件夹出错\n${it.localizedMessage}")
|
return docs
|
||||||
}.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateCallBackFlow(filterKey: String?) {
|
override fun onCleared() {
|
||||||
this.filterKey = filterKey
|
scanDocJob?.cancel()
|
||||||
dataCallback?.upAdapter()
|
super.onCleared()
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DataCallback {
|
|
||||||
|
|
||||||
fun setItems(fileDocs: List<FileDoc>)
|
|
||||||
|
|
||||||
fun addItems(fileDocs: List<FileDoc>)
|
|
||||||
|
|
||||||
fun clear()
|
|
||||||
|
|
||||||
fun upAdapter()
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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 = { }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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 io.legado.app.utils.isUri
|
||||||
import kotlinx.coroutines.Dispatchers
|
import io.legado.app.utils.startActivityForBook
|
||||||
import kotlinx.coroutines.launch
|
import io.legado.app.utils.toastOnUi
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
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,
|
onBackClick: () -> Unit
|
||||||
startReadBook: (Book) -> Unit,
|
|
||||||
onArchiveFileClick: (FileDoc) -> Unit,
|
|
||||||
selectBookFolder: () -> 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 selectDocTree = rememberLauncherForActivityResult(
|
||||||
val startRead: (RemoteBook) -> Unit =
|
contract = ActivityResultContracts.OpenDocumentTree()
|
||||||
remember(viewModel, startReadBook, onArchiveFileClick, selectBookFolder) {
|
) { uri ->
|
||||||
{ remoteBook ->
|
viewModel.dispatch(RemoteBookIntent.BookFolderPicked(uri))
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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) {
|
is RemoteBookEffect.ShowArchiveEntries -> {
|
||||||
viewModel.permissionDenialEvent.collect { selectBookFolder() }
|
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) },
|
onSearchToggle = { viewModel.dispatch(RemoteBookIntent.SearchToggle(it)) },
|
||||||
onSearchQueryChange = { viewModel.setSearchKey(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() },
|
onNavigateBack = { viewModel.dispatch(RemoteBookIntent.NavigateBack) },
|
||||||
onNavigateToLevel = { viewModel.navigateToLevel(it) }
|
onNavigateToLevel = { viewModel.dispatch(RemoteBookIntent.NavigateToLevel(it)) }
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
selectionActions = SelectionActions(
|
selectionActions = SelectionActions(
|
||||||
onSelectAll = { viewModel.selectAllCheckable() },
|
onSelectAll = { viewModel.dispatch(RemoteBookIntent.SelectAll) },
|
||||||
onSelectInvert = { viewModel.invertSelection() },
|
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 {
|
viewModel.dispatch(RemoteBookIntent.OpenItem(book))
|
||||||
book.isDir -> viewModel.navigateToDir(book)
|
|
||||||
book.isOnBookShelf -> startRead(book)
|
|
||||||
else -> viewModel.toggleSelection(itemUi.id)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
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) {
|
SelectionItemCard(
|
||||||
LegadoTheme.colorScheme.secondaryContainer
|
title = name,
|
||||||
} else {
|
subtitle = url,
|
||||||
LegadoTheme.colorScheme.surface
|
isSelected = isSelected,
|
||||||
}
|
onToggleSelection = onClick,
|
||||||
|
|
||||||
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 = {
|
leadingContent = {
|
||||||
RadioButton(
|
AppRadioButton(
|
||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
onClick = null
|
onClick = null
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
supportingContent = url?.let {
|
trailingAction = if (onEdit != null || onDelete != null) {
|
||||||
{
|
|
||||||
AppText(
|
|
||||||
text = it,
|
|
||||||
style = LegadoTheme.typography.bodySmall,
|
|
||||||
color = LegadoTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
trailingContent = if (onEdit != null || onDelete != null) {
|
|
||||||
{
|
{
|
||||||
Row {
|
Row {
|
||||||
onEdit?.let {
|
onEdit?.let {
|
||||||
IconButton(onClick = it) {
|
SmallIconButton(
|
||||||
Icon(Icons.Default.Edit, contentDescription = "编辑")
|
onClick = it,
|
||||||
}
|
imageVector = Icons.Default.Edit,
|
||||||
|
contentDescription = "Edit"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
onDelete?.let {
|
onDelete?.let {
|
||||||
IconButton(onClick = it) {
|
SmallIconButton(
|
||||||
Icon(Icons.Default.Delete, contentDescription = "删除")
|
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(
|
AppTextField(
|
||||||
text = if (server == null) "添加服务器" else "编辑服务器",
|
|
||||||
style = LegadoTheme.typography.titleLarge,
|
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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
@@ -139,8 +139,7 @@ fun <T> BatchImportDialog(
|
|||||||
|
|
||||||
AppModalBottomSheet(
|
AppModalBottomSheet(
|
||||||
show = show,
|
show = show,
|
||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest
|
||||||
containerColor = LegadoTheme.colorScheme.surfaceContainer
|
|
||||||
) {
|
) {
|
||||||
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 = "详情"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user