diff --git a/app/src/main/kotlin/plus/rua/project/PhotoEditorActivity.kt b/app/src/main/kotlin/plus/rua/project/PhotoEditorActivity.kt index 10034c8..88ca03f 100644 --- a/app/src/main/kotlin/plus/rua/project/PhotoEditorActivity.kt +++ b/app/src/main/kotlin/plus/rua/project/PhotoEditorActivity.kt @@ -18,6 +18,8 @@ class PhotoEditorActivity : BaseActivity() { super.onCreate(savedInstanceState) val sourcePath = intent?.getStringExtra(DateRecorderNav.EXTRA_TEMP_PHOTO_PATH) + val recordId = intent?.getLongExtra(DateRecorderNav.EXTRA_RECORD_ID, -1L) + ?.takeIf { it >= 0 } requireNotNull(sourcePath) { "PhotoEditorActivity 必须接收 EXTRA_TEMP_PHOTO_PATH" } setContent { @@ -28,6 +30,9 @@ class PhotoEditorActivity : BaseActivity() { startActivityWithSlide( Intent(this, RecordEditActivity::class.java).apply { putExtra(DateRecorderNav.EXTRA_TEMP_PHOTO_PATH, finalPath) + if (recordId != null) { + putExtra(DateRecorderNav.EXTRA_RECORD_ID, recordId) + } } ) finishWithSlideBack() diff --git a/app/src/main/kotlin/plus/rua/project/RecordDetailActivity.kt b/app/src/main/kotlin/plus/rua/project/RecordDetailActivity.kt index a88515b..88fb3c4 100644 --- a/app/src/main/kotlin/plus/rua/project/RecordDetailActivity.kt +++ b/app/src/main/kotlin/plus/rua/project/RecordDetailActivity.kt @@ -37,6 +37,7 @@ class RecordDetailActivity : BaseActivity() { startActivityWithSlide( Intent(this, PhotoEditorActivity::class.java).apply { putExtra(DateRecorderNav.EXTRA_TEMP_PHOTO_PATH, photoPath) + putExtra(DateRecorderNav.EXTRA_RECORD_ID, recordId) } ) } diff --git a/core/src/main/kotlin/plus/rua/project/DateRecorderRepository.kt b/core/src/main/kotlin/plus/rua/project/DateRecorderRepository.kt index b6c6463..8a605d8 100644 --- a/core/src/main/kotlin/plus/rua/project/DateRecorderRepository.kt +++ b/core/src/main/kotlin/plus/rua/project/DateRecorderRepository.kt @@ -95,7 +95,7 @@ class DateRecorderRepository( /** * 删除指定相对路径的照片文件,文件不存在时静默忽略。 */ - private fun deletePhotoFile(relativePath: String) { + internal fun deletePhotoFile(relativePath: String) { runCatching { absoluteFileOf(relativePath).delete() } } diff --git a/core/src/main/kotlin/plus/rua/project/RecordEditViewModel.kt b/core/src/main/kotlin/plus/rua/project/RecordEditViewModel.kt index 72c70d3..92ffc35 100644 --- a/core/src/main/kotlin/plus/rua/project/RecordEditViewModel.kt +++ b/core/src/main/kotlin/plus/rua/project/RecordEditViewModel.kt @@ -47,7 +47,7 @@ data class RecordEditUiState( * 记录编辑页面 ViewModel,处理新建与编辑两种模式。 * * @param repository 数据仓库 - * @param photoPath 新建模式下的照片绝对路径(来自相机/编辑器),编辑模式为 null + * @param photoPath 新建模式下的照片绝对路径,或编辑模式下重新编辑后的照片绝对路径(可空) * @param recordId 编辑模式下的已有记录 ID,新建模式为 null */ class RecordEditViewModel( @@ -61,7 +61,8 @@ class RecordEditViewModel( /** 用户是否手动编辑过标题;为 false 时标题随拍摄日期联动 */ private var titleManuallyEdited = false - + private var existingCreatedAt: Instant? = null + private var originalPhotoRelativePath: String? = null init { if (recordId != null) { loadExistingRecord(recordId) @@ -89,7 +90,13 @@ class RecordEditViewModel( viewModelScope.launch { val record = repository.observeById(id).first() if (record != null) { - val absFile = repository.absoluteFileOf(record.photoPath) + existingCreatedAt = record.createdAt + originalPhotoRelativePath = record.photoPath + val absFile = if (photoPath != null) { + File(photoPath) + } else { + repository.absoluteFileOf(record.photoPath) + } titleManuallyEdited = true _uiState.value = RecordEditUiState( loading = false, @@ -150,9 +157,14 @@ class RecordEditViewModel( shootDate = state.shootDate, linkedDate = state.linkedDate, photoPath = relPath, - createdAt = Instant.fromEpochMilliseconds(System.currentTimeMillis()) + createdAt = existingCreatedAt ?: Instant.fromEpochMilliseconds(System.currentTimeMillis()) ) ) + originalPhotoRelativePath?.let { oldPath -> + if (oldPath != relPath) { + repository.deletePhotoFile(oldPath) + } + } } else { repository.insert( DateRecord( diff --git a/core/src/test/kotlin/plus/rua/project/RecordEditViewModelTest.kt b/core/src/test/kotlin/plus/rua/project/RecordEditViewModelTest.kt index dd7d14c..95d1711 100644 --- a/core/src/test/kotlin/plus/rua/project/RecordEditViewModelTest.kt +++ b/core/src/test/kotlin/plus/rua/project/RecordEditViewModelTest.kt @@ -1,21 +1,43 @@ package plus.rua.project +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf +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 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.assertTrue +import kotlin.time.Instant /** * 新建记录标题预填与拍摄日期联动的单元测试。 * * 新建模式的被测路径不会触发 DAO 调用,[FakeDateRecordDao] 返回空数据即可。 */ +@OptIn(ExperimentalCoroutinesApi::class) class RecordEditViewModelTest { + private val testDispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + private fun newViewModel(): RecordEditViewModel { val repository = DateRecorderRepository(FakeDateRecordDao(), File("build/tmp/record_edit_test")) return RecordEditViewModel(repository, photoPath = "/tmp/fake_photo.jpg", recordId = null) @@ -51,12 +73,72 @@ class RecordEditViewModelTest { vm.onTitleChange("") assertFalse(vm.uiState.value.canSave) } + + @Test + fun existingRecord_loadsRecordData_withoutNewPhotoPath() = runTest(testDispatcher) { + val rootDir = File("build/tmp/record_edit_test") + val existingRecord = DateRecord( + id = 5L, + title = "旧标题", + note = "旧备注", + shootDate = LocalDate(2026, 1, 1), + linkedDate = null, + photoPath = "Pictures/date_recorder/old.jpg", + createdAt = Instant.fromEpochMilliseconds(100000L) + ) + val dao = FakeDateRecordDao(existingRecord) + val repository = DateRecorderRepository(dao, rootDir) + + val vm = RecordEditViewModel(repository, photoPath = null, recordId = 5L) + testScheduler.advanceUntilIdle() + val state = vm.uiState.value + assertEquals("旧标题", state.title) + assertEquals("旧备注", state.note) + assertTrue(state.isExistingRecord) + assertEquals(File(rootDir, "Pictures/date_recorder/old.jpg").absolutePath, state.photoAbsolutePath) + } + + @Test + fun existingRecord_usesNewPhotoPath_whenPhotoPathProvided() = runTest(testDispatcher) { + val rootDir = File("build/tmp/record_edit_test") + val existingRecord = DateRecord( + id = 5L, + title = "旧标题", + note = "旧备注", + shootDate = LocalDate(2026, 1, 1), + linkedDate = null, + photoPath = "Pictures/date_recorder/old.jpg", + createdAt = Instant.fromEpochMilliseconds(100000L) + ) + val dao = FakeDateRecordDao(existingRecord) + val repository = DateRecorderRepository(dao, rootDir) + + val newPhotoFile = File(rootDir, "Pictures/date_recorder/edited.jpg") + val vm = RecordEditViewModel(repository, photoPath = newPhotoFile.absolutePath, recordId = 5L) + testScheduler.advanceUntilIdle() + val state = vm.uiState.value + assertEquals("旧标题", state.title) + assertTrue(state.isExistingRecord) + assertEquals(newPhotoFile.absolutePath, state.photoAbsolutePath) + + vm.save() + testScheduler.advanceUntilIdle() + assertEquals(5L, dao.updatedRecord?.id) + assertEquals("Pictures/date_recorder/edited.jpg", dao.updatedRecord?.photoPath) + assertEquals(Instant.fromEpochMilliseconds(100000L), dao.updatedRecord?.createdAt) + } } -private class FakeDateRecordDao : DateRecordDao { - override fun getByIdFlow(id: Long): Flow = flowOf(null) - override fun getAllFlow(): Flow> = flowOf(emptyList()) +private class FakeDateRecordDao( + private val record: DateRecord? = null +) : DateRecordDao { + var updatedRecord: DateRecord? = null + override fun getByIdFlow(id: Long): Flow = flowOf(record) + override fun getAllFlow(): Flow> = flowOf(record?.let { listOf(it) } ?: emptyList()) override suspend fun insert(record: DateRecord): Long = 0 - override suspend fun update(record: DateRecord) = Unit + override suspend fun update(record: DateRecord) { + this.updatedRecord = record + } override suspend fun deleteByIds(ids: List): Int = 0 } +