feat(date-recorder): 美化记录器界面,卡片图上叠加文字、排序菜单精简

- 网格卡片:照片铺满整卡,底部渐变蒙层叠加标题与拍摄日期
- 网格改为自适应约 3 列,间距 8dp→4dp,卡片圆角 12dp→8dp
- 排序菜单 6 项精简为 3 个字段项,点击已选字段切换升降序
- 新增 RecordSortOrder.nextAfter 纯函数及单元测试
This commit is contained in:
xfy 2026-07-20 18:28:39 +08:00
parent b589800eda
commit d47a38bc19
3 changed files with 127 additions and 45 deletions

View File

@ -22,6 +22,21 @@ data class RecordSortOrder(
companion object {
/** 默认按拍摄日期降序(最新的在前) */
val DEFAULT = RecordSortOrder(RecordSortField.SHOOT_DATE, ascending = false)
/**
* 计算用户在排序菜单中点击某字段后的下一个排序方式
*
* 点击当前已选字段时翻转方向点击其他字段时切换字段并保持方向不变
*
* @param current 当前排序方式
* @param field 用户点击的排序字段
*/
fun nextAfter(current: RecordSortOrder, field: RecordSortField): RecordSortOrder =
if (current.field == field) {
current.copy(ascending = !current.ascending)
} else {
RecordSortOrder(field, current.ascending)
}
}
}
@ -179,8 +194,9 @@ class DateRecorderViewModel(
RecordSortField.LINKED_DATE -> compareBy(nullsLast()) { it.linkedDate }
RecordSortField.CREATED_AT -> compareBy { it.createdAt }
}
val ordered = if (order.ascending) comparator else comparator.reversed()
return records.sortedWith(ordered.then(compareBy { it.id }))
// id 兜底随主排序一起反转保证降序时同值记录也是最新id 最大)的在前
val ordered = comparator.then(compareBy { it.id })
return records.sortedWith(if (order.ascending) ordered else ordered.reversed())
}
}
}

View File

