Compare commits
4 Commits
b1064f95b7
...
d47a38bc19
| Author | SHA1 | Date | |
|---|---|---|---|
| d47a38bc19 | |||
| b589800eda | |||
| 0d83fef3a9 | |||
| 94e81bffb1 |
@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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')}"
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 多选态下的复选角标
|
// 多选态下的复选角标
|
||||||
|
|||||||
@ -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')}"
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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