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.androidx.exifinterface)
// 日期记录器:详情页灯箱图片缩放/旋转(与 sketch 同作者 panpfSketchZoomAsyncImage 是 AsyncImage 超集)
implementation(libs.zoomimage.compose.sketch4)
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${libs.versions.kotlin.get()}")
testImplementation(libs.kotlinx.coroutines.test)
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) {
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.ChevronLeft
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.RotateLeft
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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import kotlin.math.absoluteValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
@ -158,6 +160,7 @@ fun PhotoEditorScreen(
onTabChange = { activeTab = it },
onRotate = viewModel::rotate,
onCropToggle = viewModel::toggleCrop,
onApplyCrop = viewModel::applyCrop,
onCropChange = viewModel::updateCrop,
onAddPoint = viewModel::addStrokePoint,
onEndStroke = viewModel::endStroke,
@ -182,6 +185,7 @@ private fun EditorBody(
onTabChange: (EditTab) -> Unit,
onRotate: (Int) -> Unit,
onCropToggle: () -> Unit,
onApplyCrop: () -> Unit,
onCropChange: (Float, Float, Float, Float) -> Unit,
onAddPoint: (Offset) -> Unit,
onEndStroke: () -> Unit,
@ -246,6 +250,7 @@ private fun EditorBody(
state = state,
onRotate = onRotate,
onCropToggle = onCropToggle,
onApplyCrop = onApplyCrop,
onUndoStroke = onUndoStroke,
onStrokeColorChange = onStrokeColorChange,
modifier = Modifier
@ -265,8 +270,6 @@ private fun EditableImage(
onDisplaySizeChange: (Float, Float) -> Unit
) {
val rotatedBmp = remember(state.rotationDegrees, state.sourceBitmap) { state.rotatedBitmap }
// 裁剪框拖动手柄CROP 模式下使用)
var dragHandle by remember { mutableStateOf<CropHandle?>(null) }
Box(
modifier = Modifier
@ -299,8 +302,6 @@ private fun EditableImage(
if (mode == EditTab.CROP && state.cropEnabled) {
CropOverlay(
state = state,
dragHandle = dragHandle,
onHandleChange = { dragHandle = it },
onCropChange = onCropChange,
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 回传。 */
private data class CropRect(
@ -334,16 +335,120 @@ private data class CropRect(
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
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
var dragHandle by remember { mutableStateOf(CropHandle.NONE) }
// 拖动期间的本地累积值;非拖动时为 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(
modifier = modifier.pointerInput(Unit) {
detectDragGestures(
@ -351,45 +456,47 @@ private fun CropOverlay(
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
}
)
val baseL = currentState.cropLeft ?: 0.1f
val baseT = currentState.cropTop
val baseR = currentState.cropRight ?: 0.9f
val baseB = currentState.cropBottom
// 进入拖动:把 state 当前值快照到本地累积
dragLeft = baseL
dragTop = baseT
dragRight = baseR
dragBottom = baseB
dragHandle = hitHandle(ox, oy, baseL, baseT, baseR, baseB)
},
onDrag = { change, drag ->
if (dragHandle == CropHandle.NONE) return@detectDragGestures
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)
// 在本地累积值上叠加增量,不触发 StateFlow 往返
val cur = moveCrop(dx, dy, dragLeft, dragTop, dragRight, dragBottom, dragHandle)
dragLeft = cur.left
dragTop = cur.top
dragRight = cur.right
dragBottom = cur.bottom
},
onDragEnd = { onHandleChange(null) },
onDragCancel = { onHandleChange(null) }
onDragEnd = {
// 拖动结束:一次性提交回 ViewModel
if (dragHandle != CropHandle.NONE) {
onCropChange(dragLeft, dragTop, dragRight, dragBottom)
}
dragHandle = CropHandle.NONE
},
onDragCancel = { dragHandle = CropHandle.NONE }
)
}
) {
val w = size.width
val h = size.height
val l = left * w
val t = state.cropTop * h
val t = top * h
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(r, 0f), size = Size(w - r, h))
@ -411,6 +518,7 @@ private fun ToolBar(
state: PhotoEditorState,
onRotate: (Int) -> Unit,
onCropToggle: () -> Unit,
onApplyCrop: () -> Unit,
onUndoStroke: () -> Unit,
onStrokeColorChange: (Color) -> Unit,
modifier: Modifier = Modifier
@ -429,12 +537,15 @@ private fun ToolBar(
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) {
Icon(
if (state.cropEnabled) Icons.Filled.CropFree else Icons.Filled.Crop,
contentDescription = if (state.cropEnabled) "关闭裁剪" else "开启裁剪"
)
Icon(Icons.Filled.Crop, contentDescription = "进入裁剪")
}
}
EditTab.HANDWRITE -> {

View File

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

View File

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