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" }