Add AuthenticatedImage components for secure media access

iOS:
- Add AuthenticatedImage.swift component with auth header support
- Update PhotoViewerSheet, ImageViewerSheet, DocumentDetailView, DocumentFormView
- Use TokenStorage for auth and ApiClient.getMediaBaseUrl() for URLs
- In-memory image caching for performance

Android/KMM:
- Add AuthenticatedImage.kt Compose component using Coil3 httpHeaders
- Add mediaUrl field to TaskCompletionImage and DocumentImage models
- Update PhotoViewerDialog, DocumentDetailScreen, DocumentFormScreen
- Use authenticated media URLs instead of public image URLs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-02 19:47:48 -06:00
parent b7dc8f3a29
commit 0ddd542080
11 changed files with 359 additions and 264 deletions

View File

@@ -0,0 +1,125 @@
package com.example.casera.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import coil3.compose.AsyncImagePainter
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import coil3.network.NetworkHeaders
import coil3.network.httpHeaders
import com.example.casera.network.ApiClient
import com.example.casera.storage.TokenStorage
/**
* A Compose component that loads images from authenticated API endpoints.
* Use this for media that requires auth token (documents, completions, etc.)
*
* Example usage:
* ```kotlin
* AuthenticatedImage(
* mediaUrl = document.mediaUrl,
* contentDescription = "Document image"
* )
* ```
*/
@Composable
fun AuthenticatedImage(
mediaUrl: String?,
modifier: Modifier = Modifier,
contentDescription: String? = null,
contentScale: ContentScale = ContentScale.Fit,
placeholder: @Composable () -> Unit = { DefaultPlaceholder() },
errorContent: @Composable () -> Unit = { DefaultErrorContent() }
) {
if (mediaUrl.isNullOrEmpty()) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
errorContent()
}
return
}
val baseUrl = ApiClient.getMediaBaseUrl()
val token = TokenStorage.getToken()
val fullUrl = baseUrl + mediaUrl
val context = LocalPlatformContext.current
val imageRequest = remember(fullUrl, token) {
ImageRequest.Builder(context)
.data(fullUrl)
.apply {
if (token != null) {
httpHeaders(
NetworkHeaders.Builder()
.set("Authorization", "Token $token")
.build()
)
}
}
.build()
}
SubcomposeAsyncImage(
model = imageRequest,
contentDescription = contentDescription,
modifier = modifier,
contentScale = contentScale
) {
when (painter.state) {
is AsyncImagePainter.State.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
placeholder()
}
}
is AsyncImagePainter.State.Error -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
errorContent()
}
}
else -> SubcomposeAsyncImageContent()
}
}
}
@Composable
private fun DefaultPlaceholder() {
CircularProgressIndicator(
modifier = Modifier.size(32.dp),
color = MaterialTheme.colorScheme.primary
)
}
@Composable
private fun DefaultErrorContent() {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
Icons.Default.BrokenImage,
contentDescription = "Failed to load",
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
"Failed to load",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View File

@@ -25,6 +25,7 @@ import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import com.example.casera.models.TaskCompletionImage
import com.example.casera.network.ApiClient
import com.example.casera.ui.components.AuthenticatedImage
@Composable
fun PhotoViewerDialog(
@@ -32,7 +33,6 @@ fun PhotoViewerDialog(
onDismiss: () -> Unit
) {
var selectedImage by remember { mutableStateOf<TaskCompletionImage?>(null) }
val baseUrl = ApiClient.getMediaBaseUrl()
Dialog(
onDismissRequest = {
@@ -97,51 +97,14 @@ fun PhotoViewerDialog(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
SubcomposeAsyncImage(
model = baseUrl + selectedImage!!.image,
AuthenticatedImage(
mediaUrl = selectedImage!!.mediaUrl,
contentDescription = selectedImage!!.caption ?: "Task completion photo",
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentScale = ContentScale.Fit
) {
val state = painter.state
when (state) {
is AsyncImagePainter.State.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is AsyncImagePainter.State.Error -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
Icons.Default.BrokenImage,
contentDescription = "Error loading image",
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Failed to load image",
color = MaterialTheme.colorScheme.error
)
Text(
selectedImage!!.image,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
else -> SubcomposeAsyncImageContent()
}
}
)
selectedImage!!.caption?.let { caption ->
Spacer(modifier = Modifier.height(16.dp))
@@ -176,44 +139,14 @@ fun PhotoViewerDialog(
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column {
SubcomposeAsyncImage(
model = baseUrl + image.image,
AuthenticatedImage(
mediaUrl = image.mediaUrl,
contentDescription = image.caption ?: "Task completion photo",
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
contentScale = ContentScale.Crop
) {
val state = painter.state
when (state) {
is AsyncImagePainter.State.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp)
)
}
}
is AsyncImagePainter.State.Error -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.errorContainer),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.BrokenImage,
contentDescription = "Error",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
else -> SubcomposeAsyncImageContent()
}
}
)
image.caption?.let { caption ->
Text(