Add Android avatar crop selection before publication
This commit is contained in:
1 parent
d1080129d2
commit
5f3725b72f
2 files changed
+119
-1
No files matched your search
+114
@@ -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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -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<String?>(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)
|
||||
|
||||
Reference in new issue
Block a user