The same pass the server had, on the Kotlin side: comments restating what the code says are gone, and the ones recording a measurement, a constraint or an incident are kept but cut to a few lines each. 6540 comment lines to 5674, and 920 lines off the app. Two doc comments had drifted onto the item above the one they describe -- `contextAfter`'s onto `sessionWorking` in Events.kt, and `UsageMonitor`'s equivalent on the server was fixed in the previous commit. Each is back on its own item, which is the only non-comment line this diff moves. The comments are reflowed to the column limit at their own indentation: several were written wide, and ktfmt re-wrapped them into lines holding a single orphan word. `/tmp` script, not kept -- ktfmt is idempotent over the result, which is the check. Left alone deliberately: this codebase's remaining comment density is high because the comments carry things the code cannot say -- what a null means, what a number was measured against, which bug a guard exists for. Of the 238 one-line doc comments in the app, five were pure restatement of the name and were removed; the rest each say something the signature does not. ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest pass; cargo test (127), clippy --all-targets and fmt still clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
163 lines
6.8 KiB
Kotlin
163 lines
6.8 KiB
Kotlin
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<String>,
|
|
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
|