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

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(
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,
@@ -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 = "详情"
)
}