[优化] 左滑触发合并操作,优化界面

This commit is contained in:
HapeLee committed 2026-02-21 02:03:02 +08:00
1 parent 24970eff44
commit 3c40135f12
3 files changed
+184 -111

No files matched your search

@@ -212,6 +212,7 @@ class ReadRecordRepository(
} }
} }
@Transaction
private suspend fun mergeSingleReadRecordInto(targetRecord: ReadRecord, sourceRecord: ReadRecord) { private suspend fun mergeSingleReadRecordInto(targetRecord: ReadRecord, sourceRecord: ReadRecord) {
if (targetRecord == sourceRecord) return if (targetRecord == sourceRecord) return
if (targetRecord.deviceId != sourceRecord.deviceId) return if (targetRecord.deviceId != sourceRecord.deviceId) return
@@ -3,6 +3,7 @@ package io.legado.app.ui.book.readRecord
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -23,6 +24,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Merge
import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Timeline import androidx.compose.material.icons.filled.Timeline
@@ -36,6 +38,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -47,6 +51,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -56,8 +61,11 @@ import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import cn.hutool.core.date.DateUtil import cn.hutool.core.date.DateUtil
@@ -75,6 +83,7 @@ import io.legado.app.ui.widget.components.button.SmallTopBarButton
import io.legado.app.ui.widget.components.swipe.SwipeAction import io.legado.app.ui.widget.components.swipe.SwipeAction
import io.legado.app.ui.widget.components.swipe.SwipeActionContainer import io.legado.app.ui.widget.components.swipe.SwipeActionContainer
import io.legado.app.utils.StringUtils.formatFriendlyDate import io.legado.app.utils.StringUtils.formatFriendlyDate
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -87,6 +96,9 @@ fun ReadRecordScreen(
onBackClick: () -> Unit, onBackClick: () -> Unit,
onBookClick: (String, String) -> Unit onBookClick: (String, String) -> Unit
) { ) {
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val displayMode by viewModel.displayMode.collectAsState() val displayMode by viewModel.displayMode.collectAsState()
var showSearch by remember { mutableStateOf(false) } var showSearch by remember { mutableStateOf(false) }
@@ -96,7 +108,7 @@ fun ReadRecordScreen(
var skipDeleteConfirm by remember { mutableStateOf(false) } var skipDeleteConfirm by remember { mutableStateOf(false) }
var pendingDeleteAction by remember { mutableStateOf<(() -> Unit)?>(null) } var pendingDeleteAction by remember { mutableStateOf<(() -> Unit)?>(null) }
var mergeTargetRecord by remember { mutableStateOf<ReadRecord?>(null) } var mergeDialogData by remember { mutableStateOf<Pair<ReadRecord, List<ReadRecord>>?>(null) }
val onConfirmDelete: (() -> Unit) -> Unit = { action -> val onConfirmDelete: (() -> Unit) -> Unit = { action ->
if (skipDeleteConfirm) { if (skipDeleteConfirm) {
action() action()
@@ -113,6 +125,7 @@ fun ReadRecordScreen(
Scaffold( Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
Column { Column {
GlassMediumFlexibleTopAppBar( GlassMediumFlexibleTopAppBar(
@@ -228,7 +241,16 @@ fun ReadRecordScreen(
viewModel = viewModel, viewModel = viewModel,
onBookClick = onBookClick, onBookClick = onBookClick,
onConfirmDelete = onConfirmDelete, onConfirmDelete = onConfirmDelete,
onMergeClick = { mergeTargetRecord = it } onMergeClick = { record ->
scope.launch {
val candidates = viewModel.getMergeCandidates(record)
if (candidates.isEmpty()) {
snackbarHostState.showSnackbar("没有可合并的同名记录")
} else {
mergeDialogData = record to candidates
}
}
}
) )
} }
} }
@@ -289,49 +311,40 @@ fun ReadRecordScreen(
) )
} }
mergeTargetRecord?.let { targetRecord -> mergeDialogData?.let { (targetRecord, candidates) ->
val candidates by produceState(
initialValue = emptyList<ReadRecord>(), var selectedAuthors by remember(targetRecord, candidates) {
key1 = targetRecord.bookName,
key2 = targetRecord.bookAuthor
) {
value = viewModel.getMergeCandidates(targetRecord)
}
var selectedAuthors by remember(targetRecord.bookName, targetRecord.bookAuthor, candidates) {
mutableStateOf(candidates.map { it.bookAuthor }.toSet()) mutableStateOf(candidates.map { it.bookAuthor }.toSet())
} }
AlertDialog( AlertDialog(
onDismissRequest = { mergeTargetRecord = null }, onDismissRequest = { mergeDialogData = null },
title = { Text("合并阅读记录") }, title = { Text("合并阅读记录") },
text = { text = {
if (candidates.isEmpty()) { Column {
Text("没有可合并的同名记录") Text("将以下作者的“${targetRecord.bookName}”合并到 ${targetRecord.bookAuthor.ifBlank { "未知作者" }}")
} else { Spacer(modifier = Modifier.height(8.dp))
Column {
Text("将以下作者的“${targetRecord.bookName}”合并到 ${targetRecord.bookAuthor.ifBlank { "未知作者" }}") candidates.forEach { candidate ->
Spacer(modifier = Modifier.height(8.dp)) val author = candidate.bookAuthor.ifBlank { "未知作者" }
candidates.forEach { candidate -> val isChecked = selectedAuthors.contains(candidate.bookAuthor)
val author = candidate.bookAuthor.ifBlank { "未知作者" } Row(
val isChecked = selectedAuthors.contains(candidate.bookAuthor) modifier = Modifier
Row( .fillMaxWidth()
modifier = Modifier .clickable {
.fillMaxWidth() selectedAuthors =
.clickable { if (isChecked) selectedAuthors - candidate.bookAuthor
selectedAuthors = else selectedAuthors + candidate.bookAuthor
if (isChecked) selectedAuthors - candidate.bookAuthor }
else selectedAuthors + candidate.bookAuthor .padding(vertical = 6.dp),
} verticalAlignment = Alignment.CenterVertically
.padding(vertical = 6.dp), ) {
verticalAlignment = Alignment.CenterVertically Checkbox(checked = isChecked, onCheckedChange = null)
) { Text(
Checkbox(checked = isChecked, onCheckedChange = null) text = "$author(${formatDuring(candidate.readTime)})",
Text( modifier = Modifier.padding(start = 8.dp),
text = "$author(${formatDuring(candidate.readTime)})", style = MaterialTheme.typography.bodyMedium
modifier = Modifier.padding(start = 8.dp), )
style = MaterialTheme.typography.bodyMedium
)
}
} }
} }
} }
@@ -339,20 +352,18 @@ fun ReadRecordScreen(
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onClick = {
if (candidates.isNotEmpty()) { viewModel.mergeReadRecords(
viewModel.mergeReadRecords( targetRecord,
targetRecord, candidates.filter { selectedAuthors.contains(it.bookAuthor) }
candidates.filter { selectedAuthors.contains(it.bookAuthor) } )
) mergeDialogData = null
}
mergeTargetRecord = null
} }
) { ) {
Text("合并") Text("合并")
} }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { mergeTargetRecord = null }) { TextButton(onClick = { mergeDialogData = null }) {
Text("取消") Text("取消")
} }
} }
@@ -409,6 +420,7 @@ fun LazyListScope.renderListByMode(
onConfirmDelete: (() -> Unit) -> Unit, onConfirmDelete: (() -> Unit) -> Unit,
onMergeClick: (ReadRecord) -> Unit onMergeClick: (ReadRecord) -> Unit
) { ) {
when (displayMode) { when (displayMode) {
DisplayMode.AGGREGATE -> { DisplayMode.AGGREGATE -> {
state.groupedRecords.forEach { (date, details) -> state.groupedRecords.forEach { (date, details) ->
@@ -469,13 +481,19 @@ fun LazyListScope.renderListByMode(
onSwipe = { onSwipe = {
onConfirmDelete { viewModel.deleteReadRecord(record) } onConfirmDelete { viewModel.deleteReadRecord(record) }
} }
),
endAction = SwipeAction(
icon = Icons.Default.Merge,
background = MaterialTheme.colorScheme.primary,
onSwipe = {
onMergeClick(record)
}
) )
) { ) {
LatestReadItem( LatestReadItem(
record = record, record = record,
viewModel = viewModel, viewModel = viewModel,
onClick = { onBookClick(record.bookName, record.bookAuthor) }, onClick = { onBookClick(record.bookName, record.bookAuthor) }
onMergeClick = { onMergeClick(record) }
) )
} }
} }
@@ -488,7 +506,6 @@ fun LatestReadItem(
record: ReadRecord, record: ReadRecord,
viewModel: ReadRecordViewModel, viewModel: ReadRecordViewModel,
onClick: () -> Unit, onClick: () -> Unit,
onMergeClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var coverPath by remember { mutableStateOf<String?>(null) } var coverPath by remember { mutableStateOf<String?>(null) }
@@ -512,29 +529,37 @@ fun LatestReadItem(
Text( Text(
text = record.bookName, text = record.bookName,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
maxLines = 2 maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
Text( Text(
text = record.bookAuthor.ifBlank { "未知作者" }, text = record.bookAuthor.ifBlank { "未知作者" },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = Color.Gray, color = MaterialTheme.colorScheme.outline,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "总时长: ${formatDuring(record.readTime)}", modifier = Modifier
style = MaterialTheme.typography.bodySmall, .basicMarquee(
color = Color.Gray iterations = Int.MAX_VALUE,
repeatDelayMillis = 2000,
initialDelayMillis = 1000
),
text = buildAnnotatedString {
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.outline)) {
append(formatDuring(record.readTime))
append(" • ")
}
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append("${DateUtil.format(Date(record.lastRead), "yyyy-MM-dd HH:mm")}")
}
},
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
Text(
text = "最后阅读: ${DateUtil.format(Date(record.lastRead), "yyyy-MM-dd HH:mm")}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
TextButton(onClick = onMergeClick) {
Text("合并")
} }
} }
} }
@@ -613,21 +638,21 @@ fun TimelineSessionItem(
Text( Text(
text = session.bookName, text = session.bookName,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
maxLines = 2, maxLines = 1,
minLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Text( Text(
text = session.bookAuthor.ifBlank { "未知作者" }, text = session.bookAuthor.ifBlank { "未知作者" },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = Color.Gray, color = MaterialTheme.colorScheme.outline,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = chapterTitle.orEmpty(), text = chapterTitle.orEmpty(),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.labelSmall,
color = Color.Gray, color = MaterialTheme.colorScheme.outline,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@@ -665,18 +690,18 @@ fun ReadRecordItem(
Text( Text(
text = detail.bookName, text = detail.bookName,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
maxLines = 2 maxLines = 1
) )
Text( Text(
text = detail.bookAuthor.ifBlank { "未知作者" }, text = detail.bookAuthor.ifBlank { "未知作者" },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = Color.Gray color = MaterialTheme.colorScheme.outline,
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "阅读时长: ${formatDuring(detail.readTime)}", text = "阅读时长: ${formatDuring(detail.readTime)}",
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline,
color = Color.Gray style = MaterialTheme.typography.labelSmall
) )
} }
} }
@@ -31,6 +31,7 @@ import androidx.compose.ui.unit.dp
fun SwipeActionContainer( fun SwipeActionContainer(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
startAction: SwipeAction? = null, startAction: SwipeAction? = null,
endAction: SwipeAction? = null,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
@@ -39,8 +40,6 @@ fun SwipeActionContainer(
totalDistance * 0.6f totalDistance * 0.6f
} }
) )
val isThresholdReached =
dismissState.progress > 0.5f && dismissState.targetValue == SwipeToDismissBoxValue.StartToEnd
LaunchedEffect(dismissState.targetValue) { LaunchedEffect(dismissState.targetValue) {
if (dismissState.targetValue == SwipeToDismissBoxValue.StartToEnd) { if (dismissState.targetValue == SwipeToDismissBoxValue.StartToEnd) {
@@ -51,9 +50,18 @@ fun SwipeActionContainer(
} }
LaunchedEffect(dismissState.settledValue) { LaunchedEffect(dismissState.settledValue) {
if (dismissState.settledValue == SwipeToDismissBoxValue.StartToEnd) { when (dismissState.settledValue) {
startAction?.onSwipe?.invoke() SwipeToDismissBoxValue.StartToEnd -> {
dismissState.reset() startAction?.onSwipe?.invoke()
dismissState.reset()
}
SwipeToDismissBoxValue.EndToStart -> {
endAction?.onSwipe?.invoke()
dismissState.reset()
}
else -> {}
} }
} }
@@ -61,44 +69,34 @@ fun SwipeActionContainer(
state = dismissState, state = dismissState,
modifier = modifier, modifier = modifier,
enableDismissFromStartToEnd = startAction != null, enableDismissFromStartToEnd = startAction != null,
enableDismissFromEndToStart = false, enableDismissFromEndToStart = endAction != null,
backgroundContent = { backgroundContent = {
val direction = dismissState.dismissDirection val direction = dismissState.dismissDirection
val progress = dismissState.progress val progress = dismissState.progress
if (direction == SwipeToDismissBoxValue.StartToEnd) { when (direction) {
val backgroundColor by animateColorAsState(
targetValue = if (isThresholdReached) startAction!!.background
else MaterialTheme.colorScheme.surfaceVariant,
label = "bgColor"
)
Box( SwipeToDismissBoxValue.StartToEnd -> {
modifier = Modifier startAction?.let { action ->
.fillMaxSize() SwipeBackground(
.background(backgroundColor) action = action,
.padding(horizontal = 24.dp), progress = progress,
contentAlignment = Alignment.CenterStart alignStart = true
) { )
val iconScale by animateFloatAsState( }
targetValue = if (isThresholdReached) 1.3f else progress.coerceIn(0.5f, 1f),
label = "iconScale"
)
Icon(
imageVector = startAction!!.icon,
contentDescription = null,
modifier = Modifier
.size(24.dp)
.graphicsLayer {
scaleX = iconScale
scaleY = iconScale
},
tint = if (isThresholdReached)
contentColorFor(startAction.background)
else MaterialTheme.colorScheme.onSurfaceVariant
)
} }
SwipeToDismissBoxValue.EndToStart -> {
endAction?.let { action ->
SwipeBackground(
action = action,
progress = progress,
alignStart = false
)
}
}
else -> {}
} }
}, },
content = { content = {
@@ -112,3 +110,52 @@ fun SwipeActionContainer(
} }
) )
} }
@Composable
private fun SwipeBackground(
action: SwipeAction,
progress: Float,
alignStart: Boolean
) {
val isThresholdReached = progress > 0.6f
val backgroundColor by animateColorAsState(
targetValue = if (isThresholdReached)
action.background
else
MaterialTheme.colorScheme.surfaceVariant,
label = "bgColor"
)
val iconScale by animateFloatAsState(
targetValue = if (isThresholdReached) 1.3f
else progress.coerceIn(0.5f, 1f),
label = "iconScale"
)
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
.padding(horizontal = 24.dp),
contentAlignment = if (alignStart)
Alignment.CenterStart
else
Alignment.CenterEnd
) {
Icon(
imageVector = action.icon,
contentDescription = null,
modifier = Modifier
.size(24.dp)
.graphicsLayer {
scaleX = iconScale
scaleY = iconScale
},
tint = if (isThresholdReached)
contentColorFor(action.background)
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
}