From 3f36f476112798979c16cf81b8f08fbb40d2e84f Mon Sep 17 00:00:00 2001 From: HapeLee <1321903405@qq.com> Date: Thu, 5 Mar 2026 01:16:10 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E4=BD=BF=E7=94=A8Compos?= =?UTF-8?q?e=E9=87=8D=E5=86=99=E7=9A=84=E8=BF=9C=E7=A8=8B=E4=B9=A6?= =?UTF-8?q?=E7=B1=8D=E7=95=8C=E9=9D=A2=EF=BC=8C=E4=B9=9F=E8=AE=B8=E5=AD=98?= =?UTF-8?q?=E5=9C=A8Bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/repository/RemoteBookRepository.kt | 56 ++ .../book/import/remote/RemoteBookActivity.kt | 266 +----- .../book/import/remote/RemoteBookAdapter.kt | 157 ---- .../ui/book/import/remote/RemoteBookScreen.kt | 777 ++++++++++++++++++ 4 files changed, 849 insertions(+), 407 deletions(-) create mode 100644 app/src/main/java/io/legado/app/data/repository/RemoteBookRepository.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookAdapter.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookScreen.kt diff --git a/app/src/main/java/io/legado/app/data/repository/RemoteBookRepository.kt b/app/src/main/java/io/legado/app/data/repository/RemoteBookRepository.kt new file mode 100644 index 000000000..2881216bf --- /dev/null +++ b/app/src/main/java/io/legado/app/data/repository/RemoteBookRepository.kt @@ -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 { + 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> { + return appDb.bookDao.flowLocal() + } + +} diff --git a/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookActivity.kt b/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookActivity.kt index d5ea0ce49..db892b48d 100644 --- a/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookActivity.kt @@ -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(), - RemoteBookAdapter.CallBack, - SelectActionBar.CallBack, - ServersDialog.Callback { - - override val viewModel by viewModels() - 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() - R.id.menu_log -> showDialogFragment() - 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 = { } + ) } } diff --git a/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookAdapter.kt b/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookAdapter.kt deleted file mode 100644 index 64ce19c65..000000000 --- a/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookAdapter.kt +++ /dev/null @@ -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(context) { - var selected = hashSetOf() - 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 - ) { - 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) - } -} diff --git a/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookScreen.kt b/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookScreen.kt new file mode 100644 index 000000000..756d2d97a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookScreen.kt @@ -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(null) } + var showSheet by remember { mutableStateOf(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, + 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, + 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 "加入", + ) + } + } + } +} \ No newline at end of file