[优化] 使用Compose重写的远程书籍界面,也许存在Bug
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
package io.legado.app.data.repository
|
||||||
|
|
||||||
|
import io.legado.app.constant.BookType
|
||||||
|
import io.legado.app.data.AppDatabase
|
||||||
|
import io.legado.app.data.entities.Book
|
||||||
|
import io.legado.app.lib.webdav.Authorization
|
||||||
|
import io.legado.app.model.analyzeRule.CustomUrl
|
||||||
|
import io.legado.app.model.localBook.LocalBook
|
||||||
|
import io.legado.app.model.remote.RemoteBook
|
||||||
|
import io.legado.app.model.remote.RemoteBookWebDav
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
class RemoteBookRepository(
|
||||||
|
private val appDb: AppDatabase
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun createWebDav(serverId: Long): RemoteBookWebDav? {
|
||||||
|
return appDb.serverDao.get(serverId)
|
||||||
|
?.getWebDavConfig()
|
||||||
|
?.let {
|
||||||
|
val authorization = Authorization(it)
|
||||||
|
RemoteBookWebDav(it.url, authorization, serverId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun loadBooks(
|
||||||
|
webDav: RemoteBookWebDav,
|
||||||
|
path: String?
|
||||||
|
): List<RemoteBook> {
|
||||||
|
val url = path ?: webDav.rootBookUrl
|
||||||
|
return webDav.getRemoteBookList(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun downloadBook(
|
||||||
|
webDav: RemoteBookWebDav,
|
||||||
|
remoteBook: RemoteBook
|
||||||
|
) = webDav.downloadRemoteBook(remoteBook)
|
||||||
|
|
||||||
|
suspend fun importRemoteBookToShelf(webDav: RemoteBookWebDav, remoteBook: RemoteBook): Book? {
|
||||||
|
val downloadBookUri = downloadBook(webDav, remoteBook)
|
||||||
|
val books = LocalBook.importFiles(downloadBookUri)
|
||||||
|
val book = books.firstOrNull()
|
||||||
|
book?.apply {
|
||||||
|
origin = BookType.webDavTag + CustomUrl(remoteBook.path)
|
||||||
|
.putAttribute("serverID", webDav.serverID)
|
||||||
|
.toString()
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
return book
|
||||||
|
}
|
||||||
|
|
||||||
|
fun flowLocalBooks(): Flow<List<Book>> {
|
||||||
|
return appDb.bookDao.flowLocal()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,259 +1,25 @@
|
|||||||
package io.legado.app.ui.book.import.remote
|
package io.legado.app.ui.book.import.remote
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import androidx.compose.runtime.Composable
|
||||||
import android.net.Uri
|
import io.legado.app.base.BaseComposeActivity
|
||||||
import android.os.Bundle
|
import io.legado.app.ui.theme.AppTheme
|
||||||
import android.view.Menu
|
import io.legado.app.utils.startActivityForBook
|
||||||
import android.view.MenuItem
|
|
||||||
import android.view.SubMenu
|
|
||||||
import androidx.activity.addCallback
|
|
||||||
import androidx.activity.viewModels
|
|
||||||
import androidx.core.view.isGone
|
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import io.legado.app.R
|
|
||||||
import io.legado.app.data.appDb
|
|
||||||
import io.legado.app.help.config.AppConfig
|
|
||||||
import io.legado.app.help.config.LocalConfig
|
|
||||||
import io.legado.app.lib.dialogs.alert
|
|
||||||
import io.legado.app.model.remote.RemoteBook
|
|
||||||
import io.legado.app.ui.about.AppLogDialog
|
|
||||||
import io.legado.app.ui.book.import.BaseImportBookActivity
|
|
||||||
import io.legado.app.ui.widget.SelectActionBar
|
|
||||||
import io.legado.app.utils.ArchiveUtils
|
|
||||||
import io.legado.app.utils.FileDoc
|
|
||||||
import io.legado.app.utils.find
|
|
||||||
import io.legado.app.utils.showDialogFragment
|
|
||||||
import io.legado.app.utils.showHelp
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.conflate
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 展示远程书籍
|
* 展示远程书籍
|
||||||
*/
|
*/
|
||||||
class RemoteBookActivity : BaseImportBookActivity<RemoteBookViewModel>(),
|
class RemoteBookActivity : BaseComposeActivity() {
|
||||||
RemoteBookAdapter.CallBack,
|
@Composable
|
||||||
SelectActionBar.CallBack,
|
override fun Content() {
|
||||||
ServersDialog.Callback {
|
AppTheme {
|
||||||
|
RemoteBookScreen(
|
||||||
override val viewModel by viewModels<RemoteBookViewModel>()
|
onBackClick = { finish() },
|
||||||
private val adapter by lazy { RemoteBookAdapter(this, this) }
|
startReadBook = { book ->
|
||||||
private var groupMenu: SubMenu? = null
|
startActivityForBook(book)
|
||||||
|
},
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
onArchiveFileClick = { },
|
||||||
super.onCreate(savedInstanceState)
|
selectBookFolder = { }
|
||||||
searchView.queryHint = getString(R.string.screen) + " • " + getString(R.string.remote_book)
|
)
|
||||||
onBackPressedDispatcher.addCallback(this) {
|
|
||||||
if (!goBackDir()) {
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lifecycleScope.launch {
|
|
||||||
if (!setBookStorage()) {
|
|
||||||
finish()
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
initView()
|
|
||||||
initEvent()
|
|
||||||
launch {
|
|
||||||
viewModel.dataFlow.conflate().collect { sortedRemoteBooks ->
|
|
||||||
binding.refreshProgressBar.isVisible = false
|
|
||||||
binding.tvEmptyMsg.isGone = sortedRemoteBooks.isNotEmpty()
|
|
||||||
adapter.setItems(sortedRemoteBooks)
|
|
||||||
delay(500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
viewModel.initData {
|
|
||||||
upPath()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun observeLiveBus() {
|
|
||||||
viewModel.permissionDenialLiveData.observe(this) {
|
|
||||||
localBookTreeSelect.launch {
|
|
||||||
title = getString(R.string.select_book_folder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initView() {
|
|
||||||
//binding.layTop.setBackgroundColor(backgroundColor)
|
|
||||||
binding.recyclerView.layoutManager = LinearLayoutManager(this)
|
|
||||||
binding.recyclerView.adapter = adapter
|
|
||||||
binding.selectActionBar.setMainActionText(R.string.add_to_bookshelf)
|
|
||||||
binding.selectActionBar.setCallBack(this)
|
|
||||||
if (!LocalConfig.webDavBookHelpVersionIsLast) {
|
|
||||||
showHelp("webDavBookHelp")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sortCheck(sortKey: RemoteBookSort) {
|
|
||||||
if (viewModel.sortKey == sortKey) {
|
|
||||||
viewModel.sortAscending = !viewModel.sortAscending
|
|
||||||
} else {
|
|
||||||
viewModel.sortAscending = true
|
|
||||||
viewModel.sortKey = sortKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initEvent() {
|
|
||||||
binding.tvGoBack.setOnClickListener {
|
|
||||||
goBackDir()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
|
|
||||||
menuInflater.inflate(R.menu.book_remote, menu)
|
|
||||||
return super.onCompatCreateOptionsMenu(menu)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
|
|
||||||
when (item.itemId) {
|
|
||||||
R.id.menu_refresh -> upPath()
|
|
||||||
R.id.menu_server_config -> showDialogFragment<ServersDialog>()
|
|
||||||
R.id.menu_log -> showDialogFragment<AppLogDialog>()
|
|
||||||
R.id.menu_help -> showHelp("webDavBookHelp")
|
|
||||||
R.id.menu_sort_name -> {
|
|
||||||
item.isChecked = true
|
|
||||||
sortCheck(RemoteBookSort.Name)
|
|
||||||
upPath()
|
|
||||||
}
|
|
||||||
R.id.menu_sort_time -> {
|
|
||||||
item.isChecked = true
|
|
||||||
sortCheck(RemoteBookSort.Default)
|
|
||||||
upPath()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return super.onCompatOptionsItemSelected(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
|
|
||||||
groupMenu = menu.findItem(R.id.menu_sort)?.subMenu
|
|
||||||
groupMenu?.setGroupCheckable(R.id.menu_group_sort, true, true)
|
|
||||||
groupMenu?.findItem(R.id.menu_sort_name)?.isChecked =
|
|
||||||
viewModel.sortKey == RemoteBookSort.Name
|
|
||||||
groupMenu?.findItem(R.id.menu_sort_time)?.isChecked =
|
|
||||||
viewModel.sortKey == RemoteBookSort.Default
|
|
||||||
return super.onPrepareOptionsMenu(menu)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun revertSelection() {
|
|
||||||
adapter.revertSelection()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun selectAll(selectAll: Boolean) {
|
|
||||||
adapter.selectAll(selectAll)
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
|
||||||
override fun onClickSelectBarMainAction() {
|
|
||||||
binding.refreshProgressBar.isVisible = true
|
|
||||||
viewModel.addToBookshelf(adapter.selected) {
|
|
||||||
adapter.selected.clear()
|
|
||||||
adapter.notifyDataSetChanged()
|
|
||||||
binding.refreshProgressBar.isVisible = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun goBackDir(): Boolean {
|
|
||||||
if (viewModel.dirList.isEmpty()) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
viewModel.dirList.removeLastOrNull()
|
|
||||||
upPath()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun upPath() {
|
|
||||||
binding.tvGoBack.isEnabled = viewModel.dirList.isNotEmpty()
|
|
||||||
var path = if (viewModel.isDefaultWebdav) {
|
|
||||||
"books" + File.separator
|
|
||||||
} else {
|
|
||||||
File.separator
|
|
||||||
}
|
|
||||||
viewModel.dirList.forEach {
|
|
||||||
path = path + it.filename + File.separator
|
|
||||||
}
|
|
||||||
binding.tvPath.text = path
|
|
||||||
viewModel.dataCallback?.clear()
|
|
||||||
adapter.selected.clear()
|
|
||||||
viewModel.loadRemoteBookList(
|
|
||||||
viewModel.dirList.lastOrNull()?.path
|
|
||||||
) {
|
|
||||||
binding.refreshProgressBar.isVisible = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun openDir(remoteBook: RemoteBook) {
|
|
||||||
viewModel.dirList.add(remoteBook)
|
|
||||||
upPath()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun upCountView() {
|
|
||||||
binding.selectActionBar.upCountView(adapter.selected.size, adapter.checkableCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDialogDismiss(tag: String) {
|
|
||||||
viewModel.initData {
|
|
||||||
upPath()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSearchTextChange(newText: String?) {
|
|
||||||
viewModel.updateCallBackFlow(newText)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showRemoteBookDownloadAlert(
|
|
||||||
remoteBook: RemoteBook,
|
|
||||||
onDownloadFinish: (() -> Unit)? = null
|
|
||||||
) {
|
|
||||||
alert(
|
|
||||||
R.string.draw,
|
|
||||||
R.string.archive_not_found
|
|
||||||
) {
|
|
||||||
okButton {
|
|
||||||
viewModel.addToBookshelf(hashSetOf(remoteBook)) {
|
|
||||||
onDownloadFinish?.invoke()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
noButton()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun startRead(remoteBook: RemoteBook) {
|
|
||||||
val downloadFileName = remoteBook.filename
|
|
||||||
if (!ArchiveUtils.isArchive(downloadFileName)) {
|
|
||||||
appDb.bookDao.getBookByFileName(downloadFileName)?.let {
|
|
||||||
startReadBook(it)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
AppConfig.defaultBookTreeUri ?: return
|
|
||||||
val downloadArchiveFileDoc = FileDoc.fromUri(Uri.parse(AppConfig.defaultBookTreeUri), true)
|
|
||||||
.find(downloadFileName)
|
|
||||||
if (downloadArchiveFileDoc == null) {
|
|
||||||
showRemoteBookDownloadAlert(remoteBook) {
|
|
||||||
startRead(remoteBook)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onArchiveFileClick(downloadArchiveFileDoc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun addToBookShelfAgain(remoteBook: RemoteBook) {
|
|
||||||
alert(getString(R.string.sure), "是否重新加入书架?") {
|
|
||||||
yesButton {
|
|
||||||
binding.refreshProgressBar.isVisible = true
|
|
||||||
viewModel.addToBookshelf(hashSetOf(remoteBook)) {
|
|
||||||
binding.refreshProgressBar.isVisible = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
noButton()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
package io.legado.app.ui.book.import.remote
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Context
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import io.legado.app.R
|
|
||||||
import io.legado.app.base.adapter.ItemViewHolder
|
|
||||||
import io.legado.app.base.adapter.RecyclerAdapter
|
|
||||||
import io.legado.app.constant.AppConst
|
|
||||||
import io.legado.app.databinding.ItemImportBookBinding
|
|
||||||
import io.legado.app.model.remote.RemoteBook
|
|
||||||
import io.legado.app.utils.ConvertUtils
|
|
||||||
import io.legado.app.utils.gone
|
|
||||||
import io.legado.app.utils.invisible
|
|
||||||
import io.legado.app.utils.visible
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 适配器
|
|
||||||
* @author qianfanguojin
|
|
||||||
*/
|
|
||||||
class RemoteBookAdapter(context: Context, val callBack: CallBack) :
|
|
||||||
RecyclerAdapter<RemoteBook, ItemImportBookBinding>(context) {
|
|
||||||
var selected = hashSetOf<RemoteBook>()
|
|
||||||
var checkableCount = 0
|
|
||||||
|
|
||||||
override fun getViewBinding(parent: ViewGroup): ItemImportBookBinding {
|
|
||||||
return ItemImportBookBinding.inflate(inflater, parent, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCurrentListChanged() {
|
|
||||||
upCheckableCount()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 绑定RecycleView 中每一个项的视图和数据
|
|
||||||
*/
|
|
||||||
override fun convert(
|
|
||||||
holder: ItemViewHolder,
|
|
||||||
binding: ItemImportBookBinding,
|
|
||||||
item: RemoteBook,
|
|
||||||
payloads: MutableList<Any>
|
|
||||||
) {
|
|
||||||
binding.run {
|
|
||||||
if (payloads.isEmpty()) {
|
|
||||||
if (item.isDir) {
|
|
||||||
ivIcon.setImageResource(R.drawable.ic_folder)
|
|
||||||
ivIcon.visible()
|
|
||||||
cbSelect.invisible()
|
|
||||||
llBrief.gone()
|
|
||||||
cbSelect.isChecked = false
|
|
||||||
} else {
|
|
||||||
if (item.isOnBookShelf) {
|
|
||||||
ivIcon.setImageResource(R.drawable.ic_book_has)
|
|
||||||
ivIcon.visible()
|
|
||||||
cbSelect.invisible()
|
|
||||||
} else {
|
|
||||||
ivIcon.invisible()
|
|
||||||
cbSelect.visible()
|
|
||||||
}
|
|
||||||
llBrief.visible()
|
|
||||||
tvTag.text = item.contentType
|
|
||||||
tvSize.text = ConvertUtils.formatFileSize(item.size)
|
|
||||||
tvDate.text = AppConst.dateFormat.format(item.lastModify)
|
|
||||||
cbSelect.isChecked = selected.contains(item)
|
|
||||||
}
|
|
||||||
tvName.text = item.filename
|
|
||||||
} else {
|
|
||||||
cbSelect.isChecked = selected.contains(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun registerListener(holder: ItemViewHolder, binding: ItemImportBookBinding) {
|
|
||||||
holder.itemView.setOnClickListener {
|
|
||||||
getItem(holder.layoutPosition)?.let {
|
|
||||||
if (it.isDir) {
|
|
||||||
callBack.openDir(it)
|
|
||||||
} else if (!it.isOnBookShelf) {
|
|
||||||
if (!selected.contains(it)) {
|
|
||||||
selected.add(it)
|
|
||||||
} else {
|
|
||||||
selected.remove(it)
|
|
||||||
}
|
|
||||||
notifyItemChanged(holder.layoutPosition, true)
|
|
||||||
callBack.upCountView()
|
|
||||||
} else {
|
|
||||||
/* 点击开始阅读 */
|
|
||||||
callBack.startRead(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
holder.itemView.setOnLongClickListener {
|
|
||||||
getItem(holder.layoutPosition)?.let { remoteBook ->
|
|
||||||
if (remoteBook.isOnBookShelf) {
|
|
||||||
callBack.addToBookShelfAgain(remoteBook)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun upCheckableCount() {
|
|
||||||
checkableCount = 0
|
|
||||||
getItems().forEach {
|
|
||||||
if (!it.isDir && !it.isOnBookShelf) {
|
|
||||||
checkableCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
callBack.upCountView()
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
|
||||||
fun selectAll(selectAll: Boolean) {
|
|
||||||
if (selectAll) {
|
|
||||||
getItems().forEach {
|
|
||||||
if (!it.isDir && !it.isOnBookShelf) {
|
|
||||||
selected.add(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
selected.clear()
|
|
||||||
}
|
|
||||||
notifyDataSetChanged()
|
|
||||||
callBack.upCountView()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun revertSelection() {
|
|
||||||
getItems().forEach {
|
|
||||||
if (!it.isDir && !it.isOnBookShelf) {
|
|
||||||
if (selected.contains(it)) {
|
|
||||||
selected.remove(it)
|
|
||||||
} else {
|
|
||||||
selected.add(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
notifyItemRangeChanged(0, itemCount, true)
|
|
||||||
callBack.upCountView()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeSelection() {
|
|
||||||
for (i in getItems().lastIndex downTo 0) {
|
|
||||||
if (getItem(i) in selected) {
|
|
||||||
removeItem(i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
interface CallBack {
|
|
||||||
fun openDir(remoteBook: RemoteBook)
|
|
||||||
fun upCountView()
|
|
||||||
fun startRead(remoteBook: RemoteBook)
|
|
||||||
fun addToBookShelfAgain(remoteBook: RemoteBook)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,777 @@
|
|||||||
|
package io.legado.app.ui.book.import.remote
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateContentSize
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.ChevronRight
|
||||||
|
import androidx.compose.material.icons.filled.CloudDownload
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.Folder
|
||||||
|
import androidx.compose.material.icons.filled.Storage
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material.icons.outlined.AddCircleOutline
|
||||||
|
import androidx.compose.material.icons.outlined.Book
|
||||||
|
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.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
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.model.remote.RemoteBook
|
||||||
|
import io.legado.app.ui.widget.components.ActionItem
|
||||||
|
import io.legado.app.ui.widget.components.EmptyMessageView
|
||||||
|
import io.legado.app.ui.widget.components.SelectionActions
|
||||||
|
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.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.GlassModalBottomSheet
|
||||||
|
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 org.json.JSONObject
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
sealed class RemoteBookDialog {
|
||||||
|
data class DownloadArchive(val remoteBook: RemoteBook) : RemoteBookDialog()
|
||||||
|
data class ReImport(val remoteBook: RemoteBook) : RemoteBookDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class RemoteBookSheet {
|
||||||
|
data object Servers : RemoteBookSheet()
|
||||||
|
data class ServerConfig(val server: Server?) : RemoteBookSheet()
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun RemoteBookScreen(
|
||||||
|
viewModel: RemoteBookViewModel = koinViewModel(),
|
||||||
|
onBackClick: () -> Unit,
|
||||||
|
startReadBook: (Book) -> Unit,
|
||||||
|
onArchiveFileClick: (FileDoc) -> Unit,
|
||||||
|
selectBookFolder: () -> Unit
|
||||||
|
) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogState?.let { state ->
|
||||||
|
when (state) {
|
||||||
|
is RemoteBookDialog.DownloadArchive -> {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { dialogState = null },
|
||||||
|
title = { Text(stringResource(R.string.draw)) },
|
||||||
|
text = { Text(stringResource(R.string.archive_not_found)) },
|
||||||
|
confirmButton = {
|
||||||
|
OutlinedButton(onClick = {
|
||||||
|
scope.launch { viewModel.addToBookshelf(setOf(state.remoteBook)) }
|
||||||
|
dialogState = null
|
||||||
|
}) {
|
||||||
|
Text(stringResource(android.R.string.ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { dialogState = null }) {
|
||||||
|
Text(stringResource(android.R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is RemoteBookDialog.ReImport -> {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { dialogState = null },
|
||||||
|
title = { Text("是否重新加入书架?") },
|
||||||
|
text = { Text("将会覆盖书籍") },
|
||||||
|
confirmButton = {
|
||||||
|
OutlinedButton(onClick = {
|
||||||
|
scope.launch { viewModel.addToBookshelf(setOf(state.remoteBook)) }
|
||||||
|
dialogState = null
|
||||||
|
}) {
|
||||||
|
Text(stringResource(android.R.string.ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { dialogState = null }) {
|
||||||
|
Text(stringResource(android.R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSheet != null) {
|
||||||
|
GlassModalBottomSheet(
|
||||||
|
onDismissRequest = { showSheet = null }
|
||||||
|
) {
|
||||||
|
when (val state = showSheet) {
|
||||||
|
is RemoteBookSheet.Servers -> {
|
||||||
|
ServersSheetContent(
|
||||||
|
servers = uiState.servers,
|
||||||
|
selectedServerId = uiState.selectedServerId,
|
||||||
|
onSelect = {
|
||||||
|
viewModel.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)
|
||||||
|
showSheet = null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is RemoteBookSheet.ServerConfig -> {
|
||||||
|
ServerConfigSheetContent(
|
||||||
|
server = state.server,
|
||||||
|
onSave = {
|
||||||
|
viewModel.saveServer(it)
|
||||||
|
showSheet = RemoteBookSheet.Servers
|
||||||
|
},
|
||||||
|
onCancel = { showSheet = RemoteBookSheet.Servers }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(viewModel) {
|
||||||
|
viewModel.initData { viewModel.loadRemoteBookList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(viewModel) {
|
||||||
|
viewModel.permissionDenialEvent.collect { selectBookFolder() }
|
||||||
|
}
|
||||||
|
|
||||||
|
ListScaffold(
|
||||||
|
title = "远程书籍",
|
||||||
|
state = uiState,
|
||||||
|
onBackClick = onBackClick,
|
||||||
|
onSearchToggle = { viewModel.setSearchMode(it) },
|
||||||
|
onSearchQueryChange = { viewModel.setSearchKey(it) },
|
||||||
|
searchPlaceholder = "搜索",
|
||||||
|
topBarActions = {
|
||||||
|
IconButton(onClick = { showSheet = RemoteBookSheet.Servers }) {
|
||||||
|
Icon(Icons.Default.Storage, contentDescription = "配置服务器")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dropDownMenuContent = { dismiss ->
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = { Text("按名称排序") },
|
||||||
|
onClick = {
|
||||||
|
viewModel.toggleSort(RemoteBookSort.Name)
|
||||||
|
dismiss()
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
if (uiState.sortKey == RemoteBookSort.Name) {
|
||||||
|
Icon(Icons.Default.Check, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = { Text("按时间排序") },
|
||||||
|
onClick = {
|
||||||
|
viewModel.toggleSort(RemoteBookSort.Default)
|
||||||
|
dismiss()
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
if (uiState.sortKey == RemoteBookSort.Default) {
|
||||||
|
Icon(Icons.Default.Check, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomContent = {
|
||||||
|
PathNavigationBar(
|
||||||
|
pathNames = uiState.pathNames,
|
||||||
|
canGoBack = uiState.canGoBack,
|
||||||
|
onNavigateBack = { viewModel.navigateBack() },
|
||||||
|
onNavigateToLevel = { viewModel.navigateToLevel(it) }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
selectionActions = SelectionActions(
|
||||||
|
onSelectAll = { viewModel.selectAllCheckable() },
|
||||||
|
onSelectInvert = { viewModel.invertSelection() },
|
||||||
|
primaryAction = ActionItem(
|
||||||
|
text = "添加至书架",
|
||||||
|
icon = { Icon(Icons.Default.CloudDownload, null) },
|
||||||
|
onClick = {
|
||||||
|
val selectedBooks = uiState.items
|
||||||
|
.filter { it.id in uiState.selectedIds }
|
||||||
|
.map { it.remoteBook }
|
||||||
|
.toSet()
|
||||||
|
scope.launch { viewModel.addToBookshelf(selectedBooks) }
|
||||||
|
}
|
||||||
|
),
|
||||||
|
secondaryActions = emptyList()
|
||||||
|
),
|
||||||
|
onAddClick = null,
|
||||||
|
) { paddingValues ->
|
||||||
|
val pullToRefreshState = rememberPullToRefreshState()
|
||||||
|
PullToRefreshBox(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues),
|
||||||
|
isRefreshing = uiState.isLoading,
|
||||||
|
state = pullToRefreshState,
|
||||||
|
onRefresh = { viewModel.refreshData() }
|
||||||
|
) {
|
||||||
|
if (uiState.items.isEmpty()) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||||
|
} else {
|
||||||
|
EmptyMessageView(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
message = "没有内容"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(uiState.items, key = { it.id }) { itemUi ->
|
||||||
|
val book = itemUi.remoteBook
|
||||||
|
RemoteBookItem(
|
||||||
|
modifier = Modifier.animateItem(),
|
||||||
|
book = book,
|
||||||
|
isSelected = itemUi.id in uiState.selectedIds,
|
||||||
|
onClick = {
|
||||||
|
when {
|
||||||
|
book.isDir -> viewModel.navigateToDir(book)
|
||||||
|
book.isOnBookShelf -> startRead(book)
|
||||||
|
else -> viewModel.toggleSelection(itemUi.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onAddClick = { remoteBook ->
|
||||||
|
scope.launch { viewModel.addToBookshelf(setOf(remoteBook)) }
|
||||||
|
},
|
||||||
|
onUpdateClick = { remoteBook ->
|
||||||
|
dialogState = RemoteBookDialog.ReImport(remoteBook)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ServersSheetContent(
|
||||||
|
servers: List<Server>,
|
||||||
|
selectedServerId: Long,
|
||||||
|
onSelect: (Long) -> Unit,
|
||||||
|
onEdit: (Server) -> Unit,
|
||||||
|
onDelete: (Server) -> Unit,
|
||||||
|
onAdd: () -> Unit,
|
||||||
|
onDefault: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(bottom = 8.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.server_config),
|
||||||
|
style = MaterialTheme.typography.titleLarge
|
||||||
|
)
|
||||||
|
IconButton(onClick = onAdd) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "添加")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f, fill = false),
|
||||||
|
contentPadding = PaddingValues(bottom = 16.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
ServerItem(
|
||||||
|
name = "默认",
|
||||||
|
url = "应用备份的 WebDav 配置",
|
||||||
|
isSelected = selectedServerId == AppConst.DEFAULT_WEBDAV_ID,
|
||||||
|
onClick = onDefault
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(servers, key = { it.id }) { server ->
|
||||||
|
ServerItem(
|
||||||
|
name = server.name,
|
||||||
|
url = server.getConfigJsonObject()?.optString("url"),
|
||||||
|
isSelected = server.id == selectedServerId,
|
||||||
|
onClick = { onSelect(server.id) },
|
||||||
|
onEdit = { onEdit(server) },
|
||||||
|
onDelete = { onDelete(server) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ServerItem(
|
||||||
|
name: String,
|
||||||
|
url: String?,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onEdit: (() -> Unit)? = null,
|
||||||
|
onDelete: (() -> Unit)? = null
|
||||||
|
) {
|
||||||
|
val containerColor = if (isSelected) {
|
||||||
|
MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = containerColor
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.animateContentSize(),
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = name,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
RadioButton(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = url?.let {
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.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 = "删除")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else null,
|
||||||
|
colors = ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ServerConfigSheetContent(
|
||||||
|
server: Server?,
|
||||||
|
onSave: (Server) -> Unit,
|
||||||
|
onCancel: () -> Unit
|
||||||
|
) {
|
||||||
|
var name by remember { mutableStateOf(server?.name ?: "") }
|
||||||
|
val configJson = remember { server?.getConfigJsonObject() ?: JSONObject() }
|
||||||
|
var url by remember { mutableStateOf(configJson.optString("url")) }
|
||||||
|
var username by remember { mutableStateOf(configJson.optString("username")) }
|
||||||
|
var password by remember { mutableStateOf(configJson.optString("password")) }
|
||||||
|
var passwordVisible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(16.dp)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (server == null) "添加服务器" else "编辑服务器",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text("名称") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = url,
|
||||||
|
onValueChange = { url = it },
|
||||||
|
label = { Text("URL") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = username,
|
||||||
|
onValueChange = { username = it },
|
||||||
|
label = { Text("用户名") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text("密码") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||||
|
Icon(
|
||||||
|
if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onCancel,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(stringResource(android.R.string.cancel))
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val newServer = server?.copy(name = name) ?: Server(name = name)
|
||||||
|
val newConfig = JSONObject().apply {
|
||||||
|
put("url", url)
|
||||||
|
put("username", username)
|
||||||
|
put("password", password)
|
||||||
|
}
|
||||||
|
newServer.config = newConfig.toString()
|
||||||
|
onSave(newServer)
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
enabled = name.isNotBlank() && url.isNotBlank()
|
||||||
|
) {
|
||||||
|
Text("保存")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PathNavigationBar(
|
||||||
|
pathNames: List<String>,
|
||||||
|
canGoBack: Boolean,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
onNavigateToLevel: (Int) -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 16.dp, end = 16.dp, bottom = 4.dp)
|
||||||
|
.animateContentSize(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
),
|
||||||
|
shape = MaterialTheme.shapes.medium
|
||||||
|
) {
|
||||||
|
LazyRow(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
itemsIndexed(pathNames) { index, name ->
|
||||||
|
val isLast = index == pathNames.lastIndex
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = name,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = if (isLast) FontWeight.SemiBold else FontWeight.Medium,
|
||||||
|
color = if (isLast)
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(MaterialTheme.shapes.small)
|
||||||
|
.then(
|
||||||
|
if (!isLast)
|
||||||
|
Modifier.clickable { onNavigateToLevel(index) }
|
||||||
|
else Modifier
|
||||||
|
)
|
||||||
|
.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!isLast) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ChevronRight,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canGoBack) {
|
||||||
|
SmallTonalIconButton(
|
||||||
|
onClick = onNavigateBack,
|
||||||
|
icon = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "返回上级"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun RemoteBookItem(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
book: RemoteBook,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onAddClick: (RemoteBook) -> Unit,
|
||||||
|
onUpdateClick: (RemoteBook) -> Unit
|
||||||
|
) {
|
||||||
|
val containerColor = if (isSelected) {
|
||||||
|
MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerLow
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
onClick = onClick,
|
||||||
|
colors = CardDefaults.cardColors(containerColor = containerColor)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = when {
|
||||||
|
book.isDir -> Icons.Default.Folder
|
||||||
|
book.isOnBookShelf -> Icons.Outlined.Book
|
||||||
|
else -> Icons.Outlined.Description
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = if (book.isDir) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = book.filename.substringBeforeLast("."),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!book.isDir) {
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
|
||||||
|
if (book.contentType.isNotEmpty()) {
|
||||||
|
TextCard(
|
||||||
|
text = book.contentType.uppercase(),
|
||||||
|
textStyle = MaterialTheme.typography.labelSmall,
|
||||||
|
horizontalPadding = 4.dp,
|
||||||
|
verticalPadding = 2.dp,
|
||||||
|
cornerRadius = 4.dp,
|
||||||
|
icon = null,
|
||||||
|
backgroundColor = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "${ConvertUtils.formatFileSize(book.size)} • ${
|
||||||
|
AppConst.dateFormat.format(book.lastModify)
|
||||||
|
}",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!book.isDir) {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
SmallIconButton(
|
||||||
|
onClick = {
|
||||||
|
if (book.isOnBookShelf) {
|
||||||
|
onUpdateClick(book)
|
||||||
|
} else {
|
||||||
|
onAddClick(book)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon = if (book.isOnBookShelf)
|
||||||
|
Icons.Outlined.CloudSync
|
||||||
|
else
|
||||||
|
Icons.Outlined.AddCircleOutline,
|
||||||
|
contentDescription = if (book.isOnBookShelf) "更新" else "加入",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user