471 lines
19 KiB
Kotlin
471 lines
19 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.interaction.MutableInteractionSource
|
|
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.navigationBarsPadding
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.size
|
|
import androidx.compose.material3.Button
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.DisposableEffect
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.SideEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableFloatStateOf
|
|
import androidx.compose.runtime.mutableIntStateOf
|
|
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.geometry.Offset
|
|
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.layout.onSizeChanged
|
|
import androidx.compose.ui.platform.LocalDensity
|
|
import androidx.compose.ui.platform.LocalView
|
|
import androidx.compose.ui.unit.Dp
|
|
import androidx.compose.ui.unit.IntSize
|
|
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 androidx.compose.ui.window.DialogWindowProvider
|
|
import androidx.core.view.ViewCompat
|
|
import androidx.core.view.WindowCompat
|
|
import androidx.core.view.WindowInsetsCompat
|
|
import androidx.core.view.WindowInsetsControllerCompat
|
|
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)
|
|
val view = LocalView.current
|
|
var hiddenBars by remember(ref) { mutableStateOf(ViewerBars()) }
|
|
var barInsets by remember(ref) { mutableStateOf(ViewerBarInsets()) }
|
|
Dialog(
|
|
onDismissRequest = onClose,
|
|
properties =
|
|
DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
|
|
) {
|
|
ViewerSystemBars(hiddenBars)
|
|
Box(
|
|
Modifier.fillMaxSize()
|
|
.background(Color.Black)
|
|
.clickable(
|
|
interactionSource = remember { MutableInteractionSource() },
|
|
indication = null,
|
|
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 -> {
|
|
var viewport by remember { mutableStateOf(IntSize.Zero) }
|
|
var nativeSizeRequest by remember { mutableIntStateOf(0) }
|
|
ZoomableImage(
|
|
image,
|
|
nativeSizeRequest = nativeSizeRequest,
|
|
onViewportChanged = {
|
|
viewport = it
|
|
ViewCompat.getRootWindowInsets(view)?.let { insets ->
|
|
barInsets =
|
|
ViewerBarInsets(
|
|
status =
|
|
insets
|
|
.getInsetsIgnoringVisibility(
|
|
WindowInsetsCompat.Type.statusBars()
|
|
)
|
|
.top,
|
|
navigation =
|
|
insets
|
|
.getInsetsIgnoringVisibility(
|
|
WindowInsetsCompat.Type.navigationBars()
|
|
)
|
|
.bottom,
|
|
)
|
|
}
|
|
},
|
|
onBarsChanged = { hiddenBars = it },
|
|
barInsets = barInsets,
|
|
viewport = viewport,
|
|
)
|
|
Button(
|
|
onClick = { nativeSizeRequest++ },
|
|
modifier =
|
|
Modifier.align(Alignment.BottomEnd)
|
|
.navigationBarsPadding()
|
|
.padding(16.dp),
|
|
) {
|
|
Text("100%")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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, with the whole
|
|
* image visible without enlarging a smaller one; the 100% control changes to one bitmap pixel per
|
|
* screen pixel and recenters it.
|
|
*/
|
|
@Composable
|
|
private fun ZoomableImage(
|
|
image: ImageBitmap,
|
|
nativeSizeRequest: Int,
|
|
onViewportChanged: (IntSize) -> Unit,
|
|
onBarsChanged: (ViewerBars) -> Unit,
|
|
barInsets: ViewerBarInsets,
|
|
viewport: IntSize,
|
|
) {
|
|
var scale by remember { mutableFloatStateOf(1f) }
|
|
var offsetX by remember { mutableFloatStateOf(0f) }
|
|
var offsetY by remember { mutableFloatStateOf(0f) }
|
|
val nativeScale = nativeScale(image.width, image.height, viewport.width, viewport.height)
|
|
LaunchedEffect(nativeSizeRequest, nativeScale) {
|
|
if (nativeSizeRequest > 0) {
|
|
scale = nativeScale
|
|
offsetX = 0f
|
|
offsetY = 0f
|
|
}
|
|
}
|
|
val bars =
|
|
viewerBars(
|
|
image.width,
|
|
image.height,
|
|
viewport.width,
|
|
viewport.height,
|
|
scale,
|
|
Offset(offsetX, offsetY),
|
|
barInsets,
|
|
)
|
|
SideEffect { onBarsChanged(bars) }
|
|
Image(
|
|
bitmap = image,
|
|
contentDescription = "Attached image",
|
|
contentScale = ContentScale.Inside,
|
|
// Zoomed in, the reader is looking at pixels on purpose.
|
|
filterQuality = FilterQuality.None,
|
|
modifier =
|
|
Modifier.fillMaxSize()
|
|
.onSizeChanged(onViewportChanged)
|
|
.pointerInput(nativeScale) {
|
|
detectTransformGestures { centroid, pan, zoom, _ ->
|
|
val oldScale = scale
|
|
val maximumScale = maxOf(8f, nativeScale)
|
|
val newScale = (oldScale * zoom).coerceIn(1f, maximumScale)
|
|
if (newScale > 1f) {
|
|
val offset =
|
|
zoomOffset(
|
|
Offset(offsetX, offsetY),
|
|
centroid,
|
|
pan,
|
|
oldScale,
|
|
newScale,
|
|
Offset(size.width / 2f, size.height / 2f),
|
|
)
|
|
offsetX = offset.x
|
|
offsetY = offset.y
|
|
} else {
|
|
offsetX = 0f
|
|
offsetY = 0f
|
|
}
|
|
scale = newScale
|
|
}
|
|
}
|
|
.graphicsLayer {
|
|
scaleX = scale
|
|
scaleY = scale
|
|
translationX = offsetX
|
|
translationY = offsetY
|
|
},
|
|
)
|
|
}
|
|
|
|
/** Lets the picture use the whole display, hiding only the system bars it actually reaches. */
|
|
@Composable
|
|
private fun ViewerSystemBars(hidden: ViewerBars) {
|
|
val view = LocalView.current
|
|
val window = (view.parent as? DialogWindowProvider)?.window
|
|
val controller = window?.let { WindowCompat.getInsetsController(it, view) }
|
|
SideEffect {
|
|
controller?.systemBarsBehavior =
|
|
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
|
if (hidden.status) {
|
|
controller?.hide(WindowInsetsCompat.Type.statusBars())
|
|
} else {
|
|
controller?.show(WindowInsetsCompat.Type.statusBars())
|
|
}
|
|
if (hidden.navigation) {
|
|
controller?.hide(WindowInsetsCompat.Type.navigationBars())
|
|
} else {
|
|
controller?.show(WindowInsetsCompat.Type.navigationBars())
|
|
}
|
|
}
|
|
DisposableEffect(view) {
|
|
onDispose {
|
|
controller?.show(
|
|
WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.navigationBars()
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
internal data class ViewerBars(val status: Boolean = false, val navigation: Boolean = false)
|
|
|
|
internal data class ViewerBarInsets(val status: Int = 0, val navigation: Int = 0)
|
|
|
|
/** Which full-screen system-bar regions the fitted, zoomed and panned image intersects. */
|
|
internal fun viewerBars(
|
|
imageWidth: Int,
|
|
imageHeight: Int,
|
|
viewportWidth: Int,
|
|
viewportHeight: Int,
|
|
scale: Float,
|
|
offset: Offset,
|
|
insets: ViewerBarInsets,
|
|
): ViewerBars {
|
|
if (imageWidth <= 0 || imageHeight <= 0 || viewportWidth <= 0 || viewportHeight <= 0) {
|
|
return ViewerBars()
|
|
}
|
|
val fittedScale = insideScale(imageWidth, imageHeight, viewportWidth, viewportHeight)
|
|
val width = imageWidth * fittedScale * scale
|
|
val height = imageHeight * fittedScale * scale
|
|
val left = viewportWidth / 2f + offset.x - width / 2f
|
|
val right = left + width
|
|
val top = viewportHeight / 2f + offset.y - height / 2f
|
|
val bottom = top + height
|
|
val crossesScreen = right > 0f && left < viewportWidth
|
|
return ViewerBars(
|
|
status = crossesScreen && insets.status > 0 && bottom > 0f && top < insets.status,
|
|
navigation =
|
|
crossesScreen &&
|
|
insets.navigation > 0 &&
|
|
bottom > viewportHeight - insets.navigation &&
|
|
top < viewportHeight,
|
|
)
|
|
}
|
|
|
|
/** Scale relative to [ContentScale.Inside] at which bitmap and screen pixels are one-to-one. */
|
|
internal fun nativeScale(
|
|
imageWidth: Int,
|
|
imageHeight: Int,
|
|
viewportWidth: Int,
|
|
viewportHeight: Int,
|
|
): Float {
|
|
if (imageWidth <= 0 || imageHeight <= 0 || viewportWidth <= 0 || viewportHeight <= 0) return 1f
|
|
return 1f / insideScale(imageWidth, imageHeight, viewportWidth, viewportHeight)
|
|
}
|
|
|
|
/** The downscale-only factor used by [ContentScale.Inside]. */
|
|
private fun insideScale(
|
|
imageWidth: Int,
|
|
imageHeight: Int,
|
|
viewportWidth: Int,
|
|
viewportHeight: Int,
|
|
): Float =
|
|
minOf(
|
|
1f,
|
|
viewportWidth.toFloat() / imageWidth,
|
|
viewportHeight.toFloat() / imageHeight,
|
|
)
|
|
|
|
/** Keeps the image point beneath [centroid] beneath the fingers as its scale changes. */
|
|
internal fun zoomOffset(
|
|
offset: Offset,
|
|
centroid: Offset,
|
|
pan: Offset,
|
|
oldScale: Float,
|
|
newScale: Float,
|
|
viewportCenter: Offset,
|
|
): Offset {
|
|
val scaleChange = newScale / oldScale
|
|
return offset * scaleChange + (centroid - viewportCenter) * (1f - scaleChange) + pan
|
|
}
|