diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 8ea3fc1..30f081c 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -105,6 +105,9 @@ dependencies { implementation(libs.camera.view) implementation(libs.androidx.exifinterface) + // 日期记录器:详情页灯箱图片缩放/旋转(与 sketch 同作者 panpf,SketchZoomAsyncImage 是 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) diff --git a/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt b/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt index 94f9f03..71c3a48 100644 --- a/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt +++ b/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt @@ -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 { diff --git a/core/src/main/kotlin/plus/rua/project/ui/ImageLightbox.kt b/core/src/main/kotlin/plus/rua/project/ui/ImageLightbox.kt new file mode 100644 index 0000000..e9f79a0 --- /dev/null +++ b/core/src/main/kotlin/plus/rua/project/ui/ImageLightbox.kt @@ -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() + ) + } + } +} diff --git a/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt b/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt index 71608e2..a68e8c4 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt @@ -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(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 -> { diff --git a/core/src/main/kotlin/plus/rua/project/ui/RecordDetailScreen.kt b/core/src/main/kotlin/plus/rua/project/ui/RecordDetailScreen.kt index 8168e90..c1a232e 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/RecordDetailScreen.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/RecordDetailScreen.kt @@ -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)) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a513813..62b1a53 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }