From 5f3725b72f0c0024f1996c2ab90143de8130fe83 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 19:42:22 +0200 Subject: [PATCH] Add Android avatar crop selection before publication --- .../chat/ui/components/AvatarCropDialog.kt | 114 ++++++++++++++++++ .../utun/chat/ui/components/AvatarEditors.kt | 6 +- 2 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarCropDialog.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarCropDialog.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarCropDialog.kt new file mode 100644 index 00000000..b49ff389 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarCropDialog.kt @@ -0,0 +1,114 @@ +package com.utun.chat.ui.components + +import android.graphics.Bitmap +import android.net.Uri +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +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 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 + var source by remember(uri) { mutableStateOf(null) } + var bitmap by remember(uri) { mutableStateOf(null) } + var failed by remember(uri) { mutableStateOf(false) } + var handedOver by remember(uri) { mutableStateOf(false) } + var zoom by rememberSaveable(uri.toString()) { mutableStateOf(1f) } + var ox by rememberSaveable(uri.toString()) { mutableStateOf(0f) } + var oy by rememberSaveable(uri.toString()) { mutableStateOf(0f) } + var bounds by remember { mutableStateOf(IntSize.Zero) } + LaunchedEffect(uri) { + var copied: File?=null + try { + val image=withContext(Dispatchers.IO) { + val file=AvatarAssets.copySource(context,uri); copied=file + val (w,h)=PhotoUtil.orientedDimensions(file.path) + require(w>0 && h>0 && w.toLong()*h<=16L*1024*1024) { "original exceeds 16 megapixels" } + var sample=1; while (maxOf(w,h)/sample>1600) sample*=2 + requireNotNull(PhotoUtil.decodeOriented(file.path,sample)) { "cannot decode editor image" } + } + source=copied; bitmap=image + } catch (e: Exception) { + withContext(NonCancellable+Dispatchers.IO) { + copied?.let { if (!it.delete()) LogManager.addLog("WARN","Avatar","Cannot remove editor source=$it") } + } + if (e is CancellationException) throw e + failed=true; LogManager.addLog("ERROR","Avatar","Editor load failed: ${e.message}") + } + } + DisposableEffect(source) { + val file=source + onDispose { if (!handedOver && file!=null) CoroutineScope(Dispatchers.IO).launch { + if (!file.delete()) LogManager.addLog("WARN","Avatar","Cannot remove cancelled source=$file") + } } + } + Dialog(onDismissRequest=onCancel,properties=DialogProperties(usePlatformDefaultWidth=false)) { + 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)) + if (bitmap==null && !failed) LinearProgressIndicator(Modifier.fillMaxWidth()) + if (failed) Text(stringResource(R.string.avatar_load_failed),color=MaterialTheme.colorScheme.error) + val image=bitmap + val side=minOf(bounds.width,bounds.height)*0.8f + val scale=if (image==null) 1f else maxOf(side/image.width,side/image.height)*zoom + val mx=if (image==null) 0f else ((image.width*scale-side)/2).coerceAtLeast(0f) + val my=if (image==null) 0f else ((image.height*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 } + .pointerInput(image,bounds) { + if (image!=null) detectTransformGestures { _,pan,factor,_ -> + val old=zoom; zoom=(zoom*factor).coerceIn(1f,16f) + val s=maxOf(side/image.width,side/image.height)*zoom + ox=(ox*zoom/old+pan.x).coerceIn(-(image.width*s-side)/2,(image.width*s-side)/2) + oy=(oy*zoom/old+pan.y).coerceIn(-(image.height*s-side)/2,(image.height*s-side)/2) + } + }) { + drawRect(Color.Black) + if (image!=null && side>0) { + drawImage(image.asImageBitmap(),dstOffset=IntOffset(((size.width-image.width*scale)/2+dx).roundToInt(), + ((size.height-image.height*scale)/2+dy).roundToInt()), + dstSize=IntSize((image.width*scale).roundToInt(),(image.height*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())) + } + } + Row(Modifier.fillMaxWidth(),horizontalArrangement=Arrangement.End) { + TextButton(onClick=onCancel) { Text(stringResource(R.string.cancel)) } + TextButton(enabled=image!=null && source!=null && side>0,onClick={ + val selected=requireNotNull(image) + val crop=AvatarCrop((selected.width*scale-side-2*dx)/(2*scale*selected.width), + (selected.height*scale-side-2*dy)/(2*scale*selected.height), + side.toDouble()/scale/selected.width,side.toDouble()/scale/selected.height) + handedOver=true; onConfirm(requireNotNull(source),crop) + }) { Text(stringResource(R.string.avatar_upload)) } + } + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarEditors.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarEditors.kt index 9c8ef612..2e4f941c 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarEditors.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarEditors.kt @@ -81,13 +81,17 @@ fun ProfileAvatarControls() { var menu by remember { mutableStateOf(false) } var pickedAccount by rememberSaveable { mutableStateOf(0L) } var pickedServer by rememberSaveable { mutableStateOf(0L) } + var cropUri by rememberSaveable { mutableStateOf(null) } val state = edits["profile"] ?: AvatarEditState.IDLE LaunchedEffect(servers) { if (servers.none { it.nodeId == selectedServer }) selectedServer = servers.firstOrNull()?.nodeId ?: 0L } val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> - if (uri != null) repository.editProfile(pickedServer, uri, pickedAccount) + if (uri != null) cropUri=uri.toString() } + cropUri?.let { value -> AvatarCropDialog(android.net.Uri.parse(value),onCancel={ cropUri=null },onConfirm={ file,crop -> + repository.editProfile(pickedServer,file,pickedAccount,crop); cropUri=null + }) } Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Text(stringResource(R.string.profile_avatar), style = MaterialTheme.typography.titleMedium) UserAvatar(null, repository.accountId, "", size = 64.dp, profile = true)