Преглед изворни кода

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

HapeLee пре 10 месеци
родитељ
комит
7173cbfbe2

+ 2 - 0
app/build.gradle

@@ -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)
 
 
 }
 }

+ 12 - 8
app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt

@@ -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")
-    suspend fun getTotalReadTime(): Long
+    @Query("SELECT sum(readTime) FROM readRecord")
+    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, lastReadTime DESC")
+    fun searchDetails(query: String): 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 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)

+ 23 - 1
app/src/main/java/io/legado/app/data/repository/BookRepository.kt

@@ -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)

+ 45 - 60
app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt

@@ -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.clearDetails()
+        dao.clear()
     }
     }
-
-    // 暴露总时长
-    suspend fun getTotalReadTime(): Long {
-        return dao.getTotalReadTime()
-    }
-
 }
 }

+ 10 - 21
app/src/main/java/io/legado/app/di/appModule.kt

@@ -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<UploadRepository> { DirectLinkUploadRepository() }
+    single { BookRepository(get(), get()) }
 
 
-    // Explore
+    single<UploadRepository> { DirectLinkUploadRepository() }
     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()) }
 }
 }

+ 66 - 63
app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt

@@ -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 -> {
-                        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())
+
+                                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) }
+                                    )
+                                }
 
 
-                                    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
-                                                        }
-                                                    )
-                                                }
+                                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
 @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,
-    modifier: Modifier = Modifier,
+    bookmark: BookmarkItemUi,
+    modifier: Modifier,
     onClick: () -> Unit) {
     onClick: () -> Unit) {
     Column(
     Column(
         modifier = modifier
         modifier = modifier

+ 117 - 70
app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt

@@ -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.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.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 {
-    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(
 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 bookmarksState: StateFlow<BookmarkUiState> = _searchQuery
-        .debounce(300L)
-        .flatMapLatest { query ->
-            val flow = if (query.isBlank()) {
-                bookmarkDao.flowAll()
-            } else {
-                bookmarkDao.flowSearchAll(query)
-            }
-            flow.map<List<Bookmark>, BookmarkUiState> { list ->
-                BookmarkUiState.Success(list.groupBy { BookmarkGroupHeader(it.bookName, it.bookAuthor) })
+        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)
             }
             }
-                .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
         )
         )
+    }.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 toggleGroupCollapse(groupKey: BookmarkGroupHeader) {
     fun toggleGroupCollapse(groupKey: BookmarkGroupHeader) {
         val stringKey = groupKey.toString()
         val stringKey = groupKey.toString()
-        val current = _collapsedGroups.value
-        if (current.contains(stringKey)) {
-            _collapsedGroups.value = current - stringKey
-        } else {
-            _collapsedGroups.value = current + stringKey
+        _collapsedGroups.update { current ->
+            if (current.contains(stringKey)) current - stringKey else current + stringKey
         }
         }
     }
     }
 
 
     fun toggleAllCollapse(currentKeys: Set<BookmarkGroupHeader>) {
     fun toggleAllCollapse(currentKeys: Set<BookmarkGroupHeader>) {
         val stringKeys = currentKeys.map { it.toString() }.toSet()
         val stringKeys = currentKeys.map { it.toString() }.toSet()
-        val currentCollapsed = _collapsedGroups.value
-        if (currentCollapsed.containsAll(stringKeys) && currentKeys.isNotEmpty()) {
-            _collapsedGroups.value = emptySet()
-        } else {
-            _collapsedGroups.value = stringKeys
+        _collapsedGroups.update { current ->
+            if (current.containsAll(stringKeys) && stringKeys.isNotEmpty()) {
+                emptySet()
+            } else {
+                stringKeys
+            }
         }
         }
     }
     }
 
 
-    fun onSearchQueryChanged(query: String) {
-        _searchQuery.value = query
-    }
-
     fun updateBookmark(bookmark: Bookmark) {
     fun updateBookmark(bookmark: Bookmark) {
-        viewModelScope.launch {
-            withContext(Dispatchers.IO) {
-                bookmarkDao.insert(bookmark)
-            }
+        viewModelScope.launch(Dispatchers.IO) {
+            bookmarkDao.insert(bookmark)
         }
         }
     }
     }
 
 
     fun deleteBookmark(bookmark: 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 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 = ""
-        var author = ""
+        val sb = StringBuilder()
+        var lastHeader = ""
+
         bookmarks.forEach {
         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())
     }
     }
 }
 }

+ 10 - 16
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt

