Forráskód Böngészése

[优化] 界面和性能优化

HapeLee 8 hónapja
szülő
commit
16b8164943
37 módosított fájl, 774 hozzáadás és 410 törlés
  1. 2 2
      app/src/main/java/io/legado/app/base/BaseRuleViewModel.kt
  2. 11 4
      app/src/main/java/io/legado/app/data/AppDatabase.kt
  3. 0 21
      app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt
  4. 2 0
      app/src/main/java/io/legado/app/data/entities/Bookmark.kt
  5. 0 8
      app/src/main/java/io/legado/app/data/entities/ReadRecordShow.kt
  6. 2 0
      app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordSession.kt
  7. 4 0
      app/src/main/java/io/legado/app/di/appModule.kt
  8. 0 142
      app/src/main/java/io/legado/app/ui/book/explore/ExploreShowAdapter.kt
  9. 0 14
      app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt
  10. 317 119
      app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookViewModel.kt
  11. 1 5
      app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt
  12. 4 4
      app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt
  13. 0 1
      app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt
  14. 3 3
      app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleViewModel.kt
  15. 1 6
      app/src/main/java/io/legado/app/ui/config/otherConfig/CheckSourceBottomSheet.kt
  16. 1 4
      app/src/main/java/io/legado/app/ui/config/otherConfig/DirectLinkUploadBottomSheet.kt
  17. 0 2
      app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigScreen.kt
  18. 1 4
      app/src/main/java/io/legado/app/ui/dict/rule/DictRuleEditSheet.kt
  19. 0 1
      app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt
  20. 3 3
      app/src/main/java/io/legado/app/ui/dict/rule/DictRuleViewModel.kt
  21. 1 4
      app/src/main/java/io/legado/app/ui/replace/GroupManageBottomSheet.kt
  22. 0 4
      app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt
  23. 3 3
      app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt
  24. 3 4
      app/src/main/java/io/legado/app/ui/widget/components/GlassMediumFlexibleTopAppBar.kt
  25. 66 18
      app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt
  26. 7 2
      app/src/main/java/io/legado/app/ui/widget/components/SelectionBottomBar.kt
  27. 1 4
      app/src/main/java/io/legado/app/ui/widget/components/bookmark/BookmarkEditSheet.kt
  28. 103 0
      app/src/main/java/io/legado/app/ui/widget/components/button/SmallIconButton.kt
  29. 99 6
      app/src/main/java/io/legado/app/ui/widget/components/button/TopBarButton.kt
  30. 0 3
      app/src/main/java/io/legado/app/ui/widget/components/exportComponents/FilePickerSheet.kt
  31. 0 1
      app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt
  32. 127 0
      app/src/main/java/io/legado/app/ui/widget/components/list/ListScaffold.kt
  33. 3 1
      app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/GlassModalBottomSheet.kt
  34. 2 2
      app/src/main/java/io/legado/app/ui/widget/components/rules/ListUiState.kt
  35. 1 4
      app/src/main/java/io/legado/app/ui/widget/components/rules/RuleEditSheet.kt
  36. 2 7
      app/src/main/java/io/legado/app/ui/widget/components/rules/RuleListScaffold.kt
  37. 4 4
      app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt

+ 2 - 2
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<T : SelectableItem<ID>, Entity, ID, S : RuleActionState<T>>(
+abstract class BaseRuleViewModel<T : SelectableItem<ID>, Entity, ID, S : ListUiState<T>>(
     application: Application,
     initialState: S,
     private val uploadRepository: UploadRepository? = null // 设为可空,提高灵活性

+ 11 - 4
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() {
 
     }
 
-}
+}

+ 0 - 21
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<ReadRecordSession>
-
-    @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<ReadRecordShow>
-
     @Query("SELECT sum(readTime) FROM readRecord")
     fun getTotalReadTime(): Flow<Long?>
 
-    @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<ReadRecordShow>
-
     @Query("select sum(readTime) from readRecord where bookName = :bookName")
     fun getReadTime(bookName: String): Long?
 

+ 2 - 0
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,

+ 0 - 8
app/src/main/java/io/legado/app/data/entities/ReadRecordShow.kt

@@ -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
-)

+ 2 - 0
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 = "",
 
     // 一次阅读的开始/结束

+ 4 - 0
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<UploadRepository> { DirectLinkUploadRepository() }
     single<ExploreRepository> { ExploreRepositoryImpl(get()) }
@@ -47,6 +50,7 @@ val appModule = module {
     viewModelOf(::TxtTocRuleViewModel)
     viewModelOf(::OtherConfigViewModel)
     viewModelOf(::TocViewModel)
+    viewModelOf(::RemoteBookViewModel)
 
     viewModel { (route: ReplaceEditRoute) ->
         ReplaceEditViewModel(

+ 0 - 142
app/src/main/java/io/legado/app/ui/book/explore/ExploreShowAdapter.kt

@@ -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<SearchBook, ItemSearchBinding>(context) {
-
-    override fun getViewBinding(parent: ViewGroup): ItemSearchBinding {
-        return ItemSearchBinding.inflate(inflater, parent, false)
-    }
-
-    override fun convert(
-        holder: ItemViewHolder,
-        binding: ItemSearchBinding,
-        item: SearchBook,
-        payloads: MutableList<Any>
-    ) {
-        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)
-    }
-}

+ 0 - 14
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)
         ) {

+ 317 - 119
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<RemoteBook>()
-    val permissionDenialLiveData = MutableLiveData<Int>()
+@Immutable
+data class NavigationState(
+    val pathNames: List<String> = 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<String>
 
-    val dataFlow = callbackFlow<List<RemoteBook>> {
+@Immutable
+data class RemoteBookUiState(
+    override val items: List<RemoteBookItemUi> = emptyList(),
+    override val selectedIds: Set<Any> = 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<Server> = emptyList(),
+    val selectedServerId: Long = AppConfig.remoteServerId
+) : ListUiState<RemoteBookItemUi> {
+    override val isSearch: Boolean get() = interaction.isSearchMode
+    override val isLoading: Boolean get() = interaction.isLoading
+    val pathNames: List<String> get() = navigation.pathNames
+    val canGoBack: Boolean get() = navigation.canGoBack
+}
 
-        val list = Collections.synchronizedList(ArrayList<RemoteBook>())
+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<RemoteBook> = emptyList(),
+        val sortKey: RemoteBookSort = RemoteBookSort.Default,
+        val sortAscending: Boolean = false,
+        val dirList: List<RemoteBook> = emptyList(),
+        val interaction: InteractionState = InteractionState(isLoading = true),
+        val searchKey: String = "",
+        val selectedIds: Set<String> = emptySet(),
+        val remoteBookWebDav: RemoteBookWebDav? = null,
+        val isDefaultWebdav: Boolean = false,
+        val selectedServerId: Long = AppConfig.remoteServerId
+    )
 
-            override fun setItems(remoteFiles: List<RemoteBook>) {
-                list.clear()
-                list.addAll(remoteFiles)
-                trySend(list)
-            }
+    private val _state = MutableStateFlow(InternalState())
 
-            override fun addItems(remoteFiles: List<RemoteBook>) {
-                list.addAll(remoteFiles)
-                trySend(list)
-            }
+    val uiState: StateFlow<RemoteBookUiState> = combine(
+        _state,
+        repository.flowLocalBooks(),
+        appDb.serverDao.observeAll()
+    ) { state, localBooks, servers ->
+        val localFileNames = localBooks.map { it.originName }.toSet()
 
-            override fun clear() {
-                list.clear()
-                trySend(emptyList())
-            }
+        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) }
+        }
 
-            override fun screen(key: String?) {
-                if (key.isNullOrBlank()) {
-                    trySend(list)
+        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()
+        )
+
+    private val _permissionDenialEvent = MutableSharedFlow<Int>(
+        replay = 0,
+        extraBufferCapacity = 1,
+        onBufferOverflow = BufferOverflow.DROP_OLDEST
+    )
+    val permissionDenialEvent: SharedFlow<Int> = _permissionDenialEvent
+
+    fun initData(onSuccess: () -> Unit) {
+        viewModelScope.launch {
+            try {
+                val webDav = repository.createWebDav(AppConfig.remoteServerId)
+                if (webDav != null) {
+                    _state.update {
+                        it.copy(
+                            remoteBookWebDav = webDav,
+                            isDefaultWebdav = false,
+                            selectedServerId = AppConfig.remoteServerId
+                        )
+                    }
+                    onSuccess()
                 } else {
-                    trySend(
-                        list.filter { it.filename.contains(key) }
-                    )
+                    val defaultWebDav = AppWebDav.defaultBookWebDav
+                        ?: throw NoStackTraceException("webDav没有配置")
+                    _state.update {
+                        it.copy(
+                            remoteBookWebDav = defaultWebDav,
+                            isDefaultWebdav = true,
+                            selectedServerId = AppConst.DEFAULT_WEBDAV_ID
+                        )
+                    }
+                    onSuccess()
                 }
+            } catch (e: Exception) {
+                context.toastOnUi("初始化webDav出错:${e.localizedMessage}")
+                _state.update { it.copy(interaction = it.interaction.copy(isLoading = false)) }
             }
         }
+    }
 
-        awaitClose {
-            dataCallback = null
-        }
-    }.map { list ->
-        if (sortAscending) when (sortKey) {
-            RemoteBookSort.Name -> list.sortedWith(compareBy<RemoteBook> { !it.isDir }
-                    then compareBy(AlphanumComparator) { it.filename })
-
-            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)
+    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)
                 }
