From 16b816494306054953502e18130fbdbdb4fd8c98 Mon Sep 17 00:00:00 2001 From: HapeLee <1321903405@qq.com> Date: Thu, 5 Mar 2026 00:45:01 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E5=92=8C=E6=80=A7=E8=83=BD=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../io/legado/app/base/BaseRuleViewModel.kt | 4 +- .../java/io/legado/app/data/AppDatabase.kt | 15 +- .../io/legado/app/data/dao/ReadRecordDao.kt | 21 - .../io/legado/app/data/entities/Bookmark.kt | 2 + .../app/data/entities/ReadRecordShow.kt | 8 - .../entities/readRecord/ReadRecordSession.kt | 2 + .../main/java/io/legado/app/di/appModule.kt | 4 + .../app/ui/book/explore/ExploreShowAdapter.kt | 142 ------ .../app/ui/book/explore/ExploreShowScreen.kt | 14 - .../book/import/remote/RemoteBookViewModel.kt | 464 +++++++++++++----- .../ui/book/readRecord/ReadRecordScreen.kt | 6 +- .../io/legado/app/ui/book/toc/TocViewModel.kt | 8 +- .../app/ui/book/toc/rule/TxtRuleScreen.kt | 1 - .../ui/book/toc/rule/TxtTocRuleViewModel.kt | 6 +- .../otherConfig/CheckSourceBottomSheet.kt | 7 +- .../DirectLinkUploadBottomSheet.kt | 5 +- .../config/otherConfig/OtherConfigScreen.kt | 2 - .../app/ui/dict/rule/DictRuleEditSheet.kt | 5 +- .../legado/app/ui/dict/rule/DictRuleScreen.kt | 1 - .../app/ui/dict/rule/DictRuleViewModel.kt | 6 +- .../app/ui/replace/GroupManageBottomSheet.kt | 5 +- .../app/ui/replace/ReplaceRuleScreen.kt | 4 - .../app/ui/replace/ReplaceRuleViewModel.kt | 6 +- .../GlassMediumFlexibleTopAppBar.kt | 7 +- .../ui/widget/components/SearchBarSection.kt | 84 +++- .../widget/components/SelectionBottomBar.kt | 9 +- .../components/bookmark/BookmarkEditSheet.kt | 5 +- .../components/button/SmallIconButton.kt | 103 ++++ .../widget/components/button/TopBarButton.kt | 105 +++- .../exportComponents/FilePickerSheet.kt | 3 - .../importComponents/ImportComponents.kt | 1 - .../ui/widget/components/list/ListScaffold.kt | 127 +++++ .../modalBottomSheet/GlassModalBottomSheet.kt | 4 +- .../{RuleActionState.kt => ListUiState.kt} | 4 +- .../widget/components/rules/RuleEditSheet.kt | 5 +- .../components/rules/RuleListScaffold.kt | 9 +- .../components/topbar/DynamicTopAppBar.kt | 8 +- 37 files changed, 788 insertions(+), 424 deletions(-) delete mode 100644 app/src/main/java/io/legado/app/data/entities/ReadRecordShow.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/explore/ExploreShowAdapter.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/list/ListScaffold.kt rename app/src/main/java/io/legado/app/ui/widget/components/rules/{RuleActionState.kt => ListUiState.kt} (85%) diff --git a/app/src/main/java/io/legado/app/base/BaseRuleViewModel.kt b/app/src/main/java/io/legado/app/base/BaseRuleViewModel.kt index 87f3097e4..25f304052 100644 --- a/app/src/main/java/io/legado/app/base/BaseRuleViewModel.kt +++ b/app/src/main/java/io/legado/app/base/BaseRuleViewModel.kt @@ -13,7 +13,7 @@ import io.legado.app.help.http.text import io.legado.app.ui.widget.components.importComponents.BaseImportUiState import io.legado.app.ui.widget.components.importComponents.ImportItemWrapper import io.legado.app.ui.widget.components.importComponents.ImportStatus -import io.legado.app.ui.widget.components.rules.RuleActionState +import io.legado.app.ui.widget.components.rules.ListUiState import io.legado.app.ui.widget.components.rules.SelectableItem import io.legado.app.utils.isAbsUrl import io.legado.app.utils.isUri @@ -42,7 +42,7 @@ sealed interface BaseRuleEvent { ) : BaseRuleEvent } -abstract class BaseRuleViewModel, Entity, ID, S : RuleActionState>( +abstract class BaseRuleViewModel, Entity, ID, S : ListUiState>( application: Application, initialState: S, private val uploadRepository: UploadRepository? = null // 设为可空,提高灵活性 diff --git a/app/src/main/java/io/legado/app/data/AppDatabase.kt b/app/src/main/java/io/legado/app/data/AppDatabase.kt index 7a2b25c0a..93ca714e6 100644 --- a/app/src/main/java/io/legado/app/data/AppDatabase.kt +++ b/app/src/main/java/io/legado/app/data/AppDatabase.kt @@ -25,6 +25,7 @@ import io.legado.app.data.dao.RssSourceDao import io.legado.app.data.dao.RssStarDao import io.legado.app.data.dao.RuleSubDao import io.legado.app.data.dao.SearchBookDao +import io.legado.app.data.dao.SearchContentHistoryDao import io.legado.app.data.dao.SearchKeywordDao import io.legado.app.data.dao.ServerDao import io.legado.app.data.dao.TxtTocRuleDao @@ -46,6 +47,7 @@ import io.legado.app.data.entities.RssSource import io.legado.app.data.entities.RssStar import io.legado.app.data.entities.RuleSub import io.legado.app.data.entities.SearchBook +import io.legado.app.data.entities.SearchContentHistory import io.legado.app.data.entities.SearchKeyword import io.legado.app.data.entities.Server import io.legado.app.data.entities.TxtTocRule @@ -67,14 +69,15 @@ val appDb by lazy { } @Database( - version = 83, + version = 85, exportSchema = true, entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class, ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class, RssSource::class, Bookmark::class, RssArticle::class, RssReadRecord::class, ReadRecordDetail::class, ReadRecordSession::class, RssStar::class, TxtTocRule::class, ReadRecord::class, HttpTTS::class, Cache::class, - RuleSub::class, DictRule::class, KeyboardAssist::class, Server::class], + RuleSub::class, DictRule::class, KeyboardAssist::class, Server::class, + SearchContentHistory::class], views = [BookSourcePart::class], autoMigrations = [ AutoMigration(from = 43, to = 44), @@ -115,7 +118,10 @@ val appDb by lazy { AutoMigration(from = 78, to = 79), AutoMigration(from = 79, to = 80), AutoMigration(from = 80, to = 81), - AutoMigration(from = 81, to = 82) + AutoMigration(from = 81, to = 82), + AutoMigration(from = 82, to = 83), + AutoMigration(from = 83, to = 84), + AutoMigration(from = 84, to = 85) ] ) abstract class AppDatabase : RoomDatabase() { @@ -141,6 +147,7 @@ abstract class AppDatabase : RoomDatabase() { abstract val dictRuleDao: DictRuleDao abstract val keyboardAssistsDao: KeyboardAssistsDao abstract val serverDao: ServerDao + abstract val searchContentHistoryDao: SearchContentHistoryDao companion object { @@ -272,4 +279,4 @@ abstract class AppDatabase : RoomDatabase() { } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt b/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt index 7ef0e7f9f..059d4184d 100644 --- a/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt @@ -6,7 +6,6 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update -import io.legado.app.data.entities.ReadRecordShow import io.legado.app.data.entities.readRecord.ReadRecord import io.legado.app.data.entities.readRecord.ReadRecordDetail import io.legado.app.data.entities.readRecord.ReadRecordSession @@ -23,29 +22,9 @@ interface ReadRecordDao { @get:Query("select * from readRecordSession") val allSession: List - - @get:Query( - """ - select bookName, bookAuthor, sum(readTime) as readTime, max(lastRead) as lastRead - from readRecord - group by bookName, bookAuthor - order by bookName collate localized, bookAuthor collate localized""" - ) - val allShow: List - @Query("SELECT sum(readTime) FROM readRecord") fun getTotalReadTime(): Flow - @Query( - """ - select bookName, bookAuthor, sum(readTime) as readTime, max(lastRead) as lastRead - from readRecord - where bookName like '%' || :searchKey || '%' or bookAuthor like '%' || :searchKey || '%' - group by bookName, bookAuthor - order by bookName collate localized, bookAuthor collate localized""" - ) - fun search(searchKey: String): List - @Query("select sum(readTime) from readRecord where bookName = :bookName") fun getReadTime(bookName: String): Long? diff --git a/app/src/main/java/io/legado/app/data/entities/Bookmark.kt b/app/src/main/java/io/legado/app/data/entities/Bookmark.kt index 109397042..27186fef9 100644 --- a/app/src/main/java/io/legado/app/data/entities/Bookmark.kt +++ b/app/src/main/java/io/legado/app/data/entities/Bookmark.kt @@ -1,6 +1,7 @@ package io.legado.app.data.entities import android.os.Parcelable +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey @@ -15,6 +16,7 @@ data class Bookmark( @PrimaryKey val time: Long = System.currentTimeMillis(), val bookName: String = "", + @ColumnInfo(defaultValue = "") val bookAuthor: String = "", var chapterIndex: Int = 0, var chapterPos: Int = 0, diff --git a/app/src/main/java/io/legado/app/data/entities/ReadRecordShow.kt b/app/src/main/java/io/legado/app/data/entities/ReadRecordShow.kt deleted file mode 100644 index 7db51d188..000000000 --- a/app/src/main/java/io/legado/app/data/entities/ReadRecordShow.kt +++ /dev/null @@ -1,8 +0,0 @@ -package io.legado.app.data.entities - -data class ReadRecordShow( - var bookName: String, - var bookAuthor: String, - var readTime: Long, - var lastRead: Long -) diff --git a/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordSession.kt b/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordSession.kt index 1e4bde3af..8d9d4b861 100644 --- a/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordSession.kt +++ b/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordSession.kt @@ -1,5 +1,6 @@ package io.legado.app.data.entities.readRecord +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @@ -10,6 +11,7 @@ data class ReadRecordSession( val deviceId: String = "", val bookName: String = "", + @ColumnInfo(defaultValue = "") val bookAuthor: String = "", // 一次阅读的开始/结束 diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt index e04c41b68..6e26dd2f5 100644 --- a/app/src/main/java/io/legado/app/di/appModule.kt +++ b/app/src/main/java/io/legado/app/di/appModule.kt @@ -6,10 +6,12 @@ import io.legado.app.data.repository.DirectLinkUploadRepository import io.legado.app.data.repository.ExploreRepository import io.legado.app.data.repository.ExploreRepositoryImpl import io.legado.app.data.repository.ReadRecordRepository +import io.legado.app.data.repository.RemoteBookRepository import io.legado.app.data.repository.SearchContentRepository import io.legado.app.data.repository.UploadRepository import io.legado.app.ui.book.bookmark.AllBookmarkViewModel import io.legado.app.ui.book.explore.ExploreShowViewModel +import io.legado.app.ui.book.import.remote.RemoteBookViewModel import io.legado.app.ui.book.readRecord.ReadRecordViewModel import io.legado.app.ui.book.searchContent.SearchContentViewModel import io.legado.app.ui.book.toc.TocViewModel @@ -34,6 +36,7 @@ val appModule = module { singleOf(::ReadRecordRepository) singleOf(::BookRepository) singleOf(::SearchContentRepository) + singleOf(::RemoteBookRepository) single { DirectLinkUploadRepository() } single { ExploreRepositoryImpl(get()) } @@ -47,6 +50,7 @@ val appModule = module { viewModelOf(::TxtTocRuleViewModel) viewModelOf(::OtherConfigViewModel) viewModelOf(::TocViewModel) + viewModelOf(::RemoteBookViewModel) viewModel { (route: ReplaceEditRoute) -> ReplaceEditViewModel( diff --git a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowAdapter.kt b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowAdapter.kt deleted file mode 100644 index c539772a2..000000000 --- a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowAdapter.kt +++ /dev/null @@ -1,142 +0,0 @@ -package io.legado.app.ui.book.explore - -import android.content.Context -import android.os.Bundle -import android.view.ViewGroup -import android.widget.LinearLayout -import androidx.core.view.isVisible -import io.legado.app.R -import io.legado.app.base.adapter.ItemViewHolder -import io.legado.app.base.adapter.RecyclerAdapter -import io.legado.app.data.entities.Book -import io.legado.app.data.entities.SearchBook -import io.legado.app.databinding.ItemSearchBinding -import io.legado.app.help.config.AppConfig -import io.legado.app.model.BookShelfState -import io.legado.app.ui.widget.text.AccentBgTextView -import io.legado.app.utils.dpToPx -import io.legado.app.utils.gone -import io.legado.app.utils.visible - - -class ExploreShowAdapter(context: Context, val callBack: CallBack) : - RecyclerAdapter(context) { - - override fun getViewBinding(parent: ViewGroup): ItemSearchBinding { - return ItemSearchBinding.inflate(inflater, parent, false) - } - - override fun convert( - holder: ItemViewHolder, - binding: ItemSearchBinding, - item: SearchBook, - payloads: MutableList - ) { - if (payloads.isEmpty()) { - bind(binding, item) - } else { - for (i in payloads.indices) { - val bundle = payloads[i] as Bundle - bindChange(binding, item, bundle) - } - } - - } - - private fun bind(binding: ItemSearchBinding, item: SearchBook) { - binding.run { - tvName.text = item.name - tvAuthor.text = context.getString(R.string.author_show, item.author) - when (callBack.getBookShelfState(item.name, item.author, item.bookUrl)) { - BookShelfState.IN_SHELF -> { - ivInBookshelf.isVisible = true - tvBookshelf.text = context.getString(R.string.remove_from_bookshelf) - } - BookShelfState.SAME_NAME_AUTHOR -> { - ivInBookshelf.isVisible = true - tvBookshelf.text = context.getString(R.string.same_name_book) - } - BookShelfState.NOT_IN_SHELF -> { - ivInBookshelf.isVisible = false - } - } - if (item.latestChapterTitle.isNullOrEmpty()) { - tvLasted.gone() - } else { - tvLasted.text = context.getString(R.string.lasted_show, item.latestChapterTitle) - tvLasted.visible() - } - tvIntroduce.text = item.trimIntro(context) - val kinds = item.getKindList() - if (kinds.isEmpty()) { - kindContainer.gone() - } else { - kindContainer.visible() - kindContainer.removeAllViews() - kinds.forEach { kind -> - val kindTextView = AccentBgTextView(context).apply { - layoutParams = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ).apply { - marginEnd = 6.dpToPx() - } - textSize = 11f - text = kind - setLines(1) - setSingleLine(true) - } - kindContainer.addView(kindTextView) - } - } - ivCover.load( - item.coverUrl, - item.name, - item.author, - AppConfig.loadCoverOnlyWifi, - item.origin - ) - } - } - - private fun bindChange(binding: ItemSearchBinding, item: SearchBook, bundle: Bundle) { - binding.run { - bundle.keySet().forEach { - when (it) { - "isInBookshelf" -> { - when (callBack.getBookShelfState(item.name, item.author, item.bookUrl)) { - BookShelfState.IN_SHELF -> { - ivInBookshelf.isVisible = true - tvBookshelf.text = context.getString(R.string.remove_from_bookshelf) - } - BookShelfState.SAME_NAME_AUTHOR -> { - ivInBookshelf.isVisible = true - tvBookshelf.text = context.getString(R.string.same_name_book) - } - BookShelfState.NOT_IN_SHELF -> { - ivInBookshelf.isVisible = false - } - } - } - } - } - } - } - - override fun registerListener(holder: ItemViewHolder, binding: ItemSearchBinding) { - binding.llContent.setOnClickListener { - getItem(holder.layoutPosition)?.let { - callBack.showBookInfo(it.toBook()) - } - } - } - - interface CallBack { - /** - * 是否已经加入书架 - */ - fun getBookShelfState(name: String, author: String, url: String?): BookShelfState - - fun showBookInfo(book: Book) - } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt index 213e56711..3f500c2a1 100644 --- a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt @@ -52,7 +52,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold -import androidx.compose.material3.SheetValue import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -60,7 +59,6 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -186,12 +184,8 @@ fun ExploreShowScreen( } if (showGridCountSheet) { - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true - ) GlassModalBottomSheet( - sheetState = sheetState, modifier = Modifier .padding(16.dp), onDismissRequest = { showGridCountSheet = false } @@ -244,16 +238,8 @@ fun ExploreShowScreen( } if (showKindSheet) { - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true, - confirmValueChange = { newValue -> - newValue != SheetValue.PartiallyExpanded - } - ) - GlassModalBottomSheet( onDismissRequest = { showKindSheet = false }, - sheetState = sheetState, modifier = Modifier .heightIn(max = LocalConfiguration.current.screenHeightDp.dp * 0.72f) ) { diff --git a/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookViewModel.kt b/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookViewModel.kt index 51be1e278..461d78d5b 100644 --- a/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookViewModel.kt @@ -1,174 +1,372 @@ package io.legado.app.ui.book.import.remote import android.app.Application -import androidx.lifecycle.MutableLiveData +import androidx.compose.runtime.Immutable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope import io.legado.app.base.BaseViewModel +import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.constant.BookType import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.Server +import io.legado.app.data.repository.RemoteBookRepository import io.legado.app.exception.NoStackTraceException import io.legado.app.help.AppWebDav import io.legado.app.help.config.AppConfig -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 io.legado.app.ui.widget.components.rules.InteractionState +import io.legado.app.ui.widget.components.rules.ListUiState +import io.legado.app.ui.widget.components.rules.SelectableItem import io.legado.app.utils.AlphanumComparator +import io.legado.app.utils.ConvertUtils import io.legado.app.utils.toastOnUi import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import java.util.Collections +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext -class RemoteBookViewModel(application: Application) : BaseViewModel(application) { - var sortKey = RemoteBookSort.Default - var sortAscending = false - val dirList = arrayListOf() - val permissionDenialLiveData = MutableLiveData() +@Immutable +data class NavigationState( + val pathNames: List = emptyList(), + val canGoBack: Boolean = false +) - var dataCallback: DataCallback? = null +@Immutable +data class RemoteBookItemUi( + val remoteBook: RemoteBook, + val nameWithoutExtension: String, + val formattedSize: String, + val formattedDate: String, + override val id: String = remoteBook.path +) : SelectableItem - val dataFlow = callbackFlow> { +@Immutable +data class RemoteBookUiState( + override val items: List = emptyList(), + override val selectedIds: Set = emptySet(), + override val searchKey: String = "", + val interaction: InteractionState = InteractionState(isLoading = true), + val navigation: NavigationState = NavigationState(), + val sortKey: RemoteBookSort = RemoteBookSort.Default, + val sortAscending: Boolean = false, + val servers: List = emptyList(), + val selectedServerId: Long = AppConfig.remoteServerId +) : ListUiState { + override val isSearch: Boolean get() = interaction.isSearchMode + override val isLoading: Boolean get() = interaction.isLoading + val pathNames: List get() = navigation.pathNames + val canGoBack: Boolean get() = navigation.canGoBack +} - val list = Collections.synchronizedList(ArrayList()) +class RemoteBookViewModel( + application: Application, + @Suppress("UNUSED_PARAMETER") + private val savedStateHandle: SavedStateHandle, + private val repository: RemoteBookRepository +) : BaseViewModel(application) { - dataCallback = object : DataCallback { + private data class InternalState( + val remoteBooks: List = emptyList(), + val sortKey: RemoteBookSort = RemoteBookSort.Default, + val sortAscending: Boolean = false, + val dirList: List = emptyList(), + val interaction: InteractionState = InteractionState(isLoading = true), + val searchKey: String = "", + val selectedIds: Set = emptySet(), + val remoteBookWebDav: RemoteBookWebDav? = null, + val isDefaultWebdav: Boolean = false, + val selectedServerId: Long = AppConfig.remoteServerId + ) - override fun setItems(remoteFiles: List) { - list.clear() - list.addAll(remoteFiles) - trySend(list) - } + private val _state = MutableStateFlow(InternalState()) - override fun addItems(remoteFiles: List) { - list.addAll(remoteFiles) - trySend(list) - } + val uiState: StateFlow = combine( + _state, + repository.flowLocalBooks(), + appDb.serverDao.observeAll() + ) { state, localBooks, servers -> + val localFileNames = localBooks.map { it.originName }.toSet() - override fun clear() { - list.clear() - trySend(emptyList()) - } - - override fun screen(key: String?) { - if (key.isNullOrBlank()) { - trySend(list) - } else { - trySend( - list.filter { it.filename.contains(key) } - ) - } - } + val sortedBooks = sortBooks(state.remoteBooks, state.sortKey, state.sortAscending) + val filteredBooks = if (state.searchKey.isBlank()) { + sortedBooks + } else { + sortedBooks.filter { it.filename.contains(state.searchKey, ignoreCase = true) } } - awaitClose { - dataCallback = null - } - }.map { list -> - if (sortAscending) when (sortKey) { - RemoteBookSort.Name -> list.sortedWith(compareBy { !it.isDir } - then compareBy(AlphanumComparator) { it.filename }) + RemoteBookUiState( + items = filteredBooks.map { book -> + val isOnShelf = if (book.isDir) false else localFileNames.contains(book.filename) + val updatedBook = + if (book.isOnBookShelf != isOnShelf) book.copy(isOnBookShelf = isOnShelf) else book + RemoteBookItemUi( + remoteBook = updatedBook, + nameWithoutExtension = book.filename.substringBeforeLast("."), + formattedSize = ConvertUtils.formatFileSize(book.size), + formattedDate = AppConst.dateFormat.format(book.lastModify) + ) + }, + selectedIds = state.selectedIds, + searchKey = state.searchKey, + interaction = state.interaction, + navigation = NavigationState( + pathNames = listOf(if (state.isDefaultWebdav) "books" else "/").plus(state.dirList.map { it.filename }), + canGoBack = state.dirList.isNotEmpty() + ), + sortKey = state.sortKey, + sortAscending = state.sortAscending, + servers = servers, + selectedServerId = state.selectedServerId + ) + }.flowOn(Dispatchers.Default) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = RemoteBookUiState() + ) - else -> list.sortedWith(compareBy({ !it.isDir }, { it.lastModify })) - } else when (sortKey) { - RemoteBookSort.Name -> list.sortedWith { o1, o2 -> - val compare = -compareValues(o1.isDir, o2.isDir) - if (compare == 0) { - return@sortedWith -AlphanumComparator.compare(o1.filename, o2.filename) - } - return@sortedWith compare - } - - else -> list.sortedWith { o1, o2 -> - val compare = -compareValues(o1.isDir, o2.isDir) - if (compare == 0) { - return@sortedWith -compareValues(o1.lastModify, o2.lastModify) - } - return@sortedWith compare - } - } - }.flowOn(Dispatchers.IO) - - private var remoteBookWebDav: RemoteBookWebDav? = null - var isDefaultWebdav = false + private val _permissionDenialEvent = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val permissionDenialEvent: SharedFlow = _permissionDenialEvent fun initData(onSuccess: () -> Unit) { - execute { - isDefaultWebdav = false - appDb.serverDao.get(AppConfig.remoteServerId)?.getWebDavConfig()?.let { - val authorization = Authorization(it) - remoteBookWebDav = RemoteBookWebDav(it.url, authorization, AppConfig.remoteServerId) - return@execute - } - isDefaultWebdav = true - remoteBookWebDav = AppWebDav.defaultBookWebDav - ?: throw NoStackTraceException("webDav没有配置") - }.onError { - context.toastOnUi("初始化webDav出错:${it.localizedMessage}") - }.onSuccess { - onSuccess.invoke() - } - } - - fun loadRemoteBookList(path: String?, loadCallback: (loading: Boolean) -> Unit) { - executeLazy { - val bookWebDav = remoteBookWebDav - ?: throw NoStackTraceException("没有配置webDav") - dataCallback?.clear() - val url = path ?: bookWebDav.rootBookUrl - val bookList = bookWebDav.getRemoteBookList(url) - dataCallback?.setItems(bookList) - }.onError { - AppLog.put("获取webDav书籍出错\n${it.localizedMessage}", it) - context.toastOnUi("获取webDav书籍出错\n${it.localizedMessage}") - }.onStart { - loadCallback.invoke(true) - }.onFinally { - loadCallback.invoke(false) - }.start() - } - - fun addToBookshelf(remoteBooks: HashSet, finally: () -> Unit) { - execute { - val bookWebDav = remoteBookWebDav - ?: throw NoStackTraceException("没有配置webDav") - remoteBooks.forEach { remoteBook -> - val downloadBookUri = bookWebDav.downloadRemoteBook(remoteBook) - LocalBook.importFiles(downloadBookUri).forEach { book -> - book.origin = BookType.webDavTag + CustomUrl(remoteBook.path) - .putAttribute("serverID", bookWebDav.serverID) - .toString() - book.save() + viewModelScope.launch { + try { + val webDav = repository.createWebDav(AppConfig.remoteServerId) + if (webDav != null) { + _state.update { + it.copy( + remoteBookWebDav = webDav, + isDefaultWebdav = false, + selectedServerId = AppConfig.remoteServerId + ) + } + onSuccess() + } else { + val defaultWebDav = AppWebDav.defaultBookWebDav + ?: throw NoStackTraceException("webDav没有配置") + _state.update { + it.copy( + remoteBookWebDav = defaultWebDav, + isDefaultWebdav = true, + selectedServerId = AppConst.DEFAULT_WEBDAV_ID + ) + } + onSuccess() } - remoteBook.isOnBookShelf = true + } catch (e: Exception) { + context.toastOnUi("初始化webDav出错:${e.localizedMessage}") + _state.update { it.copy(interaction = it.interaction.copy(isLoading = false)) } } - }.onError { - AppLog.put("导入出错\n${it.localizedMessage}", it) - context.toastOnUi("导入出错\n${it.localizedMessage}") - if (it is SecurityException) { - permissionDenialLiveData.postValue(1) - } - }.onFinally { - finally.invoke() } } - fun updateCallBackFlow(filterKey: String?) { - dataCallback?.screen(filterKey) + fun loadRemoteBookList(path: String? = null) { + _state.update { it.copy(interaction = it.interaction.copy(isLoading = true)) } + viewModelScope.launch { + try { + val webDav = _state.value.remoteBookWebDav + ?: throw NoStackTraceException("没有配置webDav") + val bookList = withContext(Dispatchers.IO) { + repository.loadBooks(webDav, path) + } + _state.update { it.copy(remoteBooks = bookList) } + } catch (e: Exception) { + AppLog.put("获取webDav书籍出错\n${e.localizedMessage}", e) + context.toastOnUi("获取webDav书籍出错\n${e.localizedMessage}") + } finally { + _state.update { it.copy(interaction = it.interaction.copy(isLoading = false)) } + } + } } - interface DataCallback { + suspend fun addToBookshelf(remoteBooks: Set): Result { + _state.update { it.copy(interaction = it.interaction.copy(isUploading = true)) } + return try { + val webDav = _state.value.remoteBookWebDav + ?: throw NoStackTraceException("没有配置webDav") - fun setItems(remoteFiles: List) - - fun addItems(remoteFiles: List) - - fun clear() - - fun screen(key: String?) + withContext(Dispatchers.IO) { + remoteBooks.forEach { remoteBook -> + val downloadBookUri = repository.downloadBook(webDav, remoteBook) + LocalBook.importFiles(downloadBookUri).forEach { book -> + book.origin = BookType.webDavTag + CustomUrl(remoteBook.path) + .putAttribute("serverID", webDav.serverID) + .toString() + book.save() + } + } + } + _state.update { it.copy(selectedIds = emptySet()) } + Result.success(Unit) + } catch (e: SecurityException) { + _permissionDenialEvent.emit(1) + Result.failure(e) + } catch (e: Exception) { + AppLog.put("导入出错\n${e.localizedMessage}", e) + context.toastOnUi("导入出错\n${e.localizedMessage}") + Result.failure(e) + } finally { + _state.update { it.copy(interaction = it.interaction.copy(isUploading = false)) } + } } -} \ No newline at end of file + + suspend fun getLocalBook(fileName: String): Book? { + return withContext(Dispatchers.IO) { + appDb.bookDao.getBookByFileName(fileName) + } + } + + fun navigateToDir(book: RemoteBook) { + _state.update { it.copy(dirList = it.dirList + book, selectedIds = emptySet()) } + loadRemoteBookList(book.path) + } + + fun navigateBack() { + val lastPath = _state.value.dirList.let { + if (it.size > 1) it[it.size - 2].path else null + } + _state.update { + if (it.dirList.isNotEmpty()) { + it.copy(dirList = it.dirList.dropLast(1), selectedIds = emptySet()) + } else it + } + loadRemoteBookList(lastPath) + } + + fun navigateToLevel(index: Int) { + if (index < 0) return + val currentDirList = _state.value.dirList + if (index > currentDirList.size) return + + if (index == 0) { + _state.update { it.copy(dirList = emptyList(), selectedIds = emptySet()) } + loadRemoteBookList(null) + } else { + val newDirList = currentDirList.take(index) + val targetPath = newDirList.last().path + _state.update { it.copy(dirList = newDirList, selectedIds = emptySet()) } + loadRemoteBookList(targetPath) + } + } + + fun setSearchMode(isSearch: Boolean) { + _state.update { + it.copy( + interaction = it.interaction.copy(isSearchMode = isSearch), + searchKey = if (isSearch) it.searchKey else "" + ) + } + } + + fun setSearchKey(key: String) { + _state.update { it.copy(searchKey = key) } + } + + fun updateSort(sortKey: RemoteBookSort, ascending: Boolean) { + _state.update { it.copy(sortKey = sortKey, sortAscending = ascending) } + } + + fun toggleSort(sortKey: RemoteBookSort) { + _state.update { state -> + if (state.sortKey == sortKey) { + state.copy(sortAscending = !state.sortAscending) + } else { + state.copy(sortKey = sortKey, sortAscending = true) + } + } + } + + fun toggleSelection(id: String) { + _state.update { + val newSelected = if (id in it.selectedIds) it.selectedIds - id else it.selectedIds + id + it.copy(selectedIds = newSelected) + } + } + + fun selectAllCheckable() { + _state.update { state -> + val checkableIds = state.remoteBooks + .filter { !it.isDir && !it.isOnBookShelf } + .map { it.path } + .toSet() + state.copy(selectedIds = checkableIds) + } + } + + fun invertSelection() { + _state.update { state -> + val checkableIds = state.remoteBooks + .filter { !it.isDir && !it.isOnBookShelf } + .map { it.path } + .toSet() + state.copy(selectedIds = checkableIds - state.selectedIds) + } + } + + fun clearSelection() { + _state.update { it.copy(selectedIds = emptySet()) } + } + + fun refreshData() { + val currentPath = _state.value.dirList.lastOrNull()?.path + loadRemoteBookList(currentPath) + } + + private fun sortBooks( + books: List, + sortKey: RemoteBookSort, + ascending: Boolean + ): List { + return books.sortedWith { o1, o2 -> + val dirCompare = compareValues(o1.isDir, o2.isDir) + if (dirCompare != 0) return@sortedWith -dirCompare + + val result = when (sortKey) { + RemoteBookSort.Name -> AlphanumComparator.compare(o1.filename, o2.filename) + else -> compareValues(o1.lastModify, o2.lastModify) + } + if (ascending) result else -result + } + } + + fun saveServer(server: Server) { + execute { + appDb.serverDao.insert(server) + }.onSuccess { + if (AppConfig.remoteServerId == server.id) { + initData { loadRemoteBookList() } + } + } + } + + fun deleteServer(server: Server) { + execute { + appDb.serverDao.delete(server) + } + } + + fun selectServer(serverId: Long) { + AppConfig.remoteServerId = serverId + _state.update { it.copy(selectedServerId = serverId, dirList = emptyList()) } + initData { loadRemoteBookList() } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt index 9ea526af7..fc738fbed 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt @@ -47,7 +47,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -319,11 +318,8 @@ fun ReadRecordScreen( } if (showCalendar) { - val sheetState = - rememberModalBottomSheetState(skipPartiallyExpanded = true) GlassModalBottomSheet( - onDismissRequest = { showCalendar = false }, - sheetState = sheetState + onDismissRequest = { showCalendar = false } ) { HeatmapCalendarSection( dailyReadCounts = state.dailyReadCounts, diff --git a/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt b/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt index 7cd8efa71..9623c9104 100644 --- a/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt @@ -21,7 +21,7 @@ import io.legado.app.model.ReadBook import io.legado.app.model.localBook.LocalBook import io.legado.app.ui.config.readConfig.ReadConfig import io.legado.app.ui.widget.components.importComponents.BaseImportUiState -import io.legado.app.ui.widget.components.rules.RuleActionState +import io.legado.app.ui.widget.components.rules.ListUiState import io.legado.app.ui.widget.components.rules.SelectableItem import io.legado.app.utils.toastOnUi import kotlinx.coroutines.Dispatchers @@ -74,9 +74,9 @@ data class TocActionState( override val selectedIds: Set = emptySet(), override val searchKey: String = "", override val isSearch: Boolean = false, - override val isUploading: Boolean = false, + override val isLoading: Boolean = false, val downloadSummary: String = "" -) : RuleActionState +) : ListUiState data class TocDomainItem( val chapter: BookChapter, @@ -305,7 +305,7 @@ class TocViewModel( selectedIds = selectedIds, searchKey = _searchKey.value, isSearch = isSearch, - isUploading = isUploading, + isLoading = isUploading, downloadSummary = downloadSummary.value ) } diff --git a/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt b/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt index 266646237..b43849852 100644 --- a/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt @@ -155,7 +155,6 @@ fun TxtRuleScreen( if (showFilePickerSheet) { FilePickerSheet( - sheetState = sheetState, onDismissRequest = { showFilePickerSheet = false }, onSelectSysDir = { showFilePickerSheet = false diff --git a/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleViewModel.kt b/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleViewModel.kt index a24b678ee..67669f3b6 100644 --- a/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleViewModel.kt @@ -9,7 +9,7 @@ import io.legado.app.data.repository.TxtTocRuleRepository import io.legado.app.data.repository.UploadRepository import io.legado.app.ui.widget.components.importComponents.BaseImportUiState import io.legado.app.ui.widget.components.rules.InteractionState -import io.legado.app.ui.widget.components.rules.RuleActionState +import io.legado.app.ui.widget.components.rules.ListUiState import io.legado.app.ui.widget.components.rules.SelectableItem import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonArray @@ -36,9 +36,9 @@ data class TxtTocRuleUiState( override val selectedIds: Set = emptySet(), override val searchKey: String = "", val interaction: InteractionState = InteractionState() -) : RuleActionState { +) : ListUiState { override val isSearch: Boolean get() = interaction.isSearchMode - override val isUploading: Boolean get() = interaction.isUploading + override val isLoading: Boolean get() = interaction.isUploading } diff --git a/app/src/main/java/io/legado/app/ui/config/otherConfig/CheckSourceBottomSheet.kt b/app/src/main/java/io/legado/app/ui/config/otherConfig/CheckSourceBottomSheet.kt index 748a003a3..790e95878 100644 --- a/app/src/main/java/io/legado/app/ui/config/otherConfig/CheckSourceBottomSheet.kt +++ b/app/src/main/java/io/legado/app/ui/config/otherConfig/CheckSourceBottomSheet.kt @@ -12,7 +12,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -32,13 +31,9 @@ fun CheckSourceBottomSheet( onDismiss: () -> Unit ) { val context = LocalContext.current - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true - ) GlassModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState + onDismissRequest = onDismiss ) { Column( modifier = Modifier diff --git a/app/src/main/java/io/legado/app/ui/config/otherConfig/DirectLinkUploadBottomSheet.kt b/app/src/main/java/io/legado/app/ui/config/otherConfig/DirectLinkUploadBottomSheet.kt index b8f118b3d..7014ae1f5 100644 --- a/app/src/main/java/io/legado/app/ui/config/otherConfig/DirectLinkUploadBottomSheet.kt +++ b/app/src/main/java/io/legado/app/ui/config/otherConfig/DirectLinkUploadBottomSheet.kt @@ -27,7 +27,6 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -57,7 +56,6 @@ fun DirectLinkUploadBottomSheet( onDismiss: () -> Unit ) { val context = LocalContext.current - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) var showMenu by remember { mutableStateOf(false) } var showTestResult by remember { mutableStateOf(null) } @@ -66,8 +64,7 @@ fun DirectLinkUploadBottomSheet( } GlassModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, + onDismissRequest = onDismiss ) { Column( modifier = Modifier diff --git a/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigScreen.kt b/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigScreen.kt index 29c19b312..19ade0bf2 100644 --- a/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigScreen.kt +++ b/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigScreen.kt @@ -18,7 +18,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -394,7 +393,6 @@ fun OtherConfigScreen( if (showFilePicker) { FilePickerSheet( - sheetState = rememberModalBottomSheetState(), onDismissRequest = { showFilePicker = false }, onSelectSysDir = { showFilePicker = false diff --git a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleEditSheet.kt b/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleEditSheet.kt index 8627ae3da..e3c30be93 100644 --- a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleEditSheet.kt +++ b/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleEditSheet.kt @@ -24,7 +24,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -51,7 +50,6 @@ fun DictRuleEditSheet( onPaste: () -> DictRule? ) { val scope = rememberCoroutineScope() - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) var name by remember(rule) { mutableStateOf(rule?.name ?: "") } var urlRule by remember(rule) { mutableStateOf(rule?.urlRule ?: "") } @@ -59,8 +57,7 @@ fun DictRuleEditSheet( var showMenu by remember { mutableStateOf(false) } GlassModalBottomSheet( - onDismissRequest = onDismissRequest, - sheetState = sheetState, + onDismissRequest = onDismissRequest ) { Box( modifier = Modifier diff --git a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt b/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt index 6ebff3f37..0a2ce505c 100644 --- a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt +++ b/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt @@ -152,7 +152,6 @@ fun DictRuleScreen( if (showFilePickerSheet) { FilePickerSheet( - sheetState = sheetState, onDismissRequest = { showFilePickerSheet = false }, onSelectSysDir = { showFilePickerSheet = false diff --git a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleViewModel.kt b/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleViewModel.kt index c0ff7613a..d2bd668b2 100644 --- a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleViewModel.kt @@ -9,7 +9,7 @@ import io.legado.app.data.repository.DictRuleRepository import io.legado.app.data.repository.UploadRepository import io.legado.app.ui.widget.components.importComponents.BaseImportUiState import io.legado.app.ui.widget.components.rules.InteractionState -import io.legado.app.ui.widget.components.rules.RuleActionState +import io.legado.app.ui.widget.components.rules.ListUiState import io.legado.app.ui.widget.components.rules.SelectableItem import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonArray @@ -39,9 +39,9 @@ data class DictRuleUiState( override val selectedIds: Set = emptySet(), override val searchKey: String = "", val interaction: InteractionState = InteractionState() -) : RuleActionState { +) : ListUiState { override val isSearch: Boolean get() = interaction.isSearchMode - override val isUploading: Boolean get() = interaction.isUploading + override val isLoading: Boolean get() = interaction.isUploading } class DictRuleViewModel( diff --git a/app/src/main/java/io/legado/app/ui/replace/GroupManageBottomSheet.kt b/app/src/main/java/io/legado/app/ui/replace/GroupManageBottomSheet.kt index e33ed31c6..7946b4849 100644 --- a/app/src/main/java/io/legado/app/ui/replace/GroupManageBottomSheet.kt +++ b/app/src/main/java/io/legado/app/ui/replace/GroupManageBottomSheet.kt @@ -17,7 +17,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.SheetState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -36,7 +35,6 @@ import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet @Composable fun GroupManageBottomSheet( groups: List, - sheetState: SheetState, onDismissRequest: () -> Unit, viewModel: ReplaceRuleViewModel ) { @@ -44,8 +42,7 @@ fun GroupManageBottomSheet( var updatedGroupName by remember { mutableStateOf("") } GlassModalBottomSheet( - onDismissRequest = onDismissRequest, - sheetState = sheetState + onDismissRequest = onDismissRequest ) { Column( modifier = Modifier diff --git a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt index 83cd0c65a..5600611e7 100644 --- a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt +++ b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt @@ -39,7 +39,6 @@ import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.animateFloatingActionButton -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -112,7 +111,6 @@ fun ReplaceRuleScreen( var showGroupManageSheet by remember { mutableStateOf(false) } val importState by viewModel.importState.collectAsStateWithLifecycle() - val sheetState = rememberModalBottomSheetState() var selectedTabIndex by remember { mutableIntStateOf(0) } val tabItems = remember(groups) { listOf("全部") + groups } @@ -166,7 +164,6 @@ fun ReplaceRuleScreen( if (showFilePickerSheet) { FilePickerSheet( - sheetState = sheetState, onDismissRequest = { showFilePickerSheet = false }, onSelectSysDir = { showFilePickerSheet = false @@ -255,7 +252,6 @@ fun ReplaceRuleScreen( GroupManageBottomSheet( groups = groups, onDismissRequest = { showGroupManageSheet = false }, - sheetState = sheetState, viewModel = viewModel ) } diff --git a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt index ae32aa77b..227d70cd9 100644 --- a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt @@ -14,7 +14,7 @@ import io.legado.app.data.repository.UploadRepository import io.legado.app.help.ReplaceAnalyzer import io.legado.app.ui.widget.components.importComponents.BaseImportUiState import io.legado.app.ui.widget.components.rules.InteractionState -import io.legado.app.ui.widget.components.rules.RuleActionState +import io.legado.app.ui.widget.components.rules.ListUiState import io.legado.app.ui.widget.components.rules.SelectableItem import io.legado.app.utils.GSON import io.legado.app.utils.getPrefString @@ -53,9 +53,9 @@ data class ReplaceRuleUiState( val sortMode: String = "desc", val groups: List = emptyList(), val interaction: InteractionState = InteractionState() -) : RuleActionState { +) : ListUiState { override val isSearch: Boolean get() = interaction.isSearchMode - override val isUploading: Boolean get() = interaction.isUploading + override val isLoading: Boolean get() = interaction.isUploading } class ReplaceRuleViewModel( diff --git a/app/src/main/java/io/legado/app/ui/widget/components/GlassMediumFlexibleTopAppBar.kt b/app/src/main/java/io/legado/app/ui/widget/components/GlassMediumFlexibleTopAppBar.kt index 7dc5b72a6..b1b29846a 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/GlassMediumFlexibleTopAppBar.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/GlassMediumFlexibleTopAppBar.kt @@ -63,13 +63,12 @@ object GlassTopAppBarDefaults { @Composable fun controlContainerColor(): Color { val enableBlur = ThemeConfig.enableBlur - val baseColor = MaterialTheme.colorScheme.surfaceContainerHigh + val baseColor = MaterialTheme.colorScheme.surfaceContainerHighest return if (enableBlur) { - val alpha = 0.6f - baseColor.copy(alpha = alpha) + baseColor.copy(alpha = 0.72f) } else { - baseColor.copy(alpha = ThemeConfig.containerOpacity / 100f) + baseColor } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt b/app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt index 41762ca52..f1ffd61c5 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt @@ -7,18 +7,22 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.VerticalAlignTop import androidx.compose.material3.DropdownMenu import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -26,16 +30,19 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf 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.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import io.legado.app.ui.widget.components.button.SmallIconButton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -44,8 +51,13 @@ import kotlinx.coroutines.launch fun SearchBarSection( query: String, onQueryChange: (String) -> Unit, - placeholder: String = "搜索书名", - leadingIcon: @Composable (() -> Unit)? = { Icon(Icons.Default.Search, null) }, + placeholder: String = "搜索...", + leadingIcon: @Composable (() -> Unit)? = { + SmallIconButton( + icon = Icons.Default.Search, + contentDescription = null, + onClick = {}) + }, backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainerLow, scrollState: LazyListState? = null, scope: CoroutineScope = rememberCoroutineScope(), @@ -54,6 +66,25 @@ fun SearchBarSection( ) { var showMenu by remember { mutableStateOf(false) } + val textFieldState = rememberTextFieldState(initialText = query) + + LaunchedEffect(query) { + if (query != textFieldState.text.toString()) { + textFieldState.edit { + replace(0, length, query) + } + } + } + + LaunchedEffect(textFieldState.text) { + snapshotFlow { textFieldState.text.toString() } + .collect { newText -> + if (newText != query) { + onQueryChange(newText) + } + } + } + val showScrollToTop by remember(scrollState) { derivedStateOf { (scrollState?.firstVisibleItemIndex ?: 0) > 0 @@ -63,15 +94,15 @@ fun SearchBarSection( Surface( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), + .padding(start = 16.dp, end = 16.dp, bottom = 4.dp), shape = RoundedCornerShape(32.dp), color = backgroundColor ) { TextField( - value = query, - onValueChange = onQueryChange, + state = textFieldState, modifier = Modifier .fillMaxWidth() + .heightIn(min = 48.dp) .padding(horizontal = 4.dp), placeholder = { Text(placeholder) }, leadingIcon = leadingIcon, @@ -82,14 +113,17 @@ fun SearchBarSection( .padding(end = 4.dp) .animateContentSize() ) { + AnimatedVisibility( - visible = query.isNotEmpty(), + visible = textFieldState.text.isNotEmpty(), enter = fadeIn() + scaleIn(), exit = fadeOut() + scaleOut() ) { - IconButton(onClick = { onQueryChange("") }) { - Icon(Icons.Default.Clear, "清空输入") - } + SmallIconButton( + onClick = { textFieldState.clearText() }, + icon = Icons.Default.Clear, + contentDescription = "清空输入" + ) } AnimatedVisibility( @@ -97,19 +131,27 @@ fun SearchBarSection( enter = fadeIn() + scaleIn(), exit = fadeOut() + scaleOut() ) { - IconButton(onClick = { - scope.launch { scrollState?.animateScrollToItem(0) } - }) { - Icon(Icons.Default.VerticalAlignTop, "回到顶部") - } + SmallIconButton( + onClick = { + scope.launch { + scrollState?.animateScrollToItem(0) + } + }, + icon = Icons.Default.VerticalAlignTop, + contentDescription = "回到顶部" + ) } - // 外部自定义按钮 if (trailingIcon != null) { Box { - IconButton(onClick = { if (dropdownMenu != null) showMenu = true }) { + IconButton( + onClick = { + if (dropdownMenu != null) showMenu = true + } + ) { trailingIcon() } + if (dropdownMenu != null) { DropdownMenu( expanded = showMenu, @@ -122,13 +164,19 @@ fun SearchBarSection( } } }, - singleLine = true, + lineLimits = TextFieldLineLimits.SingleLine, colors = TextFieldDefaults.colors( focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, disabledContainerColor = Color.Transparent, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, + ), + contentPadding = PaddingValues( + top = 4.dp, + bottom = 4.dp, + start = 12.dp, + end = 12.dp ) ) } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/SelectionBottomBar.kt b/app/src/main/java/io/legado/app/ui/widget/components/SelectionBottomBar.kt index b09a9bdf3..0f3b2da5e 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/SelectionBottomBar.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/SelectionBottomBar.kt @@ -1,10 +1,8 @@ package io.legado.app.ui.widget.components import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.SelectAll @@ -30,6 +28,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +data class SelectionActions( + val primaryAction: ActionItem, + val secondaryActions: List = emptyList(), + val onSelectAll: () -> Unit, + val onSelectInvert: () -> Unit, +) + data class ActionItem( val text: String, val icon: @Composable (() -> Unit)? = null, diff --git a/app/src/main/java/io/legado/app/ui/widget/components/bookmark/BookmarkEditSheet.kt b/app/src/main/java/io/legado/app/ui/widget/components/bookmark/BookmarkEditSheet.kt index b62892247..2e0b12866 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/bookmark/BookmarkEditSheet.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/bookmark/BookmarkEditSheet.kt @@ -17,7 +17,6 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -37,15 +36,13 @@ fun BookmarkEditSheet( onSave: (Bookmark) -> Unit, onDelete: (Bookmark) -> Unit ) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) var showDeleteConfirmDialog by remember { mutableStateOf(false) } var bookText by remember { mutableStateOf(bookmark.bookText) } var content by remember { mutableStateOf(bookmark.content) } GlassModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState + onDismissRequest = onDismiss ) { Column( modifier = Modifier diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/SmallIconButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/SmallIconButton.kt index d50a487ed..92a8a88a5 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/SmallIconButton.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/button/SmallIconButton.kt @@ -1,19 +1,35 @@ package io.legado.app.ui.widget.components.button +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconToggleButtonShapes import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedIconToggleButton +import androidx.compose.material3.OutlinedToggleButton +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -42,6 +58,35 @@ fun SmallIconButton( } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun SmallTonalIconButton( + onClick: () -> Unit, + icon: ImageVector, + contentDescription: String? = null +) { + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) { + FilledTonalIconButton( + onClick = onClick, + modifier = Modifier.size( + IconButtonDefaults.extraSmallContainerSize( + IconButtonDefaults.IconButtonWidthOption.Uniform + ) + ), + shape = IconButtonDefaults.extraSmallRoundShape, + colors = IconButtonDefaults.filledTonalIconButtonColors() + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + modifier = Modifier.size( + IconButtonDefaults.extraSmallIconSize + ) + ) + } + } +} + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun SmallOutlinedIconToggleButton( @@ -81,4 +126,62 @@ fun SmallOutlinedIconToggleButton( ) } } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun SmallAnimatedActionButton( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + iconChecked: ImageVector, + iconUnchecked: ImageVector, + activeText: String, + inactiveText: String, + modifier: Modifier = Modifier +) { + var showText by remember { mutableStateOf(false) } + var lastCheckedState by remember { mutableStateOf(checked) } + + LaunchedEffect(showText) { + if (showText) { + delay(1000) + showText = false + } + } + + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) { + OutlinedToggleButton( + checked = checked, + onCheckedChange = { + lastCheckedState = it + onCheckedChange(it) + showText = true + }, + modifier = modifier.height(36.dp), + contentPadding = PaddingValues(horizontal = 8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + AnimatedIcon( + modifier = Modifier.size(16.dp), + imageVector = if (checked) iconChecked else iconUnchecked, + contentDescription = null + ) + + AnimatedVisibility( + visible = showText + ) { + Text( + text = if (lastCheckedState) activeText else inactiveText, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(start = 6.dp), + maxLines = 1, + softWrap = false + ) + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/TopBarButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/TopBarButton.kt index 90d99eade..d7c3e822d 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/TopBarButton.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/button/TopBarButton.kt @@ -1,27 +1,42 @@ package io.legado.app.ui.widget.components.button import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedIconButton +import androidx.compose.material3.Text +import androidx.compose.material3.ToggleButton 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.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.ui.widget.components.GlassTopAppBarDefaults +import kotlinx.coroutines.delay enum class TopBarButtonVariant { Filled, Outlined, Icon @@ -49,7 +64,11 @@ fun TopBarButton( contentColor = MaterialTheme.colorScheme.onSurface ) ) { - SmallIcon(imageVector, contentDescription) + AnimatedIcon( + modifier = Modifier.size(20.dp), + imageVector = imageVector, + contentDescription = contentDescription + ) } } @@ -59,7 +78,11 @@ fun TopBarButton( modifier = commonModifier, border = ButtonDefaults.outlinedButtonBorder() ) { - SmallIcon(imageVector, contentDescription) + AnimatedIcon( + modifier = Modifier.size(20.dp), + imageVector = imageVector, + contentDescription = contentDescription + ) } } @@ -68,7 +91,11 @@ fun TopBarButton( onClick = onClick, modifier = commonModifier ) { - SmallIcon(imageVector, contentDescription) + AnimatedIcon( + modifier = Modifier.size(20.dp), + imageVector = imageVector, + contentDescription = contentDescription + ) } } } @@ -86,7 +113,7 @@ fun TopbarNavigationButton( onClick = onClick, imageVector = imageVector, contentDescription = contentDescription, - modifier = modifier.padding(start = 12.dp, end = 4.dp), + modifier = modifier.padding(horizontal = 12.dp), style = style ) } @@ -118,8 +145,74 @@ fun TopBarActionButton( } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun SmallIcon(imageVector: ImageVector, contentDescription: String?) { +fun TopBarAnimatedActionButton( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + iconChecked: ImageVector, + iconUnchecked: ImageVector, + activeText: String, + inactiveText: String, + modifier: Modifier = Modifier +) { + var showText by remember { mutableStateOf(false) } + var lastCheckedState by remember { mutableStateOf(checked) } + + LaunchedEffect(showText) { + if (showText) { + delay(1000) + showText = false + } + } + + ToggleButton( + modifier = modifier.height(36.dp), + contentPadding = PaddingValues(horizontal = 8.dp), + checked = checked, + onCheckedChange = { + lastCheckedState = it + onCheckedChange(it) + showText = true + } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + + AnimatedContent( + targetState = checked, + label = "IconAnimation" + ) { targetChecked -> + AnimatedIcon( + modifier = Modifier.size(20.dp), + imageVector = if (targetChecked) iconChecked else iconUnchecked, + contentDescription = null + ) + } + + AnimatedVisibility( + visible = showText + ) { + Text( + text = if (lastCheckedState) activeText else inactiveText, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(start = 8.dp), + maxLines = 1, + softWrap = false + ) + } + } + } +} + +@Composable +fun AnimatedIcon( + imageVector: ImageVector, + contentDescription: String?, + modifier: Modifier = Modifier +) { AnimatedContent( targetState = imageVector, transitionSpec = { @@ -131,7 +224,7 @@ private fun SmallIcon(imageVector: ImageVector, contentDescription: String?) { Icon( imageVector = targetIcon, contentDescription = contentDescription, - modifier = Modifier.size(20.dp) + modifier = modifier ) } } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/exportComponents/FilePickerSheet.kt b/app/src/main/java/io/legado/app/ui/widget/components/exportComponents/FilePickerSheet.kt index fe5ce8868..0c0f2c19e 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/exportComponents/FilePickerSheet.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/exportComponents/FilePickerSheet.kt @@ -19,7 +19,6 @@ import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SheetState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -38,7 +37,6 @@ enum class FilePickerSheetMode { @OptIn(ExperimentalMaterial3Api::class) @Composable fun FilePickerSheet( - sheetState: SheetState, onDismissRequest: () -> Unit, title: String = stringResource(R.string.select_operation), onSelectSysDir: (() -> Unit)? = null, @@ -49,7 +47,6 @@ fun FilePickerSheet( ) { GlassModalBottomSheet( onDismissRequest = onDismissRequest, - sheetState = sheetState, containerColor = { it.surface } ) { Column( diff --git a/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt b/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt index 3b37a3eda..27d55f67d 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt @@ -139,7 +139,6 @@ fun BatchImportDialog( GlassModalBottomSheet( onDismissRequest = onDismissRequest, - sheetState = sheetState, containerColor = { it.surfaceContainer } ) { Scaffold( diff --git a/app/src/main/java/io/legado/app/ui/widget/components/list/ListScaffold.kt b/app/src/main/java/io/legado/app/ui/widget/components/list/ListScaffold.kt new file mode 100644 index 000000000..30bbfa4ee --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/list/ListScaffold.kt @@ -0,0 +1,127 @@ +package io.legado.app.ui.widget.components.list + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset +import androidx.compose.material3.Icon +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.animateFloatingActionButton +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import io.legado.app.ui.widget.components.SelectionActions +import io.legado.app.ui.widget.components.SelectionBottomBar +import io.legado.app.ui.widget.components.rules.ListUiState +import io.legado.app.ui.widget.components.topbar.DynamicTopAppBar + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ListScaffold( + title: String, + state: ListUiState, + onBackClick: () -> Unit, + onSearchToggle: (Boolean) -> Unit, + onSearchQueryChange: (String) -> Unit, + searchPlaceholder: String = "搜索...", + topBarActions: @Composable RowScope.() -> Unit = {}, + bottomContent: @Composable (ColumnScope.() -> Unit)? = null, + dropDownMenuContent: @Composable ColumnScope.(dismiss: () -> Unit) -> Unit = {}, + selectionActions: SelectionActions? = null, + onAddClick: (() -> Unit)? = null, + floatingActionButton: @Composable () -> Unit = { + onAddClick?.let { onClick -> + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider( + TooltipAnchorPosition.Above + ), + tooltip = { PlainTooltip { Text("添加") } }, + state = rememberTooltipState(), + ) { + FloatingActionButton( + modifier = Modifier.animateFloatingActionButton( + visible = state.selectedIds.isEmpty(), + alignment = Alignment.BottomEnd, + ), + onClick = onClick + ) { + Icon(Icons.Default.Add, contentDescription = "Add") + } + } + } + }, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + content: @Composable (PaddingValues) -> Unit +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + DynamicTopAppBar( + title = title, + state = state, + scrollBehavior = scrollBehavior, + onBackClick = onBackClick, + onSearchToggle = onSearchToggle, + onSearchQueryChange = onSearchQueryChange, + searchPlaceholder = searchPlaceholder, + onClearSelection = { selectionActions?.onSelectInvert?.invoke() }, // 或其他逻辑 + topBarActions = topBarActions, + dropDownMenuContent = dropDownMenuContent, + bottomContent = bottomContent + ) + }, + floatingActionButton = floatingActionButton + ) { paddingValues -> + Box(modifier = Modifier.fillMaxSize()) { + content(paddingValues) + + AnimatedVisibility( + visible = state.selectedIds.isNotEmpty() && selectionActions != null, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp + ScreenOffset) + .zIndex(1f), + enter = slideInVertically { it } + fadeIn(), + exit = slideOutVertically { it } + fadeOut() + ) { + selectionActions?.let { actions -> + SelectionBottomBar( + onSelectAll = actions.onSelectAll, + onSelectInvert = actions.onSelectInvert, + primaryAction = actions.primaryAction, + secondaryActions = actions.secondaryActions + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/GlassModalBottomSheet.kt b/app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/GlassModalBottomSheet.kt index 826fdfb0e..00169f390 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/GlassModalBottomSheet.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/GlassModalBottomSheet.kt @@ -24,7 +24,9 @@ import io.legado.app.ui.theme.rememberOpaqueColorScheme fun GlassModalBottomSheet( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, - sheetState: SheetState = rememberModalBottomSheetState(), + sheetState: SheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ), containerColor: (ColorScheme) -> Color = { it.surfaceContainerLow }, content: @Composable ColumnScope.() -> Unit ) { diff --git a/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleActionState.kt b/app/src/main/java/io/legado/app/ui/widget/components/rules/ListUiState.kt similarity index 85% rename from app/src/main/java/io/legado/app/ui/widget/components/rules/RuleActionState.kt rename to app/src/main/java/io/legado/app/ui/widget/components/rules/ListUiState.kt index 913731e26..1d70c1f99 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleActionState.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/rules/ListUiState.kt @@ -1,11 +1,11 @@ package io.legado.app.ui.widget.components.rules -interface RuleActionState { +interface ListUiState { val items: List val selectedIds: Set val searchKey: String val isSearch: Boolean - val isUploading: Boolean + val isLoading: Boolean } data class InteractionState( diff --git a/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleEditSheet.kt b/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleEditSheet.kt index fdd530caa..0a528c2ea 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleEditSheet.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleEditSheet.kt @@ -24,7 +24,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -65,7 +64,6 @@ fun RuleEditSheet( fromFields: (RuleEditFields, T?) -> T ) { val scope = rememberCoroutineScope() - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val initialFields = remember(rule) { toFields(rule) } var name by remember(initialFields) { mutableStateOf(initialFields.name) } @@ -77,8 +75,7 @@ fun RuleEditSheet( fun getCurrentEntity() = fromFields(RuleEditFields(name, rule1, rule2), rule) GlassModalBottomSheet( - onDismissRequest = onDismissRequest, - sheetState = sheetState, + onDismissRequest = onDismissRequest ) { Box(modifier = Modifier.fillMaxWidth()) { Column( diff --git a/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleListScaffold.kt b/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleListScaffold.kt index 0285b869e..994ef67c6 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleListScaffold.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleListScaffold.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -44,7 +43,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -58,14 +56,11 @@ import io.legado.app.ui.widget.components.topbar.DynamicTopAppBar @Composable fun RuleListScaffold( title: String, - state: RuleActionState, + state: ListUiState, onBackClick: () -> Unit, onSearchToggle: (Boolean) -> Unit, onSearchQueryChange: (String) -> Unit, - searchPlaceholder: String, - searchLeadingIcon: ImageVector = Icons.Default.Search, - searchTrailingIcon: @Composable (() -> Unit)? = null, - searchDropdownMenu: (@Composable (onDismiss: () -> Unit) -> Unit)? = null, + searchPlaceholder: String = "搜索...", topBarActions: @Composable RowScope.() -> Unit = {}, bottomContent: @Composable (ColumnScope.() -> Unit)? = null, dropDownMenuContent: @Composable ColumnScope.(dismiss: () -> Unit) -> Unit = {}, diff --git a/app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt b/app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt index 7c2c49095..aa01bc162 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt @@ -31,7 +31,7 @@ import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar import io.legado.app.ui.widget.components.SearchBarSection import io.legado.app.ui.widget.components.button.TopbarNavigationButton import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu -import io.legado.app.ui.widget.components.rules.RuleActionState +import io.legado.app.ui.widget.components.rules.ListUiState @OptIn(ExperimentalMaterial3Api::class) @@ -39,7 +39,7 @@ import io.legado.app.ui.widget.components.rules.RuleActionState fun DynamicTopAppBar( title: String, subtitle: String? = null, - state: RuleActionState, + state: ListUiState, scrollBehavior: TopAppBarScrollBehavior, onBackClick: () -> Unit, onSearchToggle: (Boolean) -> Unit, @@ -60,13 +60,13 @@ fun DynamicTopAppBar( GlassMediumFlexibleTopAppBar( title = { val titleText = when { - state.isUploading -> "请稍后..." + state.isLoading -> "请稍后..." isSelecting -> "已选择 ${state.selectedIds.size}/${state.items.size}" else -> title } AdaptiveAnimatedText( text = titleText, - useCharMode = isSelecting || state.isUploading, + useCharMode = isSelecting || state.isLoading, maxLines = 1, overflow = TextOverflow.Ellipsis )