package com.example.aiapp import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp /** * What is about to be sent, directly above the box it will be sent from. * * The count on the "+" button was the whole of what said an image was attached, so the only way to * find out *which* image was to send it. A control belongs with the thing it acts on. * * Scrolls sideways rather than wrapping or shrinking: the row keeps one thumbnail size whatever is * in it, so four attachments look like four of the same thing rather than four smaller ones. */ @Composable fun PendingAttachments( settings: ServerSettings, sessionId: String, refs: List, onRemove: (String) -> Unit, modifier: Modifier = Modifier, ) { if (refs.isEmpty()) return Row( modifier = modifier.horizontalScroll(rememberScrollState()).padding(bottom = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { refs.forEach { ref -> if (isImageRef(ref)) PendingThumbnail(settings, sessionId, ref) { onRemove(ref) } else PendingFile(ref) { onRemove(ref) } } } } /** * One attachment, square, tap to take it back off. * * Removal is here because there is nowhere else it could be: an image picked by mistake could * otherwise only be dealt with by sending it. The whole thumbnail is the target rather than a * corner cross -- a cross small enough to sit on a 64dp square is smaller than a fingertip. */ @Composable private fun PendingThumbnail( settings: ServerSettings, sessionId: String, ref: String, onRemove: () -> Unit, ) { val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref) val shape = RoundedCornerShape(8.dp) Box( Modifier.size(THUMBNAIL) .clip(shape) // An outline as well as a fill. Most of what gets attached here is a screenshot of a // dark app, and cropped to a square its middle is often near-black -- against this // background the tile then had no edge at all. .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape) // Behind the picture as well as under a missing one, so the tile is a tile before // anything has arrived to fill it. .background(MaterialTheme.colorScheme.surfaceVariant) .clickable(onClick = onRemove) .semantics { contentDescription = "Attached image, tap to remove" }, contentAlignment = Alignment.Center, ) { when (val image = bitmap) { // The two are told apart for the same reason the transcript's images are: one of them // is worth waiting for and the other never resolves. null -> if (failed) { Text( "!", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { // A spinner, as the transcript's images have: one appearance for "a picture is // on its way", learned once. An ellipsis had to be read as a spinner not // moving. CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) } else -> Image( bitmap = image, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.size(THUMBNAIL), ) } // The whole square removes it, and this only says so. A cross small enough to sit in the // corner of a 64dp thumbnail is smaller than a fingertip. // // The disc is sized here and the mark centred inside it, rather than the glyph being // aligned directly: a glyph's box is wider than the cross it draws, so aligning the box to // the corner hung the visible mark over the edge. Box( Modifier.align(Alignment.TopEnd) .padding(2.dp) .size(20.dp) .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f), CircleShape), contentAlignment = Alignment.Center, ) { Glyph(CLOSE_GLYPH, colour = MaterialTheme.colorScheme.onSurface, size = 12.sp) } } } /** * One attached file: its name, tap to take it back off. The same height and removal as a thumbnail, * so a row of mixed attachments is one row; the cross sits after the name because a tile this wide * has no corner the eye goes to. */ @Composable private fun PendingFile(ref: String, onRemove: () -> Unit) { val name = attachmentName(ref) val shape = RoundedCornerShape(8.dp) Row( Modifier.height(THUMBNAIL) .clip(shape) .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape) .background(MaterialTheme.colorScheme.surfaceVariant) .clickable(onClick = onRemove) .semantics { contentDescription = "Attached file $name, tap to remove" } .padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { FileName(name, Modifier.widthIn(max = FILE_TILE_WIDTH)) Spacer(Modifier.width(6.dp)) Glyph(CLOSE_GLYPH, colour = MaterialTheme.colorScheme.onSurface, size = 12.sp) } } private val THUMBNAIL = 64.dp /** Wide enough for most names whole; longer ones lose their middle, keeping both ends. */ private val FILE_TILE_WIDTH = 200.dp