@@ -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) {
-        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) {
                 AnimatedVisibility(visible = showSearch) {
                     SearchBarSection(
                     SearchBarSection(
-                        query = searchText,
-                        onQueryChange = { searchText = it }
+                        query = state.searchKey ?: "",
+                        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)
                         ) {
                         ) {

+ 94 - 120
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt

@@ -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 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.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 bookChapterDao: BookChapterDao
+    private val bookRepository: BookRepository
 ) : 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 {
-        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) {
     fun setDisplayMode(mode: DisplayMode) {
@@ -54,132 +116,44 @@ class ReadRecordViewModel(
     }
     }
 
 
     fun setSelectedDate(date: LocalDate?) {
     fun setSelectedDate(date: LocalDate?) {
-        _uiState.update { it.copy(selectedDate = date) }
-        loadData()
+        _selectedDate.value = date
     }
     }
 
 
-    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
-                    )
-                }
-            }
-        }
+    fun deleteDetail(detail: ReadRecordDetail) {
+        viewModelScope.launch { repository.deleteDetail(detail) }
     }
     }
 
 
-    /**
-     * 遍历会话列表,合并同一本书连续阅读且间隔不超过20分钟的会话。
-     * 只保留最晚结束的那条会话(更新其结束时间)。
-     * @param sessions 原始 ReadRecordSession 列表,按开始时间排序。
-     * @return 合并后的 ReadRecordSession 列表。
-     */
-    fun mergeContinuousSessions(sessions: List<ReadRecordSession>): List<ReadRecordSession> {
+    private fun mergeContinuousSessions(sessions: List<ReadRecordSession>): List<ReadRecordSession> {
         if (sessions.isEmpty()) return emptyList()
         if (sessions.isEmpty()) return emptyList()
-
         val mergedList = mutableListOf<ReadRecordSession>()
         val mergedList = mutableListOf<ReadRecordSession>()
         mergedList.add(sessions.first().copy())
         mergedList.add(sessions.first().copy())
 
 
-        val twentyMinutesInMillis = 2 * 60 * 1000L
+        val gapLimit = 20 * 60 * 1000L
 
 
         for (i in 1 until sessions.size) {
         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)
-
+            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 {
             } else {
-                mergedList.add(currentSession.copy())
+                mergedList.add(current.copy())
             }
             }
         }
         }
-
         return mergedList
         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) {
-        viewModelScope.launch {
-            repository.deleteDetail(detail)
-            loadData()
-        }
+    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) {
-            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>
+    )
 }
 }

+ 2 - 3
app/src/main/java/io/legado/app/ui/theme/colorScheme/TransparentColorScheme.kt

@@ -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),

+ 2 - 2
app/src/main/java/io/legado/app/ui/widget/components/EmptyMessageView.kt

@@ -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,

+ 34 - 11
app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt

@@ -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),
-                        exit = fadeOut() + scaleOut(targetScale = 0.8f)
+                        enter = fadeIn() + scaleIn(),
+                        exit = fadeOut() + scaleOut()
+                    ) {
+                        IconButton(onClick = { onQueryChange("") }) {
+                            Icon(Icons.Default.Clear, "清空输入")
+                        }
+                    }
+
+                    AnimatedVisibility(
+                        visible = showScrollToTop,
+                        enter = fadeIn() + scaleIn(),
+                        exit = fadeOut() + scaleOut()
                     ) {
                     ) {
                         IconButton(onClick = {
                         IconButton(onClick = {
-                            onQueryChange("")
+                            scope.launch { scrollState?.animateScrollToItem(0) }
                         }) {
                         }) {
-                            Icon(Icons.Default.Clear, "清空输入")
+                            Icon(Icons.Default.VerticalAlignTop, "回到顶部")
                         }
                         }
                     }
                     }
 
 
+                    // 外部自定义按钮
                     if (trailingIcon != null) {
                     if (trailingIcon != null) {
                         Box {
                         Box {
-                            IconButton(onClick = {
-                                if (dropdownMenu != null) showMenu = true
-                            }) {
+                            IconButton(onClick = { if (dropdownMenu != null) showMenu = true }) {
                                 trailingIcon()
                                 trailingIcon()
                             }
                             }
-
                             if (dropdownMenu != null) {
                             if (dropdownMenu != null) {
                                 DropdownMenu(
                                 DropdownMenu(
                                     expanded = showMenu,
                                     expanded = showMenu,

+ 3 - 0
gradle/libs.versions.toml

@@ -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" }