diff --git a/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordTimelineDay.kt b/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordTimelineDay.kt new file mode 100644 index 000000000..7e6c9dd09 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordTimelineDay.kt @@ -0,0 +1,6 @@ +package io.legado.app.data.entities.readRecord + +data class ReadRecordTimelineDay( + val date: String, + val sessions: List +) diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt index 6abe925a6..66d4bedfc 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt @@ -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 = emptyList(), val groupNames: String? = null, val hasCustomGroup: Boolean = false, + val readRecordTotalTime: Long = 0L, + val readRecordTimelineDays: List = 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, diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoReadRecordSheet.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoReadRecordSheet.kt new file mode 100644 index 000000000..2dad2282f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoReadRecordSheet.kt @@ -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, + 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, + ) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt index 320bc55c8..9d932e2ce 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt @@ -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( diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt index 4b77d10b4..e10fd91b5 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt @@ -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() + } + } + ) } diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt index 75dc94984..63750707f 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt @@ -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 = emptyList() private var currentWebFiles: List = emptyList() private var currentKindLabels: List = emptyList() private var currentGroupNames: String? = null private var currentHasCustomGroup = false + private var currentReadRecordTotalTime = 0L + private var currentReadRecordTimelineDays: List = 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, diff --git a/app/src/main/java/io/legado/app/utils/ReadRecordTimeFormatter.kt b/app/src/main/java/io/legado/app/utils/ReadRecordTimeFormatter.kt new file mode 100644 index 000000000..6cf7b83f7 --- /dev/null +++ b/app/src/main/java/io/legado/app/utils/ReadRecordTimeFormatter.kt @@ -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" +}