[优化] 优化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
@@ -6,10 +6,11 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
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.readRecord.ReadRecord
import io.legado.app.data.entities.readRecord.ReadRecordDetail
import io.legado.app.data.entities.readRecord.ReadRecordSession
import kotlinx.coroutines.flow.Flow
@Dao
interface ReadRecordDao {
@@ -26,8 +27,8 @@ interface ReadRecordDao {
)
val allShow: List<ReadRecordShow>
@Query("select sum(readTime) from readRecord")
suspend fun getTotalReadTime(): Long
@Query("SELECT sum(readTime) FROM readRecord")
fun getTotalReadTime(): Flow<Long?>
@Query(
"""
@@ -113,11 +114,11 @@ interface ReadRecordDao {
/** 获取所有 ReadRecord,按最后阅读时间倒序排列 */
@Query("SELECT * FROM readRecord ORDER BY lastRead DESC")
suspend fun getAllReadRecordsSortedByLastRead(): List<ReadRecord>
fun getAllReadRecordsSortedByLastRead(): Flow<List<ReadRecord>>
/** 搜索 ReadRecord,按最后阅读时间倒序排列 */
@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)
@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")
suspend fun searchDetails(query: String): List<ReadRecordDetail>
@Query("SELECT * FROM readRecordDetail WHERE bookName LIKE '%' || :query || '%' ORDER BY date DESC, lastReadTime DESC")
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
suspend fun deleteDetail(detail: ReadRecordDetail)
@@ -1,9 +1,31 @@
package io.legado.app.data.repository
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 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? {
return appDb.bookDao.getBook(bookUrl)
@@ -1,12 +1,13 @@
package io.legado.app.data.repository
import androidx.room.Transaction
import cn.hutool.core.date.DatePattern
import cn.hutool.core.date.DateUtil
import io.legado.app.data.dao.ReadRecordDao
import io.legado.app.data.entities.readRecord.ReadRecord
import io.legado.app.data.entities.readRecord.ReadRecordDetail
import io.legado.app.data.entities.readRecord.ReadRecordSession
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.util.Date
import kotlin.math.max
import kotlin.math.min
@@ -16,11 +17,42 @@ class ReadRecordRepository(
) {
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) {
val segmentDuration = newSession.endTime - newSession.startTime
dao.insertSession(newSession)
@@ -29,37 +61,28 @@ class ReadRecordRepository(
updateReadRecord(newSession, segmentDuration)
}
/**
* 更新总记录表 (ReadRecord)
* @param durationDelta 增加的时长
*/
private suspend fun updateReadRecord(session: ReadRecordSession, durationDelta: Long) {
if (durationDelta <= 0) return
val existingRecord = dao.getReadRecord(session.deviceId, session.bookName)
if (existingRecord != null) {
val updatedRecord = existingRecord.copy(
dao.update(
existingRecord.copy(
readTime = existingRecord.readTime + durationDelta,
lastRead = session.endTime
)
)
dao.update(updatedRecord)
} else {
val newRecord = ReadRecord(
dao.insert(
ReadRecord(
deviceId = session.deviceId,
bookName = session.bookName,
readTime = durationDelta,
lastRead = session.endTime
)
)
dao.insert(newRecord)
}
}
/**
* 更新每日详情表 (ReadRecordDetail)
* @param durationDelta 增加的时长
* @param wordsDelta 增加的字数
*/
private suspend fun updateReadRecordDetail(
session: ReadRecordSession,
durationDelta: Long,
@@ -67,9 +90,7 @@ class ReadRecordRepository(
dateString: String
) {
if (durationDelta <= 0 && wordsDelta <= 0) return
val existingDetail = dao.getDetail(session.deviceId, session.bookName, dateString)
if (existingDetail != null) {
existingDetail.readTime += durationDelta
existingDetail.readWords += wordsDelta
@@ -77,7 +98,8 @@ class ReadRecordRepository(
existingDetail.lastReadTime = max(existingDetail.lastReadTime, session.endTime)
dao.insertDetail(existingDetail)
} else {
val newDetail = ReadRecordDetail(
dao.insertDetail(
ReadRecordDetail(
deviceId = session.deviceId,
bookName = session.bookName,
date = dateString,
@@ -85,53 +107,16 @@ class ReadRecordRepository(
readWords = wordsDelta,
firstReadTime = session.startTime,
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) {
dao.deleteDetail(detail)
}
suspend fun clearAll() {
dao.clear() // 清除总表
// dao.clearDetails()
dao.clear()
}
// 暴露总时长
suspend fun getTotalReadTime(): Long {
return dao.getTotalReadTime()
}
}
+10 -21
View File
@@ -21,35 +21,24 @@ import org.koin.dsl.module
val appModule = module {
viewModel { ReplaceEditViewModel(get(), get(), get()) }
viewModel { ReplaceRuleViewModel(androidApplication()) }
// ReadRecord
single { get<AppDatabase>().readRecordDao }
single { get<AppDatabase>().bookDao }
single { get<AppDatabase>().bookChapterDao }
single { ReadRecordRepository(get()) }
viewModel { ReadRecordViewModel(get(), get(), get()) }
single { BookRepository(get(), get()) }
single<UploadRepository> { DirectLinkUploadRepository() }
// Explore
single<ExploreRepository> { ExploreRepositoryImpl(get()) }
viewModel { ExploreShowViewModel(get()) }
// Bookmark
viewModel {
AllBookmarkViewModel(
androidApplication(),
get()
)
}
// Search
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 { AllBookmarkViewModel(androidApplication(), get()) }
}
@@ -6,7 +6,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandVertically
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.navigationBarsPadding
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.automirrored.filled.ArrowBack
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -81,10 +83,21 @@ fun AllBookmarkScreen(
onBack: () -> Unit,
) {
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 showSearch by remember { mutableStateOf(false) }
var editingBookmark by remember { mutableStateOf<Bookmark?>(null) }
@@ -92,10 +105,6 @@ fun AllBookmarkScreen(
var pendingExportIsMd by remember { mutableStateOf(false) }
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(
contract = ActivityResultContracts.OpenDocumentTree()
) { uri: Uri? ->
@@ -173,7 +182,9 @@ fun AllBookmarkScreen(
SearchBarSection(
query = searchText,
onQueryChange = { viewModel.onSearchQueryChanged(it) },
placeholder = "搜索..."
placeholder = "搜索...",
scrollState = listState,
scope = scope
)
}
}
@@ -185,11 +196,11 @@ fun AllBookmarkScreen(
.padding(paddingValues)
) {
AnimatedContent(
targetState = uiState,
targetState = contentState,
label = "bookmarkTransition"
) { state ->
when (state) {
BookmarkUiState.Loading -> {
"LOADING" -> {
EmptyMessageView(
message = "加载中...",
isLoading = true,
@@ -198,64 +209,55 @@ fun AllBookmarkScreen(
)
}
is BookmarkUiState.Success -> {
if (state.bookmarks.isEmpty()) {
EmptyMessageView(
message = "没有书签!",
modifier = Modifier
.fillMaxSize()
)
} else {
FastScrollLazyColumn(
modifier = Modifier.fillMaxSize()
) {
state.bookmarks.forEach { (headerKey, bookmarks) ->
"EMPTY" -> {
val isCollapsed = collapsedGroups.contains(headerKey.toString())
EmptyMessageView(
message = "没有书签!",
modifier = Modifier
.fillMaxSize()
)
}
stickyHeader(key = "${Scroller.STICKY_HEADER_KEY_PREFIX}${headerKey}") {
BookAuthorHeader(
bookTitle = headerKey.bookName,
bookAuthor = headerKey.bookAuthor,
isCollapsed = isCollapsed,
onToggle = { viewModel.toggleGroupCollapse(headerKey) }
)
}
"CONTENT" -> {
FastScrollLazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
uiState.bookmarks.forEach { (headerKey, bookmarks) ->
val isCollapsed = collapsedGroups.contains(headerKey.toString())
item(key = "content_${headerKey}") {
AnimatedVisibility(
visible = !isCollapsed,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
Column(
modifier = Modifier.animateContentSize()
) {
bookmarks.forEach { bookmark ->
BookmarkItem(
bookmark = bookmark,
modifier = Modifier
.animateItem()
.fillMaxWidth(),
onClick = {
editingBookmark = bookmark
showBottomSheet = true
}
)
}
stickyHeader(key = "${Scroller.STICKY_HEADER_KEY_PREFIX}${headerKey}") {
BookAuthorHeader(
modifier = Modifier
.animateItem()
.fillMaxWidth(),
bookTitle = headerKey.bookName,
bookAuthor = headerKey.bookAuthor,
isCollapsed = isCollapsed,
onToggle = { viewModel.toggleGroupCollapse(headerKey) }
)
}
if (!isCollapsed) {
items(
items = bookmarks,
key = { it.id }
) { bookmarkUi ->
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
fun BookAuthorHeader(
modifier: Modifier = Modifier,
bookTitle: String,
bookAuthor: String,
isCollapsed: Boolean,
@@ -289,7 +292,7 @@ fun BookAuthorHeader(
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onToggle)
) {
@@ -340,8 +343,8 @@ fun BookAuthorHeader(
@Composable
fun BookmarkItem(
bookmark: Bookmark,
modifier: Modifier = Modifier,
bookmark: BookmarkItemUi,
modifier: Modifier,
onClick: () -> Unit) {
Column(
modifier = modifier
@@ -2,6 +2,7 @@ package io.legado.app.ui.book.bookmark
import android.app.Application
import android.net.Uri
import androidx.compose.runtime.Immutable
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import io.legado.app.data.dao.BookmarkDao
@@ -14,18 +15,13 @@ import io.legado.app.utils.toastOnUi
import io.legado.app.utils.writeToOutputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
@@ -39,86 +35,130 @@ data class BookmarkGroupHeader(
override fun toString(): String = "$bookName|$bookAuthor"
}
sealed class BookmarkUiState {
object Loading : BookmarkUiState()
data class Success(val bookmarks: Map<BookmarkGroupHeader, List<Bookmark>>) : BookmarkUiState()
data class Error(val throwable: Throwable) : BookmarkUiState()
@Immutable
data class BookmarkItemUi(
val id: Long,
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(
application: Application,
private val bookmarkDao: BookmarkDao
) : AndroidViewModel(application) {
private val _searchQuery = MutableStateFlow("")
val searchQuery = _searchQuery.asStateFlow()
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 bookmarksState: StateFlow<BookmarkUiState> = _searchQuery
.debounce(300L)
.flatMapLatest { query ->
val flow = if (query.isBlank()) {
bookmarkDao.flowAll()
} else {
bookmarkDao.flowSearchAll(query)
val filteredList = if (query.isBlank()) {
allBookmarks
} else {
allBookmarks.filter {
it.bookName.contains(query, ignoreCase = true) ||
it.content.contains(query, ignoreCase = true) ||
it.bookAuthor.contains(query, ignoreCase = true)
}
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(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
BookmarkUiState.Loading
val grouped = filteredList.asSequence()
.map { bookmark ->
BookmarkItemUi(
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
)
fun toggleGroupCollapse(groupKey: BookmarkGroupHeader) {
val stringKey = groupKey.toString()
val current = _collapsedGroups.value
if (current.contains(stringKey)) {
_collapsedGroups.value = current - stringKey
} 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
}
}
}.catch { e ->
emit(BookmarkUiState(isLoading = false, error = e))
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = BookmarkUiState(isLoading = true)
)
fun onSearchQueryChanged(query: String) {
_searchQuery.value = query
}
fun updateBookmark(bookmark: Bookmark) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
bookmarkDao.insert(bookmark)
fun toggleGroupCollapse(groupKey: BookmarkGroupHeader) {
val stringKey = groupKey.toString()
_collapsedGroups.update { current ->
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) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
bookmarkDao.delete(bookmark)
}
viewModelScope.launch(Dispatchers.IO) {
bookmarkDao.delete(bookmark)
}
}
@@ -129,39 +169,46 @@ class AllBookmarkViewModel(
val dateFormat = SimpleDateFormat("yyMMddHHmmss", Locale.getDefault())
val suffix = if (isMarkdown) "md" else "json"
val fileName = "bookmark-${dateFormat.format(Date())}.$suffix"
val dirDoc = FileDoc.fromUri(treeUri, true)
val fileDoc = dirDoc.createFileIfNotExist(fileName)
fileDoc.openOutputStream().getOrThrow().use { outputStream ->
val allData = bookmarkDao.all
if (isMarkdown) {
writeMarkdown(outputStream, bookmarkDao.all)
writeMarkdown(outputStream, allData)
} else {
GSON.writeToOutputStream(outputStream, bookmarkDao.all)
GSON.writeToOutputStream(outputStream, allData)
}
}
withContext(Dispatchers.Main) {
context.toastOnUi("导出成功")
context.toastOnUi("导出成功: $fileName")
}
} catch (e: Exception) {
e.printStackTrace()
withContext(Dispatchers.Main) {
getApplication<Application>().toastOnUi("导出失败: ${e.message}")
}
}
}
}
private fun writeMarkdown(outputStream: java.io.OutputStream, bookmarks: List<Bookmark>) {
var name = ""
var author = ""
val sb = StringBuilder()
var lastHeader = ""
bookmarks.forEach {
if (it.bookName != name && it.bookAuthor != author) {
name = it.bookName
author = it.bookAuthor
outputStream.write("## ${it.bookName} ${it.bookAuthor}\n\n".toByteArray())
val currentHeader = "${it.bookName}|${it.bookAuthor}"
if (currentHeader != lastHeader) {
lastHeader = currentHeader
sb.append("\n## ${it.bookName} - ${it.bookAuthor}\n\n")
}
outputStream.write("#### ${it.chapterName}\n\n".toByteArray())
outputStream.write("###### 原文\n ${it.bookText}\n\n".toByteArray())
outputStream.write("###### 摘要\n ${it.content}\n\n".toByteArray())
sb.append("#### ${it.chapterName}\n")
sb.append("> **原文:** ${it.bookText}\n\n")
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.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
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.SectionHeader
import io.legado.app.utils.StringUtils.formatFriendlyDate
import kotlinx.coroutines.delay
import org.koin.androidx.compose.koinViewModel
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@@ -83,20 +83,12 @@ fun ReadRecordScreen(
val displayMode by viewModel.displayMode.collectAsState()
var showSearch by remember { mutableStateOf(false) }
var showCalendar by remember { mutableStateOf(false) }
var searchText by remember { mutableStateOf("") }
val listState = rememberLazyListState()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
LaunchedEffect(showSearch) {
if (!showSearch) {
viewModel.loadData("")
}
}
LaunchedEffect(searchText) {
if (showSearch && searchText.isNotBlank()) {
delay(100L)
viewModel.loadData(searchText)
LaunchedEffect(state.searchKey) {
if (state.searchKey.isNullOrBlank()) {
listState.animateScrollToItem(0)
}
}
@@ -154,8 +146,8 @@ fun ReadRecordScreen(
AnimatedVisibility(visible = showSearch) {
SearchBarSection(
query = searchText,
onQueryChange = { searchText = it }
query = state.searchKey ?: "",
onQueryChange = { viewModel.setSearchKey(it) }
)
}
AnimatedVisibility(visible = showCalendar) {
@@ -192,7 +184,8 @@ fun ReadRecordScreen(
"LOADING" -> {
EmptyMessageView(
modifier = Modifier.fillMaxSize(),
message = "加载中"
message = "加载中",
isLoading = true
)
}
@@ -205,6 +198,7 @@ fun ReadRecordScreen(
"CONTENT" -> {
LazyColumn(
state = listState,
modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection)
) {
@@ -2,20 +2,24 @@ package io.legado.app.ui.book.readRecord
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.legado.app.data.dao.BookChapterDao
import io.legado.app.data.dao.BookDao
import cn.hutool.core.date.DateUtil
import io.legado.app.data.entities.readRecord.ReadRecord
import io.legado.app.data.entities.readRecord.ReadRecordDetail
import io.legado.app.data.entities.readRecord.ReadRecordSession
import io.legado.app.data.repository.BookRepository
import io.legado.app.data.repository.ReadRecordRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
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.withContext
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Date
data class ReadRecordUiState(
val isLoading: Boolean = true,
@@ -26,7 +30,8 @@ data class ReadRecordUiState(
val timelineRecords: Map<String, List<ReadRecordSession>> = emptyMap(),
//最后阅读列表
val latestRecords: List<ReadRecord> = emptyList(),
val selectedDate: LocalDate? = null
val selectedDate: LocalDate? = null,
val searchKey: String? = null,
)
enum class DisplayMode {
@@ -35,18 +40,75 @@ enum class DisplayMode {
LATEST
}
@OptIn(ExperimentalCoroutinesApi::class)
class ReadRecordViewModel(
private val repository: ReadRecordRepository,
private val bookDao: BookDao,
private val bookChapterDao: BookChapterDao
private val bookRepository: BookRepository
) : ViewModel() {
private val _displayMode = MutableStateFlow(DisplayMode.AGGREGATE)
val displayMode = _displayMode.asStateFlow()
private val _uiState = MutableStateFlow(ReadRecordUiState())
val uiState = _uiState.asStateFlow()
init {
loadData()
private val _searchKey = MutableStateFlow("")
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) {
@@ -54,132 +116,44 @@ class ReadRecordViewModel(
}
fun setSelectedDate(date: LocalDate?) {
_uiState.update { it.copy(selectedDate = 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)
}
_selectedDate.value = date
}
fun deleteDetail(detail: ReadRecordDetail) {
viewModelScope.launch {
repository.deleteDetail(detail)
loadData()
viewModelScope.launch { repository.deleteDetail(detail) }
}
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? {
return withContext(Dispatchers.IO) {
bookDao.findByName(bookName).firstOrNull()?.getDisplayCover()
}
return bookRepository.getBookCoverByName(bookName)
}
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),
inverseOnSurface = Color(0xFFF6F0E2),
inversePrimary = Color(0xA0FFFFFF),
// Material 3.1+ Fixed Colors
primaryFixed = Color(0xA0FFFFFF),
onPrimaryFixed = Color(0xFF211B00),
primaryFixedDim = Color(0xB0FFFFFF),
@@ -52,7 +51,7 @@ internal object TransparentColorScheme : BaseColorScheme() {
surfaceDim = Color(0x1AFFFFFF),
surfaceBright = Color(0x33FFFFFF),
surfaceContainerLowest = Color(0x0DFFFFFF),
surfaceContainerLow = Color(0x1AFFFFFF),
surfaceContainerLow = Color(0x8FFFFFFF),
surfaceContainer = Color(0x33FFFFFF),
surfaceContainerHigh = Color(0x4DFFFFFF),
surfaceContainerHighest = Color(0x66FFFFFF),
@@ -102,7 +101,7 @@ internal object TransparentColorScheme : BaseColorScheme() {
surfaceDim = Color(0x1AFFFFFF),
surfaceBright = Color(0x33FFFFFF),
surfaceContainerLowest = Color(0x0DFFFFFF),
surfaceContainerLow = Color(0x1AFFFFFF),
surfaceContainerLow = Color(0x8F000000),
surfaceContainer = Color(0x33FFFFFF),
surfaceContainerHigh = Color(0x4DFFFFFF),
surfaceContainerHighest = Color(0x66FFFFFF),
@@ -9,8 +9,8 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material3.ContainedLoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -50,7 +50,7 @@ fun EmptyMessageView(
label = "LoadingStateAnimation"
) { loading ->
if (loading) {
LoadingIndicator()
ContainedLoadingIndicator()
} else {
AnimatedTextLine(
text = currentFace,
@@ -1,6 +1,7 @@
package io.legado.app.ui.widget.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
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
@@ -23,14 +26,18 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -39,13 +46,19 @@ fun SearchBarSection(
onQueryChange: (String) -> Unit,
placeholder: String = "搜索书名",
backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainerLow,
//外部定义末尾的图标按钮
scrollState: LazyListState? = null,
scope: CoroutineScope = rememberCoroutineScope(),
trailingIcon: @Composable (() -> Unit)? = null,
//外部定义下拉菜单的内容
dropdownMenu: (@Composable (onDismiss: () -> Unit) -> Unit)? = null
) {
var showMenu by remember { mutableStateOf(false) }
val showScrollToTop by remember(scrollState) {
derivedStateOf {
(scrollState?.firstVisibleItemIndex ?: 0) > 0
}
}
Surface(
modifier = Modifier
.fillMaxWidth()
@@ -64,28 +77,38 @@ fun SearchBarSection(
trailingIcon = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(end = 4.dp)
modifier = Modifier
.padding(end = 4.dp)
.animateContentSize()
) {
AnimatedVisibility(
visible = query.isNotEmpty(),
enter = fadeIn() + scaleIn(initialScale = 0.8f),
exit = fadeOut() + scaleOut(targetScale = 0.8f)
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut()
) {
IconButton(onClick = {
onQueryChange("")
}) {
IconButton(onClick = { onQueryChange("") }) {
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) {
Box {
IconButton(onClick = {
if (dropdownMenu != null) showMenu = true
}) {
IconButton(onClick = { if (dropdownMenu != null) showMenu = true }) {
trailingIcon()
}
if (dropdownMenu != null) {
DropdownMenu(
expanded = showMenu,