Merge branch 'feat/record-detail-image-lightbox'
This commit is contained in:
commit
ffafee550e
@ -105,6 +105,9 @@ dependencies {
|
|||||||
implementation(libs.camera.view)
|
implementation(libs.camera.view)
|
||||||
implementation(libs.androidx.exifinterface)
|
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("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)
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
72
core/src/main/kotlin/plus/rua/project/ui/ImageLightbox.kt
Normal file
72
core/src/main/kotlin/plus/rua/project/ui/ImageLightbox.kt
Normal 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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 -> {
|
||||||
|
|||||||
@ -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)) {
|
||||||
|
|||||||
@ -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" }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user