-                return@sortedWith compare
+                _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)) }
             }
+        }
+    }
 
-            else -> list.sortedWith { o1, o2 ->
-                val compare = -compareValues(o1.isDir, o2.isDir)
-                if (compare == 0) {
-                    return@sortedWith -compareValues(o1.lastModify, o2.lastModify)
+    suspend fun addToBookshelf(remoteBooks: Set<RemoteBook>): Result<Unit> {
+        _state.update { it.copy(interaction = it.interaction.copy(isUploading = true)) }
+        return try {
+            val webDav = _state.value.remoteBookWebDav
+                ?: throw NoStackTraceException("没有配置webDav")
+
+            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()
+                    }
                 }
-                return@sortedWith compare
             }
+
+            _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)) }
         }
-    }.flowOn(Dispatchers.IO)
+    }
 
-    private var remoteBookWebDav: RemoteBookWebDav? = null
-    var isDefaultWebdav = false
+    suspend fun getLocalBook(fileName: String): Book? {
+        return withContext(Dispatchers.IO) {
+            appDb.bookDao.getBookByFileName(fileName)
+        }
+    }
 
-    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 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 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<RemoteBook>, 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()
-                }
-                remoteBook.isOnBookShelf = true
-            }
-        }.onError {
-            AppLog.put("导入出错\n${it.localizedMessage}", it)
-            context.toastOnUi("导入出错\n${it.localizedMessage}")
-            if (it is SecurityException) {
-                permissionDenialLiveData.postValue(1)
+    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)
             }
-        }.onFinally {
-            finally.invoke()
         }
     }
 
-    fun updateCallBackFlow(filterKey: String?) {
-        dataCallback?.screen(filterKey)
+    fun toggleSelection(id: String) {
+        _state.update {
+            val newSelected = if (id in it.selectedIds) it.selectedIds - id else it.selectedIds + id
+            it.copy(selectedIds = newSelected)
+        }
     }
 
-    interface DataCallback {
+    fun selectAllCheckable() {
+        _state.update { state ->
+            val checkableIds = state.remoteBooks
+                .filter { !it.isDir && !it.isOnBookShelf }
+                .map { it.path }
+                .toSet()
+            state.copy(selectedIds = checkableIds)
+        }
+    }
 
-        fun setItems(remoteFiles: List<RemoteBook>)
+    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 addItems(remoteFiles: List<RemoteBook>)
+    fun clearSelection() {
+        _state.update { it.copy(selectedIds = emptySet()) }
+    }
 
-        fun clear()
+    fun refreshData() {
+        val currentPath = _state.value.dirList.lastOrNull()?.path
+        loadRemoteBookList(currentPath)
+    }
+
+    private fun sortBooks(
+        books: List<RemoteBook>,
+        sortKey: RemoteBookSort,
+        ascending: Boolean
+    ): List<RemoteBook> {
+        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 screen(key: String?)
+    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() }
     }
-}
+}

+ 1 - 5
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,

+ 4 - 4
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<Int> = emptySet(),
     override val searchKey: String = "",
     override val isSearch: Boolean = false,
-    override val isUploading: Boolean = false,
+    override val isLoading: Boolean = false,
     val downloadSummary: String = ""
-) : RuleActionState<TocItemUi>
+) : ListUiState<TocItemUi>
 
 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
         )
     }

+ 0 - 1
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

+ 3 - 3
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<Long> = emptySet(),
     override val searchKey: String = "",
     val interaction: InteractionState = InteractionState()
