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

This commit is contained in:
HapeLee
2026-04-19 02:33:06 +08:00
parent 979632e35c
commit 85b90f77f7
7 changed files with 312 additions and 29 deletions
@@ -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.BookChapter
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay
data class BookInfoUiState(
val book: Book? = null,
@@ -12,6 +13,8 @@ data class BookInfoUiState(
val kindLabels: List<String> = emptyList(),
val groupNames: String? = null,
val hasCustomGroup: Boolean = false,
val readRecordTotalTime: Long = 0L,
val readRecordTimelineDays: List<ReadRecordTimelineDay> = emptyList(),
val inBookshelf: Boolean = false,
val bookSource: BookSource? = null,
val isTocLoading: Boolean = true,
@@ -26,6 +29,7 @@ sealed interface BookInfoSheet {
data object CoverPicker : BookInfoSheet
data object GroupPicker : BookInfoSheet
data object SourcePicker : BookInfoSheet
data object ReadRecord : BookInfoSheet
data class WebFiles(val openAfterImport: Boolean) : BookInfoSheet
data class ArchiveEntries(
val archiveUri: Uri,
@@ -68,6 +72,7 @@ sealed interface BookInfoIntent {
data object CoverLongClick : BookInfoIntent
data object GroupClick : BookInfoIntent
data object ChangeSourceClick : BookInfoIntent
data object ReadRecordClick : BookInfoIntent
data object RemarkClick : BookInfoIntent
data object ConfirmBackAddToShelf : BookInfoIntent
data class ConfirmDelete(val deleteOriginal: Boolean) : BookInfoIntent
@@ -141,6 +146,7 @@ enum class BookInfoMenuAction {
Upload,
SyncRemote,
Refresh,
ReadRecord,
Login,
Top,
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.filled.Add
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.Code
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.Share
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.Bookmark
import androidx.compose.material.icons.outlined.BookmarkBorder
import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.material.icons.outlined.FolderZip
import androidx.compose.material.icons.outlined.Image
@@ -265,12 +263,12 @@ private fun BookInfoScreenContent(
.padding(bottom = 24.dp)
) {
BookInfoActions(
hasCustomGroup = state.hasCustomGroup,
inBookshelf = state.inBookshelf,
onShelfClick = { onIntent(BookInfoIntent.ShelfClick) },
onTocClick = { onIntent(BookInfoIntent.TocClick) },
onGroupClick = { onIntent(BookInfoIntent.GroupClick) },
onSourceClick = { onIntent(BookInfoIntent.ChangeSourceClick) },
onReadRecordClick = { onIntent(BookInfoIntent.ReadRecordClick) },
)
BookInfoSummary(
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(
show = currentSheet is BookInfoSheet.WebFiles,
files = state.webFiles,
@@ -551,6 +555,10 @@ private fun BookInfoOverflowMenu(
text = stringResource(R.string.refresh),
onClick = { onMenuAction(BookInfoMenuAction.Refresh) }
)
RoundDropdownMenuItem(
text = stringResource(R.string.read_record),
onClick = { onMenuAction(BookInfoMenuAction.ReadRecord) }
)
if (book?.isLocal == true) {
RoundDropdownMenuItem(
text = stringResource(R.string.re_sync_webdav),
@@ -736,31 +744,38 @@ private fun BookInfoHeader(
@Composable
private fun BookInfoActions(
hasCustomGroup: Boolean,
inBookshelf: Boolean,
onShelfClick: () -> Unit,
onTocClick: () -> Unit,
onGroupClick: () -> Unit,
onSourceClick: () -> Unit,
onReadRecordClick: () -> Unit,
) {
var awaitingShelfAddition by rememberSaveable { mutableStateOf(false) }
var showShelfRemoveHint by rememberSaveable { mutableStateOf(false) }
var showLongPressGroupHint by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(inBookshelf, awaitingShelfAddition) {
LaunchedEffect(inBookshelf) {
if (awaitingShelfAddition && inBookshelf) {
awaitingShelfAddition = false
showShelfRemoveHint = true
delay(1000)
showShelfRemoveHint = false
showLongPressGroupHint = true
delay(1000)
showLongPressGroupHint = false
} else if (!inBookshelf) {
awaitingShelfAddition = false
showShelfRemoveHint = false
showLongPressGroupHint = false
}
}
val shelfLabel = when {
!inBookshelf -> stringResource(R.string.add_to_bookshelf)
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(
@@ -774,12 +789,14 @@ private fun BookInfoActions(
modifier = Modifier.weight(1f),
icon = if (inBookshelf) Icons.Outlined.Book else Icons.Default.BookmarkAdd,
label = shelfLabel,
onLongClick = onGroupClick,
onClick = {
if (!inBookshelf) {
awaitingShelfAddition = true
} else {
awaitingShelfAddition = false
showShelfRemoveHint = false
showLongPressGroupHint = false
}
onShelfClick()
},
@@ -790,18 +807,18 @@ private fun BookInfoActions(
label = stringResource(R.string.view_toc),
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(
modifier = Modifier.weight(1f),
icon = Icons.Default.Code,
label = stringResource(R.string.book_source),
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,
icon: ImageVector,
label: String,
onLongClick: (() -> Unit)? = null,
onClick: () -> Unit
) {
GlassCard(
modifier = modifier,
onLongClick = onLongClick,
onClick = onClick,
containerColor = LegadoTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.2f),
containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
contentColor = LegadoTheme.colorScheme.onSurface,
) {
Column(
@@ -398,8 +398,8 @@ fun ChangeSourceSheet(
}.collectAsStateWithLifecycle()
SelectionItemCard(
title = item.originName,
containerColor = LegadoTheme.colorScheme.surfaceContainer,
selectedContainerColor = LegadoTheme.colorScheme.primaryContainer.copy(alpha = 0.2f),
containerColor = LegadoTheme.colorScheme.onSheetContent,
selectedContainerColor = LegadoTheme.colorScheme.primaryContainer.copy(alpha = 0.32f),
leadingContent = {
MediumIconButton(
onClick = {
@@ -503,15 +503,35 @@ fun ChangeSourceSheet(
})
}
if (mismatchBook != null) {
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 })
}
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() }
})
}
AppAlertDialog(
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()
}
}
)
}
@@ -15,6 +15,8 @@ import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
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.exception.NoBooksDirException
import io.legado.app.exception.NoStackTraceException
@@ -48,15 +50,20 @@ import io.legado.app.utils.postEvent
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class BookInfoViewModel(
application: Application,
private val remoteBookRepository: RemoteBookRepository
private val remoteBookRepository: RemoteBookRepository,
private val readRecordRepository: ReadRecordRepository
) : BaseViewModel(application) {
private val _uiState = MutableStateFlow(BookInfoUiState())
@@ -66,11 +73,18 @@ class BookInfoViewModel(
val effects = _effects.asSharedFlow()
private var currentBook: Book? = null
set(value) {
field = value
observeReadRecordIfNeeded(value)
}
private var currentChapterList: List<BookChapter> = emptyList()
private var currentWebFiles: List<BookInfoWebFile> = emptyList()
private var currentKindLabels: List<String> = emptyList()
private var currentGroupNames: String? = null
private var currentHasCustomGroup = false
private var currentReadRecordTotalTime = 0L
private var currentReadRecordTimelineDays: List<ReadRecordTimelineDay> = emptyList()
private var observingReadRecordKey: String? = null
private var chapterChanged = false
var inBookshelf = false
@@ -79,6 +93,7 @@ class BookInfoViewModel(
private set
private var changeSourceCoroutine: Coroutine<*>? = null
private var readRecordObserveJob: Job? = null
fun initData(intent: Intent) {
if (currentBook != null) return
@@ -123,6 +138,7 @@ class BookInfoViewModel(
BookInfoIntent.GroupClick -> setSheet(BookInfoSheet.GroupPicker)
BookInfoIntent.ChangeSourceClick -> setSheet(BookInfoSheet.SourcePicker)
BookInfoIntent.ReadRecordClick -> setSheet(BookInfoSheet.ReadRecord)
BookInfoIntent.RemarkClick -> showDialog(BookInfoDialog.EditRemark(currentBook?.remark))
BookInfoIntent.ConfirmBackAddToShelf -> {
dismissDialog()
@@ -924,6 +940,7 @@ class BookInfoViewModel(
}
BookInfoMenuAction.SyncRemote -> syncFromRemote()
BookInfoMenuAction.Refresh -> refreshCurrentBook()
BookInfoMenuAction.ReadRecord -> setSheet(BookInfoSheet.ReadRecord)
BookInfoMenuAction.Login -> bookSource?.let {
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() {
setSheet(BookInfoSheet.None)
}
@@ -1110,6 +1163,8 @@ class BookInfoViewModel(
kindLabels = currentKindLabels,
groupNames = currentGroupNames,
hasCustomGroup = currentHasCustomGroup,
readRecordTotalTime = currentReadRecordTotalTime,
readRecordTimelineDays = currentReadRecordTimelineDays,
inBookshelf = inBookshelf,
bookSource = bookSource,
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"
}