[优化] 增加书籍详情界面的阅读历史

This commit is contained in:
HapeLee committed 2026-04-19 02:33:06 +08:00
1 parent 979632e35c
commit 85b90f77f7
7 files changed
+310 -27

No files matched your search

@@ -0,0 +1,6 @@
package io.legado.app.data.entities.readRecord
data class ReadRecordTimelineDay(
val date: String,
val sessions: List<ReadRecordSession>
)
@@ -4,6 +4,7 @@ import android.net.Uri
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay
data class BookInfoUiState( data class BookInfoUiState(
val book: Book? = null, val book: Book? = null,
@@ -12,6 +13,8 @@ data class BookInfoUiState(
val kindLabels: List<String> = emptyList(), val kindLabels: List<String> = emptyList(),
val groupNames: String? = null, val groupNames: String? = null,
val hasCustomGroup: Boolean = false, val hasCustomGroup: Boolean = false,
val readRecordTotalTime: Long = 0L,
val readRecordTimelineDays: List<ReadRecordTimelineDay> = emptyList(),
val inBookshelf: Boolean = false, val inBookshelf: Boolean = false,
val bookSource: BookSource? = null, val bookSource: BookSource? = null,
val isTocLoading: Boolean = true, val isTocLoading: Boolean = true,
@@ -26,6 +29,7 @@ sealed interface BookInfoSheet {
data object CoverPicker : BookInfoSheet data object CoverPicker : BookInfoSheet
data object GroupPicker : BookInfoSheet data object GroupPicker : BookInfoSheet
data object SourcePicker : BookInfoSheet data object SourcePicker : BookInfoSheet
data object ReadRecord : BookInfoSheet
data class WebFiles(val openAfterImport: Boolean) : BookInfoSheet data class WebFiles(val openAfterImport: Boolean) : BookInfoSheet
data class ArchiveEntries( data class ArchiveEntries(
val archiveUri: Uri, val archiveUri: Uri,
@@ -68,6 +72,7 @@ sealed interface BookInfoIntent {
data object CoverLongClick : BookInfoIntent data object CoverLongClick : BookInfoIntent
data object GroupClick : BookInfoIntent data object GroupClick : BookInfoIntent
data object ChangeSourceClick : BookInfoIntent data object ChangeSourceClick : BookInfoIntent
data object ReadRecordClick : BookInfoIntent
data object RemarkClick : BookInfoIntent data object RemarkClick : BookInfoIntent
data object ConfirmBackAddToShelf : BookInfoIntent data object ConfirmBackAddToShelf : BookInfoIntent
data class ConfirmDelete(val deleteOriginal: Boolean) : BookInfoIntent data class ConfirmDelete(val deleteOriginal: Boolean) : BookInfoIntent
@@ -141,6 +146,7 @@ enum class BookInfoMenuAction {
Upload, Upload,
SyncRemote, SyncRemote,
Refresh, Refresh,
ReadRecord,
Login, Login,
Top, Top,
SetSourceVariable, SetSourceVariable,
@@ -0,0 +1,164 @@
package io.legado.app.ui.book.info
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Timeline
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import cn.hutool.core.date.DateUtil
import io.legado.app.R
import io.legado.app.data.entities.readRecord.ReadRecordSession
import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.utils.formatReadDuration
import java.util.Date
@Composable
fun BookReadRecordSheet(
show: Boolean,
totalReadTime: Long,
timelineDays: List<ReadRecordTimelineDay>,
onDismissRequest: () -> Unit,
) {
AppModalBottomSheet(
show = show,
onDismissRequest = onDismissRequest,
title = stringResource(R.string.read_record),
) {
GlassCard(
modifier = Modifier.fillMaxWidth(),
containerColor = LegadoTheme.colorScheme.surfaceContainerLow
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Default.Timeline,
contentDescription = null,
tint = LegadoTheme.colorScheme.primary
)
Column {
AppText(
text = stringResource(R.string.all_read_time),
style = LegadoTheme.typography.labelMedium,
color = LegadoTheme.colorScheme.primary
)
AppText(
text = formatReadDuration(totalReadTime),
style = LegadoTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
if (timelineDays.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp),
contentAlignment = Alignment.Center
) {
EmptyMessage(message = stringResource(R.string.empty))
}
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
timelineDays.forEach { day ->
item(key = "header_${day.date}") {
AppText(
text = day.date,
style = LegadoTheme.typography.titleSmall,
color = LegadoTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)
)
}
items(day.sessions, key = { it.id }) { session ->
TimelineSessionRow(session = session)
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
@Composable
private fun TimelineSessionRow(session: ReadRecordSession) {
val lineColor = LegadoTheme.colorScheme.surfaceContainerHigh
val nodeColor = LegadoTheme.colorScheme.primary
val duration = (session.endTime - session.startTime).coerceAtLeast(0L)
Row(
modifier = Modifier
.fillMaxWidth()
.drawBehind {
val x = 12.dp.toPx()
val centerY = size.height / 2f
drawLine(
color = lineColor,
start = Offset(x, 0f),
end = Offset(x, size.height),
strokeWidth = 2.dp.toPx()
)
drawCircle(
color = nodeColor,
radius = 4.dp.toPx(),
center = Offset(x, centerY)
)
}
.padding(start = 28.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
AppText(
text = DateUtil.format(Date(session.endTime), "HH:mm"),
style = LegadoTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
AppText(
text = formatReadDuration(duration),
style = LegadoTheme.typography.bodySmall,
color = LegadoTheme.colorScheme.onSurfaceVariant
)
}
if (session.words > 0) {
TextCard(
text = "第${session.words}章",
textStyle = LegadoTheme.typography.labelSmall,
backgroundColor = LegadoTheme.colorScheme.secondaryContainer,
contentColor = LegadoTheme.colorScheme.onSecondaryContainer,
)
}
}
}
@@ -33,7 +33,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Book import androidx.compose.material.icons.filled.Book
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.BookmarkAdd import androidx.compose.material.icons.filled.BookmarkAdd
import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
@@ -41,9 +40,8 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Timeline
import androidx.compose.material.icons.outlined.Book import androidx.compose.material.icons.outlined.Book
import androidx.compose.material.icons.outlined.Bookmark
import androidx.compose.material.icons.outlined.BookmarkBorder
import androidx.compose.material.icons.outlined.CollectionsBookmark import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.material.icons.outlined.FolderZip import androidx.compose.material.icons.outlined.FolderZip
import androidx.compose.material.icons.outlined.Image import androidx.compose.material.icons.outlined.Image
@@ -265,12 +263,12 @@ private fun BookInfoScreenContent(
.padding(bottom = 24.dp) .padding(bottom = 24.dp)
) { ) {
BookInfoActions( BookInfoActions(
hasCustomGroup = state.hasCustomGroup,
inBookshelf = state.inBookshelf, inBookshelf = state.inBookshelf,
onShelfClick = { onIntent(BookInfoIntent.ShelfClick) }, onShelfClick = { onIntent(BookInfoIntent.ShelfClick) },
onTocClick = { onIntent(BookInfoIntent.TocClick) }, onTocClick = { onIntent(BookInfoIntent.TocClick) },
onGroupClick = { onIntent(BookInfoIntent.GroupClick) }, onGroupClick = { onIntent(BookInfoIntent.GroupClick) },
onSourceClick = { onIntent(BookInfoIntent.ChangeSourceClick) }, onSourceClick = { onIntent(BookInfoIntent.ChangeSourceClick) },
onReadRecordClick = { onIntent(BookInfoIntent.ReadRecordClick) },
) )
BookInfoSummary( BookInfoSummary(
book = book, book = book,
@@ -325,6 +323,12 @@ private fun BookInfoScreenContent(
}, },
) )
} }
BookInfoSheet.ReadRecord -> BookReadRecordSheet(
show = currentSheet == BookInfoSheet.ReadRecord,
totalReadTime = state.readRecordTotalTime,
timelineDays = state.readRecordTimelineDays,
onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) },
)
is BookInfoSheet.WebFiles -> WebFileSheet( is BookInfoSheet.WebFiles -> WebFileSheet(
show = currentSheet is BookInfoSheet.WebFiles, show = currentSheet is BookInfoSheet.WebFiles,
files = state.webFiles, files = state.webFiles,
@@ -551,6 +555,10 @@ private fun BookInfoOverflowMenu(
text = stringResource(R.string.refresh), text = stringResource(R.string.refresh),
onClick = { onMenuAction(BookInfoMenuAction.Refresh) } onClick = { onMenuAction(BookInfoMenuAction.Refresh) }
) )
RoundDropdownMenuItem(
text = stringResource(R.string.read_record),
onClick = { onMenuAction(BookInfoMenuAction.ReadRecord) }
)
if (book?.isLocal == true) { if (book?.isLocal == true) {
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = stringResource(R.string.re_sync_webdav), text = stringResource(R.string.re_sync_webdav),
@@ -736,31 +744,38 @@ private fun BookInfoHeader(
@Composable @Composable
private fun BookInfoActions( private fun BookInfoActions(
hasCustomGroup: Boolean,
inBookshelf: Boolean, inBookshelf: Boolean,
onShelfClick: () -> Unit, onShelfClick: () -> Unit,
onTocClick: () -> Unit, onTocClick: () -> Unit,
onGroupClick: () -> Unit, onGroupClick: () -> Unit,
onSourceClick: () -> Unit, onSourceClick: () -> Unit,
onReadRecordClick: () -> Unit,
) { ) {
var awaitingShelfAddition by rememberSaveable { mutableStateOf(false) } var awaitingShelfAddition by rememberSaveable { mutableStateOf(false) }
var showShelfRemoveHint by rememberSaveable { mutableStateOf(false) } var showShelfRemoveHint by rememberSaveable { mutableStateOf(false) }
var showLongPressGroupHint by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(inBookshelf, awaitingShelfAddition) { LaunchedEffect(inBookshelf) {
if (awaitingShelfAddition && inBookshelf) { if (awaitingShelfAddition && inBookshelf) {
awaitingShelfAddition = false awaitingShelfAddition = false
showShelfRemoveHint = true showShelfRemoveHint = true
delay(1000) delay(1000)
showShelfRemoveHint = false showShelfRemoveHint = false
showLongPressGroupHint = true
delay(1000)
showLongPressGroupHint = false
} else if (!inBookshelf) { } else if (!inBookshelf) {
awaitingShelfAddition = false
showShelfRemoveHint = false showShelfRemoveHint = false
showLongPressGroupHint = false
} }
} }
val shelfLabel = when { val shelfLabel = when {
!inBookshelf -> stringResource(R.string.add_to_bookshelf)
showShelfRemoveHint -> stringResource(R.string.click_to_remove) showShelfRemoveHint -> stringResource(R.string.click_to_remove)
else -> stringResource(R.string.remove_from_bookshelf) showLongPressGroupHint -> stringResource(R.string.long_press_group)
inBookshelf -> stringResource(R.string.already_in_bookshelf)
else -> stringResource(R.string.add_to_bookshelf)
} }
Row( Row(
@@ -774,12 +789,14 @@ private fun BookInfoActions(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
icon = if (inBookshelf) Icons.Outlined.Book else Icons.Default.BookmarkAdd, icon = if (inBookshelf) Icons.Outlined.Book else Icons.Default.BookmarkAdd,
label = shelfLabel, label = shelfLabel,
onLongClick = onGroupClick,
onClick = { onClick = {
if (!inBookshelf) { if (!inBookshelf) {
awaitingShelfAddition = true awaitingShelfAddition = true
} else { } else {
awaitingShelfAddition = false awaitingShelfAddition = false
showShelfRemoveHint = false showShelfRemoveHint = false
showLongPressGroupHint = false
} }
onShelfClick() onShelfClick()
}, },
@@ -790,18 +807,18 @@ private fun BookInfoActions(
label = stringResource(R.string.view_toc), label = stringResource(R.string.view_toc),
onClick = onTocClick onClick = onTocClick
) )
BookInfoActionCard(
modifier = Modifier.weight(1f),
icon = if (hasCustomGroup) Icons.Default.Bookmark else Icons.Outlined.BookmarkBorder,
label = stringResource(R.string.change_group),
onClick = onGroupClick
)
BookInfoActionCard( BookInfoActionCard(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
icon = Icons.Default.Code, icon = Icons.Default.Code,
label = stringResource(R.string.book_source), label = stringResource(R.string.book_source),
onClick = onSourceClick onClick = onSourceClick
) )
BookInfoActionCard(
modifier = Modifier.weight(1f),
icon = Icons.Default.Timeline,
label = stringResource(R.string.read_record),
onClick = onReadRecordClick
)
} }
} }
@@ -810,12 +827,14 @@ private fun BookInfoActionCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
icon: ImageVector, icon: ImageVector,
label: String, label: String,
onLongClick: (() -> Unit)? = null,
onClick: () -> Unit onClick: () -> Unit
) { ) {
GlassCard( GlassCard(
modifier = modifier, modifier = modifier,
onLongClick = onLongClick,
onClick = onClick, onClick = onClick,
containerColor = LegadoTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.2f), containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
contentColor = LegadoTheme.colorScheme.onSurface, contentColor = LegadoTheme.colorScheme.onSurface,
) { ) {
Column( Column(
@@ -398,8 +398,8 @@ fun ChangeSourceSheet(
}.collectAsStateWithLifecycle() }.collectAsStateWithLifecycle()
SelectionItemCard( SelectionItemCard(
title = item.originName, title = item.originName,
containerColor = LegadoTheme.colorScheme.surfaceContainer, containerColor = LegadoTheme.colorScheme.onSheetContent,
selectedContainerColor = LegadoTheme.colorScheme.primaryContainer.copy(alpha = 0.2f), selectedContainerColor = LegadoTheme.colorScheme.primaryContainer.copy(alpha = 0.32f),
leadingContent = { leadingContent = {
MediumIconButton( MediumIconButton(
onClick = { onClick = {
@@ -503,15 +503,35 @@ fun ChangeSourceSheet(
}) })
} }
if (mismatchBook != null) { AppAlertDialog(
AppAlertDialog(show = true, onDismissRequest = { mismatchBook = null }, title = stringResource(R.string.book_type_different), text = stringResource(R.string.soure_change_source), confirmText = stringResource(android.R.string.ok), onConfirm = { actionBook = mismatchBook; mismatchBook = null }, dismissText = stringResource(android.R.string.cancel), onDismiss = { mismatchBook = null }) data = mismatchBook,
onDismissRequest = { mismatchBook = null },
title = stringResource(R.string.book_type_different),
text = stringResource(R.string.soure_change_source),
confirmText = stringResource(android.R.string.ok),
onConfirm = { searchBook ->
actionBook = searchBook
mismatchBook = null
},
dismissText = stringResource(android.R.string.cancel),
onDismiss = { mismatchBook = null }
)
AppAlertDialog(
data = actionBook,
onDismissRequest = { actionBook = null },
title = stringResource(R.string.change_source_option_title),
dismissText = stringResource(R.string.add_as_new_book),
onDismiss = { actionBook?.let { performAction(it, false) } },
confirmText = stringResource(R.string.replace_current_book),
onConfirm = { performAction(it, true) }
)
AppAlertDialog(
show = loadingAction,
onDismissRequest = {},
content = {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
} }
actionBook?.let { searchBook ->
AppAlertDialog(show = true, onDismissRequest = { actionBook = null }, title = stringResource(R.string.change_source_option_title), dismissText = stringResource(R.string.add_as_new_book), onDismiss = { performAction(searchBook, false) }, confirmText = stringResource(R.string.replace_current_book), onConfirm = { performAction(searchBook, true) })
}
if (loadingAction) {
AppAlertDialog(show = true, onDismissRequest = {}, content = {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
})
} }
)
} }
@@ -15,6 +15,8 @@ import io.legado.app.data.appDb
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay
import io.legado.app.data.repository.ReadRecordRepository
import io.legado.app.data.repository.RemoteBookRepository import io.legado.app.data.repository.RemoteBookRepository
import io.legado.app.exception.NoBooksDirException import io.legado.app.exception.NoBooksDirException
import io.legado.app.exception.NoStackTraceException import io.legado.app.exception.NoStackTraceException
@@ -48,15 +50,20 @@ import io.legado.app.utils.postEvent
import io.legado.app.utils.toastOnUi import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class BookInfoViewModel( class BookInfoViewModel(
application: Application, application: Application,
private val remoteBookRepository: RemoteBookRepository private val remoteBookRepository: RemoteBookRepository,
private val readRecordRepository: ReadRecordRepository
) : BaseViewModel(application) { ) : BaseViewModel(application) {
private val _uiState = MutableStateFlow(BookInfoUiState()) private val _uiState = MutableStateFlow(BookInfoUiState())
@@ -66,11 +73,18 @@ class BookInfoViewModel(
val effects = _effects.asSharedFlow() val effects = _effects.asSharedFlow()
private var currentBook: Book? = null private var currentBook: Book? = null
set(value) {
field = value
observeReadRecordIfNeeded(value)
}
private var currentChapterList: List<BookChapter> = emptyList() private var currentChapterList: List<BookChapter> = emptyList()
private var currentWebFiles: List<BookInfoWebFile> = emptyList() private var currentWebFiles: List<BookInfoWebFile> = emptyList()
private var currentKindLabels: List<String> = emptyList() private var currentKindLabels: List<String> = emptyList()
private var currentGroupNames: String? = null private var currentGroupNames: String? = null
private var currentHasCustomGroup = false private var currentHasCustomGroup = false
private var currentReadRecordTotalTime = 0L
private var currentReadRecordTimelineDays: List<ReadRecordTimelineDay> = emptyList()
private var observingReadRecordKey: String? = null
private var chapterChanged = false private var chapterChanged = false
var inBookshelf = false var inBookshelf = false
@@ -79,6 +93,7 @@ class BookInfoViewModel(
private set private set
private var changeSourceCoroutine: Coroutine<*>? = null private var changeSourceCoroutine: Coroutine<*>? = null
private var readRecordObserveJob: Job? = null
fun initData(intent: Intent) { fun initData(intent: Intent) {
if (currentBook != null) return if (currentBook != null) return
@@ -123,6 +138,7 @@ class BookInfoViewModel(
BookInfoIntent.GroupClick -> setSheet(BookInfoSheet.GroupPicker) BookInfoIntent.GroupClick -> setSheet(BookInfoSheet.GroupPicker)
BookInfoIntent.ChangeSourceClick -> setSheet(BookInfoSheet.SourcePicker) BookInfoIntent.ChangeSourceClick -> setSheet(BookInfoSheet.SourcePicker)
BookInfoIntent.ReadRecordClick -> setSheet(BookInfoSheet.ReadRecord)
BookInfoIntent.RemarkClick -> showDialog(BookInfoDialog.EditRemark(currentBook?.remark)) BookInfoIntent.RemarkClick -> showDialog(BookInfoDialog.EditRemark(currentBook?.remark))
BookInfoIntent.ConfirmBackAddToShelf -> { BookInfoIntent.ConfirmBackAddToShelf -> {
dismissDialog() dismissDialog()
@@ -924,6 +940,7 @@ class BookInfoViewModel(
} }
BookInfoMenuAction.SyncRemote -> syncFromRemote() BookInfoMenuAction.SyncRemote -> syncFromRemote()
BookInfoMenuAction.Refresh -> refreshCurrentBook() BookInfoMenuAction.Refresh -> refreshCurrentBook()
BookInfoMenuAction.ReadRecord -> setSheet(BookInfoSheet.ReadRecord)
BookInfoMenuAction.Login -> bookSource?.let { BookInfoMenuAction.Login -> bookSource?.let {
emitEffect(BookInfoEffect.OpenSourceLogin(it.bookSourceUrl)) emitEffect(BookInfoEffect.OpenSourceLogin(it.bookSourceUrl))
} }
@@ -1081,6 +1098,42 @@ class BookInfoViewModel(
} }
} }
private fun observeReadRecordIfNeeded(book: Book?) {
if (book == null) {
clearReadRecordObserve()
return
}
val key = "${book.name}|||${book.author}"
if (observingReadRecordKey == key && readRecordObserveJob?.isActive == true) return
observingReadRecordKey = key
readRecordObserveJob?.cancel()
readRecordObserveJob = viewModelScope.launch {
combine(
readRecordRepository.getBookReadTime(book.name, book.author),
readRecordRepository.getBookTimelineDays(book.name, book.author)
) { totalTime, timelineDays ->
totalTime to timelineDays
}.collectLatest { (totalTime, timelineDays) ->
currentReadRecordTotalTime = totalTime
currentReadRecordTimelineDays = timelineDays
_uiState.update {
it.copy(
readRecordTotalTime = currentReadRecordTotalTime,
readRecordTimelineDays = currentReadRecordTimelineDays
)
}
}
}
}
private fun clearReadRecordObserve() {
readRecordObserveJob?.cancel()
readRecordObserveJob = null
observingReadRecordKey = null
currentReadRecordTotalTime = 0L
currentReadRecordTimelineDays = emptyList()
}
private fun dismissSheet() { private fun dismissSheet() {
setSheet(BookInfoSheet.None) setSheet(BookInfoSheet.None)
} }
@@ -1110,6 +1163,8 @@ class BookInfoViewModel(
kindLabels = currentKindLabels, kindLabels = currentKindLabels,
groupNames = currentGroupNames, groupNames = currentGroupNames,
hasCustomGroup = currentHasCustomGroup, hasCustomGroup = currentHasCustomGroup,
readRecordTotalTime = currentReadRecordTotalTime,
readRecordTimelineDays = currentReadRecordTimelineDays,
inBookshelf = inBookshelf, inBookshelf = inBookshelf,
bookSource = bookSource, bookSource = bookSource,
isTocLoading = isTocLoading, isTocLoading = isTocLoading,
@@ -0,0 +1,13 @@
package io.legado.app.utils
fun formatReadDuration(millis: Long): String {
val days = millis / (1000 * 60 * 60 * 24)
val hours = millis % (1000 * 60 * 60 * 24) / (1000 * 60 * 60)
val minutes = millis % (1000 * 60 * 60) / (1000 * 60)
val seconds = millis % (1000 * 60) / 1000
val d = if (days > 0) "${days}天" else ""
val h = if (hours > 0) "${hours}小时" else ""
val m = if (minutes > 0) "${minutes}分钟" else ""
val s = if (seconds > 0) "${seconds}秒" else ""
return if ("$d$h$m$s".isBlank()) "0秒" else "$d$h$m$s"
}