-) : RuleActionState<TxtTocRuleItemUi> {
+) : ListUiState<TxtTocRuleItemUi> {
     override val isSearch: Boolean get() = interaction.isSearchMode
-    override val isUploading: Boolean get() = interaction.isUploading
+    override val isLoading: Boolean get() = interaction.isUploading
 }
 
 

+ 1 - 6
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

+ 1 - 4
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<String?>(null) }
 
@@ -66,8 +64,7 @@ fun DirectLinkUploadBottomSheet(
     }
 
     GlassModalBottomSheet(
-        onDismissRequest = onDismiss,
-        sheetState = sheetState,
+        onDismissRequest = onDismiss
     ) {
         Column(
             modifier = Modifier

+ 0 - 2
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

+ 1 - 4
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

+ 0 - 1
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

+ 3 - 3
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<String> = emptySet(),
     override val searchKey: String = "",
     val interaction: InteractionState = InteractionState()
-) : RuleActionState<DictRuleItemUi> {
+) : ListUiState<DictRuleItemUi> {
     override val isSearch: Boolean get() = interaction.isSearchMode
-    override val isUploading: Boolean get() = interaction.isUploading
+    override val isLoading: Boolean get() = interaction.isUploading
 }
 
 class DictRuleViewModel(

+ 1 - 4
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<String>,
-    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

+ 0 - 4
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
         )
     }

+ 3 - 3
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<String> = emptyList(),
     val interaction: InteractionState = InteractionState()
-) : RuleActionState<ReplaceRuleItemUi> {
+) : ListUiState<ReplaceRuleItemUi> {
     override val isSearch: Boolean get() = interaction.isSearchMode
-    override val isUploading: Boolean get() = interaction.isUploading
+    override val isLoading: Boolean get() = interaction.isUploading
 }
 
 class ReplaceRuleViewModel(

+ 3 - 4
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
         }
     }
 

+ 66 - 18
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
             )
         )
     }

+ 7 - 2
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<ActionItem> = emptyList(),
+    val onSelectAll: () -> Unit,
+    val onSelectInvert: () -> Unit,
+)
+
 data class ActionItem(
     val text: String,
     val icon: @Composable (() -> Unit)? = null,

+ 1 - 4
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

+ 103 - 0
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
+                    )
+                }
+            }
+        }
+    }
 }

+ 99 - 6
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
+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
-private fun SmallIcon(imageVector: ImageVector, contentDescription: String?) {
+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
         )
     }
 }

+ 0 - 3
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(

+ 0 - 1
app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt

@@ -139,7 +139,6 @@ fun <T> BatchImportDialog(
 
     GlassModalBottomSheet(
         onDismissRequest = onDismissRequest,
-        sheetState = sheetState,
         containerColor = { it.surfaceContainer }
     ) {
         Scaffold(

+ 127 - 0
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 <T> ListScaffold(
+    title: String,
+    state: ListUiState<T>,
+    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
+                    )
+                }
+            }
+        }
+    }
+}

+ 3 - 1
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
 ) {

+ 2 - 2
app/src/main/java/io/legado/app/ui/widget/components/rules/RuleActionState.kt → 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<T> {
+interface ListUiState<T> {
     val items: List<T>
     val selectedIds: Set<Any>
     val searchKey: String
     val isSearch: Boolean
-    val isUploading: Boolean
+    val isLoading: Boolean
 }
 
 data class InteractionState(

+ 1 - 4
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 <T> 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 <T> RuleEditSheet(
     fun getCurrentEntity() = fromFields(RuleEditFields(name, rule1, rule2), rule)
 
     GlassModalBottomSheet(
-        onDismissRequest = onDismissRequest,
-        sheetState = sheetState,
+        onDismissRequest = onDismissRequest
     ) {
         Box(modifier = Modifier.fillMaxWidth()) {
             Column(

+ 2 - 7
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 <T> RuleListScaffold(
     title: String,
-    state: RuleActionState<T>,
+    state: ListUiState<T>,
     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 = {},

+ 4 - 4
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 <T> DynamicTopAppBar(
     title: String,
     subtitle: String? = null,
-    state: RuleActionState<T>,
+    state: ListUiState<T>,
     scrollBehavior: TopAppBarScrollBehavior,
     onBackClick: () -> Unit,
     onSearchToggle: (Boolean) -> Unit,
@@ -60,13 +60,13 @@ fun <T> 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
                 )