[优化] 界面和性能优化
This commit is contained in:
@@ -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 // 设为可空,提高灵活性
|
||||
|
||||
@@ -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() {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
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 = "",
|
||||
|
||||
// 一次阅读的开始/结束
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.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)
|
||||
) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
override fun screen(key: String?) {
|
||||
if (key.isNullOrBlank()) {
|
||||
trySend(list)
|
||||
} else {
|
||||
trySend(
|
||||
list.filter { it.filename.contains(key) }
|
||||
)
|
||||
}
|
||||
}
|
||||
val sortedBooks = sortBooks(state.remoteBooks, state.sortKey, state.sortAscending)
|
||||
val filteredBooks = if (state.searchKey.isBlank()) {
|
||||
sortedBooks
|
||||
} else {
|
||||
sortedBooks.filter { it.filename.contains(state.searchKey, ignoreCase = true) }
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
dataCallback = null
|
||||
}
|
||||
}.map { list ->
|
||||
if (sortAscending) when (sortKey) {
|
||||
RemoteBookSort.Name -> list.sortedWith(compareBy<RemoteBook> { !it.isDir }
|
||||
then compareBy(AlphanumComparator) { it.filename })
|
||||
RemoteBookUiState(
|
||||
items = filteredBooks.map { book ->
|
||||
val isOnShelf = if (book.isDir) false else localFileNames.contains(book.filename)
|
||||
val updatedBook =
|
||||
if (book.isOnBookShelf != isOnShelf) book.copy(isOnBookShelf = isOnShelf) else book
|
||||
RemoteBookItemUi(
|
||||
remoteBook = updatedBook,
|
||||
nameWithoutExtension = book.filename.substringBeforeLast("."),
|
||||
formattedSize = ConvertUtils.formatFileSize(book.size),
|
||||
formattedDate = AppConst.dateFormat.format(book.lastModify)
|
||||
)
|
||||
},
|
||||
selectedIds = state.selectedIds,
|
||||
searchKey = state.searchKey,
|
||||
interaction = state.interaction,
|
||||
navigation = NavigationState(
|
||||
pathNames = listOf(if (state.isDefaultWebdav) "books" else "/").plus(state.dirList.map { it.filename }),
|
||||
canGoBack = state.dirList.isNotEmpty()
|
||||
),
|
||||
sortKey = state.sortKey,
|
||||
sortAscending = state.sortAscending,
|
||||
servers = servers,
|
||||
selectedServerId = state.selectedServerId
|
||||
)
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = RemoteBookUiState()
|
||||
)
|
||||
|
||||
else -> list.sortedWith(compareBy({ !it.isDir }, { it.lastModify }))
|
||||
} else when (sortKey) {
|
||||
RemoteBookSort.Name -> list.sortedWith { o1, o2 ->
|
||||
val compare = -compareValues(o1.isDir, o2.isDir)
|
||||
if (compare == 0) {
|
||||
return@sortedWith -AlphanumComparator.compare(o1.filename, o2.filename)
|
||||
}
|
||||
return@sortedWith compare
|
||||
}
|
||||
|
||||
else -> list.sortedWith { o1, o2 ->
|
||||
val compare = -compareValues(o1.isDir, o2.isDir)
|
||||
if (compare == 0) {
|
||||
return@sortedWith -compareValues(o1.lastModify, o2.lastModify)
|
||||
}
|
||||
return@sortedWith compare
|
||||
}
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
private var remoteBookWebDav: RemoteBookWebDav? = null
|
||||
var isDefaultWebdav = false
|
||||
private val _permissionDenialEvent = MutableSharedFlow<Int>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
val permissionDenialEvent: SharedFlow<Int> = _permissionDenialEvent
|
||||
|
||||
fun initData(onSuccess: () -> Unit) {
|
||||
execute {
|
||||
isDefaultWebdav = false
|
||||
appDb.serverDao.get(AppConfig.remoteServerId)?.getWebDavConfig()?.let {
|
||||
val authorization = Authorization(it)
|
||||
remoteBookWebDav = RemoteBookWebDav(it.url, authorization, AppConfig.remoteServerId)
|
||||
return@execute
|
||||
}
|
||||
isDefaultWebdav = true
|
||||
remoteBookWebDav = AppWebDav.defaultBookWebDav
|
||||
?: throw NoStackTraceException("webDav没有配置")
|
||||
}.onError {
|
||||
context.toastOnUi("初始化webDav出错:${it.localizedMessage}")
|
||||
}.onSuccess {
|
||||
onSuccess.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
fun loadRemoteBookList(path: String?, loadCallback: (loading: Boolean) -> Unit) {
|
||||
executeLazy {
|
||||
val bookWebDav = remoteBookWebDav
|
||||
?: throw NoStackTraceException("没有配置webDav")
|
||||
dataCallback?.clear()
|
||||
val url = path ?: bookWebDav.rootBookUrl
|
||||
val bookList = bookWebDav.getRemoteBookList(url)
|
||||
dataCallback?.setItems(bookList)
|
||||
}.onError {
|
||||
AppLog.put("获取webDav书籍出错\n${it.localizedMessage}", it)
|
||||
context.toastOnUi("获取webDav书籍出错\n${it.localizedMessage}")
|
||||
}.onStart {
|
||||
loadCallback.invoke(true)
|
||||
}.onFinally {
|
||||
loadCallback.invoke(false)
|
||||
}.start()
|
||||
}
|
||||
|
||||
fun addToBookshelf(remoteBooks: HashSet<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()
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val webDav = repository.createWebDav(AppConfig.remoteServerId)
|
||||
if (webDav != null) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
remoteBookWebDav = webDav,
|
||||
isDefaultWebdav = false,
|
||||
selectedServerId = AppConfig.remoteServerId
|
||||
)
|
||||
}
|
||||
onSuccess()
|
||||
} else {
|
||||
val defaultWebDav = AppWebDav.defaultBookWebDav
|
||||
?: throw NoStackTraceException("webDav没有配置")
|
||||
_state.update {
|
||||
it.copy(
|
||||
remoteBookWebDav = defaultWebDav,
|
||||
isDefaultWebdav = true,
|
||||
selectedServerId = AppConst.DEFAULT_WEBDAV_ID
|
||||
)
|
||||
}
|
||||
onSuccess()
|
||||
}
|
||||
remoteBook.isOnBookShelf = true
|
||||
} catch (e: Exception) {
|
||||
context.toastOnUi("初始化webDav出错:${e.localizedMessage}")
|
||||
_state.update { it.copy(interaction = it.interaction.copy(isLoading = false)) }
|
||||
}
|
||||
}.onError {
|
||||
AppLog.put("导入出错\n${it.localizedMessage}", it)
|
||||
context.toastOnUi("导入出错\n${it.localizedMessage}")
|
||||
if (it is SecurityException) {
|
||||
permissionDenialLiveData.postValue(1)
|
||||
}
|
||||
}.onFinally {
|
||||
finally.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCallBackFlow(filterKey: String?) {
|
||||
dataCallback?.screen(filterKey)
|
||||
fun loadRemoteBookList(path: String? = null) {
|
||||
_state.update { it.copy(interaction = it.interaction.copy(isLoading = true)) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val webDav = _state.value.remoteBookWebDav
|
||||
?: throw NoStackTraceException("没有配置webDav")
|
||||
val bookList = withContext(Dispatchers.IO) {
|
||||
repository.loadBooks(webDav, path)
|
||||
}
|
||||
_state.update { it.copy(remoteBooks = bookList) }
|
||||
} catch (e: Exception) {
|
||||
AppLog.put("获取webDav书籍出错\n${e.localizedMessage}", e)
|
||||
context.toastOnUi("获取webDav书籍出错\n${e.localizedMessage}")
|
||||
} finally {
|
||||
_state.update { it.copy(interaction = it.interaction.copy(isLoading = false)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface DataCallback {
|
||||
suspend fun addToBookshelf(remoteBooks: Set<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>)
|
||||
|
||||
fun addItems(remoteFiles: List<RemoteBook>)
|
||||
|
||||
fun clear()
|
||||
|
||||
fun screen(key: String?)
|
||||
withContext(Dispatchers.IO) {
|
||||
remoteBooks.forEach { remoteBook ->
|
||||
val downloadBookUri = repository.downloadBook(webDav, remoteBook)
|
||||
LocalBook.importFiles(downloadBookUri).forEach { book ->
|
||||
book.origin = BookType.webDavTag + CustomUrl(remoteBook.path)
|
||||
.putAttribute("serverID", webDav.serverID)
|
||||
.toString()
|
||||
book.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_state.update { it.copy(selectedIds = emptySet()) }
|
||||
Result.success(Unit)
|
||||
} catch (e: SecurityException) {
|
||||
_permissionDenialEvent.emit(1)
|
||||
Result.failure(e)
|
||||
} catch (e: Exception) {
|
||||
AppLog.put("导入出错\n${e.localizedMessage}", e)
|
||||
context.toastOnUi("导入出错\n${e.localizedMessage}")
|
||||
Result.failure(e)
|
||||
} finally {
|
||||
_state.update { it.copy(interaction = it.interaction.copy(isUploading = false)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -155,7 +155,6 @@ fun TxtRuleScreen(
|
||||
|
||||
if (showFilePickerSheet) {
|
||||
FilePickerSheet(
|
||||
sheetState = sheetState,
|
||||
onDismissRequest = { showFilePickerSheet = false },
|
||||
onSelectSysDir = {
|
||||
showFilePickerSheet = false
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -152,7 +152,6 @@ fun DictRuleScreen(
|
||||
|
||||
if (showFilePickerSheet) {
|
||||
FilePickerSheet(
|
||||
sheetState = sheetState,
|
||||
onDismissRequest = { showFilePickerSheet = false },
|
||||
onSelectSysDir = {
|
||||
showFilePickerSheet = false
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,42 @@
|
||||
package io.legado.app.ui.widget.components.button
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedIconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ToggleButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.legado.app.ui.config.themeConfig.ThemeConfig
|
||||
import io.legado.app.ui.widget.components.GlassTopAppBarDefaults
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
enum class TopBarButtonVariant {
|
||||
Filled, Outlined, Icon
|
||||
@@ -49,7 +64,11 @@ fun TopBarButton(
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
) {
|
||||
SmallIcon(imageVector, contentDescription)
|
||||
AnimatedIcon(
|
||||
modifier = Modifier.size(20.dp),
|
||||
imageVector = imageVector,
|
||||
contentDescription = contentDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +78,11 @@ fun TopBarButton(
|
||||
modifier = commonModifier,
|
||||
border = ButtonDefaults.outlinedButtonBorder()
|
||||
) {
|
||||
SmallIcon(imageVector, contentDescription)
|
||||
AnimatedIcon(
|
||||
modifier = Modifier.size(20.dp),
|
||||
imageVector = imageVector,
|
||||
contentDescription = contentDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +91,11 @@ fun TopBarButton(
|
||||
onClick = onClick,
|
||||
modifier = commonModifier
|
||||
) {
|
||||
SmallIcon(imageVector, contentDescription)
|
||||
AnimatedIcon(
|
||||
modifier = Modifier.size(20.dp),
|
||||
imageVector = imageVector,
|
||||
contentDescription = contentDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,7 +113,7 @@ fun TopbarNavigationButton(
|
||||
onClick = onClick,
|
||||
imageVector = imageVector,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier.padding(start = 12.dp, end = 4.dp),
|
||||
modifier = modifier.padding(horizontal = 12.dp),
|
||||
style = style
|
||||
)
|
||||
}
|
||||
@@ -118,8 +145,74 @@ fun TopBarActionButton(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun SmallIcon(imageVector: ImageVector, contentDescription: String?) {
|
||||
fun TopBarAnimatedActionButton(
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
iconChecked: ImageVector,
|
||||
iconUnchecked: ImageVector,
|
||||
activeText: String,
|
||||
inactiveText: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var showText by remember { mutableStateOf(false) }
|
||||
var lastCheckedState by remember { mutableStateOf(checked) }
|
||||
|
||||
LaunchedEffect(showText) {
|
||||
if (showText) {
|
||||
delay(1000)
|
||||
showText = false
|
||||
}
|
||||
}
|
||||
|
||||
ToggleButton(
|
||||
modifier = modifier.height(36.dp),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||
checked = checked,
|
||||
onCheckedChange = {
|
||||
lastCheckedState = it
|
||||
onCheckedChange(it)
|
||||
showText = true
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
|
||||
AnimatedContent(
|
||||
targetState = checked,
|
||||
label = "IconAnimation"
|
||||
) { targetChecked ->
|
||||
AnimatedIcon(
|
||||
modifier = Modifier.size(20.dp),
|
||||
imageVector = if (targetChecked) iconChecked else iconUnchecked,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showText
|
||||
) {
|
||||
Text(
|
||||
text = if (lastCheckedState) activeText else inactiveText,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
maxLines = 1,
|
||||
softWrap = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AnimatedIcon(
|
||||
imageVector: ImageVector,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = imageVector,
|
||||
transitionSpec = {
|
||||
@@ -131,7 +224,7 @@ private fun SmallIcon(imageVector: ImageVector, contentDescription: String?) {
|
||||
Icon(
|
||||
imageVector = targetIcon,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier.size(20.dp)
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
-3
@@ -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(
|
||||
|
||||
-1
@@ -139,7 +139,6 @@ fun <T> BatchImportDialog(
|
||||
|
||||
GlassModalBottomSheet(
|
||||
onDismissRequest = onDismissRequest,
|
||||
sheetState = sheetState,
|
||||
containerColor = { it.surfaceContainer }
|
||||
) {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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
@@ -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(
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user