[优化] 增加书籍详情界面的阅读历史
This commit is contained in:
7 files changed
+312
-29
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 },
|
||||||
actionBook?.let { searchBook ->
|
title = stringResource(R.string.book_type_different),
|
||||||
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) })
|
text = stringResource(R.string.soure_change_source),
|
||||||
}
|
confirmText = stringResource(android.R.string.ok),
|
||||||
if (loadingAction) {
|
onConfirm = { searchBook ->
|
||||||
AppAlertDialog(show = true, onDismissRequest = {}, content = {
|
actionBook = searchBook
|
||||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
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.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"
|
||||||
|
}
|
||||||
Reference in new issue
Block a user