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>
266 lines
11 KiB
Kotlin
266 lines
11 KiB
Kotlin
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.foundation.layout.size
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
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.draw.clip
|
|
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
|
|
|
|
/**
|
|
* One image from the session's files route: the bitmap once it arrives, and whether it never will.
|
|
*
|
|
* [failed] exists because the two empty states differ in kind -- still coming and never coming --
|
|
* and a reader can act on the second; each caller supplies its own words for them.
|
|
*/
|
|
data class SessionBitmap(val bitmap: ImageBitmap?, val failed: Boolean)
|
|
|
|
/**
|
|
* Fetches (authenticated, pinned) and decodes one transcript image, remembered per ref so scrolling
|
|
* does not refetch. Shared by the transcript's images and the composer's pending attachments,
|
|
* because the fetch, the decode and the two-state answer are one block of logic that had been
|
|
* written twice.
|
|
*/
|
|
@Composable
|
|
fun rememberSessionBitmap(settings: ServerSettings, sessionId: String, ref: String): SessionBitmap {
|
|
var state by remember(ref) { mutableStateOf(SessionBitmap(null, failed = false)) }
|
|
LaunchedEffect(ref) {
|
|
state =
|
|
try {
|
|
val bytes =
|
|
withContext(Dispatchers.IO) { fetchSessionFile(settings, sessionId, ref) }
|
|
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
|
|
SessionBitmap(decoded, failed = decoded == null)
|
|
} catch (_: ApiException) {
|
|
SessionBitmap(null, failed = true)
|
|
}
|
|
}
|
|
return state
|
|
}
|
|
|
|
/**
|
|
* 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 -- and in
|
|
* a bottom-anchored list, images loading above the viewport moved the text under the reader's eyes.
|
|
*
|
|
* Four lines of body text, so a screenshot reads as an attachment beside the conversation rather
|
|
* than as a page of its own. The full-size view itself is not here: [onOpen] hands the ref to the
|
|
* screen, which draws [SessionImageViewer] outside the list.
|
|
*/
|
|
@Composable
|
|
fun SessionImage(
|
|
settings: ServerSettings,
|
|
sessionId: String,
|
|
ref: String,
|
|
onOpen: (String) -> Unit,
|
|
) {
|
|
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
|
|
val height = thumbnailHeight()
|
|
val heightPx = with(LocalDensity.current) { height.roundToPx() }
|
|
Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
|
|
when (val image = bitmap) {
|
|
// 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.
|
|
null ->
|
|
if (failed) {
|
|
Text(
|
|
"[image $ref unavailable]",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
} else {
|
|
LoadingImage(height)
|
|
}
|
|
else ->
|
|
Image(
|
|
bitmap = image,
|
|
contentDescription = "Attached image, tap to view full screen",
|
|
contentScale = ContentScale.Fit,
|
|
filterQuality = enlargingFilter(image.height, heightPx),
|
|
modifier = Modifier.fillMaxSize().clickable { onOpen(ref) },
|
|
alignment = Alignment.CenterStart,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The image somebody opened, drawn by the screen rather than by the row it was tapped in.
|
|
*
|
|
* The row is the wrong place to hold this, and it took a real fault to see why: an image from a
|
|
* `Read` on its own is a row of one call, and the moment the next call arrives the two become a
|
|
* group -- a different composable in a different part of the tree, so everything the old subtree
|
|
* remembered goes, the dialog included. Somebody looking at a screenshot was thrown back to the
|
|
* transcript because the session made another tool call.
|
|
*
|
|
* Held by the screen, none of that reaches it: what is open is a property of the screen.
|
|
*
|
|
* The cost is one fetch, since the thumbnail's decoded bitmap belongs to a row this does not go
|
|
* through. Paid deliberately: it is one request for a picture somebody asked to see.
|
|
*/
|
|
@Composable
|
|
fun SessionImageViewer(
|
|
settings: ServerSettings,
|
|
sessionId: String,
|
|
ref: String,
|
|
onClose: () -> Unit,
|
|
) {
|
|
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
|
|
Dialog(
|
|
onDismissRequest = onClose,
|
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
|
) {
|
|
Box(
|
|
Modifier.fillMaxSize().background(Color.Black).clickable(onClick = onClose),
|
|
contentAlignment = Alignment.Center,
|
|
) {
|
|
when (val image = bitmap) {
|
|
// Two states, not one, exactly as the thumbnail has them. Stated in white because
|
|
// this box paints its own black behind them and a theme colour would be picked
|
|
// against a surface that is not there.
|
|
null ->
|
|
if (failed) {
|
|
Text(
|
|
"Image $ref is unavailable",
|
|
color = Color.White,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
)
|
|
} else {
|
|
// The whole dialog is the area this picture is about to fill, so the
|
|
// spinner sits in the middle of it. White for the same reason the words
|
|
// beside it are.
|
|
CircularProgressIndicator(color = Color.White)
|
|
}
|
|
else -> ZoomableImage(image)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The room a picture is about to take, with a spinner in the middle of it.
|
|
*
|
|
* A square of the row's own height rather than the full width of the transcript: the height is what
|
|
* [SessionImage] reserves and the width is not known until the bytes arrive, so a full-width
|
|
* placeholder would promise a picture wider than most turn out to be.
|
|
*
|
|
* Tinted, so the reader can see that something is being kept for a picture -- which is also what
|
|
* distinguishes it from the failure beside it, words on the ordinary surface.
|
|
*/
|
|
@Composable
|
|
private fun LoadingImage(height: Dp) {
|
|
Box(
|
|
Modifier.size(height)
|
|
.clip(MaterialTheme.shapes.small)
|
|
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
|
|
contentAlignment = Alignment.Center,
|
|
) {
|
|
CircularProgressIndicator(Modifier.size(LOADING_SPINNER), strokeWidth = 2.dp)
|
|
}
|
|
}
|
|
|
|
/** Small enough to sit inside the thumbnail's square without filling it. */
|
|
private val LOADING_SPINNER = 24.dp
|
|
|
|
/**
|
|
* 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 when a hardcoded height is
|
|
* 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.
|
|
*/
|
|
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.
|
|
*
|
|
* Inside a dialog rather than a screen -- see [SessionImageViewer] -- so the platform's back
|
|
* gesture returns to the transcript instead of leaving the app. It opens fitted, the whole image
|
|
* visible.
|
|
*/
|
|
@Composable
|
|
private fun ZoomableImage(image: ImageBitmap) {
|
|
var scale by remember { mutableFloatStateOf(1f) }
|
|
var offsetX by remember { mutableFloatStateOf(0f) }
|
|
var offsetY by remember { mutableFloatStateOf(0f) }
|
|
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()
|
|
.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
|
|
}
|
|
}
|
|
}
|
|
.graphicsLayer {
|
|
scaleX = scale
|
|
scaleY = scale
|
|
translationX = offsetX
|
|
translationY = offsetY
|
|
},
|
|
)
|
|
}
|