feat(ui): 重新设计日期记录器详情页并优化 Flow 实时监听

- 重构 RecordDetailScreen:新增 Hero 大图卡片(带全屏放大与涂鸦编辑快捷入口)、优雅标题 header、双列元信息卡片(带星期与日历关联状态)及格式化笔记卡片
- 优化底部操作栏:提供直观的编辑信息、涂鸦编辑与删除按扭
- ViewModel 变更:将 DateRecordDetailViewModel 从 single-snapshot first() 改为持续 collect,支持编辑返回后实时刷新数据
- 新增 DateRecordDetailViewModelTest 单元测试
This commit is contained in:
xfy 2026-07-23 14:51:29 +08:00
parent c3510cde39
commit 9ad9e39164
3 changed files with 630 additions and 68 deletions

View File

@ -42,15 +42,18 @@ class DateRecordDetailViewModel(
private fun load() {
viewModelScope.launch {
val record = repository.observeById(recordId).first()
if (record != null) {
_uiState.value = DateRecordDetailUiState(
loading = false,
record = record,
photoUri = "file://${repository.absoluteFileOf(record.photoPath).absolutePath}"
)
} else {
_uiState.value = DateRecordDetailUiState(loading = false)
repository.observeById(recordId).collect { record ->
if (record != null) {
_uiState.update {
it.copy(
loading = false,
record = record,
photoUri = "file://${repository.absoluteFileOf(record.photoPath).absolutePath}"
)
}
} else {
_uiState.update { it.copy(loading = false, record = null) }
}
}
}
}

View File

@ -1,29 +1,47 @@
package plus.rua.project.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.Bookmark
import androidx.compose.material.icons.outlined.BookmarkBorder
import androidx.compose.material.icons.outlined.Brush
import androidx.compose.material.icons.outlined.CalendarToday
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.EditNote
import androidx.compose.material.icons.outlined.Notes
import androidx.compose.material.icons.outlined.ZoomIn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@ -36,8 +54,9 @@ import androidx.compose.runtime.remember
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.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
@ -48,6 +67,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.github.panpf.sketch.AsyncImage
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import plus.rua.project.DateRecordDetailUiState
import plus.rua.project.DateRecordDetailViewModel
import plus.rua.project.DateRecorderRepository
@ -90,40 +111,35 @@ fun RecordDetailScreen(
LaunchedEffect(Unit) { onBack() }
}
val record = state.record
Scaffold(
modifier = modifier.semantics { testTagsAsResourceId = true },
topBar = {
TopAppBar(
title = {
Text(
state.record?.title ?: "记录详情",
text = "记录详情",
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Filled.ChevronLeft, contentDescription = "返回")
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
}
},
actions = {
val record = state.record
if (record != null) {
IconButton(onClick = { onEditInfo(record.id) }) {
Icon(Icons.Filled.Edit, contentDescription = "编辑信息")
}
IconButton(onClick = {
viewModel.currentPhotoPath()?.let(onEditPhoto)
}) {
Icon(Icons.Filled.Image, contentDescription = "编辑照片")
}
IconButton(onClick = { showDeleteDialog = true }) {
Icon(Icons.Filled.Delete, contentDescription = "删除")
}
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface)
)
},
bottomBar = {
if (record != null) {
DetailBottomBar(
onEditInfo = { onEditInfo(record.id) },
onEditPhoto = { viewModel.currentPhotoPath()?.let(onEditPhoto) },
onDelete = { showDeleteDialog = true }
)
}
},
containerColor = MaterialTheme.colorScheme.surface
) { innerPadding ->
when {
@ -134,7 +150,7 @@ fun RecordDetailScreen(
contentAlignment = Alignment.Center
) { CircularProgressIndicator() }
state.record == null -> Box(
record == null -> Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
@ -146,6 +162,8 @@ fun RecordDetailScreen(
else -> DetailContent(
state = state,
onPhotoClick = { showLightbox = true },
onEditInfo = { onEditInfo(record.id) },
onEditPhoto = { viewModel.currentPhotoPath()?.let(onEditPhoto) },
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
@ -179,10 +197,21 @@ fun RecordDetailScreen(
}
}
/**
* 详情页核心内容布局包含 Hero 大图标题 header元信息网格与笔记卡片
*
* @param state UI 状态
* @param onPhotoClick 点击图片回调打开灯箱
* @param onEditInfo 编辑信息回调
* @param onEditPhoto 编辑照片回调
* @param modifier 布局修饰符
*/
@Composable
private fun DetailContent(
state: DateRecordDetailUiState,
onPhotoClick: () -> Unit,
onEditInfo: () -> Unit,
onEditPhoto: () -> Unit,
modifier: Modifier = Modifier
) {
val record = state.record ?: return
@ -190,50 +219,458 @@ private fun DetailContent(
modifier = modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
AsyncImage(
uri = state.photoUri,
contentDescription = record.title,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onPhotoClick
)
HeroPhotoCard(
photoUri = state.photoUri,
title = record.title,
onPhotoClick = onPhotoClick,
onEditPhotoClick = onEditPhoto
)
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
Text(
text = record.title,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold
TitleHeaderSection(title = record.title)
MetadataSection(
shootDate = record.shootDate,
linkedDate = record.linkedDate
)
JournalNoteSection(
note = record.note,
onEditNoteClick = onEditInfo
)
Spacer(modifier = Modifier.padding(bottom = 12.dp))
}
}
/**
* Hero 大图卡片提供大图容器全屏放大提示与直接进入涂鸦编辑的快捷入口
*
* @param photoUri 照片 URI
* @param title 标题说明
* @param onPhotoClick 点击照片回调
* @param onEditPhotoClick 点击涂鸦编辑回调
* @param modifier 布局修饰符
*/
@Composable
private fun HeroPhotoCard(
photoUri: String?,
title: String,
onPhotoClick: () -> Unit,
onEditPhotoClick: () -> Unit,
modifier: Modifier = Modifier
) {
ElevatedCard(
onClick = onPhotoClick,
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 220.dp, max = 380.dp)
) {
AsyncImage(
uri = photoUri,
contentDescription = title,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.fillMaxSize()
)
InfoRow(label = "拍摄日期", value = "${record.shootDate}")
InfoRow(label = "关联日期", value = record.linkedDate?.toString() ?: "")
if (record.note.isNotBlank()) {
// 右上角全屏提示胶囊
Surface(
color = Color.Black.copy(alpha = 0.45f),
shape = CircleShape,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
) {
Icon(
imageVector = Icons.Outlined.ZoomIn,
contentDescription = "全屏预览",
tint = Color.White,
modifier = Modifier.size(16.dp)
)
Text(
text = "全屏查看",
style = MaterialTheme.typography.labelSmall,
color = Color.White
)
}
}
// 右下角涂鸦编辑快捷 Badge
Surface(
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.92f),
shape = RoundedCornerShape(12.dp),
onClick = onEditPhotoClick,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Icon(
imageVector = Icons.Outlined.Brush,
contentDescription = "涂鸦编辑",
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(16.dp)
)
Text(
text = "涂鸦编辑",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
}
}
/**
* 标题头部区域展示分类 Badge 与大字号标题
*
* @param title 记录标题
* @param modifier 布局修饰符
*/
@Composable
private fun TitleHeaderSection(
title: String,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 2.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = RoundedCornerShape(6.dp)
) {
Text(
text = record.note,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp)
text = "日期记忆",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
)
}
}
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
}
}
/**
* 元信息卡片组双列卡片展示拍摄日期与关联日历信息
*
* @param shootDate 拍摄日期
* @param linkedDate 关联日历日期
* @param modifier 布局修饰符
*/
@Composable
private fun MetadataSection(
shootDate: LocalDate,
linkedDate: LocalDate?,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
MetadataCard(
icon = Icons.Outlined.CalendarToday,
iconTint = MaterialTheme.colorScheme.primary,
label = "拍摄日期",
primaryValue = formatChineseDate(shootDate),
secondaryValue = getWeekdayName(shootDate),
secondaryColor = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f)
)
val hasLinked = linkedDate != null
MetadataCard(
icon = if (hasLinked) Icons.Outlined.Bookmark else Icons.Outlined.BookmarkBorder,
iconTint = if (hasLinked) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.onSurfaceVariant,
label = "关联日历",
primaryValue = if (hasLinked) formatChineseDate(linkedDate) else "未关联",
secondaryValue = if (hasLinked) getWeekdayName(linkedDate) else "独立记录",
secondaryColor = if (hasLinked) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.outline,
modifier = Modifier.weight(1f)
)
}
}
/**
* 单条元信息卡片
*
* @param icon 图标
* @param iconTint 图标颜色
* @param label 标签名称
* @param primaryValue 主显示文本
* @param secondaryValue 副文本/星期
* @param secondaryColor 副文本颜色
* @param modifier 布局修饰符
*/
@Composable
private fun MetadataCard(
icon: ImageVector,
iconTint: Color,
label: String,
primaryValue: String,
secondaryValue: String,
secondaryColor: Color,
modifier: Modifier = Modifier
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
modifier = modifier
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Surface(
shape = RoundedCornerShape(10.dp),
color = iconTint.copy(alpha = 0.12f),
modifier = Modifier.size(38.dp)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = icon,
contentDescription = label,
tint = iconTint,
modifier = Modifier.size(20.dp)
)
}
}
Column(verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = primaryValue,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = secondaryValue,
style = MaterialTheme.typography.labelSmall,
color = secondaryColor
)
}
}
}
}
/**
* 笔记记录正文卡片
*
* @param note 笔记内容
* @param onEditNoteClick 点击卡片/编辑图标回调
* @param modifier 布局修饰符
*/
@Composable
private fun InfoRow(label: String, value: String) {
Row(
modifier = Modifier
private fun JournalNoteSection(
note: String,
onEditNoteClick: () -> Unit,
modifier: Modifier = Modifier
) {
OutlinedCard(
onClick = onEditNoteClick,
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.outlinedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)),
modifier = modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
.padding(horizontal = 16.dp)
) {
Text(
text = label,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(text = value)
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Outlined.Notes,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(20.dp)
)
Text(
text = "记录笔记",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
}
Icon(
imageVector = Icons.Outlined.Edit,
contentDescription = "编辑笔记",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp)
)
}
if (note.isNotBlank()) {
Text(
text = note,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
lineHeight = MaterialTheme.typography.bodyMedium.lineHeight * 1.3f
)
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(vertical = 4.dp)
) {
Icon(
imageVector = Icons.Outlined.EditNote,
contentDescription = null,
tint = MaterialTheme.colorScheme.outline,
modifier = Modifier.size(20.dp)
)
Text(
text = "暂无笔记内容,点击可编辑添加...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
}
}
}
/**
* 详情页底部固定操作栏
*
* @param onEditInfo 点击编辑信息回调
* @param onEditPhoto 点击涂鸦编辑回调
* @param onDelete 点击删除回调
* @param modifier 布局修饰符
*/
@Composable
private fun DetailBottomBar(
onEditInfo: () -> Unit,
onEditPhoto: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
tonalElevation = 3.dp,
color = MaterialTheme.colorScheme.surfaceContainer,
modifier = modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = onEditInfo,
shape = RoundedCornerShape(14.dp),
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.Outlined.Edit,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text("编辑信息")
}
OutlinedButton(
onClick = onEditPhoto,
shape = RoundedCornerShape(14.dp),
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.Outlined.Brush,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text("涂鸦编辑")
}
OutlinedIconButton(
onClick = onDelete,
shape = RoundedCornerShape(14.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.5f)),
colors = IconButtonDefaults.outlinedIconButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(
imageVector = Icons.Outlined.Delete,
contentDescription = "删除记录"
)
}
}
}
}
private fun formatChineseDate(date: LocalDate): String {
return "${date.year}${date.monthNumber}${date.dayOfMonth}"
}
private fun getWeekdayName(date: LocalDate): String {
return when (date.dayOfWeek) {
DayOfWeek.MONDAY -> "星期一"
DayOfWeek.TUESDAY -> "星期二"
DayOfWeek.WEDNESDAY -> "星期三"
DayOfWeek.THURSDAY -> "星期四"
DayOfWeek.FRIDAY -> "星期五"
DayOfWeek.SATURDAY -> "星期六"
DayOfWeek.SUNDAY -> "星期日"
}
}

