Merge branch 'feat/record-detail-image-lightbox'

This commit is contained in:
xfy 2026-07-22 15:23:53 +08:00
commit ffafee550e
6 changed files with 272 additions and 40 deletions

View File

@ -105,6 +105,9 @@ dependencies {
implementation(libs.camera.view) implementation(libs.camera.view)
implementation(libs.androidx.exifinterface) implementation(libs.androidx.exifinterface)
// 日期记录器:详情页灯箱图片缩放/旋转(与 sketch 同作者 panpfSketchZoomAsyncImage 是 AsyncImage 超集)
implementation(libs.zoomimage.compose.sketch4)
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) testImplementation(libs.room.testing)

View File

@ -91,6 +91,32 @@ class PhotoEditorViewModel(
} }
} }
/**
* 确认裁剪将当前裁剪框烘焙到源图用裁剪结果替换 [PhotoEditorState.sourceBitmap]
* 重置旋转与裁剪框清空手写笔触坐标系已变化
*/
fun applyCrop() {
update {
if (!it.cropEnabled) return@update it
val cropped = PhotoProcessor.crop(
bitmap = it.rotatedBitmap,
left = it.cropLeft!!,
top = it.cropTop,
right = it.cropRight!!,
bottom = it.cropBottom
)
it.copy(
sourceBitmap = cropped,
rotationDegrees = 0,
cropLeft = null,
cropTop = 0f,
cropRight = null,
cropBottom = 1f,
strokes = emptyList()
)
}
}
/** 更新裁剪框比例。 */ /** 更新裁剪框比例。 */
fun updateCrop(left: Float, top: Float, right: Float, bottom: Float) { fun updateCrop(left: Float, top: Float, right: Float, bottom: Float) {
update { update {

View File

@ -0,0 +1,72 @@
package plus.rua.project.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.github.panpf.sketch.rememberAsyncImageState
import com.github.panpf.zoomimage.SketchZoomAsyncImage
import com.github.panpf.zoomimage.rememberSketchZoomState
/**
* 全屏图片灯箱支持双指缩放平移与旋转查看
*
* 在全屏深色遮罩中居中显示图片底层用 zoomimage [SketchZoomAsyncImage]
* 提供手势双指缩放单指平移双指捻合自由旋转松手后吸附到最近的
* 0/90/180/270°双击复位点击图片外的空白区域或按返回键触发 [onDismiss]
*
* sketch [rememberAsyncImageState] 负责加载zoomimage
* [rememberSketchZoomState] 负责缩放/平移/旋转后者是 sketch `AsyncImage`
* 之外新增的必需参数
*
* @param photoUri 图片 URI null 时直接返回不渲染
* @param contentDescription 无障碍描述 null 表示纯装饰
* @param onDismiss 关闭回调点击空白区域或按返回键时触发
* @param modifier 布局修饰符
*/
@Composable
fun ImageLightbox(
photoUri: String?,
contentDescription: String?,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
if (photoUri == null) return
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
decorFitsSystemWindows = false,
usePlatformDefaultWidth = false
)
) {
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onDismiss
),
contentAlignment = Alignment.Center
) {
val asyncImageState = rememberAsyncImageState()
val zoomState = rememberSketchZoomState()
SketchZoomAsyncImage(
uri = photoUri,
contentDescription = contentDescription,
state = asyncImageState,
zoomState = zoomState,
modifier = Modifier.fillMaxSize()
)
}
}
}

View File

