[优化] 界面和性能优化

This commit is contained in:
HapeLee
2026-03-05 01:23:47 +08:00
parent bede06bf46
commit 16b8164943
37 changed files with 788 additions and 424 deletions
@@ -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
@@ -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(
@@ -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
)
}
}
@@ -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(
@@ -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
)
}
}
}
}
}
@@ -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
) {
@@ -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
)