[优化] 重写本地书籍界面
This commit is contained in:
@@ -5,10 +5,11 @@ import io.legado.app.utils.FileDoc
|
||||
|
||||
data class ImportBook(
|
||||
val file: FileDoc,
|
||||
var isOnBookShelf: Boolean = !file.isDir && LocalBook.isOnBookShelf(file.name)
|
||||
val isOnBookShelf: Boolean = !file.isDir && LocalBook.isOnBookShelf(file.name)
|
||||
) {
|
||||
val name get() = file.name
|
||||
val isDir get() = file.isDir
|
||||
val size get() = file.size
|
||||
val lastModified get() = file.lastModified
|
||||
val selectionId get() = file.toString()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.legado.app.base.BaseViewModel
|
||||
import io.legado.app.constant.AppLog
|
||||
import io.legado.app.constant.AppPattern
|
||||
import io.legado.app.constant.AppPattern.archiveFileRegex
|
||||
import io.legado.app.constant.AppPattern.bookFileRegex
|
||||
import io.legado.app.constant.PreferKey
|
||||
import io.legado.app.data.appDb
|
||||
import io.legado.app.data.entities.Book
|
||||
import io.legado.app.help.config.AppConfig
|
||||
import io.legado.app.model.localBook.LocalBook
|
||||
import io.legado.app.ui.widget.components.list.InteractionState
|
||||
import io.legado.app.ui.widget.components.list.ListUiState
|
||||
import io.legado.app.utils.AlphanumComparator
|
||||
import io.legado.app.utils.ArchiveUtils
|
||||
import io.legado.app.utils.FileDoc
|
||||
import io.legado.app.utils.delete
|
||||
import io.legado.app.utils.getPrefInt
|
||||
import io.legado.app.utils.isContentScheme
|
||||
import io.legado.app.utils.isUri
|
||||
import io.legado.app.utils.list
|
||||
import io.legado.app.utils.mapParallel
|
||||
import io.legado.app.utils.putPrefInt
|
||||
import io.legado.app.utils.takePersistablePermissionSafely
|
||||
import io.legado.app.utils.toastOnUi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.consumeAsFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.takeWhile
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Collections
|
||||
import java.io.File
|
||||
|
||||
@Immutable
|
||||
data class ImportBookUiState(
|
||||
override val items: List<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) {
|
||||
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>) {
|
||||
list.clear()
|
||||
fileDocs.mapTo(list) {
|
||||
ImportBook(it)
|
||||
}
|
||||
trySend(list)
|
||||
private var scanDocJob: Job? = null
|
||||
|
||||
fun dispatch(intent: ImportBookIntent) {
|
||||
when (intent) {
|
||||
ImportBookIntent.Initialize -> initialize()
|
||||
ImportBookIntent.SelectFolderClick -> {
|
||||
_effects.tryEmit(
|
||||
ImportBookEffect.RequestFolderPicker(
|
||||
target = ImportFolderPickTarget.IMPORT_FOLDER,
|
||||
initialUri = AppConfig.importBookPath
|
||||
?.takeIf { it.isUri() }
|
||||
?.toUri()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun addItems(fileDocs: List<FileDoc>) {
|
||||
fileDocs.mapTo(list) {
|
||||
ImportBook(it)
|
||||
}
|
||||
trySend(list)
|
||||
}
|
||||
is ImportBookIntent.FolderPicked -> onFolderPicked(intent.uri, intent.target)
|
||||
is ImportBookIntent.SearchToggle -> setSearchMode(intent.enabled)
|
||||
is ImportBookIntent.SearchQueryChange -> setSearchKey(intent.query)
|
||||
is ImportBookIntent.SortChange -> setSort(intent.sort)
|
||||
ImportBookIntent.ScanFolder -> scanCurrentDoc()
|
||||
ImportBookIntent.NavigateBack -> navigateBack()
|
||||
is ImportBookIntent.NavigateToLevel -> navigateToLevel(intent.level)
|
||||
ImportBookIntent.SelectAll -> selectAllCheckable()
|
||||
ImportBookIntent.SelectInvert -> invertSelection()
|
||||
ImportBookIntent.AddToBookshelf -> addSelectedToBookshelf()
|
||||
ImportBookIntent.DeleteSelection -> deleteSelectedDocs()
|
||||
is ImportBookIntent.ItemClick -> onItemClick(intent.item)
|
||||
is ImportBookIntent.ArchiveEntrySelected -> onArchiveEntrySelected(
|
||||
intent.fileDoc,
|
||||
intent.fileName
|
||||
)
|
||||
|
||||
override fun clear() {
|
||||
list.clear()
|
||||
trySend(emptyList())
|
||||
}
|
||||
is ImportBookIntent.ImportArchiveConfirmed -> addArchiveToBookShelf(
|
||||
intent.fileDoc,
|
||||
intent.fileName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun upAdapter() {
|
||||
trySend(list)
|
||||
}
|
||||
val uiState = combine(
|
||||
_state,
|
||||
appDb.bookDao.flowLocal()
|
||||
) { state, localBooks ->
|
||||
val localFileNames = localBooks.asSequence().map { it.originName }.toSet()
|
||||
|
||||
val docs = state.sourceDocs.map { fileDoc ->
|
||||
ImportBook(
|
||||
file = fileDoc,
|
||||
isOnBookShelf = !fileDoc.isDir && fileDoc.name in localFileNames
|
||||
)
|
||||
}
|
||||
|
||||
withContext(Main) {
|
||||
dataFlowStart?.invoke()
|
||||
val filteredDocs = if (state.searchKey.isBlank()) {
|
||||
docs
|
||||
} else {
|
||||
docs.filter { it.name.contains(state.searchKey, ignoreCase = true) }
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
dataCallback = null
|
||||
}
|
||||
|
||||
}.map { docList ->
|
||||
val docList = docList.toList()
|
||||
val filterKey = filterKey
|
||||
val skipFilter = filterKey.isNullOrBlank()
|
||||
val comparator = when (sort) {
|
||||
val comparator = when (state.sort) {
|
||||
2 -> compareBy<ImportBook>({ !it.isDir }, { -it.lastModified })
|
||||
1 -> compareBy({ !it.isDir }, { -it.size })
|
||||
else -> compareBy { !it.isDir }
|
||||
} then compareBy(AlphanumComparator) { it.name }
|
||||
docList.asSequence().filter {
|
||||
skipFilter || it.name.contains(filterKey)
|
||||
}.sortedWith(comparator).toList()
|
||||
}.flowOn(IO)
|
||||
|
||||
fun addToBookshelf(bookList: HashSet<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 })
|
||||
}
|
||||
LocalBook.importFiles(fileUris)
|
||||
} ?: emptyList()
|
||||
|
||||
ImportBookUiState(
|
||||
items = sortedDocs,
|
||||
selectedIds = selectedIds,
|
||||
searchKey = state.searchKey,
|
||||
interaction = state.interaction,
|
||||
pathNames = pathNames,
|
||||
canGoBack = state.subDocs.isNotEmpty(),
|
||||
sort = state.sort
|
||||
)
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = ImportBookUiState()
|
||||
)
|
||||
|
||||
fun hasRootDoc(): Boolean = _state.value.rootDoc != null
|
||||
|
||||
private fun initialize() {
|
||||
if (AppConfig.defaultBookTreeUri.isNullOrBlank()) {
|
||||
_effects.tryEmit(
|
||||
ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.DEFAULT_BOOK)
|
||||
)
|
||||
return
|
||||
}
|
||||
if (AppConfig.importBookPath.isNullOrBlank()) {
|
||||
AppConfig.importBookPath = AppConfig.defaultBookTreeUri
|
||||
}
|
||||
initRootDoc(changedFolder = false)
|
||||
}
|
||||
|
||||
private fun onFolderPicked(uri: Uri?, target: ImportFolderPickTarget) {
|
||||
uri ?: return
|
||||
uri.takePersistablePermissionSafely(context)
|
||||
when (target) {
|
||||
ImportFolderPickTarget.DEFAULT_BOOK -> {
|
||||
AppConfig.defaultBookTreeUri = uri.toString()
|
||||
if (AppConfig.importBookPath.isNullOrBlank()) {
|
||||
AppConfig.importBookPath = AppConfig.defaultBookTreeUri
|
||||
}
|
||||
}
|
||||
|
||||
ImportFolderPickTarget.IMPORT_FOLDER -> {
|
||||
AppConfig.importBookPath = uri.toString()
|
||||
}
|
||||
}
|
||||
initRootDoc(changedFolder = true)
|
||||
}
|
||||
|
||||
private fun initRootDoc(changedFolder: Boolean) {
|
||||
if (hasRootDoc() && !changedFolder) {
|
||||
reloadCurrentDoc()
|
||||
return
|
||||
}
|
||||
val lastPath = AppConfig.importBookPath
|
||||
if (lastPath.isNullOrBlank()) {
|
||||
_effects.tryEmit(
|
||||
ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.IMPORT_FOLDER)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val rootUri = if (lastPath.isUri()) {
|
||||
lastPath.toUri()
|
||||
} else {
|
||||
Uri.fromFile(File(lastPath))
|
||||
}
|
||||
|
||||
when {
|
||||
rootUri.isContentScheme() -> {
|
||||
kotlin.runCatching {
|
||||
val doc = DocumentFile.fromTreeUri(context, rootUri)
|
||||
if (doc == null || doc.name.isNullOrEmpty()) {
|
||||
_effects.tryEmit(
|
||||
ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.IMPORT_FOLDER)
|
||||
)
|
||||
} else {
|
||||
setRootDoc(FileDoc.fromDocumentFile(doc))
|
||||
}
|
||||
}.onFailure {
|
||||
_effects.tryEmit(
|
||||
ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.IMPORT_FOLDER)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
kotlin.runCatching {
|
||||
setRootDoc(FileDoc.fromFile(File(rootUri.path!!)))
|
||||
}.onFailure {
|
||||
_effects.tryEmit(
|
||||
ImportBookEffect.RequestFolderPicker(target = ImportFolderPickTarget.IMPORT_FOLDER)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearRoot() {
|
||||
_state.update {
|
||||
it.copy(
|
||||
rootDoc = null,
|
||||
subDocs = emptyList(),
|
||||
sourceDocs = emptyList(),
|
||||
selectedIds = emptySet()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setRootDoc(rootDoc: FileDoc) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
rootDoc = rootDoc,
|
||||
subDocs = emptyList(),
|
||||
selectedIds = emptySet()
|
||||
)
|
||||
}
|
||||
loadCurrentDoc()
|
||||
}
|
||||
|
||||
fun reloadCurrentDoc() {
|
||||
loadCurrentDoc()
|
||||
}
|
||||
|
||||
fun navigateNext(fileDoc: FileDoc) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
subDocs = it.subDocs + fileDoc,
|
||||
selectedIds = emptySet()
|
||||
)
|
||||
}
|
||||
loadCurrentDoc()
|
||||
}
|
||||
|
||||
fun navigateBack(): Boolean {
|
||||
val hasSubDocs = _state.value.subDocs.isNotEmpty()
|
||||
if (!hasSubDocs) return false
|
||||
_state.update {
|
||||
it.copy(
|
||||
subDocs = it.subDocs.dropLast(1),
|
||||
selectedIds = emptySet()
|
||||
)
|
||||
}
|
||||
loadCurrentDoc()
|
||||
return true
|
||||
}
|
||||
|
||||
fun navigateToLevel(index: Int) {
|
||||
val state = _state.value
|
||||
val rootDoc = state.rootDoc ?: return
|
||||
if (index < 0 || index > state.subDocs.size) return
|
||||
|
||||
val newSubDocs = if (index == 0) {
|
||||
emptyList()
|
||||
} else {
|
||||
state.subDocs.take(index)
|
||||
}
|
||||
_state.update {
|
||||
it.copy(
|
||||
subDocs = newSubDocs,
|
||||
selectedIds = emptySet()
|
||||
)
|
||||
}
|
||||
loadDoc(newSubDocs.lastOrNull() ?: rootDoc)
|
||||
}
|
||||
|
||||
fun setSort(sort: Int) {
|
||||
_state.update { it.copy(sort = sort) }
|
||||
context.putPrefInt(PreferKey.localBookImportSort, sort)
|
||||
}
|
||||
|
||||
fun setSearchMode(isSearch: Boolean) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
interaction = it.interaction.copy(isSearchMode = isSearch),
|
||||
searchKey = if (isSearch) it.searchKey else ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSearchKey(key: String) {
|
||||
_state.update { it.copy(searchKey = key) }
|
||||
}
|
||||
|
||||
fun toggleSelection(id: String) {
|
||||
_state.update {
|
||||
val newSelected = if (id in it.selectedIds) {
|
||||
it.selectedIds - id
|
||||
} else {
|
||||
it.selectedIds + id
|
||||
}
|
||||
it.copy(selectedIds = newSelected)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectAllCheckable() {
|
||||
val selected = uiState.value.items
|
||||
.asSequence()
|
||||
.filter { !it.isDir && !it.isOnBookShelf }
|
||||
.map { it.selectionId }
|
||||
.toSet()
|
||||
_state.update { it.copy(selectedIds = selected) }
|
||||
}
|
||||
|
||||
fun invertSelection() {
|
||||
val checkableIds = uiState.value.items
|
||||
.asSequence()
|
||||
.filter { !it.isDir && !it.isOnBookShelf }
|
||||
.map { it.selectionId }
|
||||
.toSet()
|
||||
_state.update { state ->
|
||||
state.copy(selectedIds = checkableIds - state.selectedIds)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSelection() {
|
||||
_state.update { it.copy(selectedIds = emptySet()) }
|
||||
}
|
||||
|
||||
fun addSelectedToBookshelf() {
|
||||
val selectedBooks = uiState.value.items
|
||||
.filter { it.selectionId in uiState.value.selectedIds }
|
||||
.filter { !it.isDir && !it.isOnBookShelf }
|
||||
|
||||
if (selectedBooks.isEmpty()) return
|
||||
|
||||
execute {
|
||||
LocalBook.importFiles(selectedBooks.map { it.file.uri })
|
||||
}.onError {
|
||||
context.toastOnUi("添加书架失败,请尝试重新选择文件夹")
|
||||
AppLog.put("添加书架失败\n${it.localizedMessage}", it)
|
||||
}.onSuccess {
|
||||
context.toastOnUi("添加书架成功")
|
||||
}.onFinally {
|
||||
finally.invoke()
|
||||
clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteDoc(bookList: HashSet<ImportBook>, finally: () -> Unit) {
|
||||
execute {
|
||||
bookList.forEach {
|
||||
it.file.delete()
|
||||
private fun onItemClick(item: ImportBook) {
|
||||
when {
|
||||
item.isDir -> navigateNext(item.file)
|
||||
item.isOnBookShelf -> onImportedFileClick(item.file)
|
||||
else -> toggleSelection(item.selectionId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onImportedFileClick(fileDoc: FileDoc) {
|
||||
if (!ArchiveUtils.isArchive(fileDoc.name)) {
|
||||
appDb.bookDao.getBookByFileName(fileDoc.name)?.let { book ->
|
||||
val filePath = fileDoc.toString()
|
||||
if (book.bookUrl != filePath) {
|
||||
book.bookUrl = filePath
|
||||
appDb.bookDao.insert(book)
|
||||
}
|
||||
_effects.tryEmit(ImportBookEffect.OpenBook(book))
|
||||
}
|
||||
}.onFinally {
|
||||
finally.invoke()
|
||||
return
|
||||
}
|
||||
val fileNames = ArchiveUtils.getArchiveFilesName(fileDoc) {
|
||||
it.matches(AppPattern.bookFileRegex)
|
||||
}
|
||||
when {
|
||||
fileNames.isEmpty() -> _effects.tryEmit(
|
||||
ImportBookEffect.ShowToastRes(io.legado.app.R.string.unsupport_archivefile_entry)
|
||||
)
|
||||
|
||||
fileNames.size == 1 -> onArchiveEntrySelected(fileDoc, fileNames.first())
|
||||
else -> _effects.tryEmit(ImportBookEffect.ShowArchiveEntries(fileDoc, fileNames))
|
||||
}
|
||||
}
|
||||
|
||||
fun loadDoc(fileDoc: FileDoc) {
|
||||
private fun onArchiveEntrySelected(fileDoc: FileDoc, fileName: String) {
|
||||
appDb.bookDao.getBookByFileName(fileName)?.let {
|
||||
_effects.tryEmit(ImportBookEffect.OpenBook(it))
|
||||
} ?: _effects.tryEmit(ImportBookEffect.ShowImportArchiveDialog(fileDoc, fileName))
|
||||
}
|
||||
|
||||
private fun addArchiveToBookShelf(fileDoc: FileDoc, fileName: String) {
|
||||
execute {
|
||||
val docList = fileDoc.list { item ->
|
||||
LocalBook.importArchiveFile(fileDoc.uri, fileName) { it.contains(fileName) }
|
||||
.firstOrNull()
|
||||
}.onSuccess { book ->
|
||||
if (book != null) {
|
||||
_effects.tryEmit(ImportBookEffect.OpenBook(book))
|
||||
} else {
|
||||
_effects.tryEmit(
|
||||
ImportBookEffect.ShowToastRes(io.legado.app.R.string.error)
|
||||
)
|
||||
}
|
||||
}.onError {
|
||||
_effects.tryEmit(
|
||||
ImportBookEffect.ShowToastRes(io.legado.app.R.string.error)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteSelectedDocs() {
|
||||
val selectedIds = uiState.value.selectedIds.mapTo(hashSetOf()) { it.toString() }
|
||||
if (selectedIds.isEmpty()) return
|
||||
|
||||
execute {
|
||||
_state.value.sourceDocs
|
||||
.filter { it.toString() in selectedIds }
|
||||
.forEach { it.delete() }
|
||||
}.onFinally {
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
sourceDocs = state.sourceDocs.filterNot { it.toString() in selectedIds },
|
||||
selectedIds = emptySet()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun scanCurrentDoc() {
|
||||
val current = currentDoc() ?: return
|
||||
|
||||
scanDocJob?.cancel()
|
||||
_state.update {
|
||||
it.copy(
|
||||
sourceDocs = emptyList(),
|
||||
selectedIds = emptySet(),
|
||||
interaction = it.interaction.copy(isLoading = true)
|
||||
)
|
||||
}
|
||||
|
||||
scanDocJob = viewModelScope.launch(IO) {
|
||||
kotlin.runCatching {
|
||||
scanDoc(current)
|
||||
}.onSuccess { docs ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
sourceDocs = docs,
|
||||
interaction = it.interaction.copy(isLoading = false)
|
||||
)
|
||||
}
|
||||
}.onFailure {
|
||||
withContext(Main) {
|
||||
context.toastOnUi("扫描文件夹出错\n${it.localizedMessage}")
|
||||
}
|
||||
_state.update { state ->
|
||||
state.copy(interaction = state.interaction.copy(isLoading = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCurrentDoc() {
|
||||
currentDoc()?.let { loadDoc(it) }
|
||||
}
|
||||
|
||||
private fun currentDoc(state: InternalState = _state.value): FileDoc? {
|
||||
return state.subDocs.lastOrNull() ?: state.rootDoc
|
||||
}
|
||||
|
||||
private fun loadDoc(fileDoc: FileDoc) {
|
||||
scanDocJob?.cancel()
|
||||
_state.update {
|
||||
it.copy(
|
||||
sourceDocs = emptyList(),
|
||||
selectedIds = emptySet(),
|
||||
interaction = it.interaction.copy(isLoading = true)
|
||||
)
|
||||
}
|
||||
|
||||
execute {
|
||||
fileDoc.list { item ->
|
||||
when {
|
||||
item.name.startsWith(".") -> false
|
||||
item.isDir -> true
|
||||
else -> item.name.matches(bookFileRegex) || item.name.matches(archiveFileRegex)
|
||||
}
|
||||
} ?: emptyList()
|
||||
}.onSuccess { docs ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
sourceDocs = docs,
|
||||
interaction = it.interaction.copy(isLoading = false)
|
||||
)
|
||||
}
|
||||
dataCallback?.setItems(docList!!)
|
||||
}.onError {
|
||||
context.toastOnUi("获取文件列表出错\n${it.localizedMessage}")
|
||||
_state.update { state ->
|
||||
state.copy(interaction = state.interaction.copy(isLoading = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun scanDoc(fileDoc: FileDoc) {
|
||||
dataCallback?.clear()
|
||||
private suspend fun scanDoc(fileDoc: FileDoc): List<FileDoc> {
|
||||
val channel = Channel<FileDoc>(UNLIMITED)
|
||||
var n = 1
|
||||
channel.trySend(fileDoc)
|
||||
val list = arrayListOf<FileDoc>()
|
||||
val docs = arrayListOf<FileDoc>()
|
||||
|
||||
channel.consumeAsFlow()
|
||||
.mapParallel(16) { fileDoc ->
|
||||
fileDoc.list()!!
|
||||
}.onEach { fileDocs ->
|
||||
.mapParallel(16) { doc ->
|
||||
doc.list() ?: emptyList<FileDoc>()
|
||||
}
|
||||
.onEach { fileDocs ->
|
||||
n--
|
||||
list.clear()
|
||||
fileDocs.forEach {
|
||||
if (it.isDir) {
|
||||
n++
|
||||
channel.trySend(it)
|
||||
} else if (it.name.matches(bookFileRegex)
|
||||
|| it.name.matches(archiveFileRegex)
|
||||
) {
|
||||
list.add(it)
|
||||
} else if (it.name.matches(bookFileRegex) || it.name.matches(archiveFileRegex)) {
|
||||
docs.add(it)
|
||||
}
|
||||
}
|
||||
dataCallback?.addItems(list)
|
||||
}.takeWhile {
|
||||
n > 0
|
||||
}.catch {
|
||||
context.toastOnUi("扫描文件夹出错\n${it.localizedMessage}")
|
||||
}.collect()
|
||||
}
|
||||
.takeWhile { n > 0 }
|
||||
.collect {}
|
||||
|
||||
return docs
|
||||
}
|
||||
|
||||
fun updateCallBackFlow(filterKey: String?) {
|
||||
this.filterKey = filterKey
|
||||
dataCallback?.upAdapter()
|
||||
override fun onCleared() {
|
||||
scanDocJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
interface DataCallback {
|
||||
|
||||
fun setItems(fileDocs: List<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
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -39,18 +41,13 @@ import androidx.compose.material.icons.outlined.CloudSync
|
||||
import androidx.compose.material.icons.outlined.Description
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
@@ -59,12 +56,11 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
@@ -75,30 +71,32 @@ import androidx.core.net.toUri
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import io.legado.app.R
|
||||
import io.legado.app.constant.AppConst
|
||||
import io.legado.app.data.entities.Book
|
||||
import io.legado.app.data.entities.Server
|
||||
import io.legado.app.help.config.AppConfig
|
||||
import io.legado.app.lib.dialogs.alert
|
||||
import io.legado.app.lib.dialogs.selector
|
||||
import io.legado.app.model.remote.RemoteBook
|
||||
import io.legado.app.ui.theme.LegadoTheme
|
||||
import io.legado.app.ui.widget.components.ActionItem
|
||||
import io.legado.app.ui.widget.components.AppRadioButton
|
||||
import io.legado.app.ui.widget.components.AppTextField
|
||||
import io.legado.app.ui.widget.components.EmptyMessageView
|
||||
import io.legado.app.ui.widget.components.SelectionActions
|
||||
import io.legado.app.ui.widget.components.alert.AppAlertDialog
|
||||
import io.legado.app.ui.widget.components.button.SmallIconButton
|
||||
import io.legado.app.ui.widget.components.button.SmallTonalIconButton
|
||||
import io.legado.app.ui.widget.components.card.GlassCard
|
||||
import io.legado.app.ui.widget.components.card.SelectionItemCard
|
||||
import io.legado.app.ui.widget.components.card.TextCard
|
||||
import io.legado.app.ui.widget.components.list.ListScaffold
|
||||
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
|
||||
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
|
||||
import io.legado.app.ui.widget.components.text.AppText
|
||||
import io.legado.app.utils.ArchiveUtils
|
||||
import io.legado.app.utils.ConvertUtils
|
||||
import io.legado.app.utils.FileDoc
|
||||
import io.legado.app.utils.find
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import io.legado.app.utils.isUri
|
||||
import io.legado.app.utils.startActivityForBook
|
||||
import io.legado.app.utils.toastOnUi
|
||||
import org.json.JSONObject
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
@@ -116,46 +114,17 @@ sealed class RemoteBookSheet {
|
||||
@Composable
|
||||
fun RemoteBookScreen(
|
||||
viewModel: RemoteBookViewModel = koinViewModel(),
|
||||
onBackClick: () -> Unit,
|
||||
startReadBook: (Book) -> Unit,
|
||||
onArchiveFileClick: (FileDoc) -> Unit,
|
||||
selectBookFolder: () -> Unit
|
||||
onBackClick: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
var dialogState by remember { mutableStateOf<RemoteBookDialog?>(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(
|
||||
data = dialogState as? RemoteBookDialog.DownloadArchive,
|
||||
@@ -166,7 +135,7 @@ fun RemoteBookScreen(
|
||||
},
|
||||
confirmText = stringResource(android.R.string.ok),
|
||||
onConfirm = { state ->
|
||||
scope.launch { viewModel.addToBookshelf(setOf(state.remoteBook)) }
|
||||
viewModel.dispatch(RemoteBookIntent.AddBooks(setOf(state.remoteBook)))
|
||||
dialogState = null
|
||||
},
|
||||
dismissText = stringResource(android.R.string.cancel),
|
||||
@@ -182,7 +151,7 @@ fun RemoteBookScreen(
|
||||
},
|
||||
confirmText = stringResource(android.R.string.ok),
|
||||
onConfirm = { state ->
|
||||
scope.launch { viewModel.addToBookshelf(setOf(state.remoteBook)) }
|
||||
viewModel.dispatch(RemoteBookIntent.AddBooks(setOf(state.remoteBook)))
|
||||
dialogState = null
|
||||
},
|
||||
dismissText = stringResource(android.R.string.cancel),
|
||||
@@ -191,7 +160,26 @@ fun RemoteBookScreen(
|
||||
|
||||
AppModalBottomSheet(
|
||||
show = showSheet != null,
|
||||
onDismissRequest = { showSheet = null }
|
||||
onDismissRequest = { showSheet = null },
|
||||
title = when (val state = showSheet) {
|
||||
is RemoteBookSheet.Servers -> stringResource(R.string.server_config)
|
||||
is RemoteBookSheet.ServerConfig -> {
|
||||
val actionText =
|
||||
if (state.server == null) stringResource(R.string.add)
|
||||
else stringResource(R.string.edit)
|
||||
"$actionText ${stringResource(R.string.server_config)}"
|
||||
}
|
||||
else -> null
|
||||
},
|
||||
endAction = if (showSheet is RemoteBookSheet.Servers) {
|
||||
{
|
||||
IconButton(onClick = { showSheet = RemoteBookSheet.ServerConfig(null) }) {
|
||||
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
) {
|
||||
when (val state = showSheet) {
|
||||
is RemoteBookSheet.Servers -> {
|
||||
@@ -199,14 +187,13 @@ fun RemoteBookScreen(
|
||||
servers = uiState.servers,
|
||||
selectedServerId = uiState.selectedServerId,
|
||||
onSelect = {
|
||||
viewModel.selectServer(it)
|
||||
viewModel.dispatch(RemoteBookIntent.SelectServer(it))
|
||||
showSheet = null
|
||||
},
|
||||
onEdit = { showSheet = RemoteBookSheet.ServerConfig(it) },
|
||||
onDelete = { viewModel.deleteServer(it) },
|
||||
onAdd = { showSheet = RemoteBookSheet.ServerConfig(null) },
|
||||
onDefault = {
|
||||
viewModel.selectServer(AppConst.DEFAULT_WEBDAV_ID)
|
||||
viewModel.dispatch(RemoteBookIntent.SelectServer(AppConst.DEFAULT_WEBDAV_ID))
|
||||
showSheet = null
|
||||
}
|
||||
)
|
||||
@@ -228,19 +215,54 @@ fun RemoteBookScreen(
|
||||
}
|
||||
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.initData { viewModel.loadRemoteBookList() }
|
||||
}
|
||||
viewModel.dispatch(RemoteBookIntent.Initialize)
|
||||
viewModel.effects.collect { effect ->
|
||||
when (effect) {
|
||||
is RemoteBookEffect.OpenBook -> context.startActivityForBook(effect.book)
|
||||
is RemoteBookEffect.RequestBookFolderPicker -> {
|
||||
selectDocTree.launch(
|
||||
effect.initialUri
|
||||
?: AppConfig.defaultBookTreeUri?.takeIf { it.isUri() }?.toUri()
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.permissionDenialEvent.collect { selectBookFolder() }
|
||||
is RemoteBookEffect.ShowArchiveEntries -> {
|
||||
context.selector(R.string.start_read, effect.fileNames) { _, name, _ ->
|
||||
viewModel.dispatch(
|
||||
RemoteBookIntent.ArchiveEntrySelected(effect.fileDoc, name)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is RemoteBookEffect.ShowImportArchiveDialog -> {
|
||||
context.alert(R.string.draw, R.string.no_book_found_bookshelf) {
|
||||
okButton {
|
||||
viewModel.dispatch(
|
||||
RemoteBookIntent.ImportArchiveConfirmed(
|
||||
effect.fileDoc,
|
||||
effect.fileName
|
||||
)
|
||||
)
|
||||
}
|
||||
noButton()
|
||||
}
|
||||
}
|
||||
|
||||
is RemoteBookEffect.ShowDownloadArchiveDialog -> {
|
||||
dialogState = RemoteBookDialog.DownloadArchive(effect.remoteBook)
|
||||
}
|
||||
|
||||
is RemoteBookEffect.ShowToast -> context.toastOnUi(effect.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ListScaffold(
|
||||
title = "远程书籍",
|
||||
state = uiState,
|
||||
onBackClick = onBackClick,
|
||||
onSearchToggle = { viewModel.setSearchMode(it) },
|
||||
onSearchQueryChange = { viewModel.setSearchKey(it) },
|
||||
onSearchToggle = { viewModel.dispatch(RemoteBookIntent.SearchToggle(it)) },
|
||||
onSearchQueryChange = { viewModel.dispatch(RemoteBookIntent.SearchChange(it)) },
|
||||
searchPlaceholder = "搜索",
|
||||
topBarActions = {
|
||||
IconButton(onClick = { showSheet = RemoteBookSheet.Servers }) {
|
||||
@@ -251,7 +273,7 @@ fun RemoteBookScreen(
|
||||
RoundDropdownMenuItem(
|
||||
text = "按名称排序",
|
||||
onClick = {
|
||||
viewModel.toggleSort(RemoteBookSort.Name)
|
||||
viewModel.dispatch(RemoteBookIntent.SortToggle(RemoteBookSort.Name))
|
||||
dismiss()
|
||||
},
|
||||
trailingIcon = {
|
||||
@@ -263,7 +285,7 @@ fun RemoteBookScreen(
|
||||
RoundDropdownMenuItem(
|
||||
text = "按时间排序",
|
||||
onClick = {
|
||||
viewModel.toggleSort(RemoteBookSort.Default)
|
||||
viewModel.dispatch(RemoteBookIntent.SortToggle(RemoteBookSort.Default))
|
||||
dismiss()
|
||||
},
|
||||
trailingIcon = {
|
||||
@@ -277,13 +299,13 @@ fun RemoteBookScreen(
|
||||
PathNavigationBar(
|
||||
pathNames = uiState.pathNames,
|
||||
canGoBack = uiState.canGoBack,
|
||||
onNavigateBack = { viewModel.navigateBack() },
|
||||
onNavigateToLevel = { viewModel.navigateToLevel(it) }
|
||||
onNavigateBack = { viewModel.dispatch(RemoteBookIntent.NavigateBack) },
|
||||
onNavigateToLevel = { viewModel.dispatch(RemoteBookIntent.NavigateToLevel(it)) }
|
||||
)
|
||||
},
|
||||
selectionActions = SelectionActions(
|
||||
onSelectAll = { viewModel.selectAllCheckable() },
|
||||
onSelectInvert = { viewModel.invertSelection() },
|
||||
onSelectAll = { viewModel.dispatch(RemoteBookIntent.SelectAll) },
|
||||
onSelectInvert = { viewModel.dispatch(RemoteBookIntent.SelectInvert) },
|
||||
primaryAction = ActionItem(
|
||||
text = "添加至书架",
|
||||
icon = { Icon(Icons.Default.CloudDownload, null) },
|
||||
@@ -292,7 +314,7 @@ fun RemoteBookScreen(
|
||||
.filter { it.id in uiState.selectedIds }
|
||||
.map { it.remoteBook }
|
||||
.toSet()
|
||||
scope.launch { viewModel.addToBookshelf(selectedBooks) }
|
||||
viewModel.dispatch(RemoteBookIntent.AddBooks(selectedBooks))
|
||||
}
|
||||
),
|
||||
secondaryActions = emptyList()
|
||||
@@ -306,7 +328,7 @@ fun RemoteBookScreen(
|
||||
.padding(paddingValues),
|
||||
isRefreshing = uiState.isLoading,
|
||||
state = pullToRefreshState,
|
||||
onRefresh = { viewModel.refreshData() }
|
||||
onRefresh = { viewModel.dispatch(RemoteBookIntent.Refresh) }
|
||||
) {
|
||||
if (uiState.items.isEmpty()) {
|
||||
if (uiState.isLoading) {
|
||||
@@ -328,14 +350,10 @@ fun RemoteBookScreen(
|
||||
book = book,
|
||||
isSelected = itemUi.id in uiState.selectedIds,
|
||||
onClick = {
|
||||
when {
|
||||
book.isDir -> viewModel.navigateToDir(book)
|
||||
book.isOnBookShelf -> startRead(book)
|
||||
else -> viewModel.toggleSelection(itemUi.id)
|
||||
}
|
||||
viewModel.dispatch(RemoteBookIntent.OpenItem(book))
|
||||
},
|
||||
onAddClick = { remoteBook ->
|
||||
scope.launch { viewModel.addToBookshelf(setOf(remoteBook)) }
|
||||
viewModel.dispatch(RemoteBookIntent.AddBooks(setOf(remoteBook)))
|
||||
},
|
||||
onUpdateClick = { remoteBook ->
|
||||
dialogState = RemoteBookDialog.ReImport(remoteBook)
|
||||
@@ -355,7 +373,6 @@ private fun ServersSheetContent(
|
||||
onSelect: (Long) -> Unit,
|
||||
onEdit: (Server) -> Unit,
|
||||
onDelete: (Server) -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
onDefault: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
@@ -364,22 +381,6 @@ private fun ServersSheetContent(
|
||||
.navigationBarsPadding()
|
||||
.padding(bottom = 8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
AppText(
|
||||
text = stringResource(R.string.server_config),
|
||||
style = LegadoTheme.typography.titleLarge
|
||||
)
|
||||
IconButton(onClick = onAdd) {
|
||||
Icon(Icons.Default.Add, contentDescription = "添加")
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -418,70 +419,45 @@ private fun ServerItem(
|
||||
onEdit: (() -> Unit)? = null,
|
||||
onDelete: (() -> Unit)? = null
|
||||
) {
|
||||
val containerColor = if (isSelected) {
|
||||
LegadoTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
LegadoTheme.colorScheme.surface
|
||||
}
|
||||
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = containerColor
|
||||
)
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier.animateContentSize(),
|
||||
headlineContent = {
|
||||
AppText(
|
||||
text = name,
|
||||
style = LegadoTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
RadioButton(
|
||||
selected = isSelected,
|
||||
onClick = null
|
||||
)
|
||||
},
|
||||
supportingContent = url?.let {
|
||||
{
|
||||
AppText(
|
||||
text = it,
|
||||
style = LegadoTheme.typography.bodySmall,
|
||||
color = LegadoTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingContent = if (onEdit != null || onDelete != null) {
|
||||
{
|
||||
Row {
|
||||
onEdit?.let {
|
||||
IconButton(onClick = it) {
|
||||
Icon(Icons.Default.Edit, contentDescription = "编辑")
|
||||
}
|
||||
}
|
||||
onDelete?.let {
|
||||
IconButton(onClick = it) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "删除")
|
||||
}
|
||||
}
|
||||
SelectionItemCard(
|
||||
title = name,
|
||||
subtitle = url,
|
||||
isSelected = isSelected,
|
||||
onToggleSelection = onClick,
|
||||
leadingContent = {
|
||||
AppRadioButton(
|
||||
selected = isSelected,
|
||||
onClick = null
|
||||
)
|
||||
},
|
||||
trailingAction = if (onEdit != null || onDelete != null) {
|
||||
{
|
||||
Row {
|
||||
onEdit?.let {
|
||||
SmallIconButton(
|
||||
onClick = it,
|
||||
imageVector = Icons.Default.Edit,
|
||||
contentDescription = "Edit"
|
||||
)
|
||||
}
|
||||
onDelete?.let {
|
||||
SmallIconButton(
|
||||
onClick = it,
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "Delete"
|
||||
)
|
||||
}
|
||||
}
|
||||
} else null,
|
||||
colors = ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
containerColor = LegadoTheme.colorScheme.surface,
|
||||
selectedContainerColor = LegadoTheme.colorScheme.secondaryContainer,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -503,40 +479,34 @@ private fun ServerConfigSheetContent(
|
||||
.navigationBarsPadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
AppText(
|
||||
text = if (server == null) "添加服务器" else "编辑服务器",
|
||||
style = LegadoTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
AppTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { AppText("名称") },
|
||||
label = "名称",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
AppTextField(
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
label = { AppText("URL") },
|
||||
label = "URL",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
AppTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { AppText("用户名") },
|
||||
label = "用户名",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
AppTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { AppText("密码") },
|
||||
label = "密码",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
@@ -644,7 +614,7 @@ private fun PathNavigationBar(
|
||||
if (canGoBack) {
|
||||
SmallTonalIconButton(
|
||||
onClick = onNavigateBack,
|
||||
icon = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "返回上级"
|
||||
)
|
||||
}
|
||||
@@ -664,7 +634,7 @@ private fun RemoteBookItem(
|
||||
val containerColor = if (isSelected) {
|
||||
LegadoTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
LegadoTheme.colorScheme.surfaceContainerLow
|
||||
LegadoTheme.colorScheme.surfaceContainer
|
||||
}
|
||||
|
||||
GlassCard(
|
||||
@@ -751,7 +721,7 @@ private fun RemoteBookItem(
|
||||
onAddClick(book)
|
||||
}
|
||||
},
|
||||
icon = if (book.isOnBookShelf)
|
||||
imageVector = if (book.isOnBookShelf)
|
||||
Icons.Outlined.CloudSync
|
||||
else
|
||||
Icons.Outlined.AddCircleOutline,
|
||||
@@ -760,4 +730,4 @@ private fun RemoteBookItem(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.legado.app.ui.book.import.remote
|
||||
|
||||
import android.app.Application
|
||||
import androidx.core.net.toUri
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -23,15 +24,18 @@ import io.legado.app.ui.widget.components.list.InteractionState
|
||||
import io.legado.app.ui.widget.components.list.ListUiState
|
||||
import io.legado.app.ui.widget.components.list.SelectableItem
|
||||
import io.legado.app.utils.AlphanumComparator
|
||||
import io.legado.app.utils.ArchiveUtils
|
||||
import io.legado.app.utils.ConvertUtils
|
||||
import io.legado.app.utils.FileDoc
|
||||
import io.legado.app.utils.find
|
||||
import io.legado.app.utils.takePersistablePermissionSafely
|
||||
import io.legado.app.utils.toastOnUi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
@@ -72,6 +76,35 @@ data class RemoteBookUiState(
|
||||
val canGoBack: Boolean get() = navigation.canGoBack
|
||||
}
|
||||
|
||||
sealed interface RemoteBookIntent {
|
||||
data object Initialize : RemoteBookIntent
|
||||
data object Refresh : RemoteBookIntent
|
||||
data class SearchToggle(val enabled: Boolean) : RemoteBookIntent
|
||||
data class SearchChange(val query: String) : RemoteBookIntent
|
||||
data class SortToggle(val sort: RemoteBookSort) : RemoteBookIntent
|
||||
data object SelectAll : RemoteBookIntent
|
||||
data object SelectInvert : RemoteBookIntent
|
||||
data class ToggleSelection(val id: String) : RemoteBookIntent
|
||||
data class OpenItem(val book: RemoteBook) : RemoteBookIntent
|
||||
data object NavigateBack : RemoteBookIntent
|
||||
data class NavigateToLevel(val level: Int) : RemoteBookIntent
|
||||
data class NavigateToDir(val book: RemoteBook) : RemoteBookIntent
|
||||
data class AddBooks(val books: Set<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(
|
||||
application: Application,
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
@@ -93,6 +126,43 @@ class RemoteBookViewModel(
|
||||
)
|
||||
|
||||
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(
|
||||
_state,
|
||||
@@ -139,13 +209,6 @@ class RemoteBookViewModel(
|
||||
initialValue = RemoteBookUiState()
|
||||
)
|
||||
|
||||
private val _permissionDenialEvent = MutableSharedFlow<Int>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
val permissionDenialEvent: SharedFlow<Int> = _permissionDenialEvent
|
||||
|
||||
fun initData(onSuccess: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
@@ -218,7 +281,7 @@ class RemoteBookViewModel(
|
||||
_state.update { it.copy(selectedIds = emptySet()) }
|
||||
Result.success(Unit)
|
||||
} catch (e: SecurityException) {
|
||||
_permissionDenialEvent.emit(1)
|
||||
_effects.emit(RemoteBookEffect.RequestBookFolderPicker())
|
||||
Result.failure(e)
|
||||
} catch (e: Exception) {
|
||||
AppLog.put("导入出错\n${e.localizedMessage}", e)
|
||||
@@ -229,6 +292,12 @@ class RemoteBookViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun addBooksToShelf(remoteBooks: Set<RemoteBook>) {
|
||||
viewModelScope.launch {
|
||||
addToBookshelf(remoteBooks)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getLocalBook(fileName: String): Book? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
appDb.bookDao.getBookByFileName(fileName)
|
||||
@@ -331,6 +400,83 @@ class RemoteBookViewModel(
|
||||
loadRemoteBookList(currentPath)
|
||||
}
|
||||
|
||||
private fun onOpenItem(remoteBook: RemoteBook) {
|
||||
if (remoteBook.isDir) {
|
||||
navigateToDir(remoteBook)
|
||||
return
|
||||
}
|
||||
if (!remoteBook.isOnBookShelf) {
|
||||
toggleSelection(remoteBook.path)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val downloadFileName = remoteBook.filename
|
||||
if (!ArchiveUtils.isArchive(downloadFileName)) {
|
||||
getLocalBook(downloadFileName)?.let {
|
||||
_effects.emit(RemoteBookEffect.OpenBook(it))
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
val bookTreeUri = AppConfig.defaultBookTreeUri
|
||||
if (bookTreeUri == null) {
|
||||
_effects.emit(RemoteBookEffect.RequestBookFolderPicker())
|
||||
return@launch
|
||||
}
|
||||
|
||||
val downloadArchiveFileDoc = withContext(Dispatchers.IO) {
|
||||
FileDoc.fromUri(bookTreeUri.toUri(), true)
|
||||
.find(downloadFileName)
|
||||
}
|
||||
if (downloadArchiveFileDoc == null) {
|
||||
_effects.emit(RemoteBookEffect.ShowDownloadArchiveDialog(remoteBook))
|
||||
} else {
|
||||
onArchiveFileClick(downloadArchiveFileDoc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onArchiveFileClick(fileDoc: FileDoc) {
|
||||
val fileNames = ArchiveUtils.getArchiveFilesName(fileDoc) {
|
||||
it.matches(io.legado.app.constant.AppPattern.bookFileRegex)
|
||||
}
|
||||
when {
|
||||
fileNames.isEmpty() -> _effects.emit(
|
||||
RemoteBookEffect.ShowToast(context.getString(io.legado.app.R.string.unsupport_archivefile_entry))
|
||||
)
|
||||
|
||||
fileNames.size == 1 -> onArchiveEntrySelected(fileDoc, fileNames.first())
|
||||
else -> _effects.emit(RemoteBookEffect.ShowArchiveEntries(fileDoc, fileNames))
|
||||
}
|
||||
}
|
||||
|
||||
private fun onArchiveEntrySelected(fileDoc: FileDoc, fileName: String) {
|
||||
appDb.bookDao.getBookByFileName(fileName)?.let { book ->
|
||||
viewModelScope.launch { _effects.emit(RemoteBookEffect.OpenBook(book)) }
|
||||
} ?: viewModelScope.launch {
|
||||
_effects.emit(RemoteBookEffect.ShowImportArchiveDialog(fileDoc, fileName))
|
||||
}
|
||||
}
|
||||
|
||||
private fun addArchiveToBookShelf(fileDoc: FileDoc, fileName: String) {
|
||||
execute {
|
||||
LocalBook.importArchiveFile(fileDoc.uri, fileName) { it.contains(fileName) }
|
||||
.firstOrNull()
|
||||
}.onSuccess { book ->
|
||||
if (book != null) {
|
||||
viewModelScope.launch { _effects.emit(RemoteBookEffect.OpenBook(book)) }
|
||||
} else {
|
||||
viewModelScope.launch {
|
||||
_effects.emit(RemoteBookEffect.ShowToast(context.getString(io.legado.app.R.string.error)))
|
||||
}
|
||||
}
|
||||
}.onError {
|
||||
viewModelScope.launch {
|
||||
_effects.emit(RemoteBookEffect.ShowToast(context.getString(io.legado.app.R.string.error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sortBooks(
|
||||
books: List<RemoteBook>,
|
||||
sortKey: RemoteBookSort,
|
||||
|
||||
+2
-3
@@ -139,8 +139,7 @@ fun <T> BatchImportDialog(
|
||||
|
||||
AppModalBottomSheet(
|
||||
show = show,
|
||||
onDismissRequest = onDismissRequest,
|
||||
containerColor = LegadoTheme.colorScheme.surfaceContainer
|
||||
onDismissRequest = onDismissRequest
|
||||
) {
|
||||
AppScaffold(
|
||||
modifier = Modifier
|
||||
@@ -242,7 +241,7 @@ fun ImportItemRow(
|
||||
|
||||
SmallIconButton(
|
||||
onClick = onInfoClick,
|
||||
icon = Icons.Default.Info,
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = "详情"
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user