2 changed files with 119 additions and 1 deletions
@ -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)) } |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue