Browse Source

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.
master
evgeny 4 days ago
parent
commit
9edc76e5eb
  1. 48
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarCropDialog.kt
  2. 2
      tools/chatgui-android/app/src/main/res/values-ru/strings.xml
  3. 2
      tools/chatgui-android/app/src/main/res/values/strings.xml

48
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarCropDialog.kt

@ -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)) }
}

2
tools/chatgui-android/app/src/main/res/values-ru/strings.xml

@ -361,6 +361,6 @@
<string name="avatar_storage_server">Сервер хранения: %1$s</string>
<string name="profile_avatar_saved">Аватар сохранён. Публикация в группы продолжается; недоступные серверы будут опрошены повторно.</string>
<string name="avatar_crop_title">Выбор аватарки</string>
<string name="avatar_crop_hint">Перемещайте и масштабируйте фото, чтобы выбрать квадрат.</string>
<string name="avatar_crop_hint">Перемещайте и масштабируйте фото, чтобы вписать его в круг.</string>
<string name="avatar_none">Нет аватарки</string>
</resources>

2
tools/chatgui-android/app/src/main/res/values/strings.xml

@ -359,6 +359,6 @@
<string name="avatar_storage_server">Storage server: %1$s</string>
<string name="profile_avatar_saved">Avatar saved. Publishing to groups; unavailable servers will be retried.</string>
<string name="avatar_crop_title">Crop avatar</string>
<string name="avatar_crop_hint">Move and zoom the photo to choose a square.</string>
<string name="avatar_crop_hint">Move and zoom the photo to fit the circle.</string>
<string name="avatar_none">No avatar</string>
</resources>

Loading…
Cancel
Save