[优化] 优化Compose界面动画与显示

This commit is contained in:
HapeLee
2026-01-08 01:59:22 +08:00
parent cc61742b5d
commit 7173cbfbe2
13 changed files with 439 additions and 394 deletions
+2
View File
@@ -384,5 +384,7 @@ dependencies {
implementation(libs.reorderable) implementation(libs.reorderable)
implementation(libs.material.kolor) implementation(libs.material.kolor)
implementation(libs.haze.core)
implementation(libs.haze.materials)
} }
@@ -6,10 +6,11 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Update import androidx.room.Update
import io.legado.app.data.entities.readRecord.ReadRecord
import io.legado.app.data.entities.ReadRecordShow 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.ReadRecordDetail
import io.legado.app.data.entities.readRecord.ReadRecordSession import io.legado.app.data.entities.readRecord.ReadRecordSession
import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface ReadRecordDao { interface ReadRecordDao {
@@ -26,8 +27,8 @@ interface ReadRecordDao {
) )
val allShow: List<ReadRecordShow> val allShow: List<ReadRecordShow>
@Query("select sum(readTime) from readRecord") @Query("SELECT sum(readTime) FROM readRecord")
suspend fun getTotalReadTime(): Long fun getTotalReadTime(): Flow<Long?>
@Query( @Query(
""" """
@@ -113,11 +114,11 @@ interface ReadRecordDao {
/** 获取所有 ReadRecord,按最后阅读时间倒序排列 */ /** 获取所有 ReadRecord,按最后阅读时间倒序排列 */
@Query("SELECT * FROM readRecord ORDER BY lastRead DESC") @Query("SELECT * FROM readRecord ORDER BY lastRead DESC")
suspend fun getAllReadRecordsSortedByLastRead(): List<ReadRecord> fun getAllReadRecordsSortedByLastRead(): Flow<List<ReadRecord>>
/** 搜索 ReadRecord,按最后阅读时间倒序排列 */ /** 搜索 ReadRecord,按最后阅读时间倒序排列 */
@Query("SELECT * FROM readRecord WHERE bookName LIKE '%' || :query || '%' ORDER BY lastRead DESC") @Query("SELECT * FROM readRecord WHERE bookName LIKE '%' || :query || '%' ORDER BY lastRead DESC")
suspend fun searchReadRecordsByLastRead(query: String): List<ReadRecord> fun searchReadRecordsByLastRead(query: String): Flow<List<ReadRecord>>
/** /**
* 获取某一天某一本书的所有会话记录 * 获取某一天某一本书的所有会话记录
@@ -150,10 +151,13 @@ interface ReadRecordDao {
fun deleteSessionByName(bookName: String) fun deleteSessionByName(bookName: String)
@Query("SELECT * FROM readRecordDetail ORDER BY date DESC, lastReadTime DESC") @Query("SELECT * FROM readRecordDetail ORDER BY date DESC, lastReadTime DESC")
suspend fun getAllDetails(): List<ReadRecordDetail> fun getAllDetails(): Flow<List<ReadRecordDetail>>
@Query("SELECT * FROM readRecordDetail WHERE bookName LIKE '%' || :query || '%' ORDER BY date DESC") @Query("SELECT * FROM readRecordDetail WHERE bookName LIKE '%' || :query || '%' ORDER BY date DESC, lastReadTime DESC")
suspend fun searchDetails(query: String): List<ReadRecordDetail> fun searchDetails(query: String): Flow<List<ReadRecordDetail>>
@Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId ORDER BY startTime ASC")
fun getAllSessions(deviceId: String): Flow<List<ReadRecordSession>>
@Delete @Delete
suspend fun deleteDetail(detail: ReadRecordDetail) suspend fun deleteDetail(detail: ReadRecordDetail)
@@ -1,9 +1,31 @@
package io.legado.app.data.repository package io.legado.app.data.repository
import io.legado.app.data.appDb import io.legado.app.data.appDb
import io.legado.app.data.dao.BookChapterDao
import io.legado.app.data.dao.BookDao
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class BookRepository { class BookRepository(
private val bookDao: BookDao,
private val bookChapterDao: BookChapterDao
) {
suspend fun getBookCoverByName(bookName: String): String? {
return withContext(Dispatchers.IO) {
bookDao.findByName(bookName).firstOrNull()?.getDisplayCover()
}
}
suspend fun getChapterTitle(bookName: String, chapterIndex: Int): String? {
return withContext(Dispatchers.IO) {
val book = bookDao.findByName(bookName).firstOrNull()
val bookUrl = book?.bookUrl
if (bookUrl.isNullOrEmpty()) return@withContext null
bookChapterDao.getChapterTitleByUrlAndIndex(bookUrl, chapterIndex)
}
}
suspend fun getBook(bookUrl: String): Book? { suspend fun getBook(bookUrl: String): Book? {
return appDb.bookDao.getBook(bookUrl) return appDb.bookDao.getBook(bookUrl)
@@ -1,12 +1,13 @@
package io.legado.app.data.repository package io.legado.app.data.repository
import androidx.room.Transaction
import cn.hutool.core.date.DatePattern import cn.hutool.core.date.DatePattern
import cn.hutool.core.date.DateUtil import cn.hutool.core.date.DateUtil
import io.legado.app.data.dao.ReadRecordDao import io.legado.app.data.dao.ReadRecordDao
import io.legado.app.data.entities.readRecord.ReadRecord import io.legado.app.data.entities.readRecord.ReadRecord
import io.legado.app.data.entities.readRecord.ReadRecordDetail import io.legado.app.data.entities.readRecord.ReadRecordDetail
import io.legado.app.data.entities.readRecord.ReadRecordSession import io.legado.app.data.entities.readRecord.ReadRecordSession
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.util.Date import java.util.Date
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@@ -16,11 +17,42 @@ class ReadRecordRepository(
) { ) {
private fun getCurrentDeviceId(): String = "" private fun getCurrentDeviceId(): String = ""
/**
* 获取总阅读时长流
*/
fun getTotalReadTime(): Flow<Long> {
return dao.getTotalReadTime().map { it ?: 0L }
}
/** /**
* 保存一个完整的阅读会话 * 根据搜索关键字获取最新的阅读书籍列表流
*/
fun getLatestReadRecords(query: String = ""): Flow<List<ReadRecord>> {
return if (query.isBlank()) {
dao.getAllReadRecordsSortedByLastRead()
} else {
dao.searchReadRecordsByLastRead(query)
}
}
/**
* 获取所有的每日统计详情流
*/
fun getAllRecordDetails(query: String = ""): Flow<List<ReadRecordDetail>> {
return if (query.isBlank()) {
dao.getAllDetails()
} else {
dao.searchDetails(query)
}
}
fun getAllSessions(): Flow<List<ReadRecordSession>> {
return dao.getAllSessions(getCurrentDeviceId())
}
/**
* 保存一个完整的阅读会话.
*/ */
@Transaction
suspend fun saveReadSession(newSession: ReadRecordSession) { suspend fun saveReadSession(newSession: ReadRecordSession) {
val segmentDuration = newSession.endTime - newSession.startTime val segmentDuration = newSession.endTime - newSession.startTime
dao.insertSession(newSession) dao.insertSession(newSession)
@@ -29,37 +61,28 @@ class ReadRecordRepository(
updateReadRecord(newSession, segmentDuration) updateReadRecord(newSession, segmentDuration)
} }
/**
* 更新总记录表 (ReadRecord)
* @param durationDelta 增加的时长
*/
private suspend fun updateReadRecord(session: ReadRecordSession, durationDelta: Long) { private suspend fun updateReadRecord(session: ReadRecordSession, durationDelta: Long) {
if (durationDelta <= 0) return if (durationDelta <= 0) return
val existingRecord = dao.getReadRecord(session.deviceId, session.bookName) val existingRecord = dao.getReadRecord(session.deviceId, session.bookName)
if (existingRecord != null) { if (existingRecord != null) {
val updatedRecord = existingRecord.copy( dao.update(
existingRecord.copy(
readTime = existingRecord.readTime + durationDelta, readTime = existingRecord.readTime + durationDelta,
lastRead = session.endTime lastRead = session.endTime
)
) )
dao.update(updatedRecord)
} else { } else {
val newRecord = ReadRecord( dao.insert(
ReadRecord(
deviceId = session.deviceId, deviceId = session.deviceId,
bookName = session.bookName, bookName = session.bookName,
readTime = durationDelta, readTime = durationDelta,
lastRead = session.endTime lastRead = session.endTime
)
) )
dao.insert(newRecord)
} }
} }
/**
* 更新每日详情表 (ReadRecordDetail)
* @param durationDelta 增加的时长
* @param wordsDelta 增加的字数
*/
private suspend fun updateReadRecordDetail( private suspend fun updateReadRecordDetail(
session: ReadRecordSession, session: ReadRecordSession,
durationDelta: Long, durationDelta: Long,
@@ -67,9 +90,7 @@ class ReadRecordRepository(
dateString: String dateString: String
) { ) {
if (durationDelta <= 0 && wordsDelta <= 0) return if (durationDelta <= 0 && wordsDelta <= 0) return
val existingDetail = dao.getDetail(session.deviceId, session.bookName, dateString) val existingDetail = dao.getDetail(session.deviceId, session.bookName, dateString)
if (existingDetail != null) { if (existingDetail != null) {
existingDetail.readTime += durationDelta existingDetail.readTime += durationDelta
existingDetail.readWords += wordsDelta existingDetail.readWords += wordsDelta
@@ -77,7 +98,8 @@ class ReadRecordRepository(
existingDetail.lastReadTime = max(existingDetail.lastReadTime, session.endTime) existingDetail.lastReadTime = max(existingDetail.lastReadTime, session.endTime)
dao.insertDetail(existingDetail) dao.insertDetail(existingDetail)
} else { } else {
val newDetail = ReadRecordDetail( dao.insertDetail(
ReadRecordDetail(
deviceId = session.deviceId, deviceId = session.deviceId,
bookName = session.bookName, bookName = session.bookName,
date = dateString, date = dateString,
@@ -85,53 +107,16 @@ class ReadRecordRepository(
readWords = wordsDelta, readWords = wordsDelta,
firstReadTime = session.startTime, firstReadTime = session.startTime,
lastReadTime = session.endTime lastReadTime = session.endTime
)
) )
dao.insertDetail(newDetail)
} }
} }
suspend fun getLatestReadRecords(query: String = ""): List<ReadRecord> {
return if (query.isBlank()) {
dao.getAllReadRecordsSortedByLastRead()
} else {
dao.searchReadRecordsByLastRead(query)
}
}
suspend fun getAllRecordDetails(query: String = ""): List<ReadRecordDetail> {
return if (query.isBlank()) {
dao.getAllDetails()
} else {
dao.searchDetails(query)
}
}
suspend fun getAllRecordDetailsByDate(dateString: String, query: String = ""): List<ReadRecordDetail> {
val deviceId = getCurrentDeviceId()
return if (query.isBlank()) {
dao.getDetailsByDate(deviceId, dateString)
} else {
dao.searchDetailsByDate(deviceId, dateString, query)
}
}
suspend fun getAllSessionsByDate(dateString: String): List<ReadRecordSession> {
val deviceId = getCurrentDeviceId()
return dao.getSessionsByDate(deviceId, dateString)
}
suspend fun deleteDetail(detail: ReadRecordDetail) { suspend fun deleteDetail(detail: ReadRecordDetail) {
dao.deleteDetail(detail) dao.deleteDetail(detail)
} }
suspend fun clearAll() { suspend fun clearAll() {
dao.clear() // 清除总表 dao.clear()
// dao.clearDetails()
} }
// 暴露总时长
suspend fun getTotalReadTime(): Long {
return dao.getTotalReadTime()
}
} }
+10 -21
View File
@@ -21,35 +21,24 @@ import org.koin.dsl.module
val appModule = module { val appModule = module {
viewModel { ReplaceEditViewModel(get(), get(), get()) }
viewModel { ReplaceRuleViewModel(androidApplication()) }
// ReadRecord
single { get<AppDatabase>().readRecordDao } single { get<AppDatabase>().readRecordDao }
single { get<AppDatabase>().bookDao } single { get<AppDatabase>().bookDao }
single { get<AppDatabase>().bookChapterDao } single { get<AppDatabase>().bookChapterDao }
single { ReadRecordRepository(get()) } single { ReadRecordRepository(get()) }
viewModel { ReadRecordViewModel(get(), get(), get()) }
single { BookRepository(get(), get()) }
single<UploadRepository> { DirectLinkUploadRepository() } single<UploadRepository> { DirectLinkUploadRepository() }
// Explore
single<ExploreRepository> { ExploreRepositoryImpl(get()) } single<ExploreRepository> { ExploreRepositoryImpl(get()) }
viewModel { ExploreShowViewModel(get()) }
// Bookmark
viewModel {
AllBookmarkViewModel(
androidApplication(),
get()
)
}
// Search
single { SearchContentRepository() } single { SearchContentRepository() }
single { BookRepository() }
viewModel { SearchContentViewModel(get(), get()) }
viewModel { ReadRecordViewModel(get(), get()) }
viewModel { ReplaceEditViewModel(get(), get(), get()) }
viewModel { ReplaceRuleViewModel(androidApplication()) }
viewModel { ExploreShowViewModel(get()) }
viewModel { SearchContentViewModel(get(), get()) }
viewModel { MyViewModel(get()) } viewModel { MyViewModel(get()) }
viewModel { AllBookmarkViewModel(androidApplication(), get()) }
} }
@@ -6,7 +6,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@@ -23,6 +22,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
@@ -55,6 +56,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -81,10 +83,21 @@ fun AllBookmarkScreen(
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val searchText by viewModel.searchQuery.collectAsState()
val uiState by viewModel.bookmarksState.collectAsState()
val collapsedGroups by viewModel.collapsedGroups.collectAsState()
val uiState by viewModel.uiState.collectAsState()
val contentState = when {
uiState.isLoading -> "LOADING"
uiState.bookmarks.isEmpty() -> "EMPTY"
else -> "CONTENT"
}
val searchText = uiState.searchQuery
val collapsedGroups = uiState.collapsedGroups
val bookmarksGrouped = uiState.bookmarks
val allKeys = bookmarksGrouped.keys
val isAllCollapsed =
allKeys.isNotEmpty() && allKeys.all { collapsedGroups.contains(it.toString()) }
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
var showSearch by remember { mutableStateOf(false) } var showSearch by remember { mutableStateOf(false) }
var editingBookmark by remember { mutableStateOf<Bookmark?>(null) } var editingBookmark by remember { mutableStateOf<Bookmark?>(null) }
@@ -92,10 +105,6 @@ fun AllBookmarkScreen(
var pendingExportIsMd by remember { mutableStateOf(false) } var pendingExportIsMd by remember { mutableStateOf(false) }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val bookmarksGrouped = (uiState as? BookmarkUiState.Success)?.bookmarks ?: emptyMap()
val allKeys = bookmarksGrouped.keys
val isAllCollapsed = allKeys.isNotEmpty() && allKeys.all { collapsedGroups.contains(it.toString()) }
val exportLauncher = rememberLauncherForActivityResult( val exportLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree() contract = ActivityResultContracts.OpenDocumentTree()
) { uri: Uri? -> ) { uri: Uri? ->
@@ -173,7 +182,9 @@ fun AllBookmarkScreen(
SearchBarSection( SearchBarSection(
query = searchText, query = searchText,
onQueryChange = { viewModel.onSearchQueryChanged(it) }, onQueryChange = { viewModel.onSearchQueryChanged(it) },
placeholder = "搜索..." placeholder = "搜索...",
scrollState = listState,
scope = scope
) )
} }
} }
@@ -185,11 +196,11 @@ fun AllBookmarkScreen(
.padding(paddingValues) .padding(paddingValues)
) { ) {
AnimatedContent( AnimatedContent(
targetState = uiState, targetState = contentState,
label = "bookmarkTransition" label = "bookmarkTransition"
) { state -> ) { state ->
when (state) { when (state) {
BookmarkUiState.Loading -> { "LOADING" -> {
EmptyMessageView( EmptyMessageView(
message = "加载中...", message = "加载中...",
isLoading = true, isLoading = true,
@@ -198,64 +209,55 @@ fun AllBookmarkScreen(
) )
} }
is BookmarkUiState.Success -> { "EMPTY" -> {
if (state.bookmarks.isEmpty()) {
EmptyMessageView(
message = "没有书签!",
modifier = Modifier
.fillMaxSize()
)
} else {
FastScrollLazyColumn(
modifier = Modifier.fillMaxSize()
) {
state.bookmarks.forEach { (headerKey, bookmarks) ->
val isCollapsed = collapsedGroups.contains(headerKey.toString()) EmptyMessageView(
message = "没有书签!",
modifier = Modifier
.fillMaxSize()
)
}
stickyHeader(key = "${Scroller.STICKY_HEADER_KEY_PREFIX}${headerKey}") { "CONTENT" -> {
BookAuthorHeader( FastScrollLazyColumn(
bookTitle = headerKey.bookName, state = listState,
bookAuthor = headerKey.bookAuthor, modifier = Modifier.fillMaxSize()
isCollapsed = isCollapsed, ) {
onToggle = { viewModel.toggleGroupCollapse(headerKey) } uiState.bookmarks.forEach { (headerKey, bookmarks) ->
) val isCollapsed = collapsedGroups.contains(headerKey.toString())
}
item(key = "content_${headerKey}") { stickyHeader(key = "${Scroller.STICKY_HEADER_KEY_PREFIX}${headerKey}") {
AnimatedVisibility( BookAuthorHeader(
visible = !isCollapsed, modifier = Modifier
enter = expandVertically() + fadeIn(), .animateItem()
exit = shrinkVertically() + fadeOut() .fillMaxWidth(),
) { bookTitle = headerKey.bookName,
Column( bookAuthor = headerKey.bookAuthor,
modifier = Modifier.animateContentSize() isCollapsed = isCollapsed,
) { onToggle = { viewModel.toggleGroupCollapse(headerKey) }
bookmarks.forEach { bookmark -> )
BookmarkItem( }
bookmark = bookmark,
modifier = Modifier if (!isCollapsed) {
.animateItem() items(
.fillMaxWidth(), items = bookmarks,
onClick = { key = { it.id }
editingBookmark = bookmark ) { bookmarkUi ->
showBottomSheet = true BookmarkItem(
} bookmark = bookmarkUi,
) modifier = Modifier
} .animateItem()
.fillMaxWidth(),
onClick = {
editingBookmark = bookmarkUi.rawBookmark
showBottomSheet = true
} }
} )
} }
} }
} }
} }
} }
is BookmarkUiState.Error -> {
EmptyMessageView(
message = state.throwable.localizedMessage ?: "发生错误",
)
}
} }
} }
} }
@@ -282,6 +284,7 @@ fun AllBookmarkScreen(
@Composable @Composable
fun BookAuthorHeader( fun BookAuthorHeader(
modifier: Modifier = Modifier,
bookTitle: String, bookTitle: String,
bookAuthor: String, bookAuthor: String,
isCollapsed: Boolean, isCollapsed: Boolean,
@@ -289,7 +292,7 @@ fun BookAuthorHeader(
) { ) {
Surface( Surface(
color = MaterialTheme.colorScheme.surfaceContainer, color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onToggle) .clickable(onClick = onToggle)
) { ) {
@@ -340,8 +343,8 @@ fun BookAuthorHeader(
@Composable @Composable
fun BookmarkItem( fun BookmarkItem(
bookmark: Bookmark, bookmark: BookmarkItemUi,
modifier: Modifier = Modifier, modifier: Modifier,
onClick: () -> Unit) { onClick: () -> Unit) {
Column( Column(
modifier = modifier modifier = modifier
@@ -2,6 +2,7 @@ package io.legado.app.ui.book.bookmark
import android.app.Application import android.app.Application
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.Immutable
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.legado.app.data.dao.BookmarkDao import io.legado.app.data.dao.BookmarkDao
@@ -14,18 +15,13 @@ import io.legado.app.utils.toastOnUi
import io.legado.app.utils.writeToOutputStream import io.legado.app.utils.writeToOutputStream
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -39,86 +35,130 @@ data class BookmarkGroupHeader(
override fun toString(): String = "$bookName|$bookAuthor" override fun toString(): String = "$bookName|$bookAuthor"
} }
sealed class BookmarkUiState { @Immutable
object Loading : BookmarkUiState() data class BookmarkItemUi(
data class Success(val bookmarks: Map<BookmarkGroupHeader, List<Bookmark>>) : BookmarkUiState() val id: Long,
data class Error(val throwable: Throwable) : BookmarkUiState() val content: String,
val chapterName: String,
val bookText: String,
val bookName: String,
val bookAuthor: String,
val rawBookmark: Bookmark
)
@Immutable
data class BookmarkUiState(
val isLoading: Boolean = false,
val bookmarks: Map<BookmarkGroupHeader, List<BookmarkItemUi>> = emptyMap(),
val error: Throwable? = null,
val searchQuery: String = "",
val collapsedGroups: Set<String> = emptySet()
)
sealed interface BookmarkListItem {
val key: String
data class Header(
val header: BookmarkGroupHeader,
val collapsed: Boolean
) : BookmarkListItem {
override val key = "header:${header.bookName}|${header.bookAuthor}"
}
data class Item(
val data: BookmarkItemUi
) : BookmarkListItem {
override val key = "item:${data.id}"
}
} }
class AllBookmarkViewModel( class AllBookmarkViewModel(
application: Application, application: Application,
private val bookmarkDao: BookmarkDao private val bookmarkDao: BookmarkDao
) : AndroidViewModel(application) { ) : AndroidViewModel(application) {
private val _searchQuery = MutableStateFlow("") private val _searchQuery = MutableStateFlow("")
val searchQuery = _searchQuery.asStateFlow()
private val _collapsedGroups = MutableStateFlow<Set<String>>(emptySet()) private val _collapsedGroups = MutableStateFlow<Set<String>>(emptySet())
val collapsedGroups = _collapsedGroups.asStateFlow()
@OptIn(ExperimentalCoroutinesApi::class)
val uiState: StateFlow<BookmarkUiState> = combine(
_searchQuery,
_collapsedGroups,
bookmarkDao.flowAll()
) { query, collapsed, allBookmarks ->
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) val filteredList = if (query.isBlank()) {
val bookmarksState: StateFlow<BookmarkUiState> = _searchQuery allBookmarks
.debounce(300L) } else {
.flatMapLatest { query -> allBookmarks.filter {
val flow = if (query.isBlank()) { it.bookName.contains(query, ignoreCase = true) ||
bookmarkDao.flowAll() it.content.contains(query, ignoreCase = true) ||
} else { it.bookAuthor.contains(query, ignoreCase = true)
bookmarkDao.flowSearchAll(query)
} }
flow.map<List<Bookmark>, BookmarkUiState> { list ->
BookmarkUiState.Success(list.groupBy { BookmarkGroupHeader(it.bookName, it.bookAuthor) })
}
.onStart { emit(BookmarkUiState.Loading) }
.catch { e ->
e.printStackTrace()
emit(BookmarkUiState.Error(e))
}
} }
.flowOn(Dispatchers.IO)
.stateIn( val grouped = filteredList.asSequence()
viewModelScope, .map { bookmark ->
SharingStarted.WhileSubscribed(5000), BookmarkItemUi(
BookmarkUiState.Loading id = bookmark.time,
content = bookmark.content,
chapterName = bookmark.chapterName,
bookText = bookmark.bookText,
bookName = bookmark.bookName,
bookAuthor = bookmark.bookAuthor,
rawBookmark = bookmark
)
}
.groupBy { item ->
BookmarkGroupHeader(item.bookName, item.bookAuthor)
}
BookmarkUiState(
isLoading = false,
bookmarks = grouped,
searchQuery = query,
collapsedGroups = collapsed
) )
}.catch { e ->
fun toggleGroupCollapse(groupKey: BookmarkGroupHeader) { emit(BookmarkUiState(isLoading = false, error = e))
val stringKey = groupKey.toString() }.stateIn(
val current = _collapsedGroups.value scope = viewModelScope,
if (current.contains(stringKey)) { started = SharingStarted.WhileSubscribed(5000),
_collapsedGroups.value = current - stringKey initialValue = BookmarkUiState(isLoading = true)
} else { )
_collapsedGroups.value = current + stringKey
}
}
fun toggleAllCollapse(currentKeys: Set<BookmarkGroupHeader>) {
val stringKeys = currentKeys.map { it.toString() }.toSet()
val currentCollapsed = _collapsedGroups.value
if (currentCollapsed.containsAll(stringKeys) && currentKeys.isNotEmpty()) {
_collapsedGroups.value = emptySet()
} else {
_collapsedGroups.value = stringKeys
}
}
fun onSearchQueryChanged(query: String) { fun onSearchQueryChanged(query: String) {
_searchQuery.value = query _searchQuery.value = query
} }
fun updateBookmark(bookmark: Bookmark) { fun toggleGroupCollapse(groupKey: BookmarkGroupHeader) {
viewModelScope.launch { val stringKey = groupKey.toString()
withContext(Dispatchers.IO) { _collapsedGroups.update { current ->
bookmarkDao.insert(bookmark) if (current.contains(stringKey)) current - stringKey else current + stringKey
}
}
fun toggleAllCollapse(currentKeys: Set<BookmarkGroupHeader>) {
val stringKeys = currentKeys.map { it.toString() }.toSet()
_collapsedGroups.update { current ->
if (current.containsAll(stringKeys) && stringKeys.isNotEmpty()) {
emptySet()
} else {
stringKeys
} }
} }
} }
fun updateBookmark(bookmark: Bookmark) {
viewModelScope.launch(Dispatchers.IO) {
bookmarkDao.insert(bookmark)
}
}
fun deleteBookmark(bookmark: Bookmark) { fun deleteBookmark(bookmark: Bookmark) {
viewModelScope.launch { viewModelScope.launch(Dispatchers.IO) {
withContext(Dispatchers.IO) { bookmarkDao.delete(bookmark)
bookmarkDao.delete(bookmark)
}
} }
} }
@@ -129,39 +169,46 @@ class AllBookmarkViewModel(
val dateFormat = SimpleDateFormat("yyMMddHHmmss", Locale.getDefault()) val dateFormat = SimpleDateFormat("yyMMddHHmmss", Locale.getDefault())
val suffix = if (isMarkdown) "md" else "json" val suffix = if (isMarkdown) "md" else "json"
val fileName = "bookmark-${dateFormat.format(Date())}.$suffix" val fileName = "bookmark-${dateFormat.format(Date())}.$suffix"
val dirDoc = FileDoc.fromUri(treeUri, true) val dirDoc = FileDoc.fromUri(treeUri, true)
val fileDoc = dirDoc.createFileIfNotExist(fileName) val fileDoc = dirDoc.createFileIfNotExist(fileName)
fileDoc.openOutputStream().getOrThrow().use { outputStream -> fileDoc.openOutputStream().getOrThrow().use { outputStream ->
val allData = bookmarkDao.all
if (isMarkdown) { if (isMarkdown) {
writeMarkdown(outputStream, bookmarkDao.all) writeMarkdown(outputStream, allData)
} else { } else {
GSON.writeToOutputStream(outputStream, bookmarkDao.all) GSON.writeToOutputStream(outputStream, allData)
} }
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
context.toastOnUi("导出成功") context.toastOnUi("导出成功: $fileName")
} }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
withContext(Dispatchers.Main) {
getApplication<Application>().toastOnUi("导出失败: ${e.message}")
}
} }
} }
} }
private fun writeMarkdown(outputStream: java.io.OutputStream, bookmarks: List<Bookmark>) { private fun writeMarkdown(outputStream: java.io.OutputStream, bookmarks: List<Bookmark>) {
var name = "" val sb = StringBuilder()
var author = "" var lastHeader = ""
bookmarks.forEach { bookmarks.forEach {
if (it.bookName != name && it.bookAuthor != author) { val currentHeader = "${it.bookName}|${it.bookAuthor}"
name = it.bookName if (currentHeader != lastHeader) {
author = it.bookAuthor lastHeader = currentHeader
outputStream.write("## ${it.bookName} ${it.bookAuthor}\n\n".toByteArray()) sb.append("\n## ${it.bookName} - ${it.bookAuthor}\n\n")
} }
outputStream.write("#### ${it.chapterName}\n\n".toByteArray()) sb.append("#### ${it.chapterName}\n")
outputStream.write("###### 原文\n ${it.bookText}\n\n".toByteArray()) sb.append("> **原文:** ${it.bookText}\n\n")
outputStream.write("###### 摘要\n ${it.content}\n\n".toByteArray()) sb.append("${it.content}\n\n")
sb.append("---\n")
} }
outputStream.write(sb.toString().toByteArray())
} }
} }
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -66,7 +67,6 @@ import io.legado.app.ui.widget.components.EmptyMessageView
import io.legado.app.ui.widget.components.SearchBarSection import io.legado.app.ui.widget.components.SearchBarSection
import io.legado.app.ui.widget.components.SectionHeader import io.legado.app.ui.widget.components.SectionHeader
import io.legado.app.utils.StringUtils.formatFriendlyDate import io.legado.app.utils.StringUtils.formatFriendlyDate
import kotlinx.coroutines.delay
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -83,20 +83,12 @@ fun ReadRecordScreen(
val displayMode by viewModel.displayMode.collectAsState() val displayMode by viewModel.displayMode.collectAsState()
var showSearch by remember { mutableStateOf(false) } var showSearch by remember { mutableStateOf(false) }
var showCalendar by remember { mutableStateOf(false) } var showCalendar by remember { mutableStateOf(false) }
var searchText by remember { mutableStateOf("") } val listState = rememberLazyListState()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
LaunchedEffect(showSearch) { LaunchedEffect(state.searchKey) {
if (!showSearch) { if (state.searchKey.isNullOrBlank()) {
viewModel.loadData("") listState.animateScrollToItem(0)
}
}
LaunchedEffect(searchText) {
if (showSearch && searchText.isNotBlank()) {
delay(100L)
viewModel.loadData(searchText)
} }
} }
@@ -154,8 +146,8 @@ fun ReadRecordScreen(
AnimatedVisibility(visible = showSearch) { AnimatedVisibility(visible = showSearch) {
SearchBarSection( SearchBarSection(
query = searchText, query = state.searchKey ?: "",
onQueryChange = { searchText = it } onQueryChange = { viewModel.setSearchKey(it) }
) )
} }
AnimatedVisibility(visible = showCalendar) { AnimatedVisibility(visible = showCalendar) {
@@ -192,7 +184,8 @@ fun ReadRecordScreen(
"LOADING" -> { "LOADING" -> {
EmptyMessageView( EmptyMessageView(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
message = "加载中" message = "加载中",
isLoading = true
) )
} }
@@ -205,6 +198,7 @@ fun ReadRecordScreen(
"CONTENT" -> { "CONTENT" -> {
LazyColumn( LazyColumn(
state = listState,
modifier = Modifier modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection) .nestedScroll(scrollBehavior.nestedScrollConnection)
) { ) {
@@ -2,20 +2,24 @@ package io.legado.app.ui.book.readRecord
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.legado.app.data.dao.BookChapterDao import cn.hutool.core.date.DateUtil
import io.legado.app.data.dao.BookDao
import io.legado.app.data.entities.readRecord.ReadRecord import io.legado.app.data.entities.readRecord.ReadRecord
import io.legado.app.data.entities.readRecord.ReadRecordDetail import io.legado.app.data.entities.readRecord.ReadRecordDetail
import io.legado.app.data.entities.readRecord.ReadRecordSession import io.legado.app.data.entities.readRecord.ReadRecordSession
import io.legado.app.data.repository.BookRepository
import io.legado.app.data.repository.ReadRecordRepository import io.legado.app.data.repository.ReadRecordRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Date
data class ReadRecordUiState( data class ReadRecordUiState(
val isLoading: Boolean = true, val isLoading: Boolean = true,
@@ -26,7 +30,8 @@ data class ReadRecordUiState(
val timelineRecords: Map<String, List<ReadRecordSession>> = emptyMap(), val timelineRecords: Map<String, List<ReadRecordSession>> = emptyMap(),
//最后阅读列表 //最后阅读列表
val latestRecords: List<ReadRecord> = emptyList(), val latestRecords: List<ReadRecord> = emptyList(),
val selectedDate: LocalDate? = null val selectedDate: LocalDate? = null,
val searchKey: String? = null,
) )
enum class DisplayMode { enum class DisplayMode {
@@ -35,18 +40,75 @@ enum class DisplayMode {
LATEST LATEST
} }
@OptIn(ExperimentalCoroutinesApi::class)
class ReadRecordViewModel( class ReadRecordViewModel(
private val repository: ReadRecordRepository, private val repository: ReadRecordRepository,
private val bookDao: BookDao, private val bookRepository: BookRepository
private val bookChapterDao: BookChapterDao
) : ViewModel() { ) : ViewModel() {
private val _displayMode = MutableStateFlow(DisplayMode.AGGREGATE) private val _displayMode = MutableStateFlow(DisplayMode.AGGREGATE)
val displayMode = _displayMode.asStateFlow() val displayMode = _displayMode.asStateFlow()
private val _uiState = MutableStateFlow(ReadRecordUiState())
val uiState = _uiState.asStateFlow()
init { private val _searchKey = MutableStateFlow("")
loadData() private val _selectedDate = MutableStateFlow<LocalDate?>(null)
// 数据源:直接从 Repository 获取 Flow
@OptIn(ExperimentalCoroutinesApi::class)
private val loadedDataFlow = _searchKey
.flatMapLatest { query ->
combine(
repository.getAllRecordDetails(query),
repository.getLatestReadRecords(query),
repository.getAllSessions(),
repository.getTotalReadTime()
) { details, latest, sessions, totalTime ->
LoadedData(totalTime, details, latest, sessions)
}
}
val uiState: StateFlow<ReadRecordUiState> = combine(
loadedDataFlow,
_selectedDate,
_searchKey
) { data, selectedDate, searchKey ->
val dateStr = selectedDate?.format(DateTimeFormatter.ISO_LOCAL_DATE)
val filteredDetails = if (dateStr != null) {
data.details.filter { it.date == dateStr }
} else data.details
val timelineMap = data.sessions
.asSequence()
.filter { session ->
val sDate = DateUtil.format(Date(session.startTime), "yyyy-MM-dd")
(dateStr == null || sDate == dateStr) &&
(searchKey.isEmpty() || session.bookName.contains(
searchKey,
ignoreCase = true
))
}
.groupBy { DateUtil.format(Date(it.startTime), "yyyy-MM-dd") }
.mapValues { (_, sessions) ->
mergeContinuousSessions(sessions).reversed()
}
ReadRecordUiState(
isLoading = false,
totalReadTime = data.totalReadTime,
groupedRecords = filteredDetails.groupBy { it.date },
timelineRecords = timelineMap,
latestRecords = data.latestRecords,
selectedDate = selectedDate,
searchKey = searchKey
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = ReadRecordUiState(isLoading = true)
)
fun setSearchKey(query: String) {
_searchKey.value = query
} }
fun setDisplayMode(mode: DisplayMode) { fun setDisplayMode(mode: DisplayMode) {
@@ -54,132 +116,44 @@ class ReadRecordViewModel(
} }
fun setSelectedDate(date: LocalDate?) { fun setSelectedDate(date: LocalDate?) {
_uiState.update { it.copy(selectedDate = date) } _selectedDate.value = date
loadData()
}
fun loadData(query: String = "") {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val selectedDate = _uiState.value.selectedDate
if (selectedDate != null) {
//筛选特定日期
val dateString = selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE)
//某一天所有书籍的明细
val dailyDetails = repository.getAllRecordDetailsByDate(dateString, query)
val grouped = dailyDetails.groupBy { it.date }
//某一天所有会话
val allSessions = repository.getAllSessionsByDate(dateString)
val mergedSessions = mergeContinuousSessions(allSessions).reversed()
val timelineMap = mapOf(dateString to mergedSessions)
//LATEST
val latest = repository.getLatestReadRecords(query)
val totalTime = withContext(Dispatchers.IO) { repository.getTotalReadTime() }
_uiState.update {
it.copy(
isLoading = false,
totalReadTime = totalTime,
groupedRecords = grouped,
timelineRecords = timelineMap,
latestRecords = latest
)
}
} else {
val details = repository.getAllRecordDetails(query)
val grouped = details.groupBy { it.date }
val uniqueDates = grouped.keys.toList()
val timelineMap = LinkedHashMap<String, List<ReadRecordSession>>()
for (date in uniqueDates) {
val rawSessions = repository.getAllSessionsByDate(date)
val mergedSessions = mergeContinuousSessions(rawSessions).reversed()
timelineMap[date] = mergedSessions
}
val latest = repository.getLatestReadRecords(query)
val totalTime = withContext(Dispatchers.IO) { repository.getTotalReadTime() }
_uiState.update {
it.copy(
isLoading = false,
totalReadTime = totalTime,
groupedRecords = grouped,
timelineRecords = timelineMap,
latestRecords = latest
)
}
}
}
}
/**
* 遍历会话列表,合并同一本书连续阅读且间隔不超过20分钟的会话。
* 只保留最晚结束的那条会话(更新其结束时间)。
* @param sessions 原始 ReadRecordSession 列表,按开始时间排序。
* @return 合并后的 ReadRecordSession 列表。
*/
fun mergeContinuousSessions(sessions: List<ReadRecordSession>): List<ReadRecordSession> {
if (sessions.isEmpty()) return emptyList()
val mergedList = mutableListOf<ReadRecordSession>()
mergedList.add(sessions.first().copy())
val twentyMinutesInMillis = 2 * 60 * 1000L
for (i in 1 until sessions.size) {
val currentSession = sessions[i]
val lastMergedSession = mergedList.last()
val isSameBook = currentSession.bookName == lastMergedSession.bookName
val timeGap = currentSession.startTime - lastMergedSession.endTime
val isContinuous = timeGap <= twentyMinutesInMillis && timeGap >= 0
if (isSameBook && isContinuous) {
mergedList.removeAt(mergedList.lastIndex)
val updatedSession = lastMergedSession.copy(
endTime = currentSession.endTime
)
mergedList.add(updatedSession)
} else {
mergedList.add(currentSession.copy())
}
}
return mergedList
}
suspend fun getChapterTitle(
bookName: String,
chapterIndexLong: Long
): String? {
val chapterIndex = chapterIndexLong.toInt()
val book = withContext(Dispatchers.IO) {
bookDao.findByName(bookName).firstOrNull()
}
val bookUrl = book?.bookUrl
if (bookUrl.isNullOrEmpty()) {
return null
}
return withContext(Dispatchers.IO) {
bookChapterDao.getChapterTitleByUrlAndIndex(bookUrl, chapterIndex)
}
} }
fun deleteDetail(detail: ReadRecordDetail) { fun deleteDetail(detail: ReadRecordDetail) {
viewModelScope.launch { viewModelScope.launch { repository.deleteDetail(detail) }
repository.deleteDetail(detail) }
loadData()
private fun mergeContinuousSessions(sessions: List<ReadRecordSession>): List<ReadRecordSession> {
if (sessions.isEmpty()) return emptyList()
val mergedList = mutableListOf<ReadRecordSession>()
mergedList.add(sessions.first().copy())
val gapLimit = 20 * 60 * 1000L
for (i in 1 until sessions.size) {
val current = sessions[i]
val last = mergedList.last()
if (current.bookName == last.bookName && (current.startTime - last.endTime) <= gapLimit) {
mergedList[mergedList.lastIndex] = last.copy(endTime = current.endTime)
} else {
mergedList.add(current.copy())
}
} }
return mergedList
}
suspend fun getChapterTitle(bookName: String, chapterIndexLong: Long): String? {
return bookRepository.getChapterTitle(bookName, chapterIndexLong.toInt())
} }
suspend fun getBookCover(bookName: String): String? { suspend fun getBookCover(bookName: String): String? {
return withContext(Dispatchers.IO) { return bookRepository.getBookCoverByName(bookName)
bookDao.findByName(bookName).firstOrNull()?.getDisplayCover()
}
} }
private data class LoadedData(
val totalReadTime: Long,
val details: List<ReadRecordDetail>,
val latestRecords: List<ReadRecord>,
val sessions: List<ReadRecordSession>
)
} }
@@ -36,7 +36,6 @@ internal object TransparentColorScheme : BaseColorScheme() {
inverseSurface = Color(0xFF333027), inverseSurface = Color(0xFF333027),
inverseOnSurface = Color(0xFFF6F0E2), inverseOnSurface = Color(0xFFF6F0E2),
inversePrimary = Color(0xA0FFFFFF), inversePrimary = Color(0xA0FFFFFF),
// Material 3.1+ Fixed Colors
primaryFixed = Color(0xA0FFFFFF), primaryFixed = Color(0xA0FFFFFF),
onPrimaryFixed = Color(0xFF211B00), onPrimaryFixed = Color(0xFF211B00),
primaryFixedDim = Color(0xB0FFFFFF), primaryFixedDim = Color(0xB0FFFFFF),
@@ -52,7 +51,7 @@ internal object TransparentColorScheme : BaseColorScheme() {
surfaceDim = Color(0x1AFFFFFF), surfaceDim = Color(0x1AFFFFFF),
surfaceBright = Color(0x33FFFFFF), surfaceBright = Color(0x33FFFFFF),
surfaceContainerLowest = Color(0x0DFFFFFF), surfaceContainerLowest = Color(0x0DFFFFFF),
surfaceContainerLow = Color(0x1AFFFFFF), surfaceContainerLow = Color(0x8FFFFFFF),
surfaceContainer = Color(0x33FFFFFF), surfaceContainer = Color(0x33FFFFFF),
surfaceContainerHigh = Color(0x4DFFFFFF), surfaceContainerHigh = Color(0x4DFFFFFF),
surfaceContainerHighest = Color(0x66FFFFFF), surfaceContainerHighest = Color(0x66FFFFFF),
@@ -102,7 +101,7 @@ internal object TransparentColorScheme : BaseColorScheme() {
surfaceDim = Color(0x1AFFFFFF), surfaceDim = Color(0x1AFFFFFF),
surfaceBright = Color(0x33FFFFFF), surfaceBright = Color(0x33FFFFFF),
surfaceContainerLowest = Color(0x0DFFFFFF), surfaceContainerLowest = Color(0x0DFFFFFF),
surfaceContainerLow = Color(0x1AFFFFFF), surfaceContainerLow = Color(0x8F000000),
surfaceContainer = Color(0x33FFFFFF), surfaceContainer = Color(0x33FFFFFF),
surfaceContainerHigh = Color(0x4DFFFFFF), surfaceContainerHigh = Color(0x4DFFFFFF),
surfaceContainerHighest = Color(0x66FFFFFF), surfaceContainerHighest = Color(0x66FFFFFF),
@@ -9,8 +9,8 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material3.ContainedLoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -50,7 +50,7 @@ fun EmptyMessageView(
label = "LoadingStateAnimation" label = "LoadingStateAnimation"
) { loading -> ) { loading ->
if (loading) { if (loading) {
LoadingIndicator() ContainedLoadingIndicator()
} else { } else {
AnimatedTextLine( AnimatedTextLine(
text = currentFace, text = currentFace,
@@ -1,6 +1,7 @@
package io.legado.app.ui.widget.components package io.legado.app.ui.widget.components
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
@@ -9,10 +10,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.VerticalAlignTop
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -23,14 +26,18 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -39,13 +46,19 @@ fun SearchBarSection(
onQueryChange: (String) -> Unit, onQueryChange: (String) -> Unit,
placeholder: String = "搜索书名", placeholder: String = "搜索书名",
backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainerLow, backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainerLow,
//外部定义末尾的图标按钮 scrollState: LazyListState? = null,
scope: CoroutineScope = rememberCoroutineScope(),
trailingIcon: @Composable (() -> Unit)? = null, trailingIcon: @Composable (() -> Unit)? = null,
//外部定义下拉菜单的内容
dropdownMenu: (@Composable (onDismiss: () -> Unit) -> Unit)? = null dropdownMenu: (@Composable (onDismiss: () -> Unit) -> Unit)? = null
) { ) {
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
val showScrollToTop by remember(scrollState) {
derivedStateOf {
(scrollState?.firstVisibleItemIndex ?: 0) > 0
}
}
Surface( Surface(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -64,28 +77,38 @@ fun SearchBarSection(
trailingIcon = { trailingIcon = {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(end = 4.dp) modifier = Modifier
.padding(end = 4.dp)
.animateContentSize()
) { ) {
AnimatedVisibility( AnimatedVisibility(
visible = query.isNotEmpty(), visible = query.isNotEmpty(),
enter = fadeIn() + scaleIn(initialScale = 0.8f), enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut(targetScale = 0.8f) exit = fadeOut() + scaleOut()
) { ) {
IconButton(onClick = { IconButton(onClick = { onQueryChange("") }) {
onQueryChange("")
}) {
Icon(Icons.Default.Clear, "清空输入") Icon(Icons.Default.Clear, "清空输入")
} }
} }
AnimatedVisibility(
visible = showScrollToTop,
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut()
) {
IconButton(onClick = {
scope.launch { scrollState?.animateScrollToItem(0) }
}) {
Icon(Icons.Default.VerticalAlignTop, "回到顶部")
}
}
// 外部自定义按钮
if (trailingIcon != null) { if (trailingIcon != null) {
Box { Box {
IconButton(onClick = { IconButton(onClick = { if (dropdownMenu != null) showMenu = true }) {
if (dropdownMenu != null) showMenu = true
}) {
trailingIcon() trailingIcon()
} }
if (dropdownMenu != null) { if (dropdownMenu != null) {
DropdownMenu( DropdownMenu(
expanded = showMenu, expanded = showMenu,
+3
View File
@@ -75,6 +75,7 @@ koin-bom = "4.1.1"
biometricKtx = "1.1.0" biometricKtx = "1.1.0"
reorderable = "3.0.0" reorderable = "3.0.0"
haze = "1.7.1"
[libraries] [libraries]
accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanistWebview" } accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanistWebview" }
@@ -109,6 +110,8 @@ androidx-startup-runtime = { module = "androidx.startup:startup-runtime", versio
appcompat-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } appcompat-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
haze-core = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" } koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" }
koin-core = { module = "io.insert-koin:koin-core" } koin-core = { module = "io.insert-koin:koin-core" }
koin-android = { group = "io.insert-koin", name = "koin-android" } koin-android = { group = "io.insert-koin", name = "koin-android" }