diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e238289..35630bd 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,6 +1,9 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/plus/rua/project/CameraActivity.kt b/app/src/main/kotlin/plus/rua/project/CameraActivity.kt
new file mode 100644
index 0000000..39fa810
--- /dev/null
+++ b/app/src/main/kotlin/plus/rua/project/CameraActivity.kt
@@ -0,0 +1,36 @@
+package plus.rua.project
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import plus.rua.project.ui.CameraScreen
+import plus.rua.project.ui.DateRecorderNav
+import plus.rua.project.ui.theme.YaYaTheme
+
+/**
+ * 相机拍摄页 Activity。
+ *
+ * 拍照成功后把临时照片路径透传给记录编辑页(M3 之后会先经过编辑器页)。
+ */
+class CameraActivity : BaseActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ YaYaTheme {
+ CameraScreen(
+ onBack = { finishWithSlideBack() },
+ onPhotoCaptured = { tempPath ->
+ // 拍照后先进入编辑器,编辑完成后再进入记录编辑页
+ startActivityWithSlide(
+ Intent(this, PhotoEditorActivity::class.java).apply {
+ putExtra(DateRecorderNav.EXTRA_TEMP_PHOTO_PATH, tempPath)
+ }
+ )
+ finishWithSlideBack()
+ }
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/plus/rua/project/DateRecorderActivity.kt b/app/src/main/kotlin/plus/rua/project/DateRecorderActivity.kt
new file mode 100644
index 0000000..63fc57d
--- /dev/null
+++ b/app/src/main/kotlin/plus/rua/project/DateRecorderActivity.kt
@@ -0,0 +1,32 @@
+package plus.rua.project
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import plus.rua.project.ui.DateRecorderNav
+import plus.rua.project.ui.DateRecorderScreen
+import plus.rua.project.ui.theme.YaYaTheme
+
+class DateRecorderActivity : BaseActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ YaYaTheme {
+ DateRecorderScreen(
+ onBack = { finishWithSlideBack() },
+ onOpenCamera = {
+ startActivityWithSlide(Intent(this, CameraActivity::class.java))
+ },
+ onOpenRecord = { id ->
+ startActivityWithSlide(
+ Intent(this, RecordDetailActivity::class.java).apply {
+ putExtra(DateRecorderNav.EXTRA_RECORD_ID, id)
+ }
+ )
+ }
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/plus/rua/project/PhotoEditorActivity.kt b/app/src/main/kotlin/plus/rua/project/PhotoEditorActivity.kt
new file mode 100644
index 0000000..10034c8
--- /dev/null
+++ b/app/src/main/kotlin/plus/rua/project/PhotoEditorActivity.kt
@@ -0,0 +1,40 @@
+package plus.rua.project
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import plus.rua.project.ui.DateRecorderNav
+import plus.rua.project.ui.PhotoEditorScreen
+import plus.rua.project.ui.theme.YaYaTheme
+
+/**
+ * 照片编辑页 Activity。
+ *
+ * 接收源照片路径([DateRecorderNav.EXTRA_TEMP_PHOTO_PATH],来自相机或详情页),
+ * 编辑完成后跳转记录编辑页(携带最终照片路径)。
+ */
+class PhotoEditorActivity : BaseActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val sourcePath = intent?.getStringExtra(DateRecorderNav.EXTRA_TEMP_PHOTO_PATH)
+ requireNotNull(sourcePath) { "PhotoEditorActivity 必须接收 EXTRA_TEMP_PHOTO_PATH" }
+
+ setContent {
+ YaYaTheme {
+ PhotoEditorScreen(
+ onBack = { finishWithSlideBack() },
+ onSaved = { finalPath ->
+ startActivityWithSlide(
+ Intent(this, RecordEditActivity::class.java).apply {
+ putExtra(DateRecorderNav.EXTRA_TEMP_PHOTO_PATH, finalPath)
+ }
+ )
+ finishWithSlideBack()
+ },
+ sourcePath = sourcePath
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/plus/rua/project/RecordDetailActivity.kt b/app/src/main/kotlin/plus/rua/project/RecordDetailActivity.kt
new file mode 100644
index 0000000..a88515b
--- /dev/null
+++ b/app/src/main/kotlin/plus/rua/project/RecordDetailActivity.kt
@@ -0,0 +1,47 @@
+package plus.rua.project
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import plus.rua.project.ui.DateRecorderNav
+import plus.rua.project.ui.RecordDetailScreen
+import plus.rua.project.ui.theme.YaYaTheme
+
+/**
+ * 记录详情页 Activity。
+ *
+ * 接收记录 ID([DateRecorderNav.EXTRA_RECORD_ID]),
+ * 提供"编辑信息"(→ RecordEditActivity)与"编辑照片"(→ PhotoEditorActivity)入口。
+ */
+class RecordDetailActivity : BaseActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val recordId = intent?.getLongExtra(DateRecorderNav.EXTRA_RECORD_ID, -1L)
+ ?.takeIf { it >= 0 }
+ requireNotNull(recordId) { "RecordDetailActivity 必须接收 EXTRA_RECORD_ID" }
+
+ setContent {
+ YaYaTheme {
+ RecordDetailScreen(
+ onBack = { finishWithSlideBack() },
+ recordId = recordId,
+ onEditInfo = { id ->
+ startActivityWithSlide(
+ Intent(this, RecordEditActivity::class.java).apply {
+ putExtra(DateRecorderNav.EXTRA_RECORD_ID, id)
+ }
+ )
+ },
+ onEditPhoto = { photoPath ->
+ startActivityWithSlide(
+ Intent(this, PhotoEditorActivity::class.java).apply {
+ putExtra(DateRecorderNav.EXTRA_TEMP_PHOTO_PATH, photoPath)
+ }
+ )
+ }
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/plus/rua/project/RecordEditActivity.kt b/app/src/main/kotlin/plus/rua/project/RecordEditActivity.kt
new file mode 100644
index 0000000..5dac177
--- /dev/null
+++ b/app/src/main/kotlin/plus/rua/project/RecordEditActivity.kt
@@ -0,0 +1,34 @@
+package plus.rua.project
+
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import plus.rua.project.ui.DateRecorderNav
+import plus.rua.project.ui.RecordEditScreen
+import plus.rua.project.ui.theme.YaYaTheme
+
+/**
+ * 记录编辑页 Activity。
+ *
+ * 两种入口:
+ * - 新建:Intent extra [DateRecorderNav.EXTRA_TEMP_PHOTO_PATH] 携带照片路径
+ * - 编辑:Intent extra [DateRecorderNav.EXTRA_RECORD_ID] 携带记录 ID
+ */
+class RecordEditActivity : BaseActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val photoPath = intent?.getStringExtra(DateRecorderNav.EXTRA_TEMP_PHOTO_PATH)
+ val recordId = intent?.getLongExtra(DateRecorderNav.EXTRA_RECORD_ID, -1L)
+ ?.takeIf { it >= 0 }
+
+ setContent {
+ YaYaTheme {
+ RecordEditScreen(
+ onBack = { finishWithSlideBack() },
+ photoPath = photoPath,
+ recordId = recordId
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/plus/rua/project/ToolsActivity.kt b/app/src/main/kotlin/plus/rua/project/ToolsActivity.kt
index ad250ed..f2fe8a1 100644
--- a/app/src/main/kotlin/plus/rua/project/ToolsActivity.kt
+++ b/app/src/main/kotlin/plus/rua/project/ToolsActivity.kt
@@ -16,6 +16,9 @@ class ToolsActivity : BaseActivity() {
onBack = { finishWithSlideBack() },
onNavigateToDateChecker = {
startActivityWithSlide(Intent(this, DateCheckerActivity::class.java))
+ },
+ onNavigateToDateRecorder = {
+ startActivityWithSlide(Intent(this, DateRecorderActivity::class.java))
}
)
}
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..8d89a2d
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/core/build.gradle.kts b/core/build.gradle.kts
index 1990a75..3dfb84d 100644
--- a/core/build.gradle.kts
+++ b/core/build.gradle.kts
@@ -3,6 +3,12 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidLibrary)
alias(libs.plugins.composeCompiler)
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.room)
+}
+
+room {
+ schemaDirectory("$projectDir/schemas")
}
android {
@@ -89,6 +95,16 @@ dependencies {
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
+ // 日期记录器:Room 持久化 + CameraX 相机
+ implementation(libs.room.runtime)
+ implementation(libs.room.ktx)
+ ksp(libs.room.compiler)
+ implementation(libs.camera.core)
+ implementation(libs.camera.camera2)
+ implementation(libs.camera.lifecycle)
+ implementation(libs.camera.view)
+
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${libs.versions.kotlin.get()}")
testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.room.testing)
}
diff --git a/core/schemas/plus.rua.project.DateRecordDatabase/1.json b/core/schemas/plus.rua.project.DateRecordDatabase/1.json
new file mode 100644
index 0000000..0bb1903
--- /dev/null
+++ b/core/schemas/plus.rua.project.DateRecordDatabase/1.json
@@ -0,0 +1,66 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 1,
+ "identityHash": "a70493440299ed07224c71df1b7b58c3",
+ "entities": [
+ {
+ "tableName": "date_records",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `note` TEXT NOT NULL, `shootDate` TEXT NOT NULL, `linkedDate` TEXT, `photoPath` TEXT NOT NULL, `createdAt` TEXT NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "note",
+ "columnName": "note",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "shootDate",
+ "columnName": "shootDate",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "linkedDate",
+ "columnName": "linkedDate",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "photoPath",
+ "columnName": "photoPath",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a70493440299ed07224c71df1b7b58c3')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/core/src/main/kotlin/plus/rua/project/DateRecord.kt b/core/src/main/kotlin/plus/rua/project/DateRecord.kt
new file mode 100644
index 0000000..677416d
--- /dev/null
+++ b/core/src/main/kotlin/plus/rua/project/DateRecord.kt
@@ -0,0 +1,30 @@
+package plus.rua.project
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import kotlinx.datetime.LocalDate
+import kotlin.time.Instant
+
+/**
+ * 日期记录器的一条记录。
+ *
+ * 每条记录对应一张照片及相关的文字信息,照片文件路径相对于应用 filesDir。
+ *
+ * @param id 自增主键,新建时传 0
+ * @param title 标题
+ * @param note 备注正文
+ * @param shootDate 拍摄日期
+ * @param linkedDate 关联到日历的某一天,可为空表示不关联
+ * @param photoPath 照片文件相对路径(相对 filesDir)
+ * @param createdAt 记录创建时间
+ */
+@Entity(tableName = "date_records")
+data class DateRecord(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ val title: String,
+ val note: String,
+ val shootDate: LocalDate,
+ val linkedDate: LocalDate?,
+ val photoPath: String,
+ val createdAt: Instant
+)
diff --git a/core/src/main/kotlin/plus/rua/project/DateRecordConverters.kt b/core/src/main/kotlin/plus/rua/project/DateRecordConverters.kt
new file mode 100644
index 0000000..d9890fe
--- /dev/null
+++ b/core/src/main/kotlin/plus/rua/project/DateRecordConverters.kt
@@ -0,0 +1,31 @@
+package plus.rua.project
+
+import androidx.room.TypeConverter
+import kotlinx.datetime.LocalDate
+import kotlin.time.Instant
+
+/**
+ * Room 的 kotlinx-datetime 类型转换器。
+ *
+ * Room 默认不识别 [LocalDate] 与 [Instant],这里统一以 ISO 字符串持久化:
+ * - [LocalDate] → "2026-07-17"
+ * - [Instant] → "2026-07-17T08:30:00Z"
+ *
+ * ISO 格式可读、可排序、可跨版本演进,无需关心 epoch 精度。
+ */
+class DateRecordConverters {
+
+ @TypeConverter
+ fun fromLocalDate(date: LocalDate?): String? = date?.toString()
+
+ @TypeConverter
+ fun toLocalDate(value: String?): LocalDate? =
+ value?.takeIf { it.isNotBlank() }?.let { LocalDate.parse(it) }
+
+ @TypeConverter
+ fun fromInstant(instant: Instant?): String? = instant?.toString()
+
+ @TypeConverter
+ fun toInstant(value: String?): Instant? =
+ value?.takeIf { it.isNotBlank() }?.let { Instant.parse(it) }
+}
diff --git a/core/src/main/kotlin/plus/rua/project/DateRecordDao.kt b/core/src/main/kotlin/plus/rua/project/DateRecordDao.kt
new file mode 100644
index 0000000..6eb22c8
--- /dev/null
+++ b/core/src/main/kotlin/plus/rua/project/DateRecordDao.kt
@@ -0,0 +1,57 @@
+package plus.rua.project
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Update
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * 日期记录器的数据库访问对象。
+ *
+ * 所有读操作返回 [Flow],UI 层订阅后自动响应数据库变化。
+ */
+@Dao
+interface DateRecordDao {
+
+ /**
+ * 按 ID 查询单条记录的 Flow,用于详情页订阅。
+ *
+ * @param id 记录主键
+ * @return 该 ID 的记录 Flow,不存在时 emit null
+ */
+ @Query("SELECT * FROM date_records WHERE id = :id")
+ fun getByIdFlow(id: Long): Flow
+
+ /**
+ * 查询全部记录,默认按拍摄日期降序。
+ *
+ * 最终排序在 Repository / UI 层根据用户选择重新应用,此处仅保证一个稳定默认序。
+ */
+ @Query("SELECT * FROM date_records ORDER BY shootDate DESC, id DESC")
+ fun getAllFlow(): Flow>
+
+ /**
+ * 插入一条新记录。
+ *
+ * @return 新记录的自增 ID
+ */
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(record: DateRecord): Long
+
+ /**
+ * 更新一条已有记录。
+ */
+ @Update
+ suspend fun update(record: DateRecord)
+
+ /**
+ * 按 ID 列表批量删除,支持多选删除场景。
+ *
+ * @param ids 待删除记录的主键列表
+ * @return 实际删除的行数
+ */
+ @Query("DELETE FROM date_records WHERE id IN (:ids)")
+ suspend fun deleteByIds(ids: List): Int
+}
diff --git a/core/src/main/kotlin/plus/rua/project/DateRecordDatabase.kt b/core/src/main/kotlin/plus/rua/project/DateRecordDatabase.kt
new file mode 100644
index 0000000..e9f9c61
--- /dev/null
+++ b/core/src/main/kotlin/plus/rua/project/DateRecordDatabase.kt
@@ -0,0 +1,42 @@
+package plus.rua.project
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+
+/**
+ * 日期记录器的 Room 数据库。
+ *
+ * 应用内单例,通过 [fromContext] 获取实例。Schema 导出到 `core/schemas/` 以追踪版本演进。
+ */
+@Database(
+ entities = [DateRecord::class],
+ version = 1,
+ exportSchema = true
+)
+@TypeConverters(DateRecordConverters::class)
+abstract class DateRecordDatabase : RoomDatabase() {
+
+ abstract fun dateRecordDao(): DateRecordDao
+
+ companion object {
+ @Volatile
+ private var INSTANCE: DateRecordDatabase? = null
+
+ /**
+ * 获取数据库单例。
+ *
+ * @param context 任意 Context,内部取 applicationContext
+ */
+ fun fromContext(context: Context): DateRecordDatabase =
+ INSTANCE ?: synchronized(this) {
+ INSTANCE ?: Room.databaseBuilder(
+ context.applicationContext,
+ DateRecordDatabase::class.java,
+ "date_recorder.db"
+ ).build().also { INSTANCE = it }
+ }
+ }
+}
diff --git a/core/src/main/kotlin/plus/rua/project/DateRecordDetailViewModel.kt b/core/src/main/kotlin/plus/rua/project/DateRecordDetailViewModel.kt
new file mode 100644
index 0000000..1d8e59b
--- /dev/null
+++ b/core/src/main/kotlin/plus/rua/project/DateRecordDetailViewModel.kt
@@ -0,0 +1,71 @@
+package plus.rua.project
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+/**
+ * 记录详情页 UI 状态。
+ *
+ * @param loading 加载中
+ * @param record 当前记录;不存在时为 null
+ * @param photoUri 用于 AsyncImage 显示的 URI 字符串
+ * @param deleted 删除已完成,UI 据此触发返回
+ */
+data class DateRecordDetailUiState(
+ val loading: Boolean = true,
+ val record: DateRecord? = null,
+ val photoUri: String? = null,
+ val deleted: Boolean = false
+)
+
+/**
+ * 记录详情页 ViewModel,订阅单条记录并支持删除。
+ *
+ * @param repository 数据仓库
+ * @param recordId 记录 ID
+ */
+class DateRecordDetailViewModel(
+ private val repository: DateRecorderRepository,
+ private val recordId: Long
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(DateRecordDetailUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init { load() }
+
+ 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)
+ }
+ }
+ }
+
+ /** 返回当前照片绝对路径,供"编辑照片"跳转使用。 */
+ fun currentPhotoPath(): String? = _uiState.value.record?.let {
+ repository.absoluteFileOf(it.photoPath).absolutePath
+ }
+
+ /** 删除当前记录及其照片文件。 */
+ fun delete() {
+ val record = _uiState.value.record ?: return
+ viewModelScope.launch {
+ repository.deleteWithPhoto(record)
+ _uiState.update { it.copy(deleted = true) }
+ }
+ }
+}
diff --git a/core/src/main/kotlin/plus/rua/project/DateRecorderRepository.kt b/core/src/main/kotlin/plus/rua/project/DateRecorderRepository.kt
new file mode 100644
index 0000000..b6c6463
--- /dev/null
+++ b/core/src/main/kotlin/plus/rua/project/DateRecorderRepository.kt
@@ -0,0 +1,113 @@
+package plus.rua.project
+
+import android.content.Context
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.withContext
+import java.io.File
+
+/**
+ * 日期记录器的数据仓库,封装 DAO 访问与照片文件管理。
+ *
+ * 照片统一存放在 `filesDir/Pictures/date_recorder/`,数据库中保存相对路径,
+ * 删除记录时同步删除对应照片文件。
+ *
+ * @param dao 记录 DAO
+ * @param filesDir 应用 filesDir,照片根目录
+ */
+class DateRecorderRepository(
+ private val dao: DateRecordDao,
+ private val filesDir: File
+) {
+
+ /** 照片存放的子目录(相对 filesDir) */
+ private val photoDir: File by lazy {
+ File(filesDir, PHOTO_DIR_NAME).apply { if (!exists()) mkdirs() }
+ }
+
+ /**
+ * 订阅全部记录,UI 层据此渲染相册网格。
+ */
+ fun observeAll(): Flow> = dao.getAllFlow()
+
+ /**
+ * 订阅单条记录,用于详情页。
+ *
+ * @param id 记录主键
+ */
+ fun observeById(id: Long): Flow = dao.getByIdFlow(id)
+
+ /**
+ * 新增一条记录。
+ *
+ * @param record 待插入记录,id 由 DB 自增
+ * @return 新记录的 ID
+ */
+ suspend fun insert(record: DateRecord): Long = dao.insert(record)
+
+ /**
+ * 更新一条记录。编辑信息/替换照片后调用。
+ */
+ suspend fun update(record: DateRecord) = dao.update(record)
+
+ /**
+ * 按 ID 批量删除记录,并同步删除对应照片文件。
+ *
+ * @param records 待删除记录列表(需要各自的 photoPath 来清理文件)
+ */
+ suspend fun deleteWithPhotos(records: List) = withContext(Dispatchers.IO) {
+ records.forEach { deletePhotoFile(it.photoPath) }
+ dao.deleteByIds(records.map { it.id })
+ }
+
+ /**
+ * 删除单条记录及其照片文件。
+ *
+ * @param record 待删除记录(需要其 photoPath 来清理文件)
+ */
+ suspend fun deleteWithPhoto(record: DateRecord) = withContext(Dispatchers.IO) {
+ deletePhotoFile(record.photoPath)
+ dao.deleteByIds(listOf(record.id))
+ }
+
+ /**
+ * 生成一个新的照片文件(不含扩展名),调用方写入内容后回传相对路径。
+ *
+ * @return 照片文件,路径相对 filesDir
+ */
+ fun createPhotoFile(): File {
+ val name = "rec_${System.currentTimeMillis()}"
+ return File(photoDir, "$name.jpg")
+ }
+
+ /**
+ * 将照片文件的绝对路径转换为相对 filesDir 的路径,用于 DB 持久化。
+ */
+ fun relativePathOf(file: File): String = file.absolutePath
+ .removePrefix(filesDir.absolutePath)
+ .removePrefix(File.separator)
+
+ /**
+ * 将 DB 中的相对路径转换为绝对路径 File。
+ */
+ fun absoluteFileOf(relativePath: String): File = File(filesDir, relativePath)
+
+ /**
+ * 删除指定相对路径的照片文件,文件不存在时静默忽略。
+ */
+ private fun deletePhotoFile(relativePath: String) {
+ runCatching { absoluteFileOf(relativePath).delete() }
+ }
+
+ companion object {
+ const val PHOTO_DIR_NAME = "Pictures/date_recorder"
+
+ /**
+ * 从 Context 构造 Repository,内部自建 DAO。
+ */
+ fun fromContext(context: Context): DateRecorderRepository {
+ val db = DateRecordDatabase.fromContext(context)
+ return DateRecorderRepository(db.dateRecordDao(), context.filesDir)
+ }
+ }
+}
diff --git a/core/src/main/kotlin/plus/rua/project/DateRecorderViewModel.kt b/core/src/main/kotlin/plus/rua/project/DateRecorderViewModel.kt
new file mode 100644
index 0000000..84af5be
--- /dev/null
+++ b/core/src/main/kotlin/plus/rua/project/DateRecorderViewModel.kt
@@ -0,0 +1,186 @@
+package plus.rua.project
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+
+/**
+ * 日期记录器列表的排序方式。
+ *
+ * @param field 排序字段
+ * @param ascending 是否升序;false 表示降序
+ */
+data class RecordSortOrder(
+ val field: RecordSortField,
+ val ascending: Boolean
+) {
+ companion object {
+ /** 默认按拍摄日期降序(最新的在前) */
+ val DEFAULT = RecordSortOrder(RecordSortField.SHOOT_DATE, ascending = false)
+ }
+}
+
+/** 排序字段选项。 */
+enum class RecordSortField {
+ /** 拍摄日期 */
+ SHOOT_DATE,
+
+ /** 关联日期(无关联日期的记录排末尾) */
+ LINKED_DATE,
+
+ /** 记录创建时间 */
+ CREATED_AT
+}
+
+/**
+ * 日期记录器主界面 UI 状态。
+ *
+ * @param records 排序后的记录列表
+ * @param sortOrder 当前排序方式
+ * @param isLoading 首次加载中
+ * @param selectionMode 是否处于多选模式
+ * @param selectedIds 当前选中的记录 ID 集合
+ */
+data class DateRecorderUiState(
+ val records: List = emptyList(),
+ val sortOrder: RecordSortOrder = RecordSortOrder.DEFAULT,
+ val isLoading: Boolean = true,
+ val selectionMode: Boolean = false,
+ val selectedIds: Set = emptySet()
+) {
+ /** 是否已选中全部记录(供"全选/取消全选"切换显示) */
+ val allSelected: Boolean get() = records.isNotEmpty() && selectedIds.size == records.size
+}
+
+/**
+ * 日期记录器列表的 ViewModel。
+ *
+ * 订阅 [DateRecorderRepository] 的全部记录 Flow,并按用户选择的排序方式排序后暴露。
+ *
+ * @param repository 数据仓库
+ */
+class DateRecorderViewModel(
+ private val repository: DateRecorderRepository
+) : ViewModel() {
+
+ private val _sortOrder = MutableStateFlow(RecordSortOrder.DEFAULT)
+ val sortOrder: StateFlow = _sortOrder.asStateFlow()
+
+ private val _selectionMode = MutableStateFlow(false)
+ private val _selectedIds = MutableStateFlow>(emptySet())
+
+ /**
+ * 聚合的 UI 状态:仓库记录 Flow + 排序 Flow + 多选 Flow 合并、排序。
+ */
+ val uiState: StateFlow = run {
+ kotlinx.coroutines.flow.combine(
+ repository.observeAll(),
+ _sortOrder,
+ _selectionMode,
+ _selectedIds
+ ) { records, order, selectionMode, selectedIds ->
+ DateRecorderUiState(
+ records = sortRecords(records, order),
+ sortOrder = order,
+ isLoading = false,
+ selectionMode = selectionMode,
+ selectedIds = selectedIds
+ )
+ }.stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5000),
+ initialValue = DateRecorderUiState()
+ )
+ }
+
+ /**
+ * 切换排序方式。
+ *
+ * @param order 新的排序方式
+ */
+ fun setSortOrder(order: RecordSortOrder) {
+ _sortOrder.value = order
+ }
+
+ /**
+ * 进入/退出多选模式。进入时清空已有选择。
+ */
+ fun toggleSelectionMode() {
+ _selectionMode.value = !_selectionMode.value
+ _selectedIds.value = emptySet()
+ }
+
+ /**
+ * 切换某条记录的选中状态。
+ */
+ fun toggleSelection(id: Long) {
+ _selectedIds.value = _selectedIds.value.let { current ->
+ if (id in current) current - id else current + id
+ }
+ }
+
+ /**
+ * 全选 / 取消全选。
+ */
+ fun toggleSelectAll() {
+ val records = uiState.value.records
+ _selectedIds.value = if (uiState.value.allSelected) {
+ emptySet()
+ } else {
+ records.map { it.id }.toSet()
+ }
+ }
+
+ /**
+ * 删除当前选中的所有记录(含照片文件),完成后退出多选模式。
+ */
+ fun deleteSelected() {
+ val selectedIds = _selectedIds.value
+ if (selectedIds.isEmpty()) return
+ val toDelete = uiState.value.records.filter { it.id in selectedIds }
+ viewModelScope.launch {
+ repository.deleteWithPhotos(toDelete)
+ _selectionMode.value = false
+ _selectedIds.value = emptySet()
+ }
+ }
+
+ /**
+ * 按 ID 批量删除记录(含照片文件)。
+ *
+ * @param records 待删除记录列表
+ */
+ fun deleteRecords(records: List) {
+ viewModelScope.launch {
+ repository.deleteWithPhotos(records)
+ }
+ }
+
+ private fun sortRecords(
+ records: List,
+ order: RecordSortOrder
+ ): List = sortDateRecords(records, order)
+
+ companion object {
+ /**
+ * 按指定方式排序记录列表。抽为 companion 静态方法以便单元测试。
+ */
+ fun sortDateRecords(
+ records: List,
+ order: RecordSortOrder
+ ): List {
+ val comparator: Comparator = when (order.field) {
+ RecordSortField.SHOOT_DATE -> compareBy { it.shootDate }
+ 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 }))
+ }
+ }
+}
diff --git a/core/src/main/kotlin/plus/rua/project/PhotoEditorState.kt b/core/src/main/kotlin/plus/rua/project/PhotoEditorState.kt
new file mode 100644
index 0000000..162693f
--- /dev/null
+++ b/core/src/main/kotlin/plus/rua/project/PhotoEditorState.kt
@@ -0,0 +1,82 @@
+package plus.rua.project
+
+import android.graphics.Bitmap
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.StrokeJoin
+import androidx.compose.ui.graphics.drawscope.Stroke
+
+/**
+ * 手写笔触:一条由多个采样点组成的路径,附带颜色与粗细。
+ *
+ * @param color 笔触颜色
+ * @param widthPx 笔触线宽(像素)
+ * @param points 采样点序列(基于图片显示坐标系)
+ */
+data class HandStroke(
+ val color: Color,
+ val widthPx: Float,
+ val points: List = emptyList()
+)
+
+/**
+ * 照片编辑器状态。
+ *
+ * 持有原始 Bitmap(经 inSampleSize 降采样后)、当前旋转角度(0/90/180/270)、
+ * 裁剪矩形(基于旋转后图片坐标系,null 表示不裁剪)、手写笔触列表。
+ *
+ * @param sourceBitmap 降采样后的源图(显示用)
+ * @param sourceAbsolutePath 源图绝对路径(保存时回放笔触用原图尺寸)
+ * @param rotationDegrees 累计旋转角度,始终为 90 的倍数
+ * @param cropLeft 裁剪左边界比例 [0,1];null 表示未启用裁剪
+ * @param cropTop 裁剪上边界比例 [0,1]
+ * @param cropRight 裁剪右边界比例 [0,1]
+ * @param cropBottom 裁剪下边界比例 [0,1]
+ * @param strokes 手写笔触列表(显示坐标系)
+ * @param strokeColor 当前笔触颜色
+ * @param strokeWidthPx 当前笔触线宽
+ */
+data class PhotoEditorState(
+ val sourceBitmap: Bitmap,
+ val sourceAbsolutePath: String,
+ val rotationDegrees: Int = 0,
+ val cropLeft: Float? = null,
+ val cropTop: Float = 0f,
+ val cropRight: Float? = null,
+ val cropBottom: Float = 1f,
+ val strokes: List = emptyList(),
+ val strokeColor: Color = Color.Red,
+ val strokeWidthPx: Float = 8f
+) {
+ /** 当前是否已启用裁剪 */
+ val cropEnabled: Boolean get() = cropLeft != null && cropRight != null
+
+ /** 当前旋转后的 Bitmap(不含裁剪/手写),用于显示预览 */
+ val rotatedBitmap: Bitmap
+ get() = if (rotationDegrees % 360 == 0) sourceBitmap
+ else PhotoProcessor.rotate(sourceBitmap, rotationDegrees)
+}
+
+/**
+ * 笔触绘制配置,供 Canvas drawPath 使用。
+ */
+fun strokeDraw(widthPx: Float) = Stroke(
+ width = widthPx,
+ cap = StrokeCap.Round,
+ join = StrokeJoin.Round
+)
+
+/**
+ * 将 [HandStroke] 的采样点转换为 Compose [Path]。
+ */
+fun HandStroke.toPath(): Path {
+ if (points.isEmpty()) return Path()
+ val path = Path()
+ path.moveTo(points[0].x, points[0].y)
+ for (i in 1 until points.size) {
+ path.lineTo(points[i].x, points[i].y)
+ }
+ return path
+}
diff --git a/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt b/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt
new file mode 100644
index 0000000..b70ac28
--- /dev/null
+++ b/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt
@@ -0,0 +1,176 @@
+package plus.rua.project
+
+import android.graphics.Bitmap
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.File
+
+/**
+ * 照片编辑器 UI 状态。
+ *
+ * @param loading 加载源图中
+ * @param editorState 已加载的编辑状态;加载完成前为 null
+ * @param error 加载失败信息
+ * @param displayWidth 图片显示区域宽度(笔触坐标系基准,由 UI 回传)
+ * @param displayHeight 图片显示区域高度
+ * @param saving 保存中
+ * @param savedPath 保存完成后的最终图片绝对路径;UI 据此触发跳转
+ */
+data class PhotoEditorUiState(
+ val loading: Boolean = true,
+ val editorState: PhotoEditorState? = null,
+ val error: String? = null,
+ val displayWidth: Float = 0f,
+ val displayHeight: Float = 0f,
+ val saving: Boolean = false,
+ val savedPath: String? = null
+)
+
+/**
+ * 照片编辑器 ViewModel,持有旋转/裁剪/手写状态并负责落盘。
+ *
+ * @param sourcePath 源图绝对路径
+ */
+class PhotoEditorViewModel(
+ private val sourcePath: String
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(PhotoEditorUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init { loadSource() }
+
+ private fun loadSource() {
+ viewModelScope.launch(Dispatchers.IO) {
+ runCatching {
+ val bmp = PhotoProcessor.loadSampled(sourcePath)
+ PhotoEditorState(
+ sourceBitmap = bmp,
+ sourceAbsolutePath = sourcePath
+ )
+ }.onSuccess { state ->
+ _uiState.value = PhotoEditorUiState(loading = false, editorState = state)
+ }.onFailure { e ->
+ _uiState.value = PhotoEditorUiState(
+ loading = false,
+ error = "加载图片失败:${e.message}"
+ )
+ }
+ }
+ }
+
+ /**
+ * 更新显示区域尺寸,用于笔触坐标归一化与落盘缩放。
+ */
+ fun updateDisplaySize(w: Float, h: Float) {
+ _uiState.update { it.copy(displayWidth = w, displayHeight = h) }
+ }
+
+ /** 顺时针/逆时针旋转 90° 的倍数。 */
+ fun rotate(delta: Int) {
+ update { it.copy(rotationDegrees = it.rotationDegrees + delta) }
+ }
+
+ /** 开启/关闭裁剪。开启时使用默认居中 4:3 裁剪框。 */
+ fun toggleCrop() {
+ update {
+ if (it.cropEnabled) {
+ it.copy(cropLeft = null, cropRight = null, cropTop = 0f, cropBottom = 1f)
+ } else {
+ it.copy(cropLeft = 0.1f, cropTop = 0.1f, cropRight = 0.9f, cropBottom = 0.9f)
+ }
+ }
+ }
+
+ /** 更新裁剪框比例。 */
+ fun updateCrop(left: Float, top: Float, right: Float, bottom: Float) {
+ update {
+ it.copy(
+ cropLeft = left.coerceIn(0f, 1f),
+ cropTop = top.coerceIn(0f, 1f),
+ cropRight = right.coerceIn(0f, 1f),
+ cropBottom = bottom.coerceIn(0f, 1f)
+ )
+ }
+ }
+
+ /** 追加一个手写笔触采样点。 */
+ fun addStrokePoint(offset: Offset) {
+ update {
+ val current = it.strokeColor to it.strokeWidthPx
+ val newStrokes = if (it.strokes.isEmpty() || it.strokes.last().points.isEmpty()) {
+ it.strokes + HandStroke(current.first, current.second, listOf(offset))
+ } else {
+ val last = it.strokes.last()
+ it.strokes.dropLast(1) + last.copy(points = last.points + offset)
+ }
+ it.copy(strokes = newStrokes)
+ }
+ }
+
+ /** 结束当前笔触(抬起手指)。 */
+ fun endStroke() {
+ // 当前实现笔触在 addStrokePoint 中即时累积,无需额外处理;
+ // 保留方法以便未来区分笔触段(如双击结束)。
+ }
+
+ /** 撤销最近一条笔触。 */
+ fun undoStroke() {
+ update { it.copy(strokes = it.strokes.dropLast(1)) }
+ }
+
+ /** 设置笔触颜色。 */
+ fun setStrokeColor(color: Color) {
+ update { it.copy(strokeColor = color) }
+ }
+
+ /** 设置笔触线宽。 */
+ fun setStrokeWidth(widthPx: Float) {
+ update { it.copy(strokeWidthPx = widthPx) }
+ }
+
+ /** 保存编辑结果到新文件。 */
+ fun save() {
+ val state = _uiState.value
+ val editor = state.editorState ?: return
+ _uiState.update { it.copy(saving = true) }
+ viewModelScope.launch(Dispatchers.IO) {
+ runCatching {
+ val destFile = File(editor.sourceAbsolutePath).let { src ->
+ File(src.parentFile, "edited_${System.currentTimeMillis()}.jpg")
+ }
+ PhotoProcessor.render(
+ source = editor.sourceBitmap,
+ rotationDegrees = editor.rotationDegrees,
+ cropLeft = editor.cropLeft,
+ cropTop = editor.cropTop,
+ cropRight = editor.cropRight,
+ cropBottom = editor.cropBottom,
+ strokes = editor.strokes,
+ displayWidth = state.displayWidth,
+ displayHeight = state.displayHeight,
+ destFile = destFile
+ )
+ }.onSuccess { path ->
+ _uiState.update { it.copy(saving = false, savedPath = path) }
+ }.onFailure { e ->
+ _uiState.update { it.copy(saving = false, error = "保存失败:${e.message}") }
+ }
+ }
+ }
+
+ private inline fun update(block: (PhotoEditorState) -> PhotoEditorState) {
+ _uiState.update { current ->
+ current.copy(editorState = current.editorState?.let(block))
+ }
+ }
+}
diff --git a/core/src/main/kotlin/plus/rua/project/PhotoProcessor.kt b/core/src/main/kotlin/plus/rua/project/PhotoProcessor.kt
new file mode 100644
index 0000000..9895e0f
--- /dev/null
+++ b/core/src/main/kotlin/plus/rua/project/PhotoProcessor.kt
@@ -0,0 +1,175 @@
+package plus.rua.project
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Canvas as AndroidCanvas
+import android.graphics.Color as AndroidColor
+import android.graphics.Matrix
+import android.graphics.Paint
+import androidx.compose.ui.graphics.Color
+import java.io.File
+import java.io.FileOutputStream
+
+/**
+ * 照片处理工具:加载、旋转、裁剪、合成手写笔触并落盘。
+ *
+ * 所有方法均为纯函数(不持有状态),便于测试与复用。
+ */
+object PhotoProcessor {
+
+ /**
+ * 按目标显示宽度降采样加载 Bitmap,避免大图 OOM。
+ *
+ * @param path 图片绝对路径
+ * @param reqWidth 期望显示宽度(像素),实际宽度会 >= reqWidth 的最小 2 次幂采样
+ * @return 降采样后的 Bitmap,加载失败抛异常
+ */
+ fun loadSampled(path: String, reqWidth: Int = 1080): Bitmap {
+ val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+ BitmapFactory.decodeFile(path, options)
+ options.inSampleSize = calculateInSampleSize(options.outWidth, reqWidth)
+ options.inJustDecodeBounds = false
+ return BitmapFactory.decodeFile(path, options)
+ ?: error("无法加载图片: $path")
+ }
+
+ private fun calculateInSampleSize(srcWidth: Int, reqWidth: Int): Int =
+ calculateInSampleSizePublic(srcWidth, reqWidth)
+
+ /**
+ * 降采样倍数计算(对测试可见)。
+ *
+ * 选择使 `srcWidth / sample <= reqWidth * 2` 的最小 2 次幂,
+ * 保证显示清晰度的同时避免大图 OOM。
+ */
+ internal fun calculateInSampleSizePublic(srcWidth: Int, reqWidth: Int): Int {
+ var sample = 1
+ while (srcWidth / sample > reqWidth * 2) sample *= 2
+ return sample
+ }
+
+ /**
+ * 旋转 Bitmap。
+ *
+ * @param bitmap 源图
+ * @param degrees 旋转角度(正向任意值,内部取模 360)
+ * @return 旋转后的新 Bitmap;0° 时返回原对象
+ */
+ fun rotate(bitmap: Bitmap, degrees: Int): Bitmap {
+ val normalized = degrees % 360
+ if (normalized == 0) return bitmap
+ val matrix = Matrix().apply { postRotate(normalized.toFloat()) }
+ return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
+ }
+
+ /**
+ * 按比例裁剪 Bitmap。
+ *
+ * @param bitmap 源图
+ * @param left 左边界比例 [0,1]
+ * @param top 上边界比例 [0,1]
+ * @param right 右边界比例 [0,1]
+ * @param bottom 下边界比例 [0,1]
+ * @return 裁剪后的新 Bitmap
+ */
+ fun crop(
+ bitmap: Bitmap,
+ left: Float,
+ top: Float,
+ right: Float,
+ bottom: Float
+ ): Bitmap {
+ val x = (left.coerceIn(0f, 1f) * bitmap.width).toInt()
+ val y = (top.coerceIn(0f, 1f) * bitmap.height).toInt()
+ val w = ((right - left).coerceIn(0f, 1f) * bitmap.width).toInt()
+ val h = ((bottom - top).coerceIn(0f, 1f) * bitmap.height).toInt()
+ return Bitmap.createBitmap(bitmap, x, y, w, h, null, false)
+ }
+
+ /**
+ * 将编辑结果(旋转 + 裁剪 + 手写笔触)合成为最终 Bitmap 并写入 [destFile]。
+ *
+ * 笔触坐标基于 [displayWidth]×[displayHeight] 的显示坐标系,
+ * 内部按 `最终Bitmap尺寸 / 显示尺寸` 缩放后绘制,保证落盘精度。
+ *
+ * @param source 源 Bitmap
+ * @param rotationDegrees 累计旋转角度
+ * @param cropLeft 裁剪左比例,null 表示不裁剪
+ * @param cropTop 裁剪上比例
+ * @param cropRight 裁剪右比例,null 表示不裁剪
+ * @param cropBottom 裁剪下比例
+ * @param strokes 手写笔触列表
+ * @param displayWidth 显示区域宽度(笔触坐标系基准)
+ * @param displayHeight 显示区域高度(笔触坐标系基准)
+ * @param destFile 输出文件
+ * @return 输出文件绝对路径
+ */
+ fun render(
+ source: Bitmap,
+ rotationDegrees: Int,
+ cropLeft: Float?,
+ cropTop: Float,
+ cropRight: Float?,
+ cropBottom: Float,
+ strokes: List,
+ displayWidth: Float,
+ displayHeight: Float,
+ destFile: File
+ ): String {
+ // 1. 旋转
+ var result = rotate(source, rotationDegrees)
+ // 2. 裁剪
+ if (cropLeft != null && cropRight != null) {
+ result = crop(result, cropLeft, cropTop, cropRight, cropBottom)
+ }
+ // 3. 合成手写笔触
+ if (strokes.isNotEmpty()) {
+ result = drawStrokes(result, strokes, displayWidth, displayHeight)
+ }
+ // 4. 落盘(JPEG 90% 质量)
+ FileOutputStream(destFile).use { out ->
+ result.compress(Bitmap.CompressFormat.JPEG, 90, out)
+ }
+ return destFile.absolutePath
+ }
+
+ private fun drawStrokes(
+ bitmap: Bitmap,
+ strokes: List,
+ displayWidth: Float,
+ displayHeight: Float
+ ): Bitmap {
+ val mutable = bitmap.copy(Bitmap.Config.ARGB_8888, true)
+ val canvas = AndroidCanvas(mutable)
+ // 笔触坐标从显示尺寸映射到 Bitmap 尺寸
+ val scaleX = bitmap.width / displayWidth
+ val scaleY = bitmap.height / displayHeight
+ val paint = Paint().apply {
+ style = Paint.Style.STROKE
+ strokeCap = Paint.Cap.ROUND
+ strokeJoin = Paint.Join.ROUND
+ isAntiAlias = true
+ }
+ strokes.forEach { stroke ->
+ if (stroke.points.size < 2) return@forEach
+ paint.color = stroke.color.toArgb()
+ paint.strokeWidth = stroke.widthPx * ((scaleX + scaleY) / 2f)
+ val path = android.graphics.Path()
+ val first = stroke.points[0]
+ path.moveTo(first.x * scaleX, first.y * scaleY)
+ for (i in 1 until stroke.points.size) {
+ val p = stroke.points[i]
+ path.lineTo(p.x * scaleX, p.y * scaleY)
+ }
+ canvas.drawPath(path, paint)
+ }
+ return mutable
+ }
+
+ private fun Color.toArgb(): Int = AndroidColor.argb(
+ (alpha * 255).toInt(),
+ (red * 255).toInt(),
+ (green * 255).toInt(),
+ (blue * 255).toInt()
+ )
+}
diff --git a/core/src/main/kotlin/plus/rua/project/RecordEditViewModel.kt b/core/src/main/kotlin/plus/rua/project/RecordEditViewModel.kt
new file mode 100644
index 0000000..57fab01
--- /dev/null
+++ b/core/src/main/kotlin/plus/rua/project/RecordEditViewModel.kt
@@ -0,0 +1,157 @@
+package plus.rua.project
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.todayIn
+import java.io.File
+import kotlin.time.Clock
+import kotlin.time.Instant
+
+/**
+ * 记录编辑页面 UI 状态。
+ *
+ * @param loading 加载已有记录中(仅编辑模式)
+ * @param title 标题
+ * @param note 备注
+ * @param shootDate 拍摄日期
+ * @param linkedDate 关联日期(可空)
+ * @param photoUri 用于 AsyncImage 显示的 URI 字符串(file:// 绝对路径或 content://)
+ * @param photoAbsolutePath 照片绝对路径,保存时持久化相对路径
+ * @param canSave 是否可保存(标题非空且照片就绪)
+ * @param finished 保存完成,UI 据此触发返回
+ * @param isExistingRecord 是否为编辑已有记录模式
+ */
+data class RecordEditUiState(
+ val loading: Boolean = true,
+ val title: String = "",
+ val note: String = "",
+ val shootDate: LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault()),
+ val linkedDate: LocalDate? = null,
+ val photoUri: String? = null,
+ val photoAbsolutePath: String? = null,
+ val canSave: Boolean = false,
+ val finished: Boolean = false,
+ val isExistingRecord: Boolean = false
+)
+
+/**
+ * 记录编辑页面 ViewModel,处理新建与编辑两种模式。
+ *
+ * @param repository 数据仓库
+ * @param photoPath 新建模式下的照片绝对路径(来自相机/编辑器),编辑模式为 null
+ * @param recordId 编辑模式下的已有记录 ID,新建模式为 null
+ */
+class RecordEditViewModel(
+ private val repository: DateRecorderRepository,
+ private val photoPath: String?,
+ private val recordId: Long?
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(RecordEditUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ if (recordId != null) {
+ loadExistingRecord(recordId)
+ } else {
+ initNewRecord()
+ }
+ }
+
+ private fun initNewRecord() {
+ requireNotNull(photoPath) { "新建模式必须提供 photoPath" }
+ val file = File(photoPath)
+ _uiState.value = RecordEditUiState(
+ loading = false,
+ photoUri = "file://${file.absolutePath}",
+ photoAbsolutePath = file.absolutePath,
+ canSave = false,
+ isExistingRecord = false
+ )
+ }
+
+ private fun loadExistingRecord(id: Long) {
+ viewModelScope.launch {
+ val record = repository.observeById(id).first()
+ if (record != null) {
+ val absFile = repository.absoluteFileOf(record.photoPath)
+ _uiState.value = RecordEditUiState(
+ loading = false,
+ title = record.title,
+ note = record.note,
+ shootDate = record.shootDate,
+ linkedDate = record.linkedDate,
+ photoUri = "file://${absFile.absolutePath}",
+ photoAbsolutePath = absFile.absolutePath,
+ canSave = true,
+ isExistingRecord = true
+ )
+ } else {
+ // 记录不存在(已被删除),直接结束
+ _uiState.update { it.copy(loading = false, finished = true) }
+ }
+ }
+ }
+
+ fun onTitleChange(value: String) {
+ _uiState.update { it.copy(title = value, canSave = value.isNotBlank() && it.photoAbsolutePath != null) }
+ }
+
+ fun onNoteChange(value: String) {
+ _uiState.update { it.copy(note = value) }
+ }
+
+ fun onShootDateChange(date: LocalDate) {
+ _uiState.update { it.copy(shootDate = date) }
+ }
+
+ fun onLinkedDateChange(date: LocalDate) {
+ _uiState.update { it.copy(linkedDate = date) }
+ }
+
+ fun onClearLinkedDate() {
+ _uiState.update { it.copy(linkedDate = null) }
+ }
+
+ fun save() {
+ val state = _uiState.value
+ if (!state.canSave || state.photoAbsolutePath == null) return
+ viewModelScope.launch {
+ val relPath = repository.relativePathOf(File(state.photoAbsolutePath))
+ if (state.isExistingRecord && recordId != null) {
+ repository.update(
+ DateRecord(
+ id = recordId,
+ title = state.title,
+ note = state.note,
+ shootDate = state.shootDate,
+ linkedDate = state.linkedDate,
+ photoPath = relPath,
+ createdAt = Instant.fromEpochMilliseconds(System.currentTimeMillis())
+ )
+ )
+ } else {
+ repository.insert(
+ DateRecord(
+ id = 0,
+ title = state.title,
+ note = state.note,
+ shootDate = state.shootDate,
+ linkedDate = state.linkedDate,
+ photoPath = relPath,
+ createdAt = Instant.fromEpochMilliseconds(System.currentTimeMillis())
+ )
+ )
+ }
+ _uiState.update { it.copy(finished = true) }
+ }
+ }
+}
diff --git a/core/src/main/kotlin/plus/rua/project/ui/CameraScreen.kt b/core/src/main/kotlin/plus/rua/project/ui/CameraScreen.kt
new file mode 100644
index 0000000..ae5c6e9
--- /dev/null
+++ b/core/src/main/kotlin/plus/rua/project/ui/CameraScreen.kt
@@ -0,0 +1,318 @@
+package plus.rua.project.ui
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.ImageCapture
+import androidx.camera.core.ImageCaptureException
+import androidx.camera.core.Preview
+import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.camera.view.PreviewView
+import androidx.compose.foundation.background
+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.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Cameraswitch
+import androidx.compose.material.icons.filled.ChevronLeft
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+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.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import java.io.File
+import java.util.concurrent.Executors
+
+/**
+ * 相机拍摄页面,使用 CameraX 提供应用内预览与拍照。
+ *
+ * 进入时请求 CAMERA 权限;权限通过后绑定预览,用户点击快门将照片写入临时文件,
+ * 然后通过 [onPhotoCaptured] 回调把临时文件路径回传(由 Activity 跳转编辑器页)。
+ *
+ * @param onBack 取消拍摄返回回调
+ * @param onPhotoCaptured 拍照成功回调,参数为临时照片文件绝对路径
+ * @param modifier 布局修饰符
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CameraScreen(
+ onBack: () -> Unit,
+ onPhotoCaptured: (String) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val context = LocalContext.current
+ val lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current
+
+ var hasCameraPermission by remember {
+ mutableStateOf(context.checkCameraPermission())
+ }
+ var isCapturing by remember { mutableStateOf(false) }
+ var captureError by remember { mutableStateOf(null) }
+
+ // 运行时权限请求 launcher
+ val permissionLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission()
+ ) { granted ->
+ hasCameraPermission = granted
+ }
+
+ // 进入时若无权限则发起请求
+ LaunchedEffect(Unit) {
+ if (!hasCameraPermission) {
+ permissionLauncher.launch(Manifest.permission.CAMERA)
+ }
+ }
+
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(Color.Black)
+ .semantics { testTagsAsResourceId = true }
+ ) {
+ when {
+ !hasCameraPermission -> PermissionDeniedContent(onBack = onBack)
+
+ isCapturing -> CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center),
+ color = Color.White
+ )
+
+ else -> CameraPreview(
+ context = context,
+ lifecycleOwner = lifecycleOwner,
+ onBack = onBack,
+ onCaptured = { path ->
+ isCapturing = false
+ onPhotoCaptured(path)
+ },
+ onError = { msg ->
+ isCapturing = false
+ captureError = msg
+ },
+ setCapturing = { isCapturing = it }
+ )
+ }
+
+ captureError?.let { msg ->
+ Text(
+ text = msg,
+ color = Color.White,
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = 160.dp)
+ )
+ }
+ }
+}
+
+@Composable
+private fun CameraPreview(
+ context: Context,
+ lifecycleOwner: LifecycleOwner,
+ onBack: () -> Unit,
+ onCaptured: (String) -> Unit,
+ onError: (String) -> Unit,
+ setCapturing: (Boolean) -> Unit
+) {
+ val imageCapture = remember { ImageCapture.Builder().build() }
+ val executor = remember { Executors.newSingleThreadExecutor() }
+ var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) }
+
+ DisposableEffect(Unit) {
+ onDispose { executor.shutdown() }
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ AndroidView(
+ modifier = Modifier.fillMaxSize(),
+ factory = { ctx ->
+ val previewView = PreviewView(ctx).apply {
+ scaleType = PreviewView.ScaleType.FILL_CENTER
+ }
+ bindCameraUseCases(
+ context = ctx,
+ lifecycleOwner = lifecycleOwner,
+ previewView = previewView,
+ imageCapture = imageCapture,
+ lensFacing = lensFacing
+ )
+ previewView
+ },
+ update = { previewView ->
+ bindCameraUseCases(
+ context = context,
+ lifecycleOwner = lifecycleOwner,
+ previewView = previewView,
+ imageCapture = imageCapture,
+ lensFacing = lensFacing
+ )
+ }
+ )
+
+ // 顶部返回按钮
+ IconButton(
+ onClick = onBack,
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(16.dp)
+ .testTag("camera_back")
+ ) {
+ Icon(
+ imageVector = Icons.Filled.ChevronLeft,
+ contentDescription = "返回",
+ tint = Color.White
+ )
+ }
+
+ // 底部控制栏:切换镜头(左)/ 快门(中)
+ Row(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth()
+ .padding(bottom = 48.dp),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(
+ onClick = {
+ lensFacing = if (lensFacing == CameraSelector.LENS_FACING_BACK) {
+ CameraSelector.LENS_FACING_FRONT
+ } else {
+ CameraSelector.LENS_FACING_BACK
+ }
+ },
+ modifier = Modifier.testTag("camera_switch")
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Cameraswitch,
+ contentDescription = "切换镜头",
+ tint = Color.White,
+ modifier = Modifier.size(32.dp)
+ )
+ }
+
+ // 快门按钮:外圈白色环 + 内圈白色实心
+ IconButton(
+ onClick = {
+ val photoFile = createTempPhotoFile(context)
+ val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
+ setCapturing(true)
+ imageCapture.takePicture(
+ outputOptions,
+ executor,
+ object : ImageCapture.OnImageSavedCallback {
+ override fun onImageSaved(output: ImageCapture.OutputFileResults) {
+ onCaptured(photoFile.absolutePath)
+ }
+
+ override fun onError(exception: ImageCaptureException) {
+ onError("拍照失败:${exception.message}")
+ }
+ }
+ )
+ },
+ modifier = Modifier
+ .size(72.dp)
+ .background(Color.White.copy(alpha = 0.3f), CircleShape)
+ .testTag("camera_shutter")
+ ) {
+ Box(
+ modifier = Modifier
+ .size(60.dp)
+ .background(Color.White, CircleShape)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun PermissionDeniedContent(onBack: () -> Unit) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = "需要相机权限才能拍照",
+ color = Color.White,
+ style = MaterialTheme.typography.bodyLarge
+ )
+ Text(
+ text = "请在系统设置中授权后重试",
+ color = Color.White,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+}
+
+private fun bindCameraUseCases(
+ context: Context,
+ lifecycleOwner: LifecycleOwner,
+ previewView: PreviewView,
+ imageCapture: ImageCapture,
+ lensFacing: Int
+) {
+ val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
+ cameraProviderFuture.addListener({
+ runCatching {
+ val cameraProvider = cameraProviderFuture.get()
+ val preview = Preview.Builder().build().also {
+ it.surfaceProvider = previewView.surfaceProvider
+ }
+ val selector = CameraSelector.Builder()
+ .requireLensFacing(lensFacing)
+ .build()
+
+ cameraProvider.unbindAll()
+ cameraProvider.bindToLifecycle(
+ lifecycleOwner,
+ selector,
+ preview,
+ imageCapture
+ )
+ }
+ }, ContextCompat.getMainExecutor(context))
+}
+
+private fun Context.checkCameraPermission(): Boolean =
+ ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
+ PackageManager.PERMISSION_GRANTED
+
+private fun createTempPhotoFile(context: Context): File {
+ val dir = File(context.filesDir, "Pictures/date_recorder").apply { mkdirs() }
+ return File(dir, "tmp_${System.currentTimeMillis()}.jpg")
+}
diff --git a/core/src/main/kotlin/plus/rua/project/ui/DateRecorderNav.kt b/core/src/main/kotlin/plus/rua/project/ui/DateRecorderNav.kt
new file mode 100644
index 0000000..4fd925e
--- /dev/null
+++ b/core/src/main/kotlin/plus/rua/project/ui/DateRecorderNav.kt
@@ -0,0 +1,18 @@
+package plus.rua.project.ui
+
+/**
+ * 日期记录器跨 Activity 导航协议。
+ *
+ * 由于项目采用 Activity + Intent 滑动转场(无 Compose Navigation),各页面之间
+ * 通过 Intent extra 传递照片文件路径与记录 ID。此处集中定义 extra key,避免散落硬编码。
+ */
+object DateRecorderNav {
+ /** 拍照后的临时照片文件绝对路径(相机页 → 编辑器页) */
+ const val EXTRA_TEMP_PHOTO_PATH = "extra_temp_photo_path"
+
+ /** 编辑器输出的最终照片文件绝对路径(编辑器页 → 记录编辑页) */
+ const val EXTRA_FINAL_PHOTO_PATH = "extra_final_photo_path"
+
+ /** 已有记录的 ID(详情页 → 编辑器页 / 记录编辑页) */
+ const val EXTRA_RECORD_ID = "extra_record_id"
+}
diff --git a/core/src/main/kotlin/plus/rua/project/ui/DateRecorderScreen.kt b/core/src/main/kotlin/plus/rua/project/ui/DateRecorderScreen.kt
new file mode 100644
index 0000000..b89028c
--- /dev/null
+++ b/core/src/main/kotlin/plus/rua/project/ui/DateRecorderScreen.kt
@@ -0,0 +1,388 @@
+package plus.rua.project.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+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.Add
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Checklist
+import androidx.compose.material.icons.filled.ChevronLeft
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Sort
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.BottomAppBar
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+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.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.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+import com.github.panpf.sketch.AsyncImage
+import plus.rua.project.DateRecorderRepository
+import plus.rua.project.DateRecorderViewModel
+import plus.rua.project.DateRecord
+import plus.rua.project.RecordSortField
+import plus.rua.project.RecordSortOrder
+
+/**
+ * 日期记录器主界面,以相册形式展示所有记录。
+ *
+ * 功能:
+ * - 右下角浮动按钮拍摄新照片创建记录
+ * - 点击单条记录进入详情页
+ * - TopAppBar 右侧"排序"菜单:6 种排序组合
+ * - TopAppBar 右侧"多选"按钮进入多选态,可批量删除
+ *
+ * @param onBack 返回回调
+ * @param onOpenCamera 打开相机新建记录回调
+ * @param onOpenRecord 打开指定记录详情回调,参数为记录 ID
+ * @param modifier 布局修饰符
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DateRecorderScreen(
+ onBack: () -> Unit,
+ onOpenCamera: () -> Unit,
+ onOpenRecord: (Long) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val context = LocalContext.current.applicationContext
+ val viewModel: DateRecorderViewModel = viewModel(
+ factory = viewModelFactory {
+ initializer { DateRecorderViewModel(DateRecorderRepository.fromContext(context)) }
+ }
+ )
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val repo = remember { DateRecorderRepository.fromContext(context) }
+
+ var showSortMenu by remember { mutableStateOf(false) }
+ var showBatchDeleteDialog by remember { mutableStateOf(false) }
+
+ Scaffold(
+ modifier = modifier.semantics { testTagsAsResourceId = true },
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = if (uiState.selectionMode) {
+ "已选 ${uiState.selectedIds.size}"
+ } else {
+ "日期记录器"
+ },
+ style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = {
+ if (uiState.selectionMode) viewModel.toggleSelectionMode()
+ else onBack()
+ }) {
+ Icon(
+ imageVector = if (uiState.selectionMode) Icons.Filled.Close
+ else Icons.Filled.ChevronLeft,
+ contentDescription = if (uiState.selectionMode) "退出多选" else "返回"
+ )
+ }
+ },
+ actions = {
+ if (uiState.selectionMode) {
+ IconButton(onClick = viewModel::toggleSelectAll) {
+ Icon(Icons.Filled.Checklist, contentDescription = "全选")
+ }
+ } else if (!uiState.isLoading && uiState.records.isNotEmpty()) {
+ // 排序菜单
+ Box {
+ IconButton(onClick = { showSortMenu = true }) {
+ Icon(Icons.Filled.Sort, contentDescription = "排序")
+ }
+ DropdownMenu(
+ expanded = showSortMenu,
+ onDismissRequest = { showSortMenu = false }
+ ) {
+ sortMenuItems().forEach { item ->
+ DropdownMenuItem(
+ text = { Text(item.label) },
+ onClick = {
+ viewModel.setSortOrder(item.order)
+ showSortMenu = false
+ },
+ leadingIcon = if (uiState.sortOrder == item.order) {
+ { Icon(Icons.Filled.Check, contentDescription = null) }
+ } else null
+ )
+ }
+ }
+ }
+ IconButton(onClick = viewModel::toggleSelectionMode) {
+ Icon(Icons.Filled.Checklist, contentDescription = "多选")
+ }
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
+ )
+ },
+ bottomBar = {
+ if (uiState.selectionMode) {
+ BottomAppBar {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ TextButton(onClick = viewModel::toggleSelectAll) {
+ Text(if (uiState.allSelected) "取消全选" else "全选")
+ }
+ TextButton(
+ onClick = { showBatchDeleteDialog = true },
+ enabled = uiState.selectedIds.isNotEmpty()
+ ) {
+ Icon(
+ Icons.Filled.Delete,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error
+ )
+ Text(
+ "删除(${uiState.selectedIds.size})",
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(start = 8.dp)
+ )
+ }
+ }
+ }
+ }
+ },
+ floatingActionButton = {
+ if (!uiState.selectionMode) {
+ FloatingActionButton(
+ onClick = onOpenCamera,
+ modifier = Modifier.testTag("date_recorder_fab"),
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary
+ ) {
+ Icon(Icons.Filled.Add, contentDescription = "拍照记录")
+ }
+ }
+ },
+ containerColor = MaterialTheme.colorScheme.surface
+ ) { innerPadding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ ) {
+ when {
+ uiState.isLoading -> CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center)
+ )
+ uiState.records.isEmpty() -> EmptyState()
+ else -> RecordGrid(
+ records = uiState.records,
+ selectedIds = uiState.selectedIds,
+ selectionMode = uiState.selectionMode,
+ photoRoot = repo,
+ onOpenRecord = onOpenRecord,
+ onToggleSelection = viewModel::toggleSelection
+ )
+ }
+ }
+ }
+
+ if (showBatchDeleteDialog) {
+ AlertDialog(
+ onDismissRequest = { showBatchDeleteDialog = false },
+ title = { Text("删除记录") },
+ text = { Text("确定删除选中的 ${uiState.selectedIds.size} 条记录吗?此操作不可撤销。") },
+ confirmButton = {
+ TextButton(onClick = {
+ showBatchDeleteDialog = false
+ viewModel.deleteSelected()
+ }) { Text("删除", color = MaterialTheme.colorScheme.error) }
+ },
+ dismissButton = {
+ TextButton(onClick = { showBatchDeleteDialog = false }) { Text("取消") }
+ }
+ )
+ }
+}
+
+private data class SortMenuItem(val order: RecordSortOrder, val label: String)
+
+private fun sortMenuItems(): List = 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), "创建时间 · 旧→新")
+)
+
+@Composable
+private fun RecordGrid(
+ records: List,
+ selectedIds: Set,
+ selectionMode: Boolean,
+ photoRoot: DateRecorderRepository,
+ onOpenRecord: (Long) -> Unit,
+ onToggleSelection: (Long) -> Unit
+) {
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(2),
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(records, key = { it.id }) { record ->
+ RecordCard(
+ record = record,
+ photoUri = "file://${photoRoot.absoluteFileOf(record.photoPath).absolutePath}",
+ isSelected = record.id in selectedIds,
+ selectionMode = selectionMode,
+ onClick = {
+ if (selectionMode) onToggleSelection(record.id)
+ else onOpenRecord(record.id)
+ }
+ )
+ }
+ }
+}
+
+@Composable
+private fun RecordCard(
+ record: DateRecord,
+ photoUri: String,
+ isSelected: Boolean,
+ selectionMode: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Card(
+ onClick = onClick,
+ shape = RoundedCornerShape(12.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
+ ),
+ 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))
+ )
+ 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
+ )
+ }
+ }
+
+ // 多选态下的复选角标
+ if (selectionMode) {
+ SelectionBadge(
+ isSelected = isSelected,
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(8.dp)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun SelectionBadge(isSelected: Boolean, modifier: Modifier = Modifier) {
+ val bg = if (isSelected) MaterialTheme.colorScheme.primary
+ else Color.Black.copy(alpha = 0.3f)
+ Box(
+ modifier = modifier
+ .clip(CircleShape)
+ .background(bg)
+ .padding(4.dp)
+ ) {
+ if (isSelected) {
+ Icon(
+ Icons.Filled.Check,
+ contentDescription = "已选中",
+ tint = MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ }
+}
+
+@Composable
+private fun EmptyState() {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "还没有记录",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = "点击右下角按钮拍摄第一条记录",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ }
+}
diff --git a/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt b/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt
new file mode 100644
index 0000000..93ba339
--- /dev/null
+++ b/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt
@@ -0,0 +1,459 @@
+package plus.rua.project.ui
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.gestures.detectTapGestures
+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.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Brush
+import androidx.compose.material.icons.filled.ChevronLeft
+import androidx.compose.material.icons.filled.Crop
+import androidx.compose.material.icons.filled.Done
+import androidx.compose.material.icons.filled.RotateLeft
+import androidx.compose.material.icons.filled.RotateRight
+import androidx.compose.material.icons.filled.Undo
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.StrokeJoin
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.ContentScale
+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.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+import plus.rua.project.PhotoEditorState
+import plus.rua.project.PhotoEditorViewModel
+import plus.rua.project.toPath
+
+/**
+ * 照片编辑页面,提供旋转 / 裁剪 / 手写三种编辑能力。
+ *
+ * 编辑完成后通过 [onSaved] 回调返回最终照片绝对路径,由 Activity 跳转记录编辑页。
+ *
+ * @param onBack 取消编辑返回回调
+ * @param onSaved 保存成功回调,参数为最终照片绝对路径
+ * @param sourcePath 源照片绝对路径(来自相机或详情页)
+ * @param modifier 布局修饰符
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun PhotoEditorScreen(
+ onBack: () -> Unit,
+ onSaved: (String) -> Unit,
+ sourcePath: String,
+ modifier: Modifier = Modifier
+) {
+ val viewModel: PhotoEditorViewModel = viewModel(
+ factory = viewModelFactory {
+ initializer { PhotoEditorViewModel(sourcePath) }
+ }
+ )
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+ var activeTab by remember { mutableStateOf(EditTab.ROTATE) }
+ val savedPath = state.savedPath
+
+ if (savedPath != null) {
+ LaunchedEffect(savedPath) { onSaved(savedPath) }
+ }
+
+ Scaffold(
+ modifier = modifier.semantics { testTagsAsResourceId = true },
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ "编辑照片",
+ style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Filled.ChevronLeft, contentDescription = "返回")
+ }
+ },
+ actions = {
+ IconButton(
+ onClick = viewModel::save,
+ enabled = state.editorState != null && !state.saving,
+ modifier = Modifier.testTag("editor_save")
+ ) {
+ Icon(Icons.Filled.Done, contentDescription = "保存")
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
+ )
+ },
+ containerColor = MaterialTheme.colorScheme.surface
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .padding(16.dp)
+ ) {
+ when {
+ state.loading -> Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) { CircularProgressIndicator() }
+
+ state.error != null -> Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = state.error!!,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+
+ state.editorState != null -> {
+ EditorBody(
+ state = state.editorState!!,
+ saving = state.saving,
+ activeTab = activeTab,
+ onTabChange = { activeTab = it },
+ onRotate = viewModel::rotate,
+ onCropToggle = viewModel::toggleCrop,
+ onCropChange = viewModel::updateCrop,
+ onAddPoint = viewModel::addStrokePoint,
+ onEndStroke = viewModel::endStroke,
+ onUndoStroke = viewModel::undoStroke,
+ onStrokeColorChange = viewModel::setStrokeColor,
+ onDisplaySizeChange = viewModel::updateDisplaySize,
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ }
+ }
+ }
+}
+
+private enum class EditTab { ROTATE, CROP, HANDWRITE }
+
+@Composable
+private fun EditorBody(
+ state: PhotoEditorState,
+ saving: Boolean,
+ activeTab: EditTab,
+ onTabChange: (EditTab) -> Unit,
+ onRotate: (Int) -> Unit,
+ onCropToggle: () -> Unit,
+ onCropChange: (Float, Float, Float, Float) -> Unit,
+ onAddPoint: (Offset) -> Unit,
+ onEndStroke: () -> Unit,
+ onUndoStroke: () -> Unit,
+ onStrokeColorChange: (Color) -> Unit,
+ onDisplaySizeChange: (Float, Float) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Column(modifier = modifier) {
+ SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
+ EditTab.entries.forEachIndexed { index, tab ->
+ SegmentedButton(
+ selected = activeTab == tab,
+ onClick = { onTabChange(tab) },
+ shape = SegmentedButtonDefaults.itemShape(index, EditTab.entries.size),
+ icon = {
+ Icon(
+ when (tab) {
+ EditTab.ROTATE -> Icons.Filled.RotateRight
+ EditTab.CROP -> Icons.Filled.Crop
+ EditTab.HANDWRITE -> Icons.Filled.Brush
+ },
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ },
+ label = {
+ Text(
+ when (tab) {
+ EditTab.ROTATE -> "旋转"
+ EditTab.CROP -> "裁剪"
+ EditTab.HANDWRITE -> "手写"
+ }
+ )
+ }
+ )
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ .padding(top = 16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ EditableImage(
+ state = state,
+ mode = activeTab,
+ onCropChange = onCropChange,
+ onAddPoint = onAddPoint,
+ onEndStroke = onEndStroke,
+ onDisplaySizeChange = onDisplaySizeChange
+ )
+ if (saving) {
+ CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
+ }
+ }
+
+ ToolBar(
+ mode = activeTab,
+ state = state,
+ onRotate = onRotate,
+ onCropToggle = onCropToggle,
+ onUndoStroke = onUndoStroke,
+ onStrokeColorChange = onStrokeColorChange,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 16.dp)
+ )
+ }
+}
+
+@Composable
+private fun EditableImage(
+ state: PhotoEditorState,
+ mode: EditTab,
+ onCropChange: (Float, Float, Float, Float) -> Unit,
+ onAddPoint: (Offset) -> Unit,
+ onEndStroke: () -> Unit,
+ onDisplaySizeChange: (Float, Float) -> Unit
+) {
+ val rotatedBmp = remember(state.rotationDegrees, state.sourceBitmap) { state.rotatedBitmap }
+ // 裁剪框拖动手柄(CROP 模式下使用)
+ var dragHandle by remember { mutableStateOf(null) }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(rotatedBmp.width.toFloat() / rotatedBmp.height.toFloat())
+ .background(Color.Black)
+ ) {
+ Image(
+ bitmap = rotatedBmp.asImageBitmap(),
+ contentDescription = "编辑中的照片",
+ modifier = Modifier
+ .fillMaxSize()
+ .pointerInput(mode) {
+ if (mode == EditTab.HANDWRITE) {
+ detectDragGestures(
+ onDragStart = { offset -> onAddPoint(offset) },
+ onDrag = { change, _ ->
+ change.consume()
+ onAddPoint(change.position)
+ },
+ onDragEnd = onEndStroke,
+ onDragCancel = onEndStroke
+ )
+ }
+ },
+ contentScale = ContentScale.Fit
+ )
+
+ // 裁剪框覆盖层
+ if (mode == EditTab.CROP && state.cropEnabled) {
+ CropOverlay(
+ state = state,
+ dragHandle = dragHandle,
+ onHandleChange = { dragHandle = it },
+ onCropChange = onCropChange,
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+
+ // 手写笔触覆盖层 + 同步显示尺寸
+ Canvas(modifier = Modifier.fillMaxSize()) {
+ onDisplaySizeChange(size.width, size.height)
+ state.strokes.forEach { stroke ->
+ drawPath(
+ path = stroke.toPath(),
+ color = stroke.color,
+ style = Stroke(
+ width = stroke.widthPx,
+ cap = StrokeCap.Round,
+ join = StrokeJoin.Round
+ )
+ )
+ }
+ }
+ }
+}
+
+private enum class CropHandle { TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT, NONE }
+
+/** 裁剪框四元组,支持解构以简化 onDrag 回传。 */
+private data class CropRect(
+ val left: Float,
+ val top: Float,
+ val right: Float,
+ val bottom: Float
+)
+
+@Composable
+private fun CropOverlay(
+ state: PhotoEditorState,
+ dragHandle: CropHandle?,
+ onHandleChange: (CropHandle?) -> Unit,
+ onCropChange: (Float, Float, Float, Float) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val left = state.cropLeft ?: 0.1f
+ val right = state.cropRight ?: 0.9f
+ Canvas(
+ modifier = modifier.pointerInput(Unit) {
+ detectDragGestures(
+ onDragStart = { offset ->
+ val size = this.size
+ val ox = offset.x / size.width
+ val oy = offset.y / size.height
+ onHandleChange(
+ when {
+ ox < 0.2f && oy < 0.2f -> CropHandle.TOP_LEFT
+ ox > 0.8f && oy < 0.2f -> CropHandle.TOP_RIGHT
+ ox < 0.2f && oy > 0.8f -> CropHandle.BOTTOM_LEFT
+ ox > 0.8f && oy > 0.8f -> CropHandle.BOTTOM_RIGHT
+ else -> CropHandle.NONE
+ }
+ )
+ },
+ onDrag = { change, drag ->
+ change.consume()
+ val size = this.size
+ val dx = drag.x / size.width
+ val dy = drag.y / size.height
+ val (nL, nT, nR, nB) = when (dragHandle) {
+ CropHandle.TOP_LEFT ->
+ CropRect(left + dx, state.cropTop + dy, right, state.cropBottom)
+ CropHandle.TOP_RIGHT ->
+ CropRect(left, state.cropTop + dy, right + dx, state.cropBottom)
+ CropHandle.BOTTOM_LEFT ->
+ CropRect(left + dx, state.cropTop, right, state.cropBottom + dy)
+ CropHandle.BOTTOM_RIGHT ->
+ CropRect(left, state.cropTop, right + dx, state.cropBottom + dy)
+ else -> return@detectDragGestures
+ }
+ onCropChange(nL, nT, nR, nB)
+ },
+ onDragEnd = { onHandleChange(null) },
+ onDragCancel = { onHandleChange(null) }
+ )
+ }
+ ) {
+ val w = size.width
+ val h = size.height
+ val l = left * w
+ val t = state.cropTop * h
+ val r = right * w
+ val b = state.cropBottom * h
+ // 四周遮罩
+ drawRect(Color.Black.copy(alpha = 0.5f), topLeft = Offset(0f, 0f), size = Size(l, h))
+ drawRect(Color.Black.copy(alpha = 0.5f), topLeft = Offset(r, 0f), size = Size(w - r, h))
+ drawRect(Color.Black.copy(alpha = 0.5f), topLeft = Offset(l, 0f), size = Size(r - l, t))
+ drawRect(Color.Black.copy(alpha = 0.5f), topLeft = Offset(l, b), size = Size(r - l, h - b))
+ // 裁剪框白线
+ drawRect(
+ color = Color.White,
+ topLeft = Offset(l, t),
+ size = Size(r - l, b - t),
+ style = Stroke(width = 2f)
+ )
+ }
+}
+
+@Composable
+private fun ToolBar(
+ mode: EditTab,
+ state: PhotoEditorState,
+ onRotate: (Int) -> Unit,
+ onCropToggle: () -> Unit,
+ onUndoStroke: () -> Unit,
+ onStrokeColorChange: (Color) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ when (mode) {
+ EditTab.ROTATE -> {
+ IconButton(onClick = { onRotate(-90) }) {
+ Icon(Icons.Filled.RotateLeft, contentDescription = "左转 90°")
+ }
+ IconButton(onClick = { onRotate(90) }) {
+ Icon(Icons.Filled.RotateRight, contentDescription = "右转 90°")
+ }
+ }
+ EditTab.CROP -> {
+ FilledIconButton(onClick = onCropToggle) {
+ Text(if (state.cropEnabled) "关闭裁剪" else "开启裁剪")
+ }
+ }
+ EditTab.HANDWRITE -> {
+ IconButton(onClick = onUndoStroke, enabled = state.strokes.isNotEmpty()) {
+ Icon(Icons.Filled.Undo, contentDescription = "撤销笔触")
+ }
+ listOf(Color.Red, Color.Yellow, Color.White, Color.Black).forEach { c ->
+ val selected = state.strokeColor == c
+ Box(
+ modifier = Modifier
+ .size(28.dp)
+ .background(c, RoundedCornerShape(14.dp))
+ .border(
+ width = if (selected) 3.dp else 1.dp,
+ color = if (selected) MaterialTheme.colorScheme.primary else Color.Gray,
+ shape = RoundedCornerShape(14.dp)
+ )
+ .pointerInput(c) {
+ detectTapGestures { onStrokeColorChange(c) }
+ }
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/core/src/main/kotlin/plus/rua/project/ui/RecordDetailScreen.kt b/core/src/main/kotlin/plus/rua/project/ui/RecordDetailScreen.kt
new file mode 100644
index 0000000..8168e90
--- /dev/null
+++ b/core/src/main/kotlin/plus/rua/project/ui/RecordDetailScreen.kt
@@ -0,0 +1,221 @@
+package plus.rua.project.ui
+
+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.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+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.material3.AlertDialog
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+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.platform.LocalContext
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.testTagsAsResourceId
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+import com.github.panpf.sketch.AsyncImage
+import plus.rua.project.DateRecordDetailUiState
+import plus.rua.project.DateRecordDetailViewModel
+import plus.rua.project.DateRecorderRepository
+
+/**
+ * 记录详情页面,展示单条记录的大图与全部信息,并提供编辑/删除入口。
+ *
+ * @param onBack 返回回调
+ * @param recordId 记录 ID
+ * @param onEditInfo 编辑记录信息回调(跳转记录编辑页)
+ * @param onEditPhoto 编辑照片回调(跳转照片编辑页,携带当前照片路径)
+ * @param modifier 布局修饰符
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RecordDetailScreen(
+ onBack: () -> Unit,
+ recordId: Long,
+ onEditInfo: (Long) -> Unit,
+ onEditPhoto: (String) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val context = LocalContext.current.applicationContext
+ val viewModel: DateRecordDetailViewModel = viewModel(
+ factory = viewModelFactory {
+ initializer {
+ DateRecordDetailViewModel(
+ DateRecorderRepository.fromContext(context),
+ recordId
+ )
+ }
+ }
+ )
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+ var showDeleteDialog by remember { mutableStateOf(false) }
+
+ // 删除完成后自动返回
+ if (state.deleted) {
+ LaunchedEffect(Unit) { onBack() }
+ }
+
+ Scaffold(
+ modifier = modifier.semantics { testTagsAsResourceId = true },
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ state.record?.title ?: "记录详情",
+ style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Filled.ChevronLeft, 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)
+ )
+ },
+ containerColor = MaterialTheme.colorScheme.surface
+ ) { innerPadding ->
+ when {
+ state.loading -> Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ contentAlignment = Alignment.Center
+ ) { CircularProgressIndicator() }
+
+ state.record == null -> Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ contentAlignment = Alignment.Center
+ ) {
+ Text("记录不存在", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+
+ else -> DetailContent(
+ state = state,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ )
+ }
+ }
+
+ if (showDeleteDialog) {
+ AlertDialog(
+ onDismissRequest = { showDeleteDialog = false },
+ title = { Text("删除记录") },
+ text = { Text("确定删除「${state.record?.title}」吗?此操作不可撤销。") },
+ confirmButton = {
+ TextButton(onClick = {
+ showDeleteDialog = false
+ viewModel.delete()
+ }) { Text("删除", color = MaterialTheme.colorScheme.error) }
+ },
+ dismissButton = {
+ TextButton(onClick = { showDeleteDialog = false }) { Text("取消") }
+ }
+ )
+ }
+}
+
+@Composable
+private fun DetailContent(
+ state: DateRecordDetailUiState,
+ modifier: Modifier = Modifier
+) {
+ val record = state.record ?: return
+ Column(
+ modifier = modifier.verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ AsyncImage(
+ uri = state.photoUri,
+ contentDescription = record.title,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ )
+
+ Column(modifier = Modifier.padding(horizontal = 16.dp)) {
+ Text(
+ text = record.title,
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.SemiBold
+ )
+ InfoRow(label = "拍摄日期", value = "${record.shootDate}")
+ InfoRow(label = "关联日期", value = record.linkedDate?.toString() ?: "无")
+ if (record.note.isNotBlank()) {
+ Text(
+ text = record.note,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(top = 12.dp)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun InfoRow(label: String, value: String) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = label,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(text = value)
+ }
+}
diff --git a/core/src/main/kotlin/plus/rua/project/ui/RecordEditScreen.kt b/core/src/main/kotlin/plus/rua/project/ui/RecordEditScreen.kt
new file mode 100644
index 0000000..c70e308
--- /dev/null
+++ b/core/src/main/kotlin/plus/rua/project/ui/RecordEditScreen.kt
@@ -0,0 +1,317 @@
+package plus.rua.project.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+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.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DatePicker
+import androidx.compose.material3.DatePickerDialog
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.rememberDatePickerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+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.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.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+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.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 kotlin.time.Instant
+
+/**
+ * 记录编辑页面,用于新建或修改一条日期记录的信息。
+ *
+ * 两种入口:
+ * 1. 新建:[photoPath] 非空(来自相机/编辑器),表单初始为空
+ * 2. 编辑:[recordId] 非空(来自详情页),预填已有记录,photoPath 从记录读取
+ *
+ * @param onBack 返回回调(保存或取消后触发)
+ * @param photoPath 新建模式下的最终照片文件绝对路径;编辑模式为 null
+ * @param recordId 编辑模式下的已有记录 ID;新建模式为 null
+ * @param modifier 布局修饰符
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RecordEditScreen(
+ onBack: () -> Unit,
+ photoPath: String?,
+ recordId: Long?,
+ modifier: Modifier = Modifier
+) {
+ val context = LocalContext.current.applicationContext
+ val viewModel: RecordEditViewModel = viewModel(
+ factory = viewModelFactory {
+ initializer {
+ RecordEditViewModel(
+ repository = DateRecorderRepository.fromContext(context),
+ photoPath = photoPath,
+ recordId = recordId
+ )
+ }
+ }
+ )
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+
+ if (state.finished) {
+ LaunchedEffect(Unit) { onBack() }
+ }
+
+ Scaffold(
+ modifier = modifier.semantics { testTagsAsResourceId = true },
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ if (recordId != null) "编辑记录" else "新建记录",
+ style = MaterialTheme.typography.titleLarge.copy(
+ fontWeight = FontWeight.SemiBold
+ )
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(
+ imageVector = Icons.Filled.ChevronLeft,
+ contentDescription = "返回"
+ )
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
+ )
+ },
+ containerColor = MaterialTheme.colorScheme.surface
+ ) { innerPadding ->
+ if (state.loading) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ } else {
+ RecordEditForm(
+ state = state,
+ onTitleChange = viewModel::onTitleChange,
+ onNoteChange = viewModel::onNoteChange,
+ onShootDateChange = viewModel::onShootDateChange,
+ onLinkedDateChange = viewModel::onLinkedDateChange,
+ onClearLinkedDate = viewModel::onClearLinkedDate,
+ onSave = viewModel::save,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun RecordEditForm(
+ state: RecordEditUiState,
+ onTitleChange: (String) -> Unit,
+ onNoteChange: (String) -> Unit,
+ onShootDateChange: (LocalDate) -> Unit,
+ onLinkedDateChange: (LocalDate) -> Unit,
+ onClearLinkedDate: () -> Unit,
+ onSave: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ var showShootDatePicker by remember { mutableStateOf(false) }
+ var showLinkedDatePicker by remember { mutableStateOf(false) }
+
+ Column(
+ modifier = modifier
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ // 照片预览
+ state.photoUri?.let { uri ->
+ AsyncImage(
+ uri = uri,
+ contentDescription = "记录照片",
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .testTag("record_edit_photo")
+ )
+ }
+
+ // 标题
+ OutlinedTextField(
+ value = state.title,
+ onValueChange = onTitleChange,
+ label = { Text("标题") },
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("record_edit_title")
+ )
+
+ // 备注
+ OutlinedTextField(
+ value = state.note,
+ onValueChange = onNoteChange,
+ label = { Text("备注") },
+ minLines = 3,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("record_edit_note")
+ )
+
+ // 拍摄日期
+ DatePickerField(
+ label = "拍摄日期",
+ date = state.shootDate,
+ onClick = { showShootDatePicker = true },
+ modifier = Modifier.testTag("record_edit_shoot_date")
+ )
+
+ // 关联日期(可空)
+ DatePickerField(
+ label = "关联日期",
+ date = state.linkedDate,
+ placeholder = "不关联",
+ onClick = { showLinkedDatePicker = true },
+ onClear = onClearLinkedDate,
+ modifier = Modifier.testTag("record_edit_linked_date")
+ )
+
+ // 保存按钮
+ Button(
+ onClick = onSave,
+ enabled = state.canSave,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("record_edit_save")
+ ) {
+ Text("保存")
+ }
+ }
+
+ if (showShootDatePicker) {
+ DatePickerModal(
+ initialDate = state.shootDate,
+ onConfirm = {
+ onShootDateChange(it)
+ showShootDatePicker = false
+ },
+ onDismiss = { showShootDatePicker = false }
+ )
+ }
+ if (showLinkedDatePicker) {
+ DatePickerModal(
+ initialDate = state.linkedDate ?: state.shootDate,
+ onConfirm = {
+ onLinkedDateChange(it)
+ showLinkedDatePicker = false
+ },
+ onDismiss = { showLinkedDatePicker = false }
+ )
+ }
+}
+
+@Composable
+private fun DatePickerField(
+ label: String,
+ date: LocalDate?,
+ placeholder: String = "",
+ onClick: () -> Unit,
+ onClear: (() -> Unit)? = null,
+ modifier: Modifier = Modifier
+) {
+ OutlinedButton(
+ onClick = onClick,
+ shape = RoundedCornerShape(12.dp),
+ modifier = modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = date?.let { formatLocalDate(it) } ?: placeholder,
+ modifier = Modifier.fillMaxWidth()
+ )
+ if (onClear != null && date != null) {
+ TextButton(onClick = onClear) { Text("清除") }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun DatePickerModal(
+ initialDate: LocalDate,
+ onConfirm: (LocalDate) -> Unit,
+ onDismiss: () -> Unit
+) {
+ val initialMillis = initialDate.atStartOfDayIn(TimeZone.currentSystemDefault()).toEpochMilliseconds()
+ val datePickerState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
+ DatePickerDialog(
+ onDismissRequest = onDismiss,
+ confirmButton = {
+ TextButton(
+ onClick = {
+ datePickerState.selectedDateMillis?.let { millis ->
+ onConfirm(
+ Instant.fromEpochMilliseconds(millis)
+ .toLocalDateTime(TimeZone.currentSystemDefault())
+ .date
+ )
+ }
+ }
+ ) { Text("确定") }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) { Text("取消") }
+ }
+ ) {
+ 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')}"
+}
diff --git a/core/src/main/kotlin/plus/rua/project/ui/ToolsScreen.kt b/core/src/main/kotlin/plus/rua/project/ui/ToolsScreen.kt
index 8a1ea90..b725c1c 100644
--- a/core/src/main/kotlin/plus/rua/project/ui/ToolsScreen.kt
+++ b/core/src/main/kotlin/plus/rua/project/ui/ToolsScreen.kt
@@ -1,6 +1,7 @@
package plus.rua.project.ui
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -29,6 +30,7 @@ import androidx.compose.ui.unit.dp
*
* @param onBack 返回回调
* @param onNavigateToDateChecker 跳转到日期检查器回调
+ * @param onNavigateToDateRecorder 跳转到日期记录器回调
* @param modifier 布局修饰符
*/
@OptIn(ExperimentalMaterial3Api::class)
@@ -36,6 +38,7 @@ import androidx.compose.ui.unit.dp
fun ToolsScreen(
onBack: () -> Unit,
onNavigateToDateChecker: () -> Unit,
+ onNavigateToDateRecorder: () -> Unit,
modifier: Modifier = Modifier
) {
Scaffold(
@@ -58,13 +61,19 @@ fun ToolsScreen(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
- .padding(horizontal = 16.dp, vertical = 12.dp)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
) {
ToolItem(
title = "日期检查器",
onClick = onNavigateToDateChecker,
modifier = Modifier.testTag("tool_date_checker")
)
+ ToolItem(
+ title = "日期记录器",
+ onClick = onNavigateToDateRecorder,
+ modifier = Modifier.testTag("tool_date_recorder")
+ )
}
}
}
diff --git a/core/src/test/kotlin/plus/rua/project/DateRecorderSortTest.kt b/core/src/test/kotlin/plus/rua/project/DateRecorderSortTest.kt
new file mode 100644
index 0000000..4e84dc9
--- /dev/null
+++ b/core/src/test/kotlin/plus/rua/project/DateRecorderSortTest.kt
@@ -0,0 +1,119 @@
+package plus.rua.project
+
+import kotlinx.datetime.LocalDate
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.time.Instant
+
+/**
+ * 日期记录器排序逻辑单元测试。
+ *
+ * 覆盖 [DateRecorderViewModel.sortDateRecords] 的所有字段与升降序组合,
+ * 以及无关联日期记录的末尾排列。
+ */
+class DateRecorderSortTest {
+
+ private val records = listOf(
+ record(id = 1, title = "A", shoot = LocalDate(2026, 1, 1), linked = LocalDate(2026, 1, 1), created = Instant.fromEpochSeconds(1000)),
+ record(id = 2, title = "B", shoot = LocalDate(2026, 3, 1), linked = null, created = Instant.fromEpochSeconds(2000)),
+ record(id = 3, title = "C", shoot = LocalDate(2026, 2, 1), linked = LocalDate(2026, 2, 1), created = Instant.fromEpochSeconds(3000))
+ )
+
+ @Test
+ fun sortByShootDate_descending_newestFirst() {
+ val sorted = DateRecorderViewModel.sortDateRecords(
+ records,
+ RecordSortOrder(RecordSortField.SHOOT_DATE, ascending = false)
+ )
+ assertEquals(listOf(2L, 3L, 1L), sorted.map { it.id })
+ }
+
+ @Test
+ fun sortByShootDate_ascending_oldestFirst() {
+ val sorted = DateRecorderViewModel.sortDateRecords(
+ records,
+ RecordSortOrder(RecordSortField.SHOOT_DATE, ascending = true)
+ )
+ assertEquals(listOf(1L, 3L, 2L), sorted.map { it.id })
+ }
+
+ @Test
+ fun sortByLinkedDate_descending_nullsFirst() {
+ val sorted = DateRecorderViewModel.sortDateRecords(
+ records,
+ RecordSortOrder(RecordSortField.LINKED_DATE, ascending = false)
+ )
+ // 降序时 reversed() 把 nullsLast 翻转为 nullsFirst:
+ // null(id2) → 2026-02-01(id3) → 2026-01-01(id1)
+ assertEquals(listOf(2L, 3L, 1L), sorted.map { it.id })
+ }
+
+ @Test
+ fun sortByLinkedDate_ascending_nullsLast() {
+ val sorted = DateRecorderViewModel.sortDateRecords(
+ records,
+ RecordSortOrder(RecordSortField.LINKED_DATE, ascending = true)
+ )
+ // 升序:2026-01-01(id1) → 2026-02-01(id3) → null(id2)
+ assertEquals(listOf(1L, 3L, 2L), sorted.map { it.id })
+ }
+
+ @Test
+ fun sortByCreatedAt_descending() {
+ val sorted = DateRecorderViewModel.sortDateRecords(
+ records,
+ RecordSortOrder(RecordSortField.CREATED_AT, ascending = false)
+ )
+ assertEquals(listOf(3L, 2L, 1L), sorted.map { it.id })
+ }
+
+ @Test
+ fun sortByCreatedAt_ascending() {
+ val sorted = DateRecorderViewModel.sortDateRecords(
+ records,
+ RecordSortOrder(RecordSortField.CREATED_AT, ascending = true)
+ )
+ assertEquals(listOf(1L, 2L, 3L), sorted.map { it.id })
+ }
+
+ @Test
+ fun sort_emptyList_returnsEmpty() {
+ val sorted = DateRecorderViewModel.sortDateRecords(
+ emptyList(),
+ RecordSortOrder.DEFAULT
+ )
+ assertEquals(emptyList(), sorted)
+ }
+
+ @Test
+ fun sort_tieBreakerById_ascending() {
+ // 同一拍摄日期、同一创建时间,仅 id 不同 → 按 id 升序
+ val tied = listOf(
+ record(id = 5, shoot = LocalDate(2026, 1, 1), created = Instant.fromEpochSeconds(0)),
+ record(id = 2, shoot = LocalDate(2026, 1, 1), created = Instant.fromEpochSeconds(0)),
+ record(id = 8, shoot = LocalDate(2026, 1, 1), created = Instant.fromEpochSeconds(0))
+ )
+ val sorted = DateRecorderViewModel.sortDateRecords(
+ tied,
+ RecordSortOrder(RecordSortField.SHOOT_DATE, ascending = true)
+ )
+ // 主键 id 升序兜底
+ assertEquals(listOf(2L, 5L, 8L), sorted.map { it.id })
+ }
+
+ private fun record(
+ id: Long,
+ title: String = "t",
+ shoot: LocalDate = LocalDate(2026, 1, 1),
+ linked: LocalDate? = null,
+ created: Instant = Instant.fromEpochSeconds(0)
+ ) = DateRecord(
+ id = id,
+ title = title,
+ note = "",
+ shootDate = shoot,
+ linkedDate = linked,
+ photoPath = "fake/path.jpg",
+ createdAt = created
+ )
+}
diff --git a/core/src/test/kotlin/plus/rua/project/PhotoProcessorTest.kt b/core/src/test/kotlin/plus/rua/project/PhotoProcessorTest.kt
new file mode 100644
index 0000000..57570db
--- /dev/null
+++ b/core/src/test/kotlin/plus/rua/project/PhotoProcessorTest.kt
@@ -0,0 +1,37 @@
+package plus.rua.project
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/**
+ * PhotoProcessor 降采样计算逻辑单元测试。
+ *
+ * 仅覆盖纯计算部分([PhotoProcessor.calculateInSampleSizePublic]),
+ * 涉及 Bitmap/IO 的部分依赖 Android 框架,需 instrumented 测试。
+ */
+class PhotoProcessorTest {
+
+ @Test
+ fun sampleSize_smallImage_returns1() {
+ // 源宽 500,要求 1080 → 不需降采样
+ assertEquals(1, PhotoProcessor.calculateInSampleSizePublic(500, 1080))
+ }
+
+ @Test
+ fun sampleSize_exactly2xTarget_returns1() {
+ // 源宽 2160,要求 1080 → 2160/1 = 2160 <= 2160(1080*2),返回 1
+ assertEquals(1, PhotoProcessor.calculateInSampleSizePublic(2160, 1080))
+ }
+
+ @Test
+ fun sampleSize_largeImage_returnsPowerOf2() {
+ // 源宽 8000,要求 1080 → 8000/2=4000 > 2160, 8000/4=2000 <= 2160 → 4
+ assertEquals(4, PhotoProcessor.calculateInSampleSizePublic(8000, 1080))
+ }
+
+ @Test
+ fun sampleSize_hugeImage_returnsLargerPowerOf2() {
+ // 源宽 20000,要求 1080 → 20000/2=10000, /4=5000, /8=2500, /16=1250 <= 2160 → 16
+ assertEquals(16, PhotoProcessor.calculateInSampleSizePublic(20000, 1080))
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 14b9a79..7a7d462 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -10,11 +10,14 @@ androidx-media3 = "1.6.1"
androidx-testExt = "1.3.0"
androidx-uiautomator = "2.4.0"
benchmarkMacro = "1.4.1"
+camerax = "1.5.3"
catalogUpdate = "1.1.0"
composeBom = "2026.06.01"
kotlin = "2.3.21"
kotlinx-datetime = "0.8.0"
+ksp = "2.3.10"
profileinstaller = "1.4.1"
+room = "2.8.4"
sketch = "4.4.0"
spotless = "8.8.0"
tyme4kt = "1.5.0"
@@ -23,6 +26,10 @@ versions = "0.54.0"
[libraries]
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-benchmark-macro = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmarkMacro" }
+camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" }
+camera-core = { module = "androidx.camera:camera-core", version.ref = "camerax" }
+camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" }
+camera-view = { module = "androidx.camera:camera-view", version.ref = "camerax" }
androidx-lifecycle-runtimeCompose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-viewmodelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" }
@@ -42,6 +49,10 @@ compose-uiTooling = { module = "androidx.compose.ui:ui-tooling" }
compose-uiToolingPreview = { module = "androidx.compose.ui:ui-tooling-preview" }
kotlinx-coroutines-test = "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0"
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
+room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
+room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
+room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
+room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
sketch-animated-webp = { module = "io.github.panpf.sketch4:sketch-animated-webp", version.ref = "sketch" }
sketch-compose = { module = "io.github.panpf.sketch4:sketch-compose", version.ref = "sketch" }
tyme4kt = { module = "cn.6tail:tyme4kt", version.ref = "tyme4kt" }
@@ -53,5 +64,7 @@ androidTest = { id = "com.android.test", version.ref = "agp" }
catalogUpdate = { id = "nl.littlerobots.version-catalog-update", version.ref = "catalogUpdate" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
+room = { id = "androidx.room", version.ref = "room" }
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
versions = { id = "com.github.ben-manes.versions", version.ref = "versions" }