@ -17,6 +17,8 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.ChevronLeft
@ -49,12 +51,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@ -73,7 +77,7 @@ import plus.rua.project.RecordSortOrder
* 功能
* - 右下角浮动按钮拍摄新照片创建记录
* - 点击单条记录进入详情页
* - TopAppBar 右侧"排序"菜单6 种排序组合
* - TopAppBar 右侧"排序"菜单3 个排序字段点击已选字段切换升降序
* - TopAppBar 右侧"多选"按钮进入多选态可批量删除
*
* @param onBack 返回回调
@ -142,16 +146,39 @@ fun DateRecorderScreen(
expanded = showSortMenu,
onDismissRequest = { showSortMenu = false }
) {
sortMenuItems().forEach { item ->
sortFields.forEach { (field, label) ->
val selected = uiState.sortOrder.field == field
DropdownMenuItem(
text = { Text(item.label) },
text = { Text(label) },
onClick = {
viewModel.setSortOrder(item.order)
viewModel.setSortOrder(
RecordSortOrder.nextAfter(uiState.sortOrder, field)
)
showSortMenu = false
},
leadingIcon = if (uiState.sortOrder == item.order) {
leadingIcon = if (selected) {
{ Icon(Icons.Filled.Check, contentDescription = null) }
} else null
} else {
null
},
trailingIcon = if (selected) {
{
Icon(
imageVector = if (uiState.sortOrder.ascending) {
Icons.Filled.ArrowUpward
} else {
Icons.Filled.ArrowDownward
},
contentDescription = if (uiState.sortOrder.ascending) {
"旧→新"
} else {
"新→旧"
}
)
}
} else {
null
}
)
}
}
@ -248,15 +275,11 @@ fun DateRecorderScreen(
}
}
private data class SortMenuItem(val order: RecordSortOrder, val label: String)
private fun sortMenuItems(): List<SortMenuItem> = listOf(
SortMenuItem(RecordSortOrder(RecordSortField.SHOOT_DATE, false), "拍摄日期 · 新→旧"),
SortMenuItem(RecordSortOrder(RecordSortField.SHOOT_DATE, true), "拍摄日期 · 旧→新"),
SortMenuItem(RecordSortOrder(RecordSortField.LINKED_DATE, false), "关联日期 · 新→旧"),
SortMenuItem(RecordSortOrder(RecordSortField.LINKED_DATE, true), "关联日期 · 旧→新"),
SortMenuItem(RecordSortOrder(RecordSortField.CREATED_AT, false), "创建时间 · 新→旧"),
SortMenuItem(RecordSortOrder(RecordSortField.CREATED_AT, true), "创建时间 · 旧→新")
/** 排序菜单展示的字段选项,点击已选字段时切换方向(见 [RecordSortOrder.nextAfter])。 */
private val sortFields = listOf(
RecordSortField.SHOOT_DATE to "拍摄日期",
RecordSortField.LINKED_DATE to "关联日期",
RecordSortField.CREATED_AT to "创建时间"
)
@Composable
@ -269,11 +292,11 @@ private fun RecordGrid(
onToggleSelection: (Long) -> Unit
) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
columns = GridCells.Adaptive(minSize = 100.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(records, key = { it.id }) { record ->
RecordCard(
@ -301,35 +324,46 @@ private fun RecordCard(
) {
Card(
onClick = onClick,
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
),
shape = RoundedCornerShape(8.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
modifier = modifier.fillMaxWidth()
) {
Box {
Column {
AsyncImage(
uri = photoUri,
contentDescription = record.title,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
AsyncImage(
uri = photoUri,
contentDescription = record.title,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
)
// 底部渐变蒙层,保证叠加文字在各种照片上的可读性
Column(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.65f)
)
)
)
.padding(horizontal = 8.dp, vertical = 6.dp)
) {
Text(
text = record.title,
style = MaterialTheme.typography.titleSmall,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "${record.shootDate}",
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.85f)
)
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = record.title,
style = MaterialTheme.typography.titleSmall,
maxLines = 1
)
Text(
text = "${record.shootDate}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// 多选态下的复选角标

View File

@ -76,6 +76,20 @@ class DateRecorderSortTest {
assertEquals(listOf(1L, 2L, 3L), sorted.map { it.id })
}
@Test
fun sortByShootDate_descending_sameDate_newestIdFirst() {
// 同一天拍摄多条记录降序时最新拍摄id 最大)应排在最前
val sameDay = listOf(
record(id = 1, shoot = LocalDate(2026, 7, 20), created = Instant.fromEpochSeconds(1000)),
record(id = 2, shoot = LocalDate(2026, 7, 20), created = Instant.fromEpochSeconds(2000))
)
val sorted = DateRecorderViewModel.sortDateRecords(
sameDay,
RecordSortOrder(RecordSortField.SHOOT_DATE, ascending = false)
)
assertEquals(listOf(2L, 1L), sorted.map { it.id })
}
@Test
fun sort_emptyList_returnsEmpty() {
val sorted = DateRecorderViewModel.sortDateRecords(
@ -101,6 +115,24 @@ class DateRecorderSortTest {
assertEquals(listOf(2L, 5L, 8L), sorted.map { it.id })
}
@Test
fun nextAfter_sameField_togglesDirection() {
val current = RecordSortOrder(RecordSortField.SHOOT_DATE, ascending = false)
assertEquals(
RecordSortOrder(RecordSortField.SHOOT_DATE, ascending = true),
RecordSortOrder.nextAfter(current, RecordSortField.SHOOT_DATE)
)
}
@Test
fun nextAfter_otherField_switchesFieldKeepsDirection() {
val current = RecordSortOrder(RecordSortField.SHOOT_DATE, ascending = true)
assertEquals(
RecordSortOrder(RecordSortField.CREATED_AT, ascending = true),
RecordSortOrder.nextAfter(current, RecordSortField.CREATED_AT)
)
}
private fun record(
id: Long,
title: String = "t",