Browse Source

Open Android avatar galleries with originals and five previews

master
evgeny 4 days ago
parent
commit
af6cd31e71
  1. 8
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/Avatar.kt
  2. 10
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ZoomableImage.kt
  3. 110
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/AvatarViewerScreen.kt

8
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

10
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

110
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<String>()) }
var selected by rememberSaveable(group,owner,account) { mutableStateOf("") }
var previews by remember(group,owner,account) { mutableStateOf(emptyMap<String,Bitmap>()) }
var original by remember { mutableStateOf<Bitmap?>(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<files.size,onClick={ move(1) },contentPadding=PaddingValues(4.dp),
modifier=Modifier.width(32.dp)) { Text("→") }
}
}
}
}
Loading…
Cancel
Save