Browse Source

Add Android avatar crop selection before publication

master
evgeny 6 days ago
parent
commit
5f3725b72f
  1. 114
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarCropDialog.kt
  2. 6
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarEditors.kt

114
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<File?>(null) }
var bitmap by remember(uri) { mutableStateOf<Bitmap?>(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)) }
}
}
}
}

6
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 menu by remember { mutableStateOf(false) }
var pickedAccount by rememberSaveable { mutableStateOf(0L) } var pickedAccount by rememberSaveable { mutableStateOf(0L) }
var pickedServer by rememberSaveable { mutableStateOf(0L) } var pickedServer by rememberSaveable { mutableStateOf(0L) }
var cropUri by rememberSaveable { mutableStateOf<String?>(null) }
val state = edits["profile"] ?: AvatarEditState.IDLE val state = edits["profile"] ?: AvatarEditState.IDLE
LaunchedEffect(servers) { LaunchedEffect(servers) {
if (servers.none { it.nodeId == selectedServer }) selectedServer = servers.firstOrNull()?.nodeId ?: 0L if (servers.none { it.nodeId == selectedServer }) selectedServer = servers.firstOrNull()?.nodeId ?: 0L
} }
val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> 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)) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(stringResource(R.string.profile_avatar), style = MaterialTheme.typography.titleMedium) Text(stringResource(R.string.profile_avatar), style = MaterialTheme.typography.titleMedium)
UserAvatar(null, repository.accountId, "", size = 64.dp, profile = true) UserAvatar(null, repository.accountId, "", size = 64.dp, profile = true)

Loading…
Cancel
Save