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