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:
@@ -194,6 +194,7 @@ data class TaskPatchRequest(
|
||||
data class TaskCompletionImage(
|
||||
val id: Int,
|
||||
@SerialName("image_url") val imageUrl: String,
|
||||
@SerialName("media_url") val mediaUrl: String? = null, // Authenticated endpoint: /api/media/completion-image/{id}
|
||||
val caption: String? = null,
|
||||
@SerialName("uploaded_at") val uploadedAt: String? = null
|
||||
) {
|
||||
|
||||
@@ -14,6 +14,7 @@ data class WarrantyStatus(
|
||||
data class DocumentImage(
|
||||
val id: Int? = null,
|
||||
@SerialName("image_url") val imageUrl: String,
|
||||
@SerialName("media_url") val mediaUrl: String? = null, // Authenticated endpoint: /api/media/document-image/{id}
|
||||
val caption: String? = null,
|
||||
@SerialName("uploaded_at") val uploadedAt: String? = null
|
||||
)
|
||||
@@ -26,6 +27,7 @@ data class Document(
|
||||
val category: String? = null,
|
||||
val description: String? = null,
|
||||
@SerialName("file_url") val fileUrl: String? = null, // URL to the file
|
||||
@SerialName("media_url") val mediaUrl: String? = null, // Authenticated endpoint: /api/media/document/{id}
|
||||
@SerialName("file_size") val fileSize: Int? = null,
|
||||
@SerialName("file_type") val fileType: String? = null,
|
||||
// Warranty-specific fields (only used when documentType == "warranty")
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -34,6 +34,7 @@ import androidx.compose.foundation.lazy.grid.items
|
||||
import coil3.compose.SubcomposeAsyncImage
|
||||
import coil3.compose.SubcomposeAsyncImageContent
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import com.example.casera.ui.components.AuthenticatedImage
|
||||
import com.example.casera.util.DateUtils
|
||||
import casera.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
@@ -335,8 +336,8 @@ fun DocumentDetailScreen(
|
||||
showPhotoViewer = true
|
||||
}
|
||||
) {
|
||||
AsyncImage(
|
||||
model = image.imageUrl,
|
||||
AuthenticatedImage(
|
||||
mediaUrl = image.mediaUrl,
|
||||
contentDescription = image.caption,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop
|
||||
@@ -542,46 +543,14 @@ fun DocumentImageViewer(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
SubcomposeAsyncImage(
|
||||
model = images[selectedIndex].imageUrl,
|
||||
AuthenticatedImage(
|
||||
mediaUrl = images[selectedIndex].mediaUrl,
|
||||
contentDescription = images[selectedIndex].caption,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentScale = androidx.compose.ui.layout.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
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> SubcomposeAsyncImageContent()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
images[selectedIndex].caption?.let { caption ->
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
@@ -646,44 +615,14 @@ fun DocumentImageViewer(
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column {
|
||||
SubcomposeAsyncImage(
|
||||
model = image.imageUrl,
|
||||
AuthenticatedImage(
|
||||
mediaUrl = image.mediaUrl,
|
||||
contentDescription = image.caption,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f),
|
||||
contentScale = androidx.compose.ui.layout.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(
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil3.compose.AsyncImage
|
||||
import com.example.casera.ui.components.AuthenticatedImage
|
||||
import com.example.casera.viewmodel.DocumentViewModel
|
||||
import com.example.casera.viewmodel.ResidenceViewModel
|
||||
import com.example.casera.models.*
|
||||
@@ -50,7 +51,7 @@ fun DocumentFormScreen(
|
||||
var tags by remember { mutableStateOf("") }
|
||||
var isActive by remember { mutableStateOf(true) }
|
||||
var selectedImages by remember { mutableStateOf<List<ImageData>>(emptyList()) }
|
||||
var existingImageUrls by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
var existingImages by remember { mutableStateOf<List<DocumentImage>>(emptyList()) }
|
||||
|
||||
// Warranty-specific fields
|
||||
var itemName by remember { mutableStateOf("") }
|
||||
@@ -127,7 +128,7 @@ fun DocumentFormScreen(
|
||||
tags = document.tags ?: ""
|
||||
notes = document.notes ?: ""
|
||||
isActive = document.isActive
|
||||
existingImageUrls = document.images.map { it.imageUrl }
|
||||
existingImages = document.images
|
||||
|
||||
// Warranty fields
|
||||
itemName = document.itemName ?: ""
|
||||
@@ -472,7 +473,7 @@ fun DocumentFormScreen(
|
||||
}
|
||||
|
||||
// Existing images (edit mode only)
|
||||
if (isEditMode && existingImageUrls.isNotEmpty()) {
|
||||
if (isEditMode && existingImages.isNotEmpty()) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
@@ -484,14 +485,14 @@ fun DocumentFormScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Existing Photos (${existingImageUrls.size})",
|
||||
"Existing Photos (${existingImages.size})",
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
existingImageUrls.forEach { url ->
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = null,
|
||||
existingImages.forEach { image ->
|
||||
AuthenticatedImage(
|
||||
mediaUrl = image.mediaUrl,
|
||||
contentDescription = image.caption,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
|
||||
Reference in New Issue
Block a user