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 5 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.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.view.WindowManager
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.gestures.detectTransformGestures
@ -10,27 +11,35 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset 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.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke 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.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged 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.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import com.utun.chat.R import com.utun.chat.R
import com.utun.chat.data.* import com.utun.chat.data.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.io.File import java.io.File
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** Фото перемещается под неподвижным квадратом. Подтверждение передаёт владение исходным файлом репозиторию. */ /** Фото перемещается под круглой рамкой аватара. Подтверждение передаёт владение исходным файлом репозиторию. */
@Composable @Composable
fun AvatarCropDialog(uri: Uri, onCancel: () -> Unit, onConfirm: (File, AvatarCrop) -> Unit) { fun AvatarCropDialog(uri: Uri, onCancel: () -> Unit, onConfirm: (File, AvatarCrop) -> Unit) {
val context=LocalContext.current 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) requireNotNull(PhotoUtil.decodeOriented(file.path,sample)) { "cannot decode editor image" } to IntSize(w,h)
} }
source=copied; bitmap=image.first; dimensions=image.second 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) { } catch (e: Exception) {
withContext(NonCancellable+Dispatchers.IO) { withContext(NonCancellable+Dispatchers.IO) {
copied?.let { if (!it.delete()) LogManager.addLog("WARN","Avatar","Cannot remove editor source=$it") } 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") 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)) { 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_title),style=MaterialTheme.typography.titleLarge)
Text(stringResource(R.string.avatar_crop_hint)) 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 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 my=if (image==null) 0f else ((photoH*scale-side)/2).coerceAtLeast(0f)
val dx=ox.coerceIn(-mx,mx); val dy=oy.coerceIn(-my,my) 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) { .pointerInput(image,bounds) {
if (image!=null) detectTransformGestures { _,pan,factor,_ -> if (image!=null) detectTransformGestures { _,pan,factor,_ ->
val old=zoom; zoom=(zoom*factor).coerceIn(1f,16f) val old=zoom; zoom=(zoom*factor).coerceIn(1f,16f)
val s=maxOf(side/photoW,side/photoH)*zoom val s=maxOf(side/photoW,side/photoH)*zoom
ox=(ox*zoom/old+pan.x).coerceIn(-(photoW*s-side)/2,(photoW*s-side)/2) // При минимальном масштабе погрешность Float может дать отрицательный предел.
oy=(oy*zoom/old+pan.y).coerceIn(-(photoH*s-side)/2,(photoH*s-side)/2) 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) drawRect(Color.Black)
@ -99,17 +122,20 @@ fun AvatarCropDialog(uri: Uri, onCancel: () -> Unit, onConfirm: (File, AvatarCro
((size.height-photoH*scale)/2+dy).roundToInt()), ((size.height-photoH*scale)/2+dy).roundToInt()),
dstSize=IntSize((photoW*scale).roundToInt(),(photoH*scale).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) 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)) val circle=Path().apply { addOval(Rect(x,y,x+side,y+side)) }
drawRect(shade,Offset(0f,y),Size(x,side)); drawRect(shade,Offset(x+side,y),Size(x,side)) clipPath(circle,clipOp=ClipOp.Difference) { drawRect(shade) }
drawRect(Color.White,Offset(x,y),Size(side,side),style=Stroke(2.dp.toPx())) 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(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(), val crop=AvatarCrop(((photoW*scale-side-2*dx)/(2*scale*photoW)).toDouble(),
((photoH*scale-side-2*dy)/(2*scale*photoH)).toDouble(), ((photoH*scale-side-2*dy)/(2*scale*photoH)).toDouble(),
side.toDouble()/scale/photoW,side.toDouble()/scale/photoH) side.toDouble()/scale/photoW,side.toDouble()/scale/photoH)
LogManager.addLog("INFO","Avatar","Editor confirmed crop=$crop zoom=$zoom")
handedOver=true; onConfirm(requireNotNull(source),crop) handedOver=true; onConfirm(requireNotNull(source),crop)
}) { Text(stringResource(R.string.avatar_upload)) } }) { 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="avatar_storage_server">Сервер хранения: %1$s</string>
<string name="profile_avatar_saved">Аватар сохранён. Публикация в группы продолжается; недоступные серверы будут опрошены повторно.</string> <string name="profile_avatar_saved">Аватар сохранён. Публикация в группы продолжается; недоступные серверы будут опрошены повторно.</string>
<string name="avatar_crop_title">Выбор аватарки</string> <string name="avatar_crop_title">Выбор аватарки</string>
<string name="avatar_crop_hint">Перемещайте и масштабируйте фото, чтобы выбрать квадрат.</string> <string name="avatar_crop_hint">Перемещайте и масштабируйте фото, чтобы вписать его в круг.</string>
<string name="avatar_none">Нет аватарки</string> <string name="avatar_none">Нет аватарки</string>
</resources> </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="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="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_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> <string name="avatar_none">No avatar</string>
</resources> </resources>

Loading…
Cancel
Save