Compare commits

...

4 Commits

Author SHA1 Message Date
xfy
d47a38bc19 feat(date-recorder): 美化记录器界面,卡片图上叠加文字、排序菜单精简
- 网格卡片:照片铺满整卡,底部渐变蒙层叠加标题与拍摄日期
- 网格改为自适应约 3 列,间距 8dp→4dp,卡片圆角 12dp→8dp
- 排序菜单 6 项精简为 3 个字段项,点击已选字段切换升降序
- 新增 RecordSortOrder.nextAfter 纯函数及单元测试
2026-07-20 18:28:39 +08:00
xfy
b589800eda refactor: 记录编辑页复用共享 formatLocalDate 2026-07-20 17:25:08 +08:00
xfy
0d83fef3a9 feat: 新建记录标题预填拍摄日期并支持联动 2026-07-20 17:23:33 +08:00
xfy
94e81bffb1 docs: 新建记录标题预填默认值设计规格 2026-07-20 15:54:59 +08:00
7 changed files with 276 additions and 52 deletions

View File

@ -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())
} }
} }
} }

View File

@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.number
import kotlinx.datetime.todayIn import kotlinx.datetime.todayIn
import java.io.File import java.io.File
import kotlin.time.Clock import kotlin.time.Clock
@ -58,6 +59,9 @@ class RecordEditViewModel(
private val _uiState = MutableStateFlow(RecordEditUiState()) private val _uiState = MutableStateFlow(RecordEditUiState())
val uiState: StateFlow<RecordEditUiState> = _uiState.asStateFlow() val uiState: StateFlow<RecordEditUiState> = _uiState.asStateFlow()
/** 用户是否手动编辑过标题;为 false 时标题随拍摄日期联动 */
private var titleManuallyEdited = false
init { init {
if (recordId != null) { if (recordId != null) {
loadExistingRecord(recordId) loadExistingRecord(recordId)
@ -69,11 +73,14 @@ class RecordEditViewModel(
private fun initNewRecord() { private fun initNewRecord() {
requireNotNull(photoPath) { "新建模式必须提供 photoPath" } requireNotNull(photoPath) { "新建模式必须提供 photoPath" }
val file = File(photoPath) val file = File(photoPath)
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
_uiState.value = RecordEditUiState( _uiState.value = RecordEditUiState(
loading = false, loading = false,
title = formatLocalDate(today),
shootDate = today,
photoUri = "file://${file.absolutePath}", photoUri = "file://${file.absolutePath}",
photoAbsolutePath = file.absolutePath, photoAbsolutePath = file.absolutePath,
canSave = false, canSave = true,
isExistingRecord = false isExistingRecord = false
) )
} }
@ -83,6 +90,7 @@ class RecordEditViewModel(
val record = repository.observeById(id).first() val record = repository.observeById(id).first()
if (record != null) { if (record != null) {
val absFile = repository.absoluteFileOf(record.photoPath) val absFile = repository.absoluteFileOf(record.photoPath)
titleManuallyEdited = true
_uiState.value = RecordEditUiState( _uiState.value = RecordEditUiState(
loading = false, loading = false,
title = record.title, title = record.title,
@ -102,6 +110,7 @@ class RecordEditViewModel(
} }
fun onTitleChange(value: String) { fun onTitleChange(value: String) {
titleManuallyEdited = true
_uiState.update { it.copy(title = value, canSave = value.isNotBlank() && it.photoAbsolutePath != null) } _uiState.update { it.copy(title = value, canSave = value.isNotBlank() && it.photoAbsolutePath != null) }
} }
@ -110,7 +119,13 @@ class RecordEditViewModel(
} }
fun onShootDateChange(date: LocalDate) { fun onShootDateChange(date: LocalDate) {
_uiState.update { it.copy(shootDate = date) } _uiState.update {
if (titleManuallyEdited) {
it.copy(shootDate = date)
} else {
it.copy(shootDate = date, title = formatLocalDate(date))
}
}
} }
fun onLinkedDateChange(date: LocalDate) { fun onLinkedDateChange(date: LocalDate) {
@ -155,3 +170,12 @@ class RecordEditViewModel(
} }
} }
} }
/**
* 将日期格式化为 `yyyy-MM-dd`用于新建记录的默认标题和编辑页的日期显示
*
* @param date 待格式化的日期
*/
internal fun formatLocalDate(date: LocalDate): String {
return "${date.year}-${date.month.number.toString().padStart(2, '0')}-${date.day.toString().padStart(2, '0')}"
}

View File

@ -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
)
}
} }
// 多选态下的复选角标 // 多选态下的复选角标

View File

@ -52,11 +52,11 @@ import com.github.panpf.sketch.AsyncImage
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import plus.rua.project.DateRecorderRepository import plus.rua.project.DateRecorderRepository
import plus.rua.project.RecordEditUiState import plus.rua.project.RecordEditUiState
import plus.rua.project.RecordEditViewModel import plus.rua.project.RecordEditViewModel
import plus.rua.project.formatLocalDate
import kotlin.time.Instant import kotlin.time.Instant
/** /**
@ -311,7 +311,3 @@ private fun DatePickerModal(
DatePicker(state = datePickerState) DatePicker(state = datePickerState)
} }
} }
private fun formatLocalDate(date: LocalDate): String {
return "${date.year}-${date.month.number.toString().padStart(2, '0')}-${date.day.toString().padStart(2, '0')}"
}

View File

@ -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",

View File

@ -0,0 +1,62 @@
package plus.rua.project
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.datetime.LocalDate
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* 新建记录标题预填与拍摄日期联动的单元测试
*
* 新建模式的被测路径不会触发 DAO 调用[FakeDateRecordDao] 返回空数据即可
*/
class RecordEditViewModelTest {
private fun newViewModel(): RecordEditViewModel {
val repository = DateRecorderRepository(FakeDateRecordDao(), File("build/tmp/record_edit_test"))
return RecordEditViewModel(repository, photoPath = "/tmp/fake_photo.jpg", recordId = null)
}
@Test
fun newRecord_prefillsTitleWithShootDate_andCanSave() {
val vm = newViewModel()
val state = vm.uiState.value
assertEquals(formatLocalDate(state.shootDate), state.title)
assertTrue(state.canSave)
}
@Test
fun shootDateChange_updatesTitle_whenTitleNotManuallyEdited() {
val vm = newViewModel()
vm.onShootDateChange(LocalDate(2026, 3, 5))
assertEquals("2026-03-05", vm.uiState.value.title)
assertTrue(vm.uiState.value.canSave)
}
@Test
fun shootDateChange_keepsTitle_whenTitleManuallyEdited() {
val vm = newViewModel()
vm.onTitleChange("我的记录")
vm.onShootDateChange(LocalDate(2026, 3, 5))
assertEquals("我的记录", vm.uiState.value.title)
}
@Test
fun titleCleared_disablesSave() {
val vm = newViewModel()
vm.onTitleChange("")
assertFalse(vm.uiState.value.canSave)
}
}
private class FakeDateRecordDao : DateRecordDao {
override fun getByIdFlow(id: Long): Flow<DateRecord?> = flowOf(null)
override fun getAllFlow(): Flow<List<DateRecord>> = flowOf(emptyList())
override suspend fun insert(record: DateRecord): Long = 0
override suspend fun update(record: DateRecord) = Unit
override suspend fun deleteByIds(ids: List<Long>): Int = 0
}

View File

@ -0,0 +1,60 @@
# 新建记录标题预填默认值 — 设计规格
日期2026-07-20
## 背景
「日期记录器」拍照后进入新建记录页(`RecordEditScreen`),标题初始为空,保存按钮禁用(`canSave = false`),用户必须手动输入标题才能保存。
## 目标
新建记录时标题预填默认值,用户进入页面即可直接点保存。
## 需求确认
- 默认标题格式:**拍摄日期**`yyyy-MM-dd`(如 `2026-07-20`)。
- 联动规则:用户**未手动编辑过标题**时,修改「拍摄日期」标题跟随同步;手动编辑过后不再联动。
- 编辑模式(`recordId != null`)行为不变:标题来自已有记录,不参与联动。
## 方案
ViewModel 负责预填和联动(状态单一来源、可单测)。不采用 Screen 层 `LaunchedEffect` 预填(状态分散、难测)。
## 改动点
### `core/src/main/kotlin/plus/rua/project/RecordEditViewModel.kt`
1. `initNewRecord()``title` 预填为 `formatLocalDate(shootDate)``canSave = true`(标题非空 + 照片就绪,沿用现有校验规则)。
2. 新增私有标记 `private var titleManuallyEdited = false`
- `onTitleChange()` 中置 `true`(任何手动输入都算,包括清空)。
- `onShootDateChange()` 中:标记为 `false` 时同步 `title = formatLocalDate(date)`
- `loadExistingRecord()` 加载成功后置 `true`(编辑模式已有标题不参与联动)。
3. 新增 `internal fun formatLocalDate(date: LocalDate): String``yyyy-MM-dd`),实现与现有 Screen 私有版本一致。
### `core/src/main/kotlin/plus/rua/project/ui/RecordEditScreen.kt`
1. 删除私有 `formatLocalDate`,改用 `RecordEditViewModel.kt` 中的 `internal` 版本,保证标题与「拍摄日期」按钮显示格式一致。
2. UI 结构、交互不变。
### 测试 `core/src/test/kotlin/plus/rua/project/RecordEditViewModelTest.kt`(新增)
覆盖用例:
- 新建模式:标题预填为拍摄日期格式,且 `canSave = true`
- 未手动编辑:修改拍摄日期后标题跟随更新。
- 手动编辑后:修改拍摄日期标题不变。
- 手动清空标题:`canSave = false`
测试需要 fake/stub `DateRecorderRepository`,参照现有 `DateRecorderSortTest.kt` 的测试写法。
## 边界情况
- 用户手动清空标题 → 保存按钮禁用(与现有校验一致)。
- 编辑模式:加载已有记录标题时同时将 `titleManuallyEdited` 置为 `true`,确保已有标题不会被拍摄日期联动覆盖(与"编辑模式不参与联动"的需求一致)。
## 验证
```bash
./gradlew :core:testDebugUnitTest --tests "plus.rua.project.RecordEditViewModelTest"
./gradlew spotlessApply
./gradlew :app:assembleDebug
```