diff --git a/app/build.gradle b/app/build.gradle index 752ad65da..d0759cbe1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -384,5 +384,7 @@ dependencies { implementation(libs.reorderable) implementation(libs.material.kolor) + implementation(libs.haze.core) + implementation(libs.haze.materials) } diff --git a/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt b/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt index e60548246..1a103f816 100644 --- a/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt +++ b/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.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 - @Query("select sum(readTime) from readRecord") - suspend fun getTotalReadTime(): Long + @Query("SELECT sum(readTime) FROM readRecord") + fun getTotalReadTime(): Flow @Query( """ @@ -113,11 +114,11 @@ interface ReadRecordDao { /** 获取所有 ReadRecord,按最后阅读时间倒序排列 */ @Query("SELECT * FROM readRecord ORDER BY lastRead DESC") - suspend fun getAllReadRecordsSortedByLastRead(): List + fun getAllReadRecordsSortedByLastRead(): Flow> /** 搜索 ReadRecord,按最后阅读时间倒序排列 */ @Query("SELECT * FROM readRecord WHERE bookName LIKE '%' || :query || '%' ORDER BY lastRead DESC") - suspend fun searchReadRecordsByLastRead(query: String): List + fun searchReadRecordsByLastRead(query: String): Flow> /** * 获取某一天某一本书的所有会话记录 @@ -150,10 +151,13 @@ interface ReadRecordDao { fun deleteSessionByName(bookName: String) @Query("SELECT * FROM readRecordDetail ORDER BY date DESC, lastReadTime DESC") - suspend fun getAllDetails(): List + fun getAllDetails(): Flow> - @Query("SELECT * FROM readRecordDetail WHERE bookName LIKE '%' || :query || '%' ORDER BY date DESC") - suspend fun searchDetails(query: String): List + @Query("SELECT * FROM readRecordDetail WHERE bookName LIKE '%' || :query || '%' ORDER BY date DESC, lastReadTime DESC") + fun searchDetails(query: String): Flow> + + @Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId ORDER BY startTime ASC") + fun getAllSessions(deviceId: String): Flow> @Delete suspend fun deleteDetail(detail: ReadRecordDetail) diff --git a/app/src/main/java/io/legado/app/data/repository/BookRepository.kt b/app/src/main/java/io/legado/app/data/repository/BookRepository.kt index f79696688..e0f3d5b35 100644 --- a/app/src/main/java/io/legado/app/data/repository/BookRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/BookRepository.kt @@ -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) diff --git a/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt b/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt index 4e5f27639..6cb8bd819 100644 --- a/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt @@ -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 { + return dao.getTotalReadTime().map { it ?: 0L } + } /** - * 保存一个完整的阅读会话 + * 根据搜索关键字获取最新的阅读书籍列表流 + */ + fun getLatestReadRecords(query: String = ""): Flow> { + return if (query.isBlank()) { + dao.getAllReadRecordsSortedByLastRead() + } else { + dao.searchReadRecordsByLastRead(query) + } + } + + /** + * 获取所有的每日统计详情流 + */ + fun getAllRecordDetails(query: String = ""): Flow> { + return if (query.isBlank()) { + dao.getAllDetails() + } else { + dao.searchDetails(query) + } + } + + fun getAllSessions(): Flow> { + 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 { - return if (query.isBlank()) { - dao.getAllReadRecordsSortedByLastRead() - } else { - dao.searchReadRecordsByLastRead(query) - } - } - - suspend fun getAllRecordDetails(query: String = ""): List { - return if (query.isBlank()) { - dao.getAllDetails() - } else { - dao.searchDetails(query) - } - } - - suspend fun getAllRecordDetailsByDate(dateString: String, query: String = ""): List { - val deviceId = getCurrentDeviceId() - return if (query.isBlank()) { - dao.getDetailsByDate(deviceId, dateString) - } else { - dao.searchDetailsByDate(deviceId, dateString, query) - } - } - - suspend fun getAllSessionsByDate(dateString: String): List { - 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() - } - } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt index 1bdb9cda5..7a844990c 100644 --- a/app/src/main/java/io/legado/app/di/appModule.kt +++ b/app/src/main/java/io/legado/app/di/appModule.kt @@ -21,35 +21,24 @@ import org.koin.dsl.module val appModule = module { - viewModel { ReplaceEditViewModel(get(), get(), get()) } - - viewModel { ReplaceRuleViewModel(androidApplication()) } - - // ReadRecord single { get().readRecordDao } single { get().bookDao } single { get().bookChapterDao } + single { ReadRecordRepository(get()) } - viewModel { ReadRecordViewModel(get(), get(), get()) } + + single { BookRepository(get(), get()) } single { DirectLinkUploadRepository() } - - // Explore single { 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()) } } diff --git a/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt b/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt index ce66f5b23..afd4e7f33 100644 --- a/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt +++ b/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.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(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 diff --git a/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt b/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt index 2c812f259..de98c77f8 100644 --- a/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt +++ b/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.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>) : 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> = emptyMap(), + val error: Throwable? = null, + val searchQuery: String = "", + val collapsedGroups: Set = 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>(emptySet()) - val collapsedGroups = _collapsedGroups.asStateFlow() + @OptIn(ExperimentalCoroutinesApi::class) + val uiState: StateFlow = combine( + _searchQuery, + _collapsedGroups, + bookmarkDao.flowAll() + ) { query, collapsed, allBookmarks -> - @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) - val bookmarksState: StateFlow = _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, 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) { - 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) { + 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().toastOnUi("导出失败: ${e.message}") + } } } } private fun writeMarkdown(outputStream: java.io.OutputStream, bookmarks: List) { - 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()) } } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt index 74991025f..234297bc2 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt +++ b/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.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) ) { diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt index 65febe075..ef48f98e6 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt +++ b/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.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> = emptyMap(), //最后阅读列表 val latestRecords: List = 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(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 = 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>() - - 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): List { - if (sessions.isEmpty()) return emptyList() - - val mergedList = mutableListOf() - 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): List { + if (sessions.isEmpty()) return emptyList() + val mergedList = mutableListOf() + 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, + val latestRecords: List, + val sessions: List + ) } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/theme/colorScheme/TransparentColorScheme.kt b/app/src/main/java/io/legado/app/ui/theme/colorScheme/TransparentColorScheme.kt index 67310b8e2..6b38b60f4 100644 --- a/app/src/main/java/io/legado/app/ui/theme/colorScheme/TransparentColorScheme.kt +++ b/app/src/main/java/io/legado/app/ui/theme/colorScheme/TransparentColorScheme.kt @@ -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), diff --git a/app/src/main/java/io/legado/app/ui/widget/components/EmptyMessageView.kt b/app/src/main/java/io/legado/app/ui/widget/components/EmptyMessageView.kt index f57880fad..cb7b7b980 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/EmptyMessageView.kt +++ b/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.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, diff --git a/app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt b/app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt index e91a3716f..b22778e98 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt @@ -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, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index aab735747..09107c315 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -75,6 +75,7 @@ koin-bom = "4.1.1" biometricKtx = "1.1.0" reorderable = "3.0.0" +haze = "1.7.1" [libraries] 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" } 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-core = { module = "io.insert-koin:koin-core" } koin-android = { group = "io.insert-koin", name = "koin-android" }