Compare commits
4 Commits
b1064f95b7
...
d47a38bc19
| Author | SHA1 | Date | |
|---|---|---|---|
| d47a38bc19 | |||
| b589800eda | |||
| 0d83fef3a9 | |||
| 94e81bffb1 |
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.todayIn
|
||||
import java.io.File
|
||||
import kotlin.time.Clock
|
||||
@ -58,6 +59,9 @@ class RecordEditViewModel(
|
||||
private val _uiState = MutableStateFlow(RecordEditUiState())
|
||||
val uiState: StateFlow<RecordEditUiState> = _uiState.asStateFlow()
|
||||
|
||||
/** 用户是否手动编辑过标题;为 false 时标题随拍摄日期联动 */
|
||||
private var titleManuallyEdited = false
|
||||
|
||||
init {
|
||||
if (recordId != null) {
|
||||
loadExistingRecord(recordId)
|
||||
@ -69,11 +73,14 @@ class RecordEditViewModel(
|
||||
private fun initNewRecord() {
|
||||
requireNotNull(photoPath) { "新建模式必须提供 photoPath" }
|
||||
val file = File(photoPath)
|
||||
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
|
||||
_uiState.value = RecordEditUiState(
|
||||
loading = false,
|
||||
title = formatLocalDate(today),
|
||||
shootDate = today,
|
||||
photoUri = "file://${file.absolutePath}",
|
||||
photoAbsolutePath = file.absolutePath,
|
||||
canSave = false,
|
||||
canSave = true,
|
||||
isExistingRecord = false
|
||||
)
|
||||
}
|
||||
@ -83,6 +90,7 @@ class RecordEditViewModel(
|
||||
val record = repository.observeById(id).first()
|
||||
if (record != null) {
|
||||
val absFile = repository.absoluteFileOf(record.photoPath)
|
||||
titleManuallyEdited = true
|
||||
_uiState.value = RecordEditUiState(
|
||||
loading = false,
|
||||
title = record.title,
|
||||
@ -102,6 +110,7 @@ class RecordEditViewModel(
|
||||
}
|
||||
|
||||
fun onTitleChange(value: String) {
|
||||
titleManuallyEdited = true
|
||||
_uiState.update { it.copy(title = value, canSave = value.isNotBlank() && it.photoAbsolutePath != null) }
|
||||
}
|
||||
|
||||
@ -110,7 +119,13 @@ class RecordEditViewModel(
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -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')}"
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 多选态下的复选角标
|
||||
|
||||
@ -52,11 +52,11 @@ import com.github.panpf.sketch.AsyncImage
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import plus.rua.project.DateRecorderRepository
|
||||
import plus.rua.project.RecordEditUiState
|
||||
import plus.rua.project.RecordEditViewModel
|
||||
import plus.rua.project.formatLocalDate
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
@ -311,7 +311,3 @@ private fun DatePickerModal(
|
||||
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')}"
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
```
|
||||
Loading…
x
Reference in New Issue
Block a user