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:
parent
c665adfeff
commit
8963b6162b
@ -1,6 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!-- 日期记录器:相机拍摄;照片存 filesDir,minSdk 24 免存储权限 -->
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:enableOnBackInvokedCallback="true"
|
android:enableOnBackInvokedCallback="true"
|
||||||
@ -49,9 +52,41 @@
|
|||||||
android:name=".DateCheckerActivity"
|
android:name=".DateCheckerActivity"
|
||||||
android:exported="false" />
|
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
|
<activity
|
||||||
android:name=".ShiftPatternActivity"
|
android:name=".ShiftPatternActivity"
|
||||||
android:exported="false" />
|
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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
36
app/src/main/kotlin/plus/rua/project/CameraActivity.kt
Normal file
36
app/src/main/kotlin/plus/rua/project/CameraActivity.kt
Normal 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()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/src/main/kotlin/plus/rua/project/DateRecorderActivity.kt
Normal file
32
app/src/main/kotlin/plus/rua/project/DateRecorderActivity.kt
Normal 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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/src/main/kotlin/plus/rua/project/PhotoEditorActivity.kt
Normal file
40
app/src/main/kotlin/plus/rua/project/PhotoEditorActivity.kt
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/src/main/kotlin/plus/rua/project/RecordDetailActivity.kt
Normal file
47
app/src/main/kotlin/plus/rua/project/RecordDetailActivity.kt
Normal 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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/src/main/kotlin/plus/rua/project/RecordEditActivity.kt
Normal file
34
app/src/main/kotlin/plus/rua/project/RecordEditActivity.kt
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,6 +16,9 @@ class ToolsActivity : BaseActivity() {
|
|||||||
onBack = { finishWithSlideBack() },
|
onBack = { finishWithSlideBack() },
|
||||||
onNavigateToDateChecker = {
|
onNavigateToDateChecker = {
|
||||||
startActivityWithSlide(Intent(this, DateCheckerActivity::class.java))
|
startActivityWithSlide(Intent(this, DateCheckerActivity::class.java))
|
||||||
|
},
|
||||||
|
onNavigateToDateRecorder = {
|
||||||
|
startActivityWithSlide(Intent(this, DateRecorderActivity::class.java))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
7
app/src/main/res/xml/file_paths.xml
Normal file
7
app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||||
@ -3,6 +3,12 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.androidLibrary)
|
alias(libs.plugins.androidLibrary)
|
||||||
alias(libs.plugins.composeCompiler)
|
alias(libs.plugins.composeCompiler)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
|
alias(libs.plugins.room)
|
||||||
|
}
|
||||||
|
|
||||||
|
room {
|
||||||
|
schemaDirectory("$projectDir/schemas")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@ -89,6 +95,16 @@ dependencies {
|
|||||||
implementation(libs.androidx.media3.exoplayer)
|
implementation(libs.androidx.media3.exoplayer)
|
||||||
implementation(libs.androidx.media3.ui)
|
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("org.jetbrains.kotlin:kotlin-test-junit:${libs.versions.kotlin.get()}")
|
||||||
testImplementation(libs.kotlinx.coroutines.test)
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
testImplementation(libs.room.testing)
|
||||||
}
|
}
|
||||||
|
|||||||
66
core/schemas/plus.rua.project.DateRecordDatabase/1.json
Normal file
66
core/schemas/plus.rua.project.DateRecordDatabase/1.json
Normal 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')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
30
core/src/main/kotlin/plus/rua/project/DateRecord.kt
Normal file
30
core/src/main/kotlin/plus/rua/project/DateRecord.kt
Normal 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
|
||||||
|
)
|
||||||
@ -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) }
|
||||||
|
}
|
||||||
57
core/src/main/kotlin/plus/rua/project/DateRecordDao.kt
Normal file
57
core/src/main/kotlin/plus/rua/project/DateRecordDao.kt
Normal 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
|
||||||
|
}
|
||||||
42
core/src/main/kotlin/plus/rua/project/DateRecordDatabase.kt
Normal file
42
core/src/main/kotlin/plus/rua/project/DateRecordDatabase.kt
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
113
core/src/main/kotlin/plus/rua/project/DateRecorderRepository.kt
Normal file
113
core/src/main/kotlin/plus/rua/project/DateRecorderRepository.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
186
core/src/main/kotlin/plus/rua/project/DateRecorderViewModel.kt
Normal file
186
core/src/main/kotlin/plus/rua/project/DateRecorderViewModel.kt
Normal 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 }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
core/src/main/kotlin/plus/rua/project/PhotoEditorState.kt
Normal file
82
core/src/main/kotlin/plus/rua/project/PhotoEditorState.kt
Normal 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
|
||||||
|
}
|
||||||
176
core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt
Normal file
176
core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
175
core/src/main/kotlin/plus/rua/project/PhotoProcessor.kt
Normal file
175
core/src/main/kotlin/plus/rua/project/PhotoProcessor.kt
Normal 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 旋转后的新 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<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()
|
||||||
|
)
|
||||||
|
}
|
||||||
157
core/src/main/kotlin/plus/rua/project/RecordEditViewModel.kt
Normal file
157
core/src/main/kotlin/plus/rua/project/RecordEditViewModel.kt
Normal 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
318
core/src/main/kotlin/plus/rua/project/ui/CameraScreen.kt
Normal file
318
core/src/main/kotlin/plus/rua/project/ui/CameraScreen.kt
Normal 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")
|
||||||
|
}
|
||||||
18
core/src/main/kotlin/plus/rua/project/ui/DateRecorderNav.kt
Normal file
18
core/src/main/kotlin/plus/rua/project/ui/DateRecorderNav.kt
Normal 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"
|
||||||
|
}
|
||||||
388
core/src/main/kotlin/plus/rua/project/ui/DateRecorderScreen.kt
Normal file
388
core/src/main/kotlin/plus/rua/project/ui/DateRecorderScreen.kt
Normal 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
459
core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt
Normal file
459
core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt
Normal 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) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
221
core/src/main/kotlin/plus/rua/project/ui/RecordDetailScreen.kt
Normal file
221
core/src/main/kotlin/plus/rua/project/ui/RecordDetailScreen.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
317
core/src/main/kotlin/plus/rua/project/ui/RecordEditScreen.kt
Normal file
317
core/src/main/kotlin/plus/rua/project/ui/RecordEditScreen.kt
Normal 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')}"
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
package plus.rua.project.ui
|
package plus.rua.project.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@ -29,6 +30,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
*
|
*
|
||||||
* @param onBack 返回回调
|
* @param onBack 返回回调
|
||||||
* @param onNavigateToDateChecker 跳转到日期检查器回调
|
* @param onNavigateToDateChecker 跳转到日期检查器回调
|
||||||
|
* @param onNavigateToDateRecorder 跳转到日期记录器回调
|
||||||
* @param modifier 布局修饰符
|
* @param modifier 布局修饰符
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@ -36,6 +38,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
fun ToolsScreen(
|
fun ToolsScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onNavigateToDateChecker: () -> Unit,
|
onNavigateToDateChecker: () -> Unit,
|
||||||
|
onNavigateToDateRecorder: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@ -58,13 +61,19 @@ fun ToolsScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
ToolItem(
|
ToolItem(
|
||||||
title = "日期检查器",
|
title = "日期检查器",
|
||||||
onClick = onNavigateToDateChecker,
|
onClick = onNavigateToDateChecker,
|
||||||
modifier = Modifier.testTag("tool_date_checker")
|
modifier = Modifier.testTag("tool_date_checker")
|
||||||
)
|
)
|
||||||
|
ToolItem(
|
||||||
|
title = "日期记录器",
|
||||||
|
onClick = onNavigateToDateRecorder,
|
||||||
|
modifier = Modifier.testTag("tool_date_recorder")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
119
core/src/test/kotlin/plus/rua/project/DateRecorderSortTest.kt
Normal file
119
core/src/test/kotlin/plus/rua/project/DateRecorderSortTest.kt
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
37
core/src/test/kotlin/plus/rua/project/PhotoProcessorTest.kt
Normal file
37
core/src/test/kotlin/plus/rua/project/PhotoProcessorTest.kt
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,11 +10,14 @@ androidx-media3 = "1.6.1"
|
|||||||
androidx-testExt = "1.3.0"
|
androidx-testExt = "1.3.0"
|
||||||
androidx-uiautomator = "2.4.0"
|
androidx-uiautomator = "2.4.0"
|
||||||
benchmarkMacro = "1.4.1"
|
benchmarkMacro = "1.4.1"
|
||||||
|
camerax = "1.5.3"
|
||||||
catalogUpdate = "1.1.0"
|
catalogUpdate = "1.1.0"
|
||||||
composeBom = "2026.06.01"
|
composeBom = "2026.06.01"
|
||||||
kotlin = "2.3.21"
|
kotlin = "2.3.21"
|
||||||
kotlinx-datetime = "0.8.0"
|
kotlinx-datetime = "0.8.0"
|
||||||
|
ksp = "2.3.10"
|
||||||
profileinstaller = "1.4.1"
|
profileinstaller = "1.4.1"
|
||||||
|
room = "2.8.4"
|
||||||
sketch = "4.4.0"
|
sketch = "4.4.0"
|
||||||
spotless = "8.8.0"
|
spotless = "8.8.0"
|
||||||
tyme4kt = "1.5.0"
|
tyme4kt = "1.5.0"
|
||||||
@ -23,6 +26,10 @@ versions = "0.54.0"
|
|||||||
[libraries]
|
[libraries]
|
||||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
||||||
androidx-benchmark-macro = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmarkMacro" }
|
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-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-lifecycle-viewmodelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
||||||
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" }
|
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" }
|
compose-uiToolingPreview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||||
kotlinx-coroutines-test = "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0"
|
kotlinx-coroutines-test = "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0"
|
||||||
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
|
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-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" }
|
sketch-compose = { module = "io.github.panpf.sketch4:sketch-compose", version.ref = "sketch" }
|
||||||
tyme4kt = { module = "cn.6tail:tyme4kt", version.ref = "tyme4kt" }
|
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" }
|
catalogUpdate = { id = "nl.littlerobots.version-catalog-update", version.ref = "catalogUpdate" }
|
||||||
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
kotlinAndroid = { id = "org.jetbrains.kotlin.android", 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" }
|
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
|
||||||
versions = { id = "com.github.ben-manes.versions", version.ref = "versions" }
|
versions = { id = "com.github.ben-manes.versions", version.ref = "versions" }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user