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

This commit is contained in:
HapeLee
2026-04-11 17:20:59 +08:00
parent 1c8e4e88ac
commit 42dda827bc
7 changed files with 840 additions and 630 deletions
@@ -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,
@@ -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 = "详情"
) )
} }