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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape 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, and what * these are attached to is the message being typed -- which is why they sit here rather than * anywhere else on the screen. * * 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 -> PendingThumbnail(settings, sessionId, 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 -- and * the label is what says so, since nothing about the picture does. */ @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, and the only thing saying an image was // attached was the cross drawn on top of nothing. .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 -> Text( if (failed) "!" else "…", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) 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, so making it the target // would be a control drawn at a size nobody can hit. // // 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 and put its backing somewhere the // eye reads as a second, misplaced square. 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) } } } private val THUMBNAIL = 64.dp