@ -20,7 +20,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Brush import androidx.compose.material.icons.filled.Brush
import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.Crop import androidx.compose.material.icons.filled.Crop
import androidx.compose.material.icons.filled.CropFree
import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.RotateLeft import androidx.compose.material.icons.filled.RotateLeft
import androidx.compose.material.icons.filled.RotateRight import androidx.compose.material.icons.filled.RotateRight
@ -41,10 +40,13 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import kotlin.math.absoluteValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
@ -158,6 +160,7 @@ fun PhotoEditorScreen(
onTabChange = { activeTab = it }, onTabChange = { activeTab = it },
onRotate = viewModel::rotate, onRotate = viewModel::rotate,
onCropToggle = viewModel::toggleCrop, onCropToggle = viewModel::toggleCrop,
onApplyCrop = viewModel::applyCrop,
onCropChange = viewModel::updateCrop, onCropChange = viewModel::updateCrop,
onAddPoint = viewModel::addStrokePoint, onAddPoint = viewModel::addStrokePoint,
onEndStroke = viewModel::endStroke, onEndStroke = viewModel::endStroke,
@ -182,6 +185,7 @@ private fun EditorBody(
onTabChange: (EditTab) -> Unit, onTabChange: (EditTab) -> Unit,
onRotate: (Int) -> Unit, onRotate: (Int) -> Unit,
onCropToggle: () -> Unit, onCropToggle: () -> Unit,
onApplyCrop: () -> Unit,
onCropChange: (Float, Float, Float, Float) -> Unit, onCropChange: (Float, Float, Float, Float) -> Unit,
onAddPoint: (Offset) -> Unit, onAddPoint: (Offset) -> Unit,
onEndStroke: () -> Unit, onEndStroke: () -> Unit,
@ -246,6 +250,7 @@ private fun EditorBody(
state = state, state = state,
onRotate = onRotate, onRotate = onRotate,
onCropToggle = onCropToggle, onCropToggle = onCropToggle,
onApplyCrop = onApplyCrop,
onUndoStroke = onUndoStroke, onUndoStroke = onUndoStroke,
onStrokeColorChange = onStrokeColorChange, onStrokeColorChange = onStrokeColorChange,
modifier = Modifier modifier = Modifier
@ -265,8 +270,6 @@ private fun EditableImage(
onDisplaySizeChange: (Float, Float) -> Unit onDisplaySizeChange: (Float, Float) -> Unit
) { ) {
val rotatedBmp = remember(state.rotationDegrees, state.sourceBitmap) { state.rotatedBitmap } val rotatedBmp = remember(state.rotationDegrees, state.sourceBitmap) { state.rotatedBitmap }
// 裁剪框拖动手柄CROP 模式下使用)
var dragHandle by remember { mutableStateOf<CropHandle?>(null) }
Box( Box(
modifier = Modifier modifier = Modifier
@ -299,8 +302,6 @@ private fun EditableImage(
if (mode == EditTab.CROP && state.cropEnabled) { if (mode == EditTab.CROP && state.cropEnabled) {
CropOverlay( CropOverlay(
state = state, state = state,
dragHandle = dragHandle,
onHandleChange = { dragHandle = it },
onCropChange = onCropChange, onCropChange = onCropChange,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
@ -324,7 +325,7 @@ private fun EditableImage(
} }
} }
private enum class CropHandle { TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT, NONE } private enum class CropHandle { TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT, BODY, NONE }
/** 裁剪框四元组,支持解构以简化 onDrag 回传。 */ /** 裁剪框四元组,支持解构以简化 onDrag 回传。 */
private data class CropRect( private data class CropRect(
@ -334,16 +335,120 @@ private data class CropRect(
val bottom: Float val bottom: Float
) )
/**
* 判断触摸点落在裁剪框的哪个区域用于决定拖动行为
*
* - 落在四角阈值范围内 对应角手柄缩放裁剪框
* - 落在框内 整体平移
* - 落在框外 [CropHandle.NONE]不响应拖动
*
* @param ox 触摸点 x 比例坐标 [0,1]
* @param oy 触摸点 y 比例坐标 [0,1]
* @param l/t/r/b 当前裁剪框四边比例
*/
private fun hitHandle(
ox: Float,
oy: Float,
l: Float,
t: Float,
r: Float,
b: Float
): CropHandle {
val corner = 0.1f
val atTopLeft = (ox - l).absoluteValue < corner && (oy - t).absoluteValue < corner
val atTopRight = (ox - r).absoluteValue < corner && (oy - t).absoluteValue < corner
val atBottomLeft = (ox - l).absoluteValue < corner && (oy - b).absoluteValue < corner
val atBottomRight = (ox - r).absoluteValue < corner && (oy - b).absoluteValue < corner
return when {
atTopLeft -> CropHandle.TOP_LEFT
atTopRight -> CropHandle.TOP_RIGHT
atBottomLeft -> CropHandle.BOTTOM_LEFT
atBottomRight -> CropHandle.BOTTOM_RIGHT
ox in l..r && oy in t..b -> CropHandle.BODY
else -> CropHandle.NONE
}
}
/**
* 将单次拖动增量应用到当前裁剪框返回受边界约束的新四元组
*
* - 角手柄只移动对应角并用 [minSize] 约束避免左右/上下交叉
* - [CropHandle.BODY]整体平移 [0,1] 边界约束
*
* @param dx x 方向增量比例已归一化
* @param dy y 方向增量比例已归一化
* @param l/t/r/b 当前裁剪框四边比例
* @param handle 本次拖动的手柄
* @param minSize 裁剪框最小宽/高比例防止四角交叉
*/
private fun moveCrop(
dx: Float,
dy: Float,
l: Float,
t: Float,
r: Float,
b: Float,
handle: CropHandle,
minSize: Float = 0.1f
): CropRect {
val left = l.coerceIn(0f, 1f)
val top = t.coerceIn(0f, 1f)
val right = r.coerceIn(0f, 1f)
val bottom = b.coerceIn(0f, 1f)
return when (handle) {
CropHandle.TOP_LEFT -> {
val nl = (left + dx).coerceIn(0f, right - minSize)
val nt = (top + dy).coerceIn(0f, bottom - minSize)
CropRect(nl, nt, right, bottom)
}
CropHandle.TOP_RIGHT -> {
val nr = (right + dx).coerceIn(left + minSize, 1f)
val nt = (top + dy).coerceIn(0f, bottom - minSize)
CropRect(left, nt, nr, bottom)
}
CropHandle.BOTTOM_LEFT -> {
val nl = (left + dx).coerceIn(0f, right - minSize)
val nb = (bottom + dy).coerceIn(top + minSize, 1f)
CropRect(nl, top, right, nb)
}
CropHandle.BOTTOM_RIGHT -> {
val nr = (right + dx).coerceIn(left + minSize, 1f)
val nb = (bottom + dy).coerceIn(top + minSize, 1f)
CropRect(left, top, nr, nb)
}
CropHandle.BODY -> {
val w = right - left
val h = bottom - top
val nl = (left + dx).coerceIn(0f, 1f - w)
val nt = (top + dy).coerceIn(0f, 1f - h)
CropRect(nl, nt, nl + w, nt + h)
}
CropHandle.NONE -> CropRect(left, top, right, bottom)
}
}
@Composable @Composable
private fun CropOverlay( private fun CropOverlay(
state: PhotoEditorState, state: PhotoEditorState,
dragHandle: CropHandle?,
onHandleChange: (CropHandle?) -> Unit,
onCropChange: (Float, Float, Float, Float) -> Unit, onCropChange: (Float, Float, Float, Float) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val left = state.cropLeft ?: 0.1f var dragHandle by remember { mutableStateOf(CropHandle.NONE) }
val right = state.cropRight ?: 0.9f // 拖动期间的本地累积值;非拖动时为 null绘制回退到 state
var dragLeft by remember { mutableFloatStateOf(0f) }
var dragTop by remember { mutableFloatStateOf(0f) }
var dragRight by remember { mutableFloatStateOf(0f) }
var dragBottom by remember { mutableFloatStateOf(0f) }
// pointerInput(Unit) 只捕获一次 state用 rememberUpdatedState 让
// 手势 lambda 内始终读到最新的 state含上次拖动提交后的值
val currentState by rememberUpdatedState(state)
// 绘制用值:拖动中取本地累积,否则取 state
val left = if (dragHandle != CropHandle.NONE) dragLeft else (state.cropLeft ?: 0.1f)
val top = if (dragHandle != CropHandle.NONE) dragTop else state.cropTop
val right = if (dragHandle != CropHandle.NONE) dragRight else (state.cropRight ?: 0.9f)
val bottom = if (dragHandle != CropHandle.NONE) dragBottom else state.cropBottom
Canvas( Canvas(
modifier = modifier.pointerInput(Unit) { modifier = modifier.pointerInput(Unit) {
detectDragGestures( detectDragGestures(
@ -351,45 +456,47 @@ private fun CropOverlay(
val size = this.size val size = this.size
val ox = offset.x / size.width val ox = offset.x / size.width
val oy = offset.y / size.height val oy = offset.y / size.height
onHandleChange( val baseL = currentState.cropLeft ?: 0.1f
when { val baseT = currentState.cropTop
ox < 0.2f && oy < 0.2f -> CropHandle.TOP_LEFT val baseR = currentState.cropRight ?: 0.9f
ox > 0.8f && oy < 0.2f -> CropHandle.TOP_RIGHT val baseB = currentState.cropBottom
ox < 0.2f && oy > 0.8f -> CropHandle.BOTTOM_LEFT // 进入拖动:把 state 当前值快照到本地累积
ox > 0.8f && oy > 0.8f -> CropHandle.BOTTOM_RIGHT dragLeft = baseL
else -> CropHandle.NONE dragTop = baseT
} dragRight = baseR
) dragBottom = baseB
dragHandle = hitHandle(ox, oy, baseL, baseT, baseR, baseB)
}, },
onDrag = { change, drag -> onDrag = { change, drag ->
if (dragHandle == CropHandle.NONE) return@detectDragGestures
change.consume() change.consume()
val size = this.size val size = this.size
val dx = drag.x / size.width val dx = drag.x / size.width
val dy = drag.y / size.height val dy = drag.y / size.height
val (nL, nT, nR, nB) = when (dragHandle) { // 在本地累积值上叠加增量,不触发 StateFlow 往返
CropHandle.TOP_LEFT -> val cur = moveCrop(dx, dy, dragLeft, dragTop, dragRight, dragBottom, dragHandle)
CropRect(left + dx, state.cropTop + dy, right, state.cropBottom) dragLeft = cur.left
CropHandle.TOP_RIGHT -> dragTop = cur.top
CropRect(left, state.cropTop + dy, right + dx, state.cropBottom) dragRight = cur.right
CropHandle.BOTTOM_LEFT -> dragBottom = cur.bottom
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) }, onDragEnd = {
onDragCancel = { onHandleChange(null) } // 拖动结束:一次性提交回 ViewModel
if (dragHandle != CropHandle.NONE) {
onCropChange(dragLeft, dragTop, dragRight, dragBottom)
}
dragHandle = CropHandle.NONE
},
onDragCancel = { dragHandle = CropHandle.NONE }
) )
} }
) { ) {
val w = size.width val w = size.width
val h = size.height val h = size.height
val l = left * w val l = left * w
val t = state.cropTop * h val t = top * h
val r = right * w val r = right * w
val b = state.cropBottom * h val b = bottom * 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(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(r, 0f), size = Size(w - r, h))
@ -411,6 +518,7 @@ private fun ToolBar(
state: PhotoEditorState, state: PhotoEditorState,
onRotate: (Int) -> Unit, onRotate: (Int) -> Unit,
onCropToggle: () -> Unit, onCropToggle: () -> Unit,
onApplyCrop: () -> Unit,
onUndoStroke: () -> Unit, onUndoStroke: () -> Unit,
onStrokeColorChange: (Color) -> Unit, onStrokeColorChange: (Color) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@ -429,12 +537,15 @@ private fun ToolBar(
Icon(Icons.Filled.RotateRight, contentDescription = "右转 90°") Icon(Icons.Filled.RotateRight, contentDescription = "右转 90°")
} }
} }
EditTab.CROP -> { EditTab.CROP -> if (state.cropEnabled) {
// 已进入裁剪模式:✓ 确认裁剪(烘焙到源图)
FilledIconButton(onClick = onApplyCrop) {
Icon(Icons.Filled.Done, contentDescription = "确认裁剪")
}
} else {
// 未进入:点击进入裁剪模式
FilledIconButton(onClick = onCropToggle) { FilledIconButton(onClick = onCropToggle) {
Icon( Icon(Icons.Filled.Crop, contentDescription = "进入裁剪")
if (state.cropEnabled) Icons.Filled.CropFree else Icons.Filled.Crop,
contentDescription = if (state.cropEnabled) "关闭裁剪" else "开启裁剪"
)
} }
} }
EditTab.HANDWRITE -> { EditTab.HANDWRITE -> {

View File

@ -1,5 +1,7 @@
package plus.rua.project.ui package plus.rua.project.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -81,6 +83,7 @@ fun RecordDetailScreen(
) )
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
var showLightbox by remember { mutableStateOf(false) }
// 删除完成后自动返回 // 删除完成后自动返回
if (state.deleted) { if (state.deleted) {
@ -142,6 +145,7 @@ fun RecordDetailScreen(
else -> DetailContent( else -> DetailContent(
state = state, state = state,
onPhotoClick = { showLightbox = true },
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding) .padding(innerPadding)
@ -165,11 +169,20 @@ fun RecordDetailScreen(
} }
) )
} }
if (showLightbox) {
ImageLightbox(
photoUri = state.photoUri,
contentDescription = state.record?.title,
onDismiss = { showLightbox = false }
)
}
} }
@Composable @Composable
private fun DetailContent( private fun DetailContent(
state: DateRecordDetailUiState, state: DateRecordDetailUiState,
onPhotoClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val record = state.record ?: return val record = state.record ?: return
@ -183,6 +196,11 @@ private fun DetailContent(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onPhotoClick
)
) )
Column(modifier = Modifier.padding(horizontal = 16.dp)) { Column(modifier = Modifier.padding(horizontal = 16.dp)) {

View File

@ -22,6 +22,7 @@ sketch = "4.4.0"
spotless = "8.8.0" spotless = "8.8.0"
tyme4kt = "1.5.0" tyme4kt = "1.5.0"
versions = "0.54.0" versions = "0.54.0"
zoomimage = "1.6.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" }
@ -57,6 +58,7 @@ 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" }
zoomimage-compose-sketch4 = { module = "io.github.panpf.zoomimage:zoomimage-compose-sketch4", version.ref = "zoomimage" }
[plugins] [plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" } androidApplication = { id = "com.android.application", version.ref = "agp" }