feat(date-recorder): 美化记录器界面,卡片图上叠加文字、排序菜单精简
- 网格卡片:照片铺满整卡,底部渐变蒙层叠加标题与拍摄日期 - 网格改为自适应约 3 列,间距 8dp→4dp,卡片圆角 12dp→8dp - 排序菜单 6 项精简为 3 个字段项,点击已选字段切换升降序 - 新增 RecordSortOrder.nextAfter 纯函数及单元测试
This commit is contained in:
parent
b589800eda
commit
d47a38bc19
@ -22,6 +22,21 @@ data class RecordSortOrder(
|
|||||||
companion object {
|
companion object {
|
||||||
/** 默认按拍摄日期降序(最新的在前) */
|
/** 默认按拍摄日期降序(最新的在前) */
|
||||||
val DEFAULT = RecordSortOrder(RecordSortField.SHOOT_DATE, ascending = false)
|
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.LINKED_DATE -> compareBy(nullsLast()) { it.linkedDate }
|
||||||
RecordSortField.CREATED_AT -> compareBy { it.createdAt }
|
RecordSortField.CREATED_AT -> compareBy { it.createdAt }
|
||||||
}
|
}
|
||||||
val ordered = if (order.ascending) comparator else comparator.reversed()
|
// id 兜底随主排序一起反转,保证降序时同值记录也是最新(id 最大)的在前
|
||||||
return records.sortedWith(ordered.then(compareBy { it.id }))
|
val ordered = comparator.then(compareBy { it.id })
|
||||||
|
return records.sortedWith(if (order.ascending) ordered else ordered.reversed())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,8 @@ import androidx.compose.foundation.lazy.grid.items
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
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.Check
|
||||||
import androidx.compose.material.icons.filled.Checklist
|
import androidx.compose.material.icons.filled.Checklist
|
||||||
import androidx.compose.material.icons.filled.ChevronLeft
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||||
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.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
@ -73,7 +77,7 @@ import plus.rua.project.RecordSortOrder
|
|||||||
* 功能:
|
* 功能:
|
||||||
* - 右下角浮动按钮拍摄新照片创建记录
|
* - 右下角浮动按钮拍摄新照片创建记录
|
||||||
* - 点击单条记录进入详情页
|
* - 点击单条记录进入详情页
|
||||||
* - TopAppBar 右侧"排序"菜单:6 种排序组合
|
* - TopAppBar 右侧"排序"菜单:3 个排序字段,点击已选字段切换升降序
|
||||||
* - TopAppBar 右侧"多选"按钮进入多选态,可批量删除
|
* - TopAppBar 右侧"多选"按钮进入多选态,可批量删除
|
||||||
*
|
*
|
||||||
* @param onBack 返回回调
|
* @param onBack 返回回调
|
||||||
@ -142,16 +146,39 @@ fun DateRecorderScreen(
|
|||||||
expanded = showSortMenu,
|
expanded = showSortMenu,
|
||||||
onDismissRequest = { showSortMenu = false }
|
onDismissRequest = { showSortMenu = false }
|
||||||
) {
|
) {
|
||||||
sortMenuItems().forEach { item ->
|
sortFields.forEach { (field, label) ->
|
||||||
|
val selected = uiState.sortOrder.field == field
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(item.label) },
|
text = { Text(label) },
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.setSortOrder(item.order)
|
viewModel.setSortOrder(
|
||||||
|
RecordSortOrder.nextAfter(uiState.sortOrder, field)
|
||||||
|
)
|
||||||
showSortMenu = false
|
showSortMenu = false
|
||||||
},
|
},
|
||||||
leadingIcon = if (uiState.sortOrder == item.order) {
|
leadingIcon = if (selected) {
|
||||||
{ Icon(Icons.Filled.Check, contentDescription = null) }
|
{ 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)
|
/** 排序菜单展示的字段选项,点击已选字段时切换方向(见 [RecordSortOrder.nextAfter])。 */
|
||||||
|
private val sortFields = listOf(
|
||||||
private fun sortMenuItems(): List<SortMenuItem> = listOf(
|
RecordSortField.SHOOT_DATE to "拍摄日期",
|
||||||
SortMenuItem(RecordSortOrder(RecordSortField.SHOOT_DATE, false), "拍摄日期 · 新→旧"),
|
RecordSortField.LINKED_DATE to "关联日期",
|
||||||
SortMenuItem(RecordSortOrder(RecordSortField.SHOOT_DATE, true), "拍摄日期 · 旧→新"),
|
RecordSortField.CREATED_AT to "创建时间"
|
||||||
SortMenuItem(RecordSortOrder(RecordSortField.LINKED_DATE, false), "关联日期 · 新→旧"),
|
|
||||||
SortMenuItem(RecordSortOrder(RecordSortField.LINKED_DATE, true), "关联日期 · 旧→新"),
|
|
||||||
SortMenuItem(RecordSortOrder(RecordSortField.CREATED_AT, false), "创建时间 · 新→旧"),
|
|
||||||
SortMenuItem(RecordSortOrder(RecordSortField.CREATED_AT, true), "创建时间 · 旧→新")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -269,11 +292,11 @@ private fun RecordGrid(
|
|||||||
onToggleSelection: (Long) -> Unit
|
onToggleSelection: (Long) -> Unit
|
||||||
) {
|
) {
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Fixed(2),
|
columns = GridCells.Adaptive(minSize = 100.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(8.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
items(records, key = { it.id }) { record ->
|
items(records, key = { it.id }) { record ->
|
||||||
RecordCard(
|
RecordCard(
|
||||||
@ -301,35 +324,46 @@ private fun RecordCard(
|
|||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
|
||||||
),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||||
modifier = modifier.fillMaxWidth()
|
modifier = modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Box {
|
Box {
|
||||||
Column {
|
AsyncImage(
|
||||||
AsyncImage(
|
uri = photoUri,
|
||||||
uri = photoUri,
|
contentDescription = record.title,
|
||||||
contentDescription = record.title,
|
modifier = Modifier
|
||||||
modifier = Modifier
|
.fillMaxWidth()
|
||||||
.fillMaxWidth()
|
.aspectRatio(1f)
|
||||||
.aspectRatio(1f)
|
)
|
||||||
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
|
||||||
|
// 底部渐变蒙层,保证叠加文字在各种照片上的可读性
|
||||||
|
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 多选态下的复选角标
|
// 多选态下的复选角标
|
||||||
|
|||||||
@ -76,6 +76,20 @@ class DateRecorderSortTest {
|
|||||||
assertEquals(listOf(1L, 2L, 3L), sorted.map { it.id })
|
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
|
@Test
|
||||||
fun sort_emptyList_returnsEmpty() {
|
fun sort_emptyList_returnsEmpty() {
|
||||||
val sorted = DateRecorderViewModel.sortDateRecords(
|
val sorted = DateRecorderViewModel.sortDateRecords(
|
||||||
@ -101,6 +115,24 @@ class DateRecorderSortTest {
|
|||||||
assertEquals(listOf(2L, 5L, 8L), sorted.map { it.id })
|
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(
|
private fun record(
|
||||||
id: Long,
|
id: Long,
|
||||||
title: String = "t",
|
title: String = "t",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user