[优化] 使用Compose重写的远程书籍界面,也许存在Bug

This commit is contained in:
HapeLee
2026-03-05 01:23:47 +08:00
parent 3b8083bc28
commit 3f36f47611
4 changed files with 849 additions and 407 deletions
@@ -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
import android.annotation.SuppressLint
import android.net.Uri
import android.os.Bundle
import android.view.Menu
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
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 : BaseImportBookActivity<RemoteBookViewModel>(),
RemoteBookAdapter.CallBack,
SelectActionBar.CallBack,
ServersDialog.Callback {
override val viewModel by viewModels<RemoteBookViewModel>()
private val adapter by lazy { RemoteBookAdapter(this, this) }
private var groupMenu: SubMenu? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
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()
class RemoteBookActivity : BaseComposeActivity() {
@Composable
override fun Content() {
AppTheme {
RemoteBookScreen(
onBackClick = { finish() },
startReadBook = { book ->
startActivityForBook(book)
},
onArchiveFileClick = { },
selectBookFolder = { }
)
}
}
@@ -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 "加入",
)
}
}
}
}