View File

@ -0,0 +1,122 @@
package plus.rua.project
import java.io.File
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.datetime.LocalDate
import kotlin.time.Instant
/**
* [DateRecordDetailViewModel] 的状态加载与删除功能单元测试
*/
@OptIn(ExperimentalCoroutinesApi::class)
class DateRecordDetailViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
@BeforeTest
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
private val testRecord = DateRecord(
id = 100L,
title = "测试记录",
note = "测试笔记",
shootDate = LocalDate(2026, 7, 23),
linkedDate = LocalDate(2026, 7, 23),
photoPath = "test_photo.jpg",
createdAt = Instant.fromEpochMilliseconds(1000000L)
)
@Test
fun load_populatesRecordState_whenRecordExists() = runTest(testDispatcher) {
val dao = DetailFakeDao(testRecord)
val repository = DateRecorderRepository(dao, File("build/tmp/record_detail_test"))
val vm = DateRecordDetailViewModel(repository, recordId = 100L)
backgroundScope.launch { vm.uiState.collect {} }
testScheduler.advanceUntilIdle()
val state = vm.uiState.value
assertFalse(state.loading)
assertNotNull(state.record)
assertEquals("测试记录", state.record?.title)
assertNotNull(state.photoUri)
assertTrue(state.photoUri!!.endsWith("test_photo.jpg"))
assertTrue(vm.currentPhotoPath()?.endsWith("test_photo.jpg") == true)
}
@Test
fun load_setsLoadingFalse_whenRecordNotFound() = runTest(testDispatcher) {
val dao = DetailFakeDao(null)
val repository = DateRecorderRepository(dao, File("build/tmp/record_detail_test"))
val vm = DateRecordDetailViewModel(repository, recordId = 999L)
backgroundScope.launch { vm.uiState.collect {} }
val state = vm.uiState.value
assertFalse(state.loading)
assertNull(state.record)
assertNull(vm.currentPhotoPath())
}
@Test
fun delete_triggersRepositoryDelete_andSetsDeletedState() = runTest(testDispatcher) {
val dao = DetailFakeDao(testRecord)
val repository = DateRecorderRepository(dao, File("build/tmp/record_detail_test"))
val vm = DateRecordDetailViewModel(repository, recordId = 100L)
backgroundScope.launch { vm.uiState.collect {} }
testScheduler.advanceUntilIdle()
vm.delete()
testScheduler.advanceUntilIdle()
var retries = 0
while (!vm.uiState.value.deleted && retries < 50) {
kotlinx.coroutines.delay(10)
retries++
}
assertTrue(vm.uiState.value.deleted)
assertEquals(listOf(100L), dao.deletedIds)
}
}
private class DetailFakeDao(initialRecord: DateRecord?) : DateRecordDao {
val recordFlow = MutableStateFlow(initialRecord)
val deletedIds = mutableListOf<Long>()
override fun getByIdFlow(id: Long): Flow<DateRecord?> = recordFlow
override fun getAllFlow(): Flow<List<DateRecord>> = flowOf(emptyList())
override suspend fun insert(record: DateRecord): Long = record.id
override suspend fun update(record: DateRecord) {
recordFlow.value = record
}
override suspend fun deleteByIds(ids: List<Long>): Int {
deletedIds.addAll(ids)
recordFlow.value = null
return ids.size
}
}