[优化] 界面和性能优化

This commit is contained in:
HapeLee committed 2026-03-05 01:23:47 +08:00
1 parent bede06bf46
commit 16b8164943
37 files changed
+788 -424

No files matched your search

@@ -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.BaseImportUiState
import io.legado.app.ui.widget.components.importComponents.ImportItemWrapper 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.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.ui.widget.components.rules.SelectableItem
import io.legado.app.utils.isAbsUrl import io.legado.app.utils.isAbsUrl
import io.legado.app.utils.isUri import io.legado.app.utils.isUri
@@ -42,7 +42,7 @@ sealed interface BaseRuleEvent {
) : 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, application: Application,
initialState: S, initialState: S,
private val uploadRepository: UploadRepository? = null // 设为可空,提高灵活性 private val uploadRepository: UploadRepository? = null // 设为可空,提高灵活性
@@ -25,6 +25,7 @@ import io.legado.app.data.dao.RssSourceDao
import io.legado.app.data.dao.RssStarDao import io.legado.app.data.dao.RssStarDao
import io.legado.app.data.dao.RuleSubDao import io.legado.app.data.dao.RuleSubDao
import io.legado.app.data.dao.SearchBookDao 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.SearchKeywordDao
import io.legado.app.data.dao.ServerDao import io.legado.app.data.dao.ServerDao
import io.legado.app.data.dao.TxtTocRuleDao 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.RssStar
import io.legado.app.data.entities.RuleSub import io.legado.app.data.entities.RuleSub
import io.legado.app.data.entities.SearchBook 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.SearchKeyword
import io.legado.app.data.entities.Server import io.legado.app.data.entities.Server
import io.legado.app.data.entities.TxtTocRule import io.legado.app.data.entities.TxtTocRule
@@ -67,14 +69,15 @@ val appDb by lazy {
} }
@Database( @Database(
version = 83, version = 85,
exportSchema = true, exportSchema = true,
entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class, entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class, ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
RssSource::class, Bookmark::class, RssArticle::class, RssSource::class, Bookmark::class, RssArticle::class,
RssReadRecord::class, ReadRecordDetail::class, ReadRecordSession::class, RssReadRecord::class, ReadRecordDetail::class, ReadRecordSession::class,
RssStar::class, TxtTocRule::class, ReadRecord::class, HttpTTS::class, Cache::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], views = [BookSourcePart::class],
autoMigrations = [ autoMigrations = [
AutoMigration(from = 43, to = 44), AutoMigration(from = 43, to = 44),
@@ -115,7 +118,10 @@ val appDb by lazy {
AutoMigration(from = 78, to = 79), AutoMigration(from = 78, to = 79),
AutoMigration(from = 79, to = 80), AutoMigration(from = 79, to = 80),
AutoMigration(from = 80, to = 81), 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() { abstract class AppDatabase : RoomDatabase() {
@@ -141,6 +147,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract val dictRuleDao: DictRuleDao abstract val dictRuleDao: DictRuleDao
abstract val keyboardAssistsDao: KeyboardAssistsDao abstract val keyboardAssistsDao: KeyboardAssistsDao
abstract val serverDao: ServerDao abstract val serverDao: ServerDao
abstract val searchContentHistoryDao: SearchContentHistoryDao
companion object { companion object {
@@ -272,4 +279,4 @@ abstract class AppDatabase : RoomDatabase() {
} }
} }
@@ -6,7 +6,6 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Update 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.ReadRecord
import io.legado.app.data.entities.readRecord.ReadRecordDetail import io.legado.app.data.entities.readRecord.ReadRecordDetail
import io.legado.app.data.entities.readRecord.ReadRecordSession import io.legado.app.data.entities.readRecord.ReadRecordSession
@@ -23,29 +22,9 @@ interface ReadRecordDao {
@get:Query("select * from readRecordSession") @get:Query("select * from readRecordSession")
val allSession: List<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") @Query("SELECT sum(readTime) FROM readRecord")
fun getTotalReadTime(): Flow<Long?> 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") @Query("select sum(readTime) from readRecord where bookName = :bookName")
fun getReadTime(bookName: String): Long? fun getReadTime(bookName: String): Long?
@@ -1,6 +1,7 @@
package io.legado.app.data.entities package io.legado.app.data.entities
import android.os.Parcelable import android.os.Parcelable
import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
@@ -15,6 +16,7 @@ data class Bookmark(
@PrimaryKey @PrimaryKey
val time: Long = System.currentTimeMillis(), val time: Long = System.currentTimeMillis(),
val bookName: String = "", val bookName: String = "",
@ColumnInfo(defaultValue = "")
val bookAuthor: String = "", val bookAuthor: String = "",
var chapterIndex: Int = 0, var chapterIndex: Int = 0,
var chapterPos: Int = 0, var chapterPos: Int = 0,
@@ -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
)
@@ -1,5 +1,6 @@
package io.legado.app.data.entities.readRecord package io.legado.app.data.entities.readRecord
import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
@@ -10,6 +11,7 @@ data class ReadRecordSession(
val deviceId: String = "", val deviceId: String = "",
val bookName: String = "", val bookName: String = "",
@ColumnInfo(defaultValue = "")
val bookAuthor: String = "", val bookAuthor: String = "",
// 一次阅读的开始/结束 // 一次阅读的开始/结束
@@ -6,10 +6,12 @@ import io.legado.app.data.repository.DirectLinkUploadRepository
import io.legado.app.data.repository.ExploreRepository import io.legado.app.data.repository.ExploreRepository
import io.legado.app.data.repository.ExploreRepositoryImpl import io.legado.app.data.repository.ExploreRepositoryImpl
import io.legado.app.data.repository.ReadRecordRepository 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.SearchContentRepository
import io.legado.app.data.repository.UploadRepository import io.legado.app.data.repository.UploadRepository
import io.legado.app.ui.book.bookmark.AllBookmarkViewModel import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
import io.legado.app.ui.book.explore.ExploreShowViewModel 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.readRecord.ReadRecordViewModel
import io.legado.app.ui.book.searchContent.SearchContentViewModel import io.legado.app.ui.book.searchContent.SearchContentViewModel
import io.legado.app.ui.book.toc.TocViewModel import io.legado.app.ui.book.toc.TocViewModel
@@ -34,6 +36,7 @@ val appModule = module {
singleOf(::ReadRecordRepository) singleOf(::ReadRecordRepository)
singleOf(::BookRepository) singleOf(::BookRepository)
singleOf(::SearchContentRepository) singleOf(::SearchContentRepository)
singleOf(::RemoteBookRepository)
single<UploadRepository> { DirectLinkUploadRepository() } single<UploadRepository> { DirectLinkUploadRepository() }
single<ExploreRepository> { ExploreRepositoryImpl(get()) } single<ExploreRepository> { ExploreRepositoryImpl(get()) }
@@ -47,6 +50,7 @@ val appModule = module {
viewModelOf(::TxtTocRuleViewModel) viewModelOf(::TxtTocRuleViewModel)
viewModelOf(::OtherConfigViewModel) viewModelOf(::OtherConfigViewModel)
viewModelOf(::TocViewModel) viewModelOf(::TocViewModel)
viewModelOf(::RemoteBookViewModel)
viewModel { (route: ReplaceEditRoute) -> viewModel { (route: ReplaceEditRoute) ->
ReplaceEditViewModel( ReplaceEditViewModel(
@@ -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)
}
}
@@ -52,7 +52,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SheetValue
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text 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.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -186,12 +184,8 @@ fun ExploreShowScreen(
} }
if (showGridCountSheet) { if (showGridCountSheet) {
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
GlassModalBottomSheet( GlassModalBottomSheet(
sheetState = sheetState,
modifier = Modifier modifier = Modifier
.padding(16.dp), .padding(16.dp),
onDismissRequest = { showGridCountSheet = false } onDismissRequest = { showGridCountSheet = false }
@@ -244,16 +238,8 @@ fun ExploreShowScreen(
} }
if (showKindSheet) { if (showKindSheet) {
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true,
confirmValueChange = { newValue ->
newValue != SheetValue.PartiallyExpanded
}
)
GlassModalBottomSheet( GlassModalBottomSheet(
onDismissRequest = { showKindSheet = false }, onDismissRequest = { showKindSheet = false },
sheetState = sheetState,
modifier = Modifier modifier = Modifier
.heightIn(max = LocalConfiguration.current.screenHeightDp.dp * 0.72f) .heightIn(max = LocalConfiguration.current.screenHeightDp.dp * 0.72f)
) { ) {
@@ -1,174 +1,372 @@
package io.legado.app.ui.book.import.remote package io.legado.app.ui.book.import.remote
import android.app.Application 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.base.BaseViewModel
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppLog import io.legado.app.constant.AppLog
import io.legado.app.constant.BookType import io.legado.app.constant.BookType
import io.legado.app.data.appDb 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.exception.NoStackTraceException
import io.legado.app.help.AppWebDav import io.legado.app.help.AppWebDav
import io.legado.app.help.config.AppConfig 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.analyzeRule.CustomUrl
import io.legado.app.model.localBook.LocalBook import io.legado.app.model.localBook.LocalBook
import io.legado.app.model.remote.RemoteBook import io.legado.app.model.remote.RemoteBook
import io.legado.app.model.remote.RemoteBookWebDav 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.AlphanumComparator
import io.legado.app.utils.ConvertUtils
import io.legado.app.utils.toastOnUi import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.callbackFlow 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.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn
import java.util.Collections import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class RemoteBookViewModel(application: Application) : BaseViewModel(application) { @Immutable
var sortKey = RemoteBookSort.Default data class NavigationState(
var sortAscending = false val pathNames: List<String> = emptyList(),
val dirList = arrayListOf<RemoteBook>() val canGoBack: Boolean = false
val permissionDenialLiveData = MutableLiveData<Int>() )
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>) { private val _state = MutableStateFlow(InternalState())
list.clear()
list.addAll(remoteFiles)
trySend(list)
}
override fun addItems(remoteFiles: List<RemoteBook>) { val uiState: StateFlow<RemoteBookUiState> = combine(
list.addAll(remoteFiles) _state,
trySend(list) repository.flowLocalBooks(),
} appDb.serverDao.observeAll()
) { state, localBooks, servers ->
val localFileNames = localBooks.map { it.originName }.toSet()
override fun clear() { val sortedBooks = sortBooks(state.remoteBooks, state.sortKey, state.sortAscending)
list.clear() val filteredBooks = if (state.searchKey.isBlank()) {
trySend(emptyList()) sortedBooks
} } else {
sortedBooks.filter { it.filename.contains(state.searchKey, ignoreCase = true) }
override fun screen(key: String?) {
if (key.isNullOrBlank()) {
trySend(list)
} else {
trySend(
list.filter { it.filename.contains(key) }
)
}
}
} }
awaitClose { RemoteBookUiState(
dataCallback = null items = filteredBooks.map { book ->
} val isOnShelf = if (book.isDir) false else localFileNames.contains(book.filename)
}.map { list -> val updatedBook =
if (sortAscending) when (sortKey) { if (book.isOnBookShelf != isOnShelf) book.copy(isOnBookShelf = isOnShelf) else book
RemoteBookSort.Name -> list.sortedWith(compareBy<RemoteBook> { !it.isDir } RemoteBookItemUi(
then compareBy(AlphanumComparator) { it.filename }) 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 })) private val _permissionDenialEvent = MutableSharedFlow<Int>(
} else when (sortKey) { replay = 0,
RemoteBookSort.Name -> list.sortedWith { o1, o2 -> extraBufferCapacity = 1,
val compare = -compareValues(o1.isDir, o2.isDir) onBufferOverflow = BufferOverflow.DROP_OLDEST
if (compare == 0) { )
return@sortedWith -AlphanumComparator.compare(o1.filename, o2.filename) val permissionDenialEvent: SharedFlow<Int> = _permissionDenialEvent
}
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
fun initData(onSuccess: () -> Unit) { fun initData(onSuccess: () -> Unit) {
execute { viewModelScope.launch {
isDefaultWebdav = false try {
appDb.serverDao.get(AppConfig.remoteServerId)?.getWebDavConfig()?.let { val webDav = repository.createWebDav(AppConfig.remoteServerId)
val authorization = Authorization(it) if (webDav != null) {
remoteBookWebDav = RemoteBookWebDav(it.url, authorization, AppConfig.remoteServerId) _state.update {
return@execute it.copy(
} remoteBookWebDav = webDav,
isDefaultWebdav = true isDefaultWebdav = false,
remoteBookWebDav = AppWebDav.defaultBookWebDav selectedServerId = AppConfig.remoteServerId
?: throw NoStackTraceException("webDav没有配置") )
}.onError { }
context.toastOnUi("初始化webDav出错:${it.localizedMessage}") onSuccess()
}.onSuccess { } else {
onSuccess.invoke() val defaultWebDav = AppWebDav.defaultBookWebDav
} ?: throw NoStackTraceException("webDav没有配置")
} _state.update {
it.copy(
fun loadRemoteBookList(path: String?, loadCallback: (loading: Boolean) -> Unit) { remoteBookWebDav = defaultWebDav,
executeLazy { isDefaultWebdav = true,
val bookWebDav = remoteBookWebDav selectedServerId = AppConst.DEFAULT_WEBDAV_ID
?: throw NoStackTraceException("没有配置webDav") )
dataCallback?.clear() }
val url = path ?: bookWebDav.rootBookUrl onSuccess()
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 } 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?) { fun loadRemoteBookList(path: String? = null) {
dataCallback?.screen(filterKey) _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<RemoteBook>): Result<Unit> {
_state.update { it.copy(interaction = it.interaction.copy(isUploading = true)) }
return try {
val webDav = _state.value.remoteBookWebDav
?: throw NoStackTraceException("没有配置webDav")
fun setItems(remoteFiles: List<RemoteBook>) withContext(Dispatchers.IO) {
remoteBooks.forEach { remoteBook ->
fun addItems(remoteFiles: List<RemoteBook>) val downloadBookUri = repository.downloadBook(webDav, remoteBook)
LocalBook.importFiles(downloadBookUri).forEach { book ->
fun clear() book.origin = BookType.webDavTag + CustomUrl(remoteBook.path)
.putAttribute("serverID", webDav.serverID)
fun screen(key: String?) .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)) }
}
} }
}
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<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 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() }
}
}
@@ -47,7 +47,6 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -319,11 +318,8 @@ fun ReadRecordScreen(
} }
if (showCalendar) { if (showCalendar) {
val sheetState =
rememberModalBottomSheetState(skipPartiallyExpanded = true)
GlassModalBottomSheet( GlassModalBottomSheet(
onDismissRequest = { showCalendar = false }, onDismissRequest = { showCalendar = false }
sheetState = sheetState
) { ) {
HeatmapCalendarSection( HeatmapCalendarSection(
dailyReadCounts = state.dailyReadCounts, dailyReadCounts = state.dailyReadCounts,
@@ -21,7 +21,7 @@ import io.legado.app.model.ReadBook
import io.legado.app.model.localBook.LocalBook import io.legado.app.model.localBook.LocalBook
import io.legado.app.ui.config.readConfig.ReadConfig import io.legado.app.ui.config.readConfig.ReadConfig
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState 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.ui.widget.components.rules.SelectableItem
import io.legado.app.utils.toastOnUi import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -74,9 +74,9 @@ data class TocActionState(
override val selectedIds: Set<Int> = emptySet(), override val selectedIds: Set<Int> = emptySet(),
override val searchKey: String = "", override val searchKey: String = "",
override val isSearch: Boolean = false, override val isSearch: Boolean = false,
override val isUploading: Boolean = false, override val isLoading: Boolean = false,
val downloadSummary: String = "" val downloadSummary: String = ""
) : RuleActionState<TocItemUi> ) : ListUiState<TocItemUi>
data class TocDomainItem( data class TocDomainItem(
val chapter: BookChapter, val chapter: BookChapter,
@@ -305,7 +305,7 @@ class TocViewModel(
selectedIds = selectedIds, selectedIds = selectedIds,
searchKey = _searchKey.value, searchKey = _searchKey.value,
isSearch = isSearch, isSearch = isSearch,
isUploading = isUploading, isLoading = isUploading,
downloadSummary = downloadSummary.value downloadSummary = downloadSummary.value
) )
} }
@@ -155,7 +155,6 @@ fun TxtRuleScreen(
if (showFilePickerSheet) { if (showFilePickerSheet) {
FilePickerSheet( FilePickerSheet(
sheetState = sheetState,
onDismissRequest = { showFilePickerSheet = false }, onDismissRequest = { showFilePickerSheet = false },
onSelectSysDir = { onSelectSysDir = {
showFilePickerSheet = false showFilePickerSheet = false
@@ -9,7 +9,7 @@ import io.legado.app.data.repository.TxtTocRuleRepository
import io.legado.app.data.repository.UploadRepository import io.legado.app.data.repository.UploadRepository
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState 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.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.ui.widget.components.rules.SelectableItem
import io.legado.app.utils.GSON import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonArray import io.legado.app.utils.fromJsonArray
@@ -36,9 +36,9 @@ data class TxtTocRuleUiState(
override val selectedIds: Set<Long> = emptySet(), override val selectedIds: Set<Long> = emptySet(),
override val searchKey: String = "", override val searchKey: String = "",
val interaction: InteractionState = InteractionState() val interaction: InteractionState = InteractionState()
) : RuleActionState<TxtTocRuleItemUi> { ) : ListUiState<TxtTocRuleItemUi> {
override val isSearch: Boolean get() = interaction.isSearchMode override val isSearch: Boolean get() = interaction.isSearchMode
override val isUploading: Boolean get() = interaction.isUploading override val isLoading: Boolean get() = interaction.isUploading
} }
@@ -12,7 +12,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -32,13 +31,9 @@ fun CheckSourceBottomSheet(
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
GlassModalBottomSheet( GlassModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss
sheetState = sheetState
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -27,7 +27,6 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -57,7 +56,6 @@ fun DirectLinkUploadBottomSheet(
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
var showTestResult by remember { mutableStateOf<String?>(null) } var showTestResult by remember { mutableStateOf<String?>(null) }
@@ -66,8 +64,7 @@ fun DirectLinkUploadBottomSheet(
} }
GlassModalBottomSheet( GlassModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss
sheetState = sheetState,
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -18,7 +18,6 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -394,7 +393,6 @@ fun OtherConfigScreen(
if (showFilePicker) { if (showFilePicker) {
FilePickerSheet( FilePickerSheet(
sheetState = rememberModalBottomSheetState(),
onDismissRequest = { showFilePicker = false }, onDismissRequest = { showFilePicker = false },
onSelectSysDir = { onSelectSysDir = {
showFilePicker = false showFilePicker = false
@@ -24,7 +24,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -51,7 +50,6 @@ fun DictRuleEditSheet(
onPaste: () -> DictRule? onPaste: () -> DictRule?
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var name by remember(rule) { mutableStateOf(rule?.name ?: "") } var name by remember(rule) { mutableStateOf(rule?.name ?: "") }
var urlRule by remember(rule) { mutableStateOf(rule?.urlRule ?: "") } var urlRule by remember(rule) { mutableStateOf(rule?.urlRule ?: "") }
@@ -59,8 +57,7 @@ fun DictRuleEditSheet(
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
GlassModalBottomSheet( GlassModalBottomSheet(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest
sheetState = sheetState,
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -152,7 +152,6 @@ fun DictRuleScreen(
if (showFilePickerSheet) { if (showFilePickerSheet) {
FilePickerSheet( FilePickerSheet(
sheetState = sheetState,
onDismissRequest = { showFilePickerSheet = false }, onDismissRequest = { showFilePickerSheet = false },
onSelectSysDir = { onSelectSysDir = {
showFilePickerSheet = false showFilePickerSheet = false
@@ -9,7 +9,7 @@ import io.legado.app.data.repository.DictRuleRepository
import io.legado.app.data.repository.UploadRepository import io.legado.app.data.repository.UploadRepository
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState 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.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.ui.widget.components.rules.SelectableItem
import io.legado.app.utils.GSON import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonArray import io.legado.app.utils.fromJsonArray
@@ -39,9 +39,9 @@ data class DictRuleUiState(
override val selectedIds: Set<String> = emptySet(), override val selectedIds: Set<String> = emptySet(),
override val searchKey: String = "", override val searchKey: String = "",
val interaction: InteractionState = InteractionState() val interaction: InteractionState = InteractionState()
) : RuleActionState<DictRuleItemUi> { ) : ListUiState<DictRuleItemUi> {
override val isSearch: Boolean get() = interaction.isSearchMode override val isSearch: Boolean get() = interaction.isSearchMode
override val isUploading: Boolean get() = interaction.isUploading override val isLoading: Boolean get() = interaction.isUploading
} }
class DictRuleViewModel( class DictRuleViewModel(
@@ -17,7 +17,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -36,7 +35,6 @@ import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet
@Composable @Composable
fun GroupManageBottomSheet( fun GroupManageBottomSheet(
groups: List<String>, groups: List<String>,
sheetState: SheetState,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
viewModel: ReplaceRuleViewModel viewModel: ReplaceRuleViewModel
) { ) {
@@ -44,8 +42,7 @@ fun GroupManageBottomSheet(
var updatedGroupName by remember { mutableStateOf("") } var updatedGroupName by remember { mutableStateOf("") }
GlassModalBottomSheet( GlassModalBottomSheet(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest
sheetState = sheetState
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -39,7 +39,6 @@ import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.animateFloatingActionButton import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberTooltipState import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -112,7 +111,6 @@ fun ReplaceRuleScreen(
var showGroupManageSheet by remember { mutableStateOf(false) } var showGroupManageSheet by remember { mutableStateOf(false) }
val importState by viewModel.importState.collectAsStateWithLifecycle() val importState by viewModel.importState.collectAsStateWithLifecycle()
val sheetState = rememberModalBottomSheetState()
var selectedTabIndex by remember { mutableIntStateOf(0) } var selectedTabIndex by remember { mutableIntStateOf(0) }
val tabItems = remember(groups) { listOf("全部") + groups } val tabItems = remember(groups) { listOf("全部") + groups }
@@ -166,7 +164,6 @@ fun ReplaceRuleScreen(
if (showFilePickerSheet) { if (showFilePickerSheet) {
FilePickerSheet( FilePickerSheet(
sheetState = sheetState,
onDismissRequest = { showFilePickerSheet = false }, onDismissRequest = { showFilePickerSheet = false },
onSelectSysDir = { onSelectSysDir = {
showFilePickerSheet = false showFilePickerSheet = false
@@ -255,7 +252,6 @@ fun ReplaceRuleScreen(
GroupManageBottomSheet( GroupManageBottomSheet(
groups = groups, groups = groups,
onDismissRequest = { showGroupManageSheet = false }, onDismissRequest = { showGroupManageSheet = false },
sheetState = sheetState,
viewModel = viewModel viewModel = viewModel
) )
} }
@@ -14,7 +14,7 @@ import io.legado.app.data.repository.UploadRepository
import io.legado.app.help.ReplaceAnalyzer import io.legado.app.help.ReplaceAnalyzer
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState 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.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.ui.widget.components.rules.SelectableItem
import io.legado.app.utils.GSON import io.legado.app.utils.GSON
import io.legado.app.utils.getPrefString import io.legado.app.utils.getPrefString
@@ -53,9 +53,9 @@ data class ReplaceRuleUiState(
val sortMode: String = "desc", val sortMode: String = "desc",
val groups: List<String> = emptyList(), val groups: List<String> = emptyList(),
val interaction: InteractionState = InteractionState() val interaction: InteractionState = InteractionState()
) : RuleActionState<ReplaceRuleItemUi> { ) : ListUiState<ReplaceRuleItemUi> {
override val isSearch: Boolean get() = interaction.isSearchMode override val isSearch: Boolean get() = interaction.isSearchMode
override val isUploading: Boolean get() = interaction.isUploading override val isLoading: Boolean get() = interaction.isUploading
} }
class ReplaceRuleViewModel( class ReplaceRuleViewModel(
@@ -63,13 +63,12 @@ object GlassTopAppBarDefaults {
@Composable @Composable
fun controlContainerColor(): Color { fun controlContainerColor(): Color {
val enableBlur = ThemeConfig.enableBlur val enableBlur = ThemeConfig.enableBlur
val baseColor = MaterialTheme.colorScheme.surfaceContainerHigh val baseColor = MaterialTheme.colorScheme.surfaceContainerHighest
return if (enableBlur) { return if (enableBlur) {
val alpha = 0.6f baseColor.copy(alpha = 0.72f)
baseColor.copy(alpha = alpha)
} else { } else {
baseColor.copy(alpha = ThemeConfig.containerOpacity / 100f) baseColor
} }
} }
@@ -7,18 +7,22 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.RoundedCornerShape 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.Icons
import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.VerticalAlignTop import androidx.compose.material.icons.filled.VerticalAlignTop
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -26,16 +30,19 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.legado.app.ui.widget.components.button.SmallIconButton
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -44,8 +51,13 @@ import kotlinx.coroutines.launch
fun SearchBarSection( fun SearchBarSection(
query: String, query: String,
onQueryChange: (String) -> Unit, onQueryChange: (String) -> Unit,
placeholder: String = "搜索书名", placeholder: String = "搜索...",
leadingIcon: @Composable (() -> Unit)? = { Icon(Icons.Default.Search, null) }, leadingIcon: @Composable (() -> Unit)? = {
SmallIconButton(
icon = Icons.Default.Search,
contentDescription = null,
onClick = {})
},
backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainerLow, backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainerLow,
scrollState: LazyListState? = null, scrollState: LazyListState? = null,
scope: CoroutineScope = rememberCoroutineScope(), scope: CoroutineScope = rememberCoroutineScope(),
@@ -54,6 +66,25 @@ fun SearchBarSection(
) { ) {
var showMenu by remember { mutableStateOf(false) } 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) { val showScrollToTop by remember(scrollState) {
derivedStateOf { derivedStateOf {
(scrollState?.firstVisibleItemIndex ?: 0) > 0 (scrollState?.firstVisibleItemIndex ?: 0) > 0
@@ -63,15 +94,15 @@ fun SearchBarSection(
Surface( Surface(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(start = 16.dp, end = 16.dp, bottom = 4.dp),
shape = RoundedCornerShape(32.dp), shape = RoundedCornerShape(32.dp),
color = backgroundColor color = backgroundColor
) { ) {
TextField( TextField(
value = query, state = textFieldState,
onValueChange = onQueryChange,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 48.dp)
.padding(horizontal = 4.dp), .padding(horizontal = 4.dp),
placeholder = { Text(placeholder) }, placeholder = { Text(placeholder) },
leadingIcon = leadingIcon, leadingIcon = leadingIcon,
@@ -82,14 +113,17 @@ fun SearchBarSection(
.padding(end = 4.dp) .padding(end = 4.dp)
.animateContentSize() .animateContentSize()
) { ) {
AnimatedVisibility( AnimatedVisibility(
visible = query.isNotEmpty(), visible = textFieldState.text.isNotEmpty(),
enter = fadeIn() + scaleIn(), enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut() exit = fadeOut() + scaleOut()
) { ) {
IconButton(onClick = { onQueryChange("") }) { SmallIconButton(
Icon(Icons.Default.Clear, "清空输入") onClick = { textFieldState.clearText() },
} icon = Icons.Default.Clear,
contentDescription = "清空输入"
)
} }
AnimatedVisibility( AnimatedVisibility(
@@ -97,19 +131,27 @@ fun SearchBarSection(
enter = fadeIn() + scaleIn(), enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut() exit = fadeOut() + scaleOut()
) { ) {
IconButton(onClick = { SmallIconButton(
scope.launch { scrollState?.animateScrollToItem(0) } onClick = {
}) { scope.launch {
Icon(Icons.Default.VerticalAlignTop, "回到顶部") scrollState?.animateScrollToItem(0)
} }
},
icon = Icons.Default.VerticalAlignTop,
contentDescription = "回到顶部"
)
} }
// 外部自定义按钮
if (trailingIcon != null) { if (trailingIcon != null) {
Box { Box {
IconButton(onClick = { if (dropdownMenu != null) showMenu = true }) { IconButton(
onClick = {
if (dropdownMenu != null) showMenu = true
}
) {
trailingIcon() trailingIcon()
} }
if (dropdownMenu != null) { if (dropdownMenu != null) {
DropdownMenu( DropdownMenu(
expanded = showMenu, expanded = showMenu,
@@ -122,13 +164,19 @@ fun SearchBarSection(
} }
} }
}, },
singleLine = true, lineLimits = TextFieldLineLimits.SingleLine,
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent, disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent, focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent,
),
contentPadding = PaddingValues(
top = 4.dp,
bottom = 4.dp,
start = 12.dp,
end = 12.dp
) )
) )
} }
@@ -1,10 +1,8 @@
package io.legado.app.ui.widget.components package io.legado.app.ui.widget.components
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons 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.MoreVert
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.SelectAll 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.Modifier
import androidx.compose.ui.unit.dp 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( data class ActionItem(
val text: String, val text: String,
val icon: @Composable (() -> Unit)? = null, val icon: @Composable (() -> Unit)? = null,
@@ -17,7 +17,6 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -37,15 +36,13 @@ fun BookmarkEditSheet(
onSave: (Bookmark) -> Unit, onSave: (Bookmark) -> Unit,
onDelete: (Bookmark) -> Unit onDelete: (Bookmark) -> Unit
) { ) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var showDeleteConfirmDialog by remember { mutableStateOf(false) } var showDeleteConfirmDialog by remember { mutableStateOf(false) }
var bookText by remember { mutableStateOf(bookmark.bookText) } var bookText by remember { mutableStateOf(bookmark.bookText) }
var content by remember { mutableStateOf(bookmark.content) } var content by remember { mutableStateOf(bookmark.content) }
GlassModalBottomSheet( GlassModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss
sheetState = sheetState
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -1,19 +1,35 @@
package io.legado.app.ui.widget.components.button 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.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.IconToggleButtonShapes import androidx.compose.material3.IconToggleButtonShapes
import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedIconToggleButton import androidx.compose.material3.OutlinedIconToggleButton
import androidx.compose.material3.OutlinedToggleButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider 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.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @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) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun SmallOutlinedIconToggleButton( 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
)
}
}
}
}
} }
@@ -1,27 +1,42 @@
package io.legado.app.ui.widget.components.button package io.legado.app.ui.widget.components.button
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.togetherWith 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedIconButton import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.Text
import androidx.compose.material3.ToggleButton
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.ui.widget.components.GlassTopAppBarDefaults import io.legado.app.ui.widget.components.GlassTopAppBarDefaults
import kotlinx.coroutines.delay
enum class TopBarButtonVariant { enum class TopBarButtonVariant {
Filled, Outlined, Icon Filled, Outlined, Icon
@@ -49,7 +64,11 @@ fun TopBarButton(
contentColor = MaterialTheme.colorScheme.onSurface 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, modifier = commonModifier,
border = ButtonDefaults.outlinedButtonBorder() border = ButtonDefaults.outlinedButtonBorder()
) { ) {
SmallIcon(imageVector, contentDescription) AnimatedIcon(
modifier = Modifier.size(20.dp),
imageVector = imageVector,
contentDescription = contentDescription
)
} }
} }
@@ -68,7 +91,11 @@ fun TopBarButton(
onClick = onClick, onClick = onClick,
modifier = commonModifier modifier = commonModifier
) { ) {
SmallIcon(imageVector, contentDescription) AnimatedIcon(
modifier = Modifier.size(20.dp),
imageVector = imageVector,
contentDescription = contentDescription
)
} }
} }
} }
@@ -86,7 +113,7 @@ fun TopbarNavigationButton(
onClick = onClick, onClick = onClick,
imageVector = imageVector, imageVector = imageVector,
contentDescription = contentDescription, contentDescription = contentDescription,
modifier = modifier.padding(start = 12.dp, end = 4.dp), modifier = modifier.padding(horizontal = 12.dp),
style = style style = style
) )
} }
@@ -118,8 +145,74 @@ fun TopBarActionButton(
} }
} }
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @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( AnimatedContent(
targetState = imageVector, targetState = imageVector,
transitionSpec = { transitionSpec = {
@@ -131,7 +224,7 @@ private fun SmallIcon(imageVector: ImageVector, contentDescription: String?) {
Icon( Icon(
imageVector = targetIcon, imageVector = targetIcon,
contentDescription = contentDescription, contentDescription = contentDescription,
modifier = Modifier.size(20.dp) modifier = modifier
) )
} }
} }
@@ -19,7 +19,6 @@ import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -38,7 +37,6 @@ enum class FilePickerSheetMode {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun FilePickerSheet( fun FilePickerSheet(
sheetState: SheetState,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
title: String = stringResource(R.string.select_operation), title: String = stringResource(R.string.select_operation),
onSelectSysDir: (() -> Unit)? = null, onSelectSysDir: (() -> Unit)? = null,
@@ -49,7 +47,6 @@ fun FilePickerSheet(
) { ) {
GlassModalBottomSheet( GlassModalBottomSheet(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
sheetState = sheetState,
containerColor = { it.surface } containerColor = { it.surface }
) { ) {
Column( Column(
@@ -139,7 +139,6 @@ fun <T> BatchImportDialog(
GlassModalBottomSheet( GlassModalBottomSheet(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
sheetState = sheetState,
containerColor = { it.surfaceContainer } containerColor = { it.surfaceContainer }
) { ) {
Scaffold( Scaffold(
@@ -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
)
}
}
}
}
}
@@ -24,7 +24,9 @@ import io.legado.app.ui.theme.rememberOpaqueColorScheme
fun GlassModalBottomSheet( fun GlassModalBottomSheet(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(), sheetState: SheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
),
containerColor: (ColorScheme) -> Color = { it.surfaceContainerLow }, containerColor: (ColorScheme) -> Color = { it.surfaceContainerLow },
content: @Composable ColumnScope.() -> Unit content: @Composable ColumnScope.() -> Unit
) { ) {
@@ -1,11 +1,11 @@
package io.legado.app.ui.widget.components.rules package io.legado.app.ui.widget.components.rules
interface RuleActionState<T> { interface ListUiState<T> {
val items: List<T> val items: List<T>
val selectedIds: Set<Any> val selectedIds: Set<Any>
val searchKey: String val searchKey: String
val isSearch: Boolean val isSearch: Boolean
val isUploading: Boolean val isLoading: Boolean
} }
data class InteractionState( data class InteractionState(
@@ -24,7 +24,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -65,7 +64,6 @@ fun <T> RuleEditSheet(
fromFields: (RuleEditFields, T?) -> T fromFields: (RuleEditFields, T?) -> T
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val initialFields = remember(rule) { toFields(rule) } val initialFields = remember(rule) { toFields(rule) }
var name by remember(initialFields) { mutableStateOf(initialFields.name) } var name by remember(initialFields) { mutableStateOf(initialFields.name) }
@@ -77,8 +75,7 @@ fun <T> RuleEditSheet(
fun getCurrentEntity() = fromFields(RuleEditFields(name, rule1, rule2), rule) fun getCurrentEntity() = fromFields(RuleEditFields(name, rule1, rule2), rule)
GlassModalBottomSheet( GlassModalBottomSheet(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest
sheetState = sheetState,
) { ) {
Box(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) {
Column( Column(
@@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -44,7 +43,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -58,14 +56,11 @@ import io.legado.app.ui.widget.components.topbar.DynamicTopAppBar
@Composable @Composable
fun <T> RuleListScaffold( fun <T> RuleListScaffold(
title: String, title: String,
state: RuleActionState<T>, state: ListUiState<T>,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onSearchToggle: (Boolean) -> Unit, onSearchToggle: (Boolean) -> Unit,
onSearchQueryChange: (String) -> Unit, onSearchQueryChange: (String) -> Unit,
searchPlaceholder: String, searchPlaceholder: String = "搜索...",
searchLeadingIcon: ImageVector = Icons.Default.Search,
searchTrailingIcon: @Composable (() -> Unit)? = null,
searchDropdownMenu: (@Composable (onDismiss: () -> Unit) -> Unit)? = null,
topBarActions: @Composable RowScope.() -> Unit = {}, topBarActions: @Composable RowScope.() -> Unit = {},
bottomContent: @Composable (ColumnScope.() -> Unit)? = null, bottomContent: @Composable (ColumnScope.() -> Unit)? = null,
dropDownMenuContent: @Composable ColumnScope.(dismiss: () -> Unit) -> Unit = {}, dropDownMenuContent: @Composable ColumnScope.(dismiss: () -> Unit) -> Unit = {},
@@ -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.SearchBarSection
import io.legado.app.ui.widget.components.button.TopbarNavigationButton 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.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.rules.RuleActionState import io.legado.app.ui.widget.components.rules.ListUiState
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -39,7 +39,7 @@ import io.legado.app.ui.widget.components.rules.RuleActionState
fun <T> DynamicTopAppBar( fun <T> DynamicTopAppBar(
title: String, title: String,
subtitle: String? = null, subtitle: String? = null,
state: RuleActionState<T>, state: ListUiState<T>,
scrollBehavior: TopAppBarScrollBehavior, scrollBehavior: TopAppBarScrollBehavior,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onSearchToggle: (Boolean) -> Unit, onSearchToggle: (Boolean) -> Unit,
@@ -60,13 +60,13 @@ fun <T> DynamicTopAppBar(
GlassMediumFlexibleTopAppBar( GlassMediumFlexibleTopAppBar(
title = { title = {
val titleText = when { val titleText = when {
state.isUploading -> "请稍后..." state.isLoading -> "请稍后..."
isSelecting -> "已选择 ${state.selectedIds.size}/${state.items.size}" isSelecting -> "已选择 ${state.selectedIds.size}/${state.items.size}"
else -> title else -> title
} }
AdaptiveAnimatedText( AdaptiveAnimatedText(
text = titleText, text = titleText,
useCharMode = isSelecting || state.isUploading, useCharMode = isSelecting || state.isLoading,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )