package com.example.aiapp import android.graphics.BitmapFactory import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext /** * An image in the transcript: a fixed-height thumbnail that opens full screen. * * The height is decided before the bytes arrive and never changes. An image row that grew when it * finished loading pushed everything below it, so a transcript being read scrolled itself while * somebody was looking at it -- and in a bottom-anchored list, images loading above the viewport * moved the text under the reader's eyes. Reserving the final height makes loading invisible, which * is what it should be. * * Four lines of body text, so a screenshot reads as an attachment beside the conversation rather * than as a page of its own. Full size is one tap away. */ @Composable fun SessionImage(settings: ServerSettings, sessionId: String, ref: String) { var bitmap by remember(ref) { mutableStateOf(null) } var failed by remember(ref) { mutableStateOf(false) } var full by remember(ref) { mutableStateOf(false) } LaunchedEffect(ref) { try { val bytes = withContext(Dispatchers.IO) { fetchSessionFile(settings, sessionId, ref) } bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap() failed = bitmap == null } catch (_: ApiException) { failed = true } } val height = thumbnailHeight() val heightPx = with(LocalDensity.current) { height.roundToPx() } Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) { when (val image = bitmap) { null -> Text( // Two states, not one: an image still arriving and an image that will never // arrive look nothing alike to a reader who can do something about the second. if (failed) "[image $ref unavailable]" else "[loading image…]", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) else -> Image( bitmap = image, contentDescription = "Attached image, tap to view full screen", contentScale = ContentScale.Fit, filterQuality = enlargingFilter(image.height, heightPx), modifier = Modifier.fillMaxSize().clickable { full = true }, alignment = Alignment.CenterStart, ) } } if (full) bitmap?.let { image -> ImageViewer(image) { full = false } } } /** * Four lines of the body style the transcript is set in. * * Measured from the type rather than written as a dp, so it stays four lines when the text size * changes -- including when the reader has scaled fonts up, which is exactly when a hardcoded * height would be wrong. */ @Composable private fun thumbnailHeight(): Dp { val line = MaterialTheme.typography.bodyLarge.lineHeight val density = LocalDensity.current return remember(line, density) { with(density) { if (line.isSpecified) (line * 4).toDp() else 96.dp } } } /** * Nearest neighbour when the image is being enlarged, smooth when it is being shrunk. * * A small image blown up with interpolation turns into a blur that hides what it is -- the same * image with hard pixel edges stays readable. Shrinking wants the opposite, so this is a decision * per image rather than a preference set once. */ private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality = if (sourceHeight < drawnHeight) FilterQuality.None else FilterQuality.High /** * The image on its own, as large as it fits, with pinch to zoom. * * A dialog rather than a screen, so the platform's back gesture returns to the transcript instead * of leaving the app. It opens fitted -- the whole image visible, which is the thing a reader wants * first -- and zoom is theirs from there. */ @Composable private fun ImageViewer(image: ImageBitmap, onClose: () -> Unit) { Dialog( onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false), ) { var scale by remember { mutableFloatStateOf(1f) } var offsetX by remember { mutableFloatStateOf(0f) } var offsetY by remember { mutableFloatStateOf(0f) } Box( Modifier.fillMaxSize() .background(Color.Black) .clickable(onClick = onClose) .pointerInput(Unit) { detectTransformGestures { _, pan, zoom, _ -> // Floor of 1 so the image cannot be pinched smaller than fitted, which is // already the whole of it; a ceiling so it cannot be lost off-screen. scale = (scale * zoom).coerceIn(1f, 8f) if (scale > 1f) { offsetX += pan.x offsetY += pan.y } else { offsetX = 0f offsetY = 0f } } }, contentAlignment = Alignment.Center, ) { Image( bitmap = image, contentDescription = "Attached image", contentScale = ContentScale.Fit, // Zoomed in, the reader is looking at pixels on purpose. filterQuality = FilterQuality.None, modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = scale scaleY = scale translationX = offsetX translationY = offsetY }, ) } } }