Fix Android avatar crop layout and circular preview

Keep the crop actions inside the available dialog window, show a circular mask, and clamp gesture limits to prevent float rounding crashes.
This commit is contained in:
evgeny committed 2026-10-06 22:37:04 +02:00
1 parent b0933eb282
commit 9edc76e5eb
3 files changed
+39 -13

No files matched your search

@@ -2,6 +2,7 @@ package com.utun.chat.ui.components
import android.graphics.Bitmap
import android.net.Uri
import android.view.WindowManager
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTransformGestures
@@ -10,27 +11,35 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.ClipOp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import com.utun.chat.R
import com.utun.chat.data.*
import kotlinx.coroutines.*
import java.io.File
import kotlin.math.roundToInt
/** Фото перемещается под неподвижным квадратом. Подтверждение передаёт владение исходным файлом репозиторию. */
/** Фото перемещается под круглой рамкой аватара. Подтверждение передаёт владение исходным файлом репозиторию. */
@Composable
fun AvatarCropDialog(uri: Uri, onCancel: () -> Unit, onConfirm: (File, AvatarCrop) -> Unit) {
val context=LocalContext.current
@@ -57,6 +66,8 @@ fun AvatarCropDialog(uri: Uri, onCancel: () -> Unit, onConfirm: (File, AvatarCro
requireNotNull(PhotoUtil.decodeOriented(file.path,sample)) { "cannot decode editor image" } to IntSize(w,h)
}
source=copied; bitmap=image.first; dimensions=image.second
LogManager.addLog("INFO","Avatar","Editor loaded original=${dimensions.width}x${dimensions.height} " +
"preview=${image.first.width}x${image.first.height} mask=circle")
} catch (e: Exception) {
withContext(NonCancellable+Dispatchers.IO) {
copied?.let { if (!it.delete()) LogManager.addLog("WARN","Avatar","Cannot remove editor source=$it") }
@@ -71,7 +82,13 @@ fun AvatarCropDialog(uri: Uri, onCancel: () -> Unit, onConfirm: (File, AvatarCro
if (!file.delete()) LogManager.addLog("WARN","Avatar","Cannot remove cancelled source=$file")
} }
}
Dialog(onDismissRequest=onCancel,properties=DialogProperties(usePlatformDefaultWidth=false)) {
Dialog(onDismissRequest=onCancel,properties=DialogProperties(decorFitsSystemWindows=false)) {
val window=(LocalView.current.parent as DialogWindowProvider).window
DisposableEffect(window) {
// Compose 1.6 с usePlatformDefaultWidth=false измеряет по дисплею, а не по доступной области окна.
window.setLayout(WindowManager.LayoutParams.MATCH_PARENT,WindowManager.LayoutParams.MATCH_PARENT)
onDispose { }
}
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface).systemBarsPadding().padding(12.dp)) {
Text(stringResource(R.string.avatar_crop_title),style=MaterialTheme.typography.titleLarge)
Text(stringResource(R.string.avatar_crop_hint))
@@ -84,13 +101,19 @@ fun AvatarCropDialog(uri: Uri, onCancel: () -> Unit, onConfirm: (File, AvatarCro
val mx=if (image==null) 0f else ((photoW*scale-side)/2).coerceAtLeast(0f)
val my=if (image==null) 0f else ((photoH*scale-side)/2).coerceAtLeast(0f)
val dx=ox.coerceIn(-mx,mx); val dy=oy.coerceIn(-my,my)
Canvas(Modifier.weight(1f).fillMaxWidth().onSizeChanged { bounds=it }
Canvas(Modifier.weight(1f).fillMaxWidth().clipToBounds().onSizeChanged {
bounds=it; LogManager.addLog("DEBUG","Avatar","Editor canvas=${it.width}x${it.height}")
}
.pointerInput(image,bounds) {
if (image!=null) detectTransformGestures { _,pan,factor,_ ->
val old=zoom; zoom=(zoom*factor).coerceIn(1f,16f)
val s=maxOf(side/photoW,side/photoH)*zoom
ox=(ox*zoom/old+pan.x).coerceIn(-(photoW*s-side)/2,(photoW*s-side)/2)
oy=(oy*zoom/old+pan.y).coerceIn(-(photoH*s-side)/2,(photoH*s-side)/2)
// При минимальном масштабе погрешность Float может дать отрицательный предел.
val limitX=((photoW*s-side)/2).coerceAtLeast(0f)
val limitY=((photoH*s-side)/2).coerceAtLeast(0f)
ox=(ox*zoom/old+pan.x).coerceIn(-limitX,limitX)
oy=(oy*zoom/old+pan.y).coerceIn(-limitY,limitY)
LogManager.addLog("DEBUG","Avatar","Editor transform zoom=$zoom limits=$limitX,$limitY offset=$ox,$oy")
}
}) {
drawRect(Color.Black)
@@ -99,17 +122,20 @@ fun AvatarCropDialog(uri: Uri, onCancel: () -> Unit, onConfirm: (File, AvatarCro
((size.height-photoH*scale)/2+dy).roundToInt()),
dstSize=IntSize((photoW*scale).roundToInt(),(photoH*scale).roundToInt()))
val x=(size.width-side)/2; val y=(size.height-side)/2; val shade=Color.Black.copy(alpha=0.6f)
drawRect(shade,size=Size(size.width,y)); drawRect(shade,Offset(0f,y+side),Size(size.width,y))
drawRect(shade,Offset(0f,y),Size(x,side)); drawRect(shade,Offset(x+side,y),Size(x,side))
drawRect(Color.White,Offset(x,y),Size(side,side),style=Stroke(2.dp.toPx()))
val circle=Path().apply { addOval(Rect(x,y,x+side,y+side)) }
clipPath(circle,clipOp=ClipOp.Difference) { drawRect(shade) }
drawCircle(Color.White,radius=side/2,center=Offset(size.width/2,size.height/2),style=Stroke(2.dp.toPx()))
}
}
Row(Modifier.fillMaxWidth(),horizontalArrangement=Arrangement.End) {
Row(Modifier.fillMaxWidth().onGloballyPositioned {
LogManager.addLog("DEBUG","Avatar","Editor actions bounds=${it.boundsInWindow()}")
},horizontalArrangement=Arrangement.End) {
TextButton(onClick=onCancel) { Text(stringResource(R.string.cancel)) }
TextButton(enabled=image!=null && source!=null && side>0,onClick={
Button(enabled=image!=null && source!=null && side>0,onClick={
val crop=AvatarCrop(((photoW*scale-side-2*dx)/(2*scale*photoW)).toDouble(),
((photoH*scale-side-2*dy)/(2*scale*photoH)).toDouble(),
side.toDouble()/scale/photoW,side.toDouble()/scale/photoH)
LogManager.addLog("INFO","Avatar","Editor confirmed crop=$crop zoom=$zoom")
handedOver=true; onConfirm(requireNotNull(source),crop)
}) { Text(stringResource(R.string.avatar_upload)) }
}