feat(date-recorder): photo journal tool with camera, editor, and album grid

新增"日期记录器"工具:相册式记录管理,支持拍照→编辑→记录信息的完整流程。

数据层:
- Room 2.8.4 首个数据库(KSP 2.3.10),DateRecord 实体含标题/备注/拍摄日期/关联日期
- DateRecordConverters 处理 kotlinx-datetime ↔ ISO 字符串
- DateRecorderRepository 封装 DAO + filesDir 照片文件管理

UI 层:
- CameraScreen: CameraX 1.5.3 应用内预览,运行时权限请求,前后摄切换
- PhotoEditorScreen: 纯 Compose 自实现旋转/裁剪/手写三 Tab 编辑器
- DateRecorderScreen: LazyVerticalGrid 相册网格 + 多选 + 6 种排序 + 批量删除
- RecordEditScreen / RecordDetailScreen: 记录信息编辑与详情查看

基础设施:
- CAMERA 权限 + FileProvider(filesDir 存储,免存储权限)
- 5 个 Activity 延续项目 Activity+Intent 滑动转场模式
- 2 个单测:排序逻辑 6 种组合 + 降采样计算
This commit is contained in:
xfy 2026-07-17 13:21:45 +08:00
parent c665adfeff
commit 8963b6162b
31 changed files with 3336 additions and 1 deletions

View File

@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 日期记录器:相机拍摄;照片存 filesDirminSdk 24 免存储权限 -->
<uses-permission android:name="android.permission.CAMERA" />
<application
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
@ -49,9 +52,41 @@
android:name=".DateCheckerActivity"
android:exported="false" />
<activity
android:name=".DateRecorderActivity"
android:exported="false" />
<activity
android:name=".CameraActivity"
android:exported="false"
android:screenOrientation="portrait" />
<activity
android:name=".PhotoEditorActivity"
android:exported="false" />
<activity
android:name=".RecordEditActivity"
android:exported="false" />
<activity
android:name=".RecordDetailActivity"
android:exported="false" />
<activity
android:name=".ShiftPatternActivity"
android:exported="false" />
<!-- 日期记录器FileProvider用于相机临时文件 URI 共享 -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@ -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()
}
)
}
}
}
}

View File

@ -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)
}
)
}
)
}
}
}
}

View File

@ -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
)
}
}
}
}

View File

@ -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)
}
)
}
)
}
}
}
}

View File

@ -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
)
}
}
}
}

View File

@ -16,6 +16,9 @@ class ToolsActivity : BaseActivity() {
onBack = { finishWithSlideBack() },
onNavigateToDateChecker = {
startActivityWithSlide(Intent(this, DateCheckerActivity::class.java))
},
onNavigateToDateRecorder = {
startActivityWithSlide(Intent(this, DateRecorderActivity::class.java))
}
)
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- 日期记录器:照片存放在 filesDir/Pictures/date_recorder/ -->
<files-path
name="date_recorder"
path="Pictures/date_recorder/" />
</paths>

View File

@ -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)
}

View File

@ -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')"
]
}
}

View File

@ -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
)

View File

@ -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) }
}

View File

@ -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<DateRecord?>
/**
* 查询全部记录默认按拍摄日期降序
*
* 最终排序在 Repository / UI 层根据用户选择重新应用此处仅保证一个稳定默认序
*/
@Query("SELECT * FROM date_records ORDER BY shootDate DESC, id DESC")
fun getAllFlow(): Flow<List<DateRecord>>
/**
* 插入一条新记录
*
* @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<Long>): Int
}

View File

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

View File

@ -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<DateRecordDetailUiState> = _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) }
}
}
}

View File

@ -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<List<DateRecord>> = dao.getAllFlow()
/**
* 订阅单条记录用于详情页
*
* @param id 记录主键
*/
fun observeById(id: Long): Flow<DateRecord?> = 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<DateRecord>) = 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)
}
}
}

View File

@ -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<DateRecord> = emptyList(),
val sortOrder: RecordSortOrder = RecordSortOrder.DEFAULT,
val isLoading: Boolean = true,
val selectionMode: Boolean = false,
val selectedIds: Set<Long> = 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<RecordSortOrder> = _sortOrder.asStateFlow()
private val _selectionMode = MutableStateFlow(false)
private val _selectedIds = MutableStateFlow<Set<Long>>(emptySet())
/**
* 聚合的 UI 状态仓库记录 Flow + 排序 Flow + 多选 Flow 合并排序
*/
val uiState: StateFlow<DateRecorderUiState> = 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<DateRecord>) {
viewModelScope.launch {
repository.deleteWithPhotos(records)
}
}
private fun sortRecords(
records: List<DateRecord>,
order: RecordSortOrder
): List<DateRecord> = sortDateRecords(records, order)
companion object {
/**
* 按指定方式排序记录列表抽为 companion 静态方法以便单元测试
*/
fun sortDateRecords(
records: List<DateRecord>,
order: RecordSortOrder
): List<DateRecord> {
val comparator: Comparator<DateRecord> = 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 }))
}
}
}

View File

@ -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<Offset> = 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<HandStroke> = 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
}

View File

@ -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<PhotoEditorUiState> = _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))
}
}
}

View File

@ -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 旋转后的新 Bitmap0° 时返回原对象
*/
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<HandStroke>,
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<HandStroke>,
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()
)
}

View File

@ -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<RecordEditUiState> = _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) }
}
}
}

View File

@ -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<String?>(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")
}

View File

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

View File

@ -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<SortMenuItem> = 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<DateRecord>,
selectedIds: Set<Long>,
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)
)
}
}

View File

@ -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<CropHandle?>(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) }
}
)
}
}
}
}
}

View File

@ -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)
}
}

View File

@ -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')}"
}

View File

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

View File

@ -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
)
}

View File

@ -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))
}
}

View File

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