diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarCropDialog.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarCropDialog.kt
index 63136077..af4f9002 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarCropDialog.kt
+++ b/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)) }
}
diff --git a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml
index 34648990..1248ffc2 100644
--- a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml
+++ b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml
@@ -361,6 +361,6 @@
Сервер хранения: %1$s
Аватар сохранён. Публикация в группы продолжается; недоступные серверы будут опрошены повторно.
Выбор аватарки
- Перемещайте и масштабируйте фото, чтобы выбрать квадрат.
+ Перемещайте и масштабируйте фото, чтобы вписать его в круг.
Нет аватарки
diff --git a/tools/chatgui-android/app/src/main/res/values/strings.xml b/tools/chatgui-android/app/src/main/res/values/strings.xml
index bd469ebd..a8ed201d 100644
--- a/tools/chatgui-android/app/src/main/res/values/strings.xml
+++ b/tools/chatgui-android/app/src/main/res/values/strings.xml
@@ -359,6 +359,6 @@
Storage server: %1$s
Avatar saved. Publishing to groups; unavailable servers will be retried.
Crop avatar
- Move and zoom the photo to choose a square.
+ Move and zoom the photo to fit the circle.
No avatar