refactor(date-recorder): use SharedTransitionLayout for smooth view mode card morphing
This commit is contained in:
parent
2a3da75859
commit
1248dff648
@ -3,6 +3,10 @@ package plus.rua.project.ui
|
|||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.animation.SharedTransitionLayout
|
||||||
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
import androidx.compose.animation.animateContentSize
|
import androidx.compose.animation.animateContentSize
|
||||||
import androidx.compose.animation.core.Spring
|
import androidx.compose.animation.core.Spring
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
@ -700,6 +704,7 @@ private fun SortOrderDirectionOption(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun RecordGrid(
|
private fun RecordGrid(
|
||||||
records: List<DateRecord>,
|
records: List<DateRecord>,
|
||||||
@ -727,121 +732,151 @@ private fun RecordGrid(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val columns = when (viewMode) {
|
SharedTransitionLayout(modifier = Modifier.fillMaxSize()) {
|
||||||
DateRecorderViewMode.TIMELINE -> GridCells.Fixed(1)
|
AnimatedContent(
|
||||||
DateRecorderViewMode.GRID -> GridCells.Fixed(2)
|
targetState = viewMode,
|
||||||
DateRecorderViewMode.COMPACT -> GridCells.Adaptive(minSize = 100.dp)
|
label = "date_recorder_view_mode_transition",
|
||||||
}
|
transitionSpec = {
|
||||||
|
fadeIn(tween(280)) togetherWith fadeOut(tween(280))
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) { currentMode ->
|
||||||
|
val columns = when (currentMode) {
|
||||||
|
DateRecorderViewMode.TIMELINE -> GridCells.Fixed(1)
|
||||||
|
DateRecorderViewMode.GRID -> GridCells.Fixed(2)
|
||||||
|
DateRecorderViewMode.COMPACT -> GridCells.Adaptive(minSize = 100.dp)
|
||||||
|
}
|
||||||
|
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
state = gridState,
|
state = gridState,
|
||||||
columns = columns,
|
columns = columns,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
awaitEachGesture {
|
awaitEachGesture {
|
||||||
val down = awaitFirstDown(pass = PointerEventPass.Initial, requireUnconsumed = false)
|
val down = awaitFirstDown(pass = PointerEventPass.Initial, requireUnconsumed = false)
|
||||||
val startOffset = down.position
|
val startOffset = down.position
|
||||||
val startRecordId = findRecordIdAtOffset(gridState, startOffset) ?: return@awaitEachGesture
|
val startRecordId = findRecordIdAtOffset(gridState, startOffset) ?: return@awaitEachGesture
|
||||||
val startTime = down.uptimeMillis
|
val startTime = down.uptimeMillis
|
||||||
|
|
||||||
var isDragSelecting = false
|
var isDragSelecting = false
|
||||||
var isScrolling = false
|
var isScrolling = false
|
||||||
val initialSelected = if (currentSelectionMode) currentSelectedIds else emptySet()
|
val initialSelected = if (currentSelectionMode) currentSelectedIds else emptySet()
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
val event = awaitPointerEvent(pass = PointerEventPass.Initial)
|
val event = awaitPointerEvent(pass = PointerEventPass.Initial)
|
||||||
val pointerChange = event.changes.firstOrNull { it.id == down.id } ?: break
|
val pointerChange = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||||
if (!pointerChange.pressed) break
|
if (!pointerChange.pressed) break
|
||||||
|
|
||||||
val currentOffset = pointerChange.position
|
val currentOffset = pointerChange.position
|
||||||
val delta = currentOffset - startOffset
|
val delta = currentOffset - startOffset
|
||||||
val distance = delta.getDistance()
|
val distance = delta.getDistance()
|
||||||
val elapsedTime = pointerChange.uptimeMillis - startTime
|
val elapsedTime = pointerChange.uptimeMillis - startTime
|
||||||
|
|
||||||
if (!isDragSelecting && !isScrolling) {
|
if (!isDragSelecting && !isScrolling) {
|
||||||
if (currentSelectionMode) {
|
if (currentSelectionMode) {
|
||||||
if (distance > 20f && elapsedTime < 120L && kotlin.math.abs(delta.y) > kotlin.math.abs(delta.x) * 2f) {
|
if (distance > 20f && elapsedTime < 120L && kotlin.math.abs(delta.y) > kotlin.math.abs(delta.x) * 2f) {
|
||||||
isScrolling = true
|
isScrolling = true
|
||||||
} else if (distance >= 16f || (elapsedTime >= 120L && distance >= 6f)) {
|
} else if (distance >= 16f || (elapsedTime >= 120L && distance >= 6f)) {
|
||||||
isDragSelecting = true
|
isDragSelecting = true
|
||||||
currentOnSetSelectedIds(initialSelected + startRecordId)
|
currentOnSetSelectedIds(initialSelected + startRecordId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (distance > 30f && elapsedTime < 200L && kotlin.math.abs(delta.y) > kotlin.math.abs(delta.x) * 1.5f) {
|
||||||
|
isScrolling = true
|
||||||
|
} else if (elapsedTime >= 250L || (elapsedTime >= 150L && distance >= 10f)) {
|
||||||
|
isDragSelecting = true
|
||||||
|
currentOnSetSelectedIds(setOf(startRecordId))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
if (distance > 30f && elapsedTime < 200L && kotlin.math.abs(delta.y) > kotlin.math.abs(delta.x) * 1.5f) {
|
if (isDragSelecting) {
|
||||||
isScrolling = true
|
pointerChange.consume()
|
||||||
} else if (elapsedTime >= 250L || (elapsedTime >= 150L && distance >= 10f)) {
|
val currentRecordId = findRecordIdAtOffset(gridState, currentOffset)
|
||||||
isDragSelecting = true
|
if (currentRecordId != null) {
|
||||||
currentOnSetSelectedIds(setOf(startRecordId))
|
val startIdx = currentRecords.indexOfFirst { it.id == startRecordId }
|
||||||
|
val currentIdx = currentRecords.indexOfFirst { it.id == currentRecordId }
|
||||||
|
if (startIdx != -1 && currentIdx != -1) {
|
||||||
|
val minIdx = minOf(startIdx, currentIdx)
|
||||||
|
val maxIdx = maxOf(startIdx, currentIdx)
|
||||||
|
val draggedIds = (minIdx..maxIdx).map { currentRecords[it].id }.toSet()
|
||||||
|
currentOnSetSelectedIds(initialSelected + draggedIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val viewportHeight = gridState.layoutInfo.viewportSize.height
|
||||||
|
if (currentOffset.y < 120f) {
|
||||||
|
coroutineScope.launch { gridState.scrollBy(-30f) }
|
||||||
|
} else if (currentOffset.y > viewportHeight - 120f) {
|
||||||
|
coroutineScope.launch { gridState.scrollBy(25f) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
contentPadding = PaddingValues(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 80.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
groupedRecords.forEach { (monthTitle, monthRecords) ->
|
||||||
|
item(
|
||||||
|
key = "header_$monthTitle",
|
||||||
|
span = { GridItemSpan(maxLineSpan) }
|
||||||
|
) {
|
||||||
|
MonthHeaderItem(title = monthTitle, count = monthRecords.size)
|
||||||
|
}
|
||||||
|
|
||||||
if (isDragSelecting) {
|
items(
|
||||||
pointerChange.consume()
|
items = monthRecords,
|
||||||
val currentRecordId = findRecordIdAtOffset(gridState, currentOffset)
|
key = { it.id }
|
||||||
if (currentRecordId != null) {
|
) { record ->
|
||||||
val startIdx = currentRecords.indexOfFirst { it.id == startRecordId }
|
val photoUri = "file://${photoRoot.absoluteFileOf(record.photoPath).absolutePath}"
|
||||||
val currentIdx = currentRecords.indexOfFirst { it.id == currentRecordId }
|
val isSelected = record.id in selectedIds
|
||||||
if (startIdx != -1 && currentIdx != -1) {
|
|
||||||
val minIdx = minOf(startIdx, currentIdx)
|
when (currentMode) {
|
||||||
val maxIdx = maxOf(startIdx, currentIdx)
|
DateRecorderViewMode.TIMELINE -> {
|
||||||
val draggedIds = (minIdx..maxIdx).map { currentRecords[it].id }.toSet()
|
TimelineRecordCard(
|
||||||
currentOnSetSelectedIds(initialSelected + draggedIds)
|
record = record,
|
||||||
}
|
photoUri = photoUri,
|
||||||
|
isSelected = isSelected,
|
||||||
|
selectionMode = selectionMode,
|
||||||
|
animatedVisibilityScope = this@AnimatedContent,
|
||||||
|
onClick = {
|
||||||
|
if (selectionMode) onToggleSelection(record.id)
|
||||||
|
else onOpenRecord(record.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
DateRecorderViewMode.GRID -> {
|
||||||
val viewportHeight = gridState.layoutInfo.viewportSize.height
|
GridRecordCard(
|
||||||
if (currentOffset.y < 120f) {
|
record = record,
|
||||||
coroutineScope.launch { gridState.scrollBy(-30f) }
|
photoUri = photoUri,
|
||||||
} else if (currentOffset.y > viewportHeight - 120f) {
|
isSelected = isSelected,
|
||||||
coroutineScope.launch { gridState.scrollBy(25f) }
|
selectionMode = selectionMode,
|
||||||
|
animatedVisibilityScope = this@AnimatedContent,
|
||||||
|
onClick = {
|
||||||
|
if (selectionMode) onToggleSelection(record.id)
|
||||||
|
else onOpenRecord(record.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DateRecorderViewMode.COMPACT -> {
|
||||||
|
CompactRecordCard(
|
||||||
|
record = record,
|
||||||
|
photoUri = photoUri,
|
||||||
|
isSelected = isSelected,
|
||||||
|
selectionMode = selectionMode,
|
||||||
|
animatedVisibilityScope = this@AnimatedContent,
|
||||||
|
onClick = {
|
||||||
|
if (selectionMode) onToggleSelection(record.id)
|
||||||
|
else onOpenRecord(record.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
contentPadding = PaddingValues(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 80.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
|
||||||
) {
|
|
||||||
groupedRecords.forEach { (monthTitle, monthRecords) ->
|
|
||||||
// 月份分组 Header (跨满整行)
|
|
||||||
item(
|
|
||||||
key = "header_$monthTitle",
|
|
||||||
span = { GridItemSpan(maxLineSpan) }
|
|
||||||
) {
|
|
||||||
MonthHeaderItem(title = monthTitle, count = monthRecords.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
items(
|
|
||||||
items = monthRecords,
|
|
||||||
key = { it.id }
|
|
||||||
) { record ->
|
|
||||||
val photoUri = "file://${photoRoot.absoluteFileOf(record.photoPath).absolutePath}"
|
|
||||||
val isSelected = record.id in selectedIds
|
|
||||||
|
|
||||||
val cardModifier = Modifier.animateItem(
|
|
||||||
fadeInSpec = tween(300),
|
|
||||||
fadeOutSpec = tween(300),
|
|
||||||
placementSpec = spring(
|
|
||||||
stiffness = Spring.StiffnessMediumLow,
|
|
||||||
dampingRatio = Spring.DampingRatioMediumBouncy
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
AnimatedRecordCard(
|
|
||||||
record = record,
|
|
||||||
photoUri = photoUri,
|
|
||||||
isSelected = isSelected,
|
|
||||||
selectionMode = selectionMode,
|
|
||||||
viewMode = viewMode,
|
|
||||||
onClick = {
|
|
||||||
if (selectionMode) onToggleSelection(record.id)
|
|
||||||
else onOpenRecord(record.id)
|
|
||||||
},
|
|
||||||
modifier = cardModifier
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -891,76 +926,42 @@ private fun MonthHeaderItem(title: String, count: Int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 支持视图切换平滑变形过渡的统一记录卡片。 */
|
/** 时光流模式 (TIMELINE) 卡片:宽幅展图、标题手记摘要、拍摄与关联日期 Badge。 */
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun AnimatedRecordCard(
|
private fun SharedTransitionScope.TimelineRecordCard(
|
||||||
record: DateRecord,
|
record: DateRecord,
|
||||||
photoUri: String,
|
photoUri: String,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
selectionMode: Boolean,
|
selectionMode: Boolean,
|
||||||
viewMode: DateRecorderViewMode,
|
animatedVisibilityScope: AnimatedVisibilityScope,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val targetCorner = when (viewMode) {
|
|
||||||
DateRecorderViewMode.TIMELINE -> 16.dp
|
|
||||||
DateRecorderViewMode.GRID -> 14.dp
|
|
||||||
DateRecorderViewMode.COMPACT -> 8.dp
|
|
||||||
}
|
|
||||||
val cornerRadius by animateDpAsState(
|
|
||||||
targetValue = targetCorner,
|
|
||||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
|
|
||||||
label = "card_corner_radius"
|
|
||||||
)
|
|
||||||
|
|
||||||
val targetScale = if (selectionMode && isSelected) {
|
|
||||||
when (viewMode) {
|
|
||||||
DateRecorderViewMode.TIMELINE -> 0.96f
|
|
||||||
DateRecorderViewMode.GRID -> 0.95f
|
|
||||||
DateRecorderViewMode.COMPACT -> 0.92f
|
|
||||||
}
|
|
||||||
} else 1f
|
|
||||||
val cardScale by animateFloatAsState(
|
val cardScale by animateFloatAsState(
|
||||||
targetValue = targetScale,
|
targetValue = if (selectionMode && isSelected) 0.96f else 1f,
|
||||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
|
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
|
||||||
label = "card_scale"
|
label = "timeline_card_scale"
|
||||||
)
|
)
|
||||||
|
|
||||||
val targetBorderWidth = if (selectionMode && isSelected) {
|
|
||||||
when (viewMode) {
|
|
||||||
DateRecorderViewMode.TIMELINE, DateRecorderViewMode.GRID -> 2.5.dp
|
|
||||||
DateRecorderViewMode.COMPACT -> 2.dp
|
|
||||||
}
|
|
||||||
} else 0.dp
|
|
||||||
val borderWidth by animateDpAsState(
|
val borderWidth by animateDpAsState(
|
||||||
targetValue = targetBorderWidth,
|
targetValue = if (selectionMode && isSelected) 2.5.dp else 0.dp,
|
||||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
|
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
|
||||||
label = "card_border"
|
label = "timeline_card_border"
|
||||||
)
|
|
||||||
|
|
||||||
val defaultElevation = if (viewMode == DateRecorderViewMode.COMPACT) 0.dp else 1.dp
|
|
||||||
val elevation by animateDpAsState(
|
|
||||||
targetValue = defaultElevation,
|
|
||||||
animationSpec = tween(200),
|
|
||||||
label = "card_elevation"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
shape = RoundedCornerShape(cornerRadius),
|
shape = RoundedCornerShape(16.dp),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
|
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
|
||||||
),
|
),
|
||||||
border = BorderStroke(borderWidth, MaterialTheme.colorScheme.primary),
|
border = BorderStroke(borderWidth, MaterialTheme.colorScheme.primary),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = elevation),
|
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.animateContentSize(
|
.sharedBounds(
|
||||||
animationSpec = spring(
|
sharedContentState = rememberSharedContentState(key = "card_${record.id}"),
|
||||||
stiffness = Spring.StiffnessLow,
|
animatedVisibilityScope = animatedVisibilityScope
|
||||||
dampingRatio = Spring.DampingRatioMediumBouncy
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
scaleX = cardScale
|
scaleX = cardScale
|
||||||
@ -969,69 +970,19 @@ private fun AnimatedRecordCard(
|
|||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
Box {
|
Box {
|
||||||
val imageModifier = when (viewMode) {
|
|
||||||
DateRecorderViewMode.TIMELINE -> Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(180.dp)
|
|
||||||
else -> Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.aspectRatio(1f)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
uri = photoUri,
|
uri = photoUri,
|
||||||
contentDescription = record.title,
|
contentDescription = record.title,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = imageModifier
|
modifier = Modifier
|
||||||
)
|
.fillMaxWidth()
|
||||||
|
.height(180.dp)
|
||||||
// 底部蒙层与文字信息 (仅在 GRID 网格模式下展示)
|
.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp))
|
||||||
androidx.compose.animation.AnimatedVisibility(
|
.sharedElement(
|
||||||
visible = viewMode == DateRecorderViewMode.GRID,
|
sharedContentState = rememberSharedContentState(key = "photo_${record.id}"),
|
||||||
enter = fadeIn(tween(200)) + expandVertically(),
|
animatedVisibilityScope = animatedVisibilityScope
|
||||||
exit = fadeOut(tween(150)) + shrinkVertically(),
|
|
||||||
modifier = Modifier.align(Alignment.BottomStart)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
Brush.verticalGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color.Transparent,
|
|
||||||
Color.Black.copy(alpha = 0.75f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = record.title.ifBlank { "记录" },
|
|
||||||
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
|
|
||||||
color = Color.White,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
)
|
||||||
Row(
|
)
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "${record.shootDate}",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = Color.White.copy(alpha = 0.85f)
|
|
||||||
)
|
|
||||||
if (record.linkedDate != null) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.CalendarToday,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = Color.White.copy(alpha = 0.85f),
|
|
||||||
modifier = Modifier.size(10.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 顶端多选 Badge
|
// 顶端多选 Badge
|
||||||
androidx.compose.animation.AnimatedVisibility(
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
@ -1040,63 +991,56 @@ private fun AnimatedRecordCard(
|
|||||||
exit = scaleOut() + fadeOut(),
|
exit = scaleOut() + fadeOut(),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.padding(if (viewMode == DateRecorderViewMode.COMPACT) 6.dp else 8.dp)
|
.padding(10.dp)
|
||||||
) {
|
) {
|
||||||
SelectionBadge(isSelected = isSelected)
|
SelectionBadge(isSelected = isSelected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 时光流模式 (TIMELINE) 下的文字手记与日期 Tag 区域
|
Column(modifier = Modifier.padding(14.dp)) {
|
||||||
androidx.compose.animation.AnimatedVisibility(
|
Text(
|
||||||
visible = viewMode == DateRecorderViewMode.TIMELINE,
|
text = record.title.ifBlank { "无标题记录" },
|
||||||
enter = expandVertically(spring(stiffness = Spring.StiffnessLow)) + fadeIn(),
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||||
exit = shrinkVertically(spring(stiffness = Spring.StiffnessLow)) + fadeOut()
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
) {
|
maxLines = 1,
|
||||||
Column(modifier = Modifier.padding(14.dp)) {
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
|
||||||
|
if (record.note.isNotBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = record.title.ifBlank { "无标题记录" },
|
text = record.note,
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (record.note.isNotBlank()) {
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = record.note,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
// 日期标签行
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
// 拍摄日期 Pill
|
||||||
|
DateTag(
|
||||||
|
icon = Icons.Outlined.PhotoCamera,
|
||||||
|
text = "拍摄 ${record.shootDate}",
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
// 日期标签行
|
// 关联日期 Pill
|
||||||
FlowRow(
|
if (record.linkedDate != null) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
val relativeDays = computeRelativeDaysDescription(record.shootDate, record.linkedDate)
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
|
||||||
// 拍摄日期 Pill
|
|
||||||
DateTag(
|
DateTag(
|
||||||
icon = Icons.Outlined.PhotoCamera,
|
icon = Icons.Outlined.CalendarToday,
|
||||||
text = "拍摄 ${record.shootDate}",
|
text = "关联 ${record.linkedDate}" + if (relativeDays.isNotBlank()) " ($relativeDays)" else "",
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
)
|
)
|
||||||
|
|
||||||
// 关联日期 Pill
|
|
||||||
if (record.linkedDate != null) {
|
|
||||||
val relativeDays = computeRelativeDaysDescription(record.shootDate, record.linkedDate)
|
|
||||||
DateTag(
|
|
||||||
icon = Icons.Outlined.CalendarToday,
|
|
||||||
text = "关联 ${record.linkedDate}" + if (relativeDays.isNotBlank()) " ($relativeDays)" else "",
|
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1104,6 +1048,182 @@ private fun AnimatedRecordCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 相册网格模式 (GRID) 卡片:双列拍立得相框与沉浸阴影。 */
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun SharedTransitionScope.GridRecordCard(
|
||||||
|
record: DateRecord,
|
||||||
|
photoUri: String,
|
||||||
|
isSelected: Boolean,
|
||||||
|
selectionMode: Boolean,
|
||||||
|
animatedVisibilityScope: AnimatedVisibilityScope,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val cardScale by animateFloatAsState(
|
||||||
|
targetValue = if (selectionMode && isSelected) 0.95f else 1f,
|
||||||
|
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
|
||||||
|
label = "grid_card_scale"
|
||||||
|
)
|
||||||
|
val borderWidth by animateDpAsState(
|
||||||
|
targetValue = if (selectionMode && isSelected) 2.5.dp else 0.dp,
|
||||||
|
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
|
||||||
|
label = "grid_card_border"
|
||||||
|
)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
|
||||||
|
border = BorderStroke(borderWidth, MaterialTheme.colorScheme.primary),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.sharedBounds(
|
||||||
|
sharedContentState = rememberSharedContentState(key = "card_${record.id}"),
|
||||||
|
animatedVisibilityScope = animatedVisibilityScope
|
||||||
|
)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = cardScale
|
||||||
|
scaleY = cardScale
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Box {
|
||||||
|
AsyncImage(
|
||||||
|
uri = photoUri,
|
||||||
|
contentDescription = record.title,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.sharedElement(
|
||||||
|
sharedContentState = rememberSharedContentState(key = "photo_${record.id}"),
|
||||||
|
animatedVisibilityScope = animatedVisibilityScope
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 底部蒙层与文字信息
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color.Transparent,
|
||||||
|
Color.Black.copy(alpha = 0.75f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = record.title.ifBlank { "记录" },
|
||||||
|
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = Color.White,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "${record.shootDate}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color.White.copy(alpha = 0.85f)
|
||||||
|
)
|
||||||
|
if (record.linkedDate != null) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.CalendarToday,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White.copy(alpha = 0.85f),
|
||||||
|
modifier = Modifier.size(10.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
|
visible = selectionMode,
|
||||||
|
enter = scaleIn(spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + fadeIn(),
|
||||||
|
exit = scaleOut() + fadeOut(),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
SelectionBadge(isSelected = isSelected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 紧凑矩阵模式 (COMPACT) 卡片:高效高密度。 */
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun SharedTransitionScope.CompactRecordCard(
|
||||||
|
record: DateRecord,
|
||||||
|
photoUri: String,
|
||||||
|
isSelected: Boolean,
|
||||||
|
selectionMode: Boolean,
|
||||||
|
animatedVisibilityScope: AnimatedVisibilityScope,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val cardScale by animateFloatAsState(
|
||||||
|
targetValue = if (selectionMode && isSelected) 0.92f else 1f,
|
||||||
|
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
|
||||||
|
label = "compact_card_scale"
|
||||||
|
)
|
||||||
|
val borderWidth by animateDpAsState(
|
||||||
|
targetValue = if (selectionMode && isSelected) 2.dp else 0.dp,
|
||||||
|
label = "compact_card_border"
|
||||||
|
)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
border = BorderStroke(borderWidth, MaterialTheme.colorScheme.primary),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.sharedBounds(
|
||||||
|
sharedContentState = rememberSharedContentState(key = "card_${record.id}"),
|
||||||
|
animatedVisibilityScope = animatedVisibilityScope
|
||||||
|
)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = cardScale
|
||||||
|
scaleY = cardScale
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Box {
|
||||||
|
AsyncImage(
|
||||||
|
uri = photoUri,
|
||||||
|
contentDescription = record.title,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.sharedElement(
|
||||||
|
sharedContentState = rememberSharedContentState(key = "photo_${record.id}"),
|
||||||
|
animatedVisibilityScope = animatedVisibilityScope
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
|
visible = selectionMode,
|
||||||
|
enter = scaleIn(spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + fadeIn(),
|
||||||
|
exit = scaleOut() + fadeOut(),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(6.dp)
|
||||||
|
) {
|
||||||
|
SelectionBadge(isSelected = isSelected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 日期标签 Chip。 */
|
/** 日期标签 Chip。 */
|
||||||
@Composable
|
@Composable
|
||||||
private fun DateTag(
|
private fun DateTag(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user