From af6cd31e71f0e5cde28de7013d106ea47d5b69cb Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 19:43:27 +0200 Subject: [PATCH] Open Android avatar galleries with originals and five previews --- .../com/utun/chat/ui/components/Avatar.kt | 8 +- .../utun/chat/ui/components/ZoomableImage.kt | 10 +- .../chat/ui/screens/AvatarViewerScreen.kt | 110 ++++++++++++++++++ 3 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/AvatarViewerScreen.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/Avatar.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/Avatar.kt index f1c624e7..ce2f4435 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/Avatar.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/Avatar.kt @@ -2,6 +2,7 @@ package com.utun.chat.ui.components import android.graphics.Bitmap import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size @@ -9,6 +10,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -17,16 +19,20 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.utun.chat.ChatApplication +import com.utun.chat.ui.screens.AvatarViewerScreen @Composable fun UserAvatar(group: String?, owner: Long, name: String, modifier: Modifier = Modifier, size: Dp = 40.dp, profile: Boolean = false) { val repository = ChatApplication.instance.avatars val revision by repository.revision.collectAsState() + var viewing by rememberSaveable(group,owner) { mutableStateOf(false) } LaunchedEffect(group, owner, revision, profile) { if (profile) repository.ensureProfile() else repository.ensureUser(group, owner) } - AvatarImage(if (profile) repository.profileBitmap() else repository.userBitmap(group, owner), name, modifier, size) + AvatarImage(if (profile) repository.profileBitmap() else repository.userBitmap(group, owner), name, + modifier.clickable(enabled=!profile) { viewing=true }, size) + if (viewing) AvatarViewerScreen(group,owner,name,onClose={ viewing=false }) } @Composable diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ZoomableImage.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ZoomableImage.kt index 07fb29bf..37e723b8 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ZoomableImage.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ZoomableImage.kt @@ -27,7 +27,8 @@ private fun clampOffset(scale: Float, o: Offset, cW: Float, cH: Float, iw: Float } @Composable -fun ZoomableImage(bitmap: Bitmap, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Fit) { +fun ZoomableImage(bitmap: Bitmap, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Fit, + onSwipe: ((Int) -> Unit)? = null) { var zoomScale by remember(bitmap) { mutableStateOf(1f) } var zoomOffset by remember(bitmap) { mutableStateOf(Offset.Zero) } @@ -59,8 +60,13 @@ fun ZoomableImage(bitmap: Bitmap, modifier: Modifier = Modifier, contentScale: C Box( modifier = Modifier .fillMaxSize() - .pointerInput(Unit) { + .pointerInput(bitmap,cW,cH,onSwipe) { + var swipe=0f detectTransformGestures { centroid, pan, zoom, _ -> + if (onSwipe!=null && zoomScale==1f && zoom==1f && kotlin.math.abs(pan.x)>kotlin.math.abs(pan.y)) { + swipe+=pan.x + if (kotlin.math.abs(swipe)>96*density.density) { onSwipe(if (swipe<0) 1 else -1); swipe=0f } + } else swipe=0f val old = zoomScale val new = (zoomScale * zoom).coerceIn(1f, MAX_ZOOM) val z = new / old diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/AvatarViewerScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/AvatarViewerScreen.kt new file mode 100644 index 00000000..a474195b --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/AvatarViewerScreen.kt @@ -0,0 +1,110 @@ +package com.utun.chat.ui.screens + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.utun.chat.ChatApplication +import com.utun.chat.R +import com.utun.chat.data.LogManager +import com.utun.chat.ui.components.ZoomableImage +import kotlinx.coroutines.* + +/** Каталог содержит описания аватарок; оригинал загружается только для выбранной картинки. */ +@Composable +fun AvatarViewerScreen(group: String?, owner: Long, name: String, onClose: () -> Unit) { + val repository=ChatApplication.instance.avatars + val members by repository.members.collectAsState() + val revision by repository.revision.collectAsState() + val account=repository.accountId + val source=remember(members,group,owner) { repository.gallerySource(group,owner) } + var files by remember(group,owner,account) { mutableStateOf(emptyList()) } + var selected by rememberSaveable(group,owner,account) { mutableStateOf("") } + var previews by remember(group,owner,account) { mutableStateOf(emptyMap()) } + var original by remember { mutableStateOf(null) } + var failed by remember { mutableStateOf(false) } + var catalogFailed by remember { mutableStateOf(false) } + var retry by remember { mutableStateOf(0) } + LaunchedEffect(account,revision) { if (account==0L) onClose() } + LaunchedEffect(source?.first,source?.second?.avatarServer,source?.second?.avatarFile,retry) { + val (channel,member)=source ?: return@LaunchedEffect + files=listOf(member.avatarFile); if (selected !in files) selected=member.avatarFile + catalogFailed=false + try { files=repository.catalog(channel,member) } + catch (e: CancellationException) { throw e } + catch (e: Exception) { catalogFailed=true; LogManager.addLog("WARN","Avatar","Catalog failed owner=$owner: ${e.message}") } + } + val index=files.indexOf(selected) + LaunchedEffect(source?.first,source?.second?.avatarServer,selected,files,retry) { + original=null; failed=false + val (channel,member)=source ?: return@LaunchedEffect + if (index<0) return@LaunchedEffect + val visible=files.subList((index-2).coerceAtLeast(0),(index+3).coerceAtMost(files.size)) + previews=previews.filterKeys { it in visible } + coroutineScope { + launch { + try { previews=previews+(selected to repository.loadAvatar(channel,member.copy(avatarFile=selected),false)) } + catch (e: CancellationException) { throw e } + catch (e: Exception) { failed=true; LogManager.addLog("WARN","Avatar","Preview failed file=$selected: ${e.message}") } + } + launch { + try { original=repository.loadAvatar(channel,member.copy(avatarFile=selected),true) } + catch (e: CancellationException) { throw e } + catch (e: Exception) { failed=true; LogManager.addLog("WARN","Avatar","Original failed file=$selected: ${e.message}") } + } + visible.filter { it!=selected && it !in previews }.forEach { file -> launch { + try { previews=previews+(file to repository.loadAvatar(channel,member.copy(avatarFile=file),false)) } + catch (e: CancellationException) { throw e } + catch (e: Exception) { LogManager.addLog("WARN","Avatar","Neighbor failed file=$file: ${e.message}") } + } } + } + } + fun move(delta: Int) { val next=index+delta; if (next in files.indices) selected=files[next] } + Dialog(onDismissRequest=onClose,properties=DialogProperties(usePlatformDefaultWidth=false)) { + Column(Modifier.fillMaxSize().background(Color.Black).systemBarsPadding()) { + Row(Modifier.fillMaxWidth(),verticalAlignment=Alignment.CenterVertically) { + Text(name,color=Color.White,modifier=Modifier.weight(1f).padding(12.dp)) + TextButton(onClick=onClose) { Text(stringResource(R.string.close)) } + } + Box(Modifier.weight(1f).fillMaxWidth(),contentAlignment=Alignment.Center) { + val image=original ?: previews[selected] + if (image!=null) ZoomableImage(image,Modifier.fillMaxSize(),onSwipe={ move(it) }) + else if (source!=null && !failed) CircularProgressIndicator() + else Text(stringResource(if (source==null) R.string.avatar_none else R.string.avatar_load_failed),color=Color.White) + } + if (failed || catalogFailed) TextButton(onClick={ retry++ },modifier=Modifier.align(Alignment.CenterHorizontally)) { + Text(stringResource(R.string.avatar_retry)) + } else if (original==null && source!=null) LinearProgressIndicator(Modifier.fillMaxWidth()) + if (index>=0) Text("${index+1} / ${files.size}",color=Color.White,modifier=Modifier.align(Alignment.CenterHorizontally)) + Row(Modifier.fillMaxWidth().padding(vertical=8.dp),verticalAlignment=Alignment.CenterVertically, + horizontalArrangement=Arrangement.Center) { + TextButton(enabled=index>0,onClick={ move(-1) },contentPadding=PaddingValues(4.dp),modifier=Modifier.width(32.dp)) { Text("←") } + repeat(5) { slot -> + val item=index+slot-2; val file=files.getOrNull(item) + Box(Modifier.weight(1f).aspectRatio(1f).padding(2.dp) + .border(if (slot==2) 2.dp else 0.dp,if (slot==2) Color(0xFF77AAFF) else Color.Transparent) + .clickable(enabled=file!=null) { if (file!=null) selected=file },contentAlignment=Alignment.Center) { + previews[file]?.let { Image(it.asImageBitmap(),contentDescription=null,contentScale=ContentScale.Crop, + modifier=Modifier.fillMaxSize()) } + } + } + TextButton(enabled=index>=0 && index+1