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

@@ -194,6 +194,7 @@ data class TaskPatchRequest(
data class TaskCompletionImage( data class TaskCompletionImage(
val id: Int, val id: Int,
@SerialName("image_url") val imageUrl: String, @SerialName("image_url") val imageUrl: String,
@SerialName("media_url") val mediaUrl: String? = null, // Authenticated endpoint: /api/media/completion-image/{id}
val caption: String? = null, val caption: String? = null,
@SerialName("uploaded_at") val uploadedAt: String? = null @SerialName("uploaded_at") val uploadedAt: String? = null
) { ) {

View File

@@ -14,6 +14,7 @@ data class WarrantyStatus(
data class DocumentImage( data class DocumentImage(
val id: Int? = null, val id: Int? = null,
@SerialName("image_url") val imageUrl: String, @SerialName("image_url") val imageUrl: String,
@SerialName("media_url") val mediaUrl: String? = null, // Authenticated endpoint: /api/media/document-image/{id}
val caption: String? = null, val caption: String? = null,
@SerialName("uploaded_at") val uploadedAt: String? = null @SerialName("uploaded_at") val uploadedAt: String? = null
) )
@@ -26,6 +27,7 @@ data class Document(
val category: String? = null, val category: String? = null,
val description: String? = null, val description: String? = null,
@SerialName("file_url") val fileUrl: String? = null, // URL to the file @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_size") val fileSize: Int? = null,
@SerialName("file_type") val fileType: String? = null, @SerialName("file_type") val fileType: String? = null,
// Warranty-specific fields (only used when documentType == "warranty") // Warranty-specific fields (only used when documentType == "warranty")

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 coil3.compose.SubcomposeAsyncImageContent
import com.example.casera.models.TaskCompletionImage import com.example.casera.models.TaskCompletionImage
import com.example.casera.network.ApiClient import com.example.casera.network.ApiClient
import com.example.casera.ui.components.AuthenticatedImage
@Composable @Composable
fun PhotoViewerDialog( fun PhotoViewerDialog(
@@ -32,7 +33,6 @@ fun PhotoViewerDialog(
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
var selectedImage by remember { mutableStateOf<TaskCompletionImage?>(null) } var selectedImage by remember { mutableStateOf<TaskCompletionImage?>(null) }
val baseUrl = ApiClient.getMediaBaseUrl()
Dialog( Dialog(
onDismissRequest = { onDismissRequest = {
@@ -97,51 +97,14 @@ fun PhotoViewerDialog(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
SubcomposeAsyncImage( AuthenticatedImage(
model = baseUrl + selectedImage!!.image, mediaUrl = selectedImage!!.mediaUrl,
contentDescription = selectedImage!!.caption ?: "Task completion photo", contentDescription = selectedImage!!.caption ?: "Task completion photo",
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .weight(1f),
contentScale = ContentScale.Fit 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 -> selectedImage!!.caption?.let { caption ->
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -176,44 +139,14 @@ fun PhotoViewerDialog(
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) { ) {
Column { Column {
SubcomposeAsyncImage( AuthenticatedImage(
model = baseUrl + image.image, mediaUrl = image.mediaUrl,
contentDescription = image.caption ?: "Task completion photo", contentDescription = image.caption ?: "Task completion photo",
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(1f), .aspectRatio(1f),
contentScale = ContentScale.Crop 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 -> image.caption?.let { caption ->
Text( Text(

View File

@@ -34,6 +34,7 @@ import androidx.compose.foundation.lazy.grid.items
import coil3.compose.SubcomposeAsyncImage import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent import coil3.compose.SubcomposeAsyncImageContent
import coil3.compose.AsyncImagePainter import coil3.compose.AsyncImagePainter
import com.example.casera.ui.components.AuthenticatedImage
import com.example.casera.util.DateUtils import com.example.casera.util.DateUtils
import casera.composeapp.generated.resources.* import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -335,8 +336,8 @@ fun DocumentDetailScreen(
showPhotoViewer = true showPhotoViewer = true
} }
) { ) {
AsyncImage( AuthenticatedImage(
model = image.imageUrl, mediaUrl = image.mediaUrl,
contentDescription = image.caption, contentDescription = image.caption,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = androidx.compose.ui.layout.ContentScale.Crop contentScale = androidx.compose.ui.layout.ContentScale.Crop
@@ -542,46 +543,14 @@ fun DocumentImageViewer(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
SubcomposeAsyncImage( AuthenticatedImage(
model = images[selectedIndex].imageUrl, mediaUrl = images[selectedIndex].mediaUrl,
contentDescription = images[selectedIndex].caption, contentDescription = images[selectedIndex].caption,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .weight(1f),
contentScale = androidx.compose.ui.layout.ContentScale.Fit 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 -> images[selectedIndex].caption?.let { caption ->
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -646,44 +615,14 @@ fun DocumentImageViewer(
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) { ) {
Column { Column {
SubcomposeAsyncImage( AuthenticatedImage(
model = image.imageUrl, mediaUrl = image.mediaUrl,
contentDescription = image.caption, contentDescription = image.caption,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(1f), .aspectRatio(1f),
contentScale = androidx.compose.ui.layout.ContentScale.Crop 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 -> image.caption?.let { caption ->
Text( Text(

View File

@@ -18,6 +18,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.example.casera.ui.components.AuthenticatedImage
import com.example.casera.viewmodel.DocumentViewModel import com.example.casera.viewmodel.DocumentViewModel
import com.example.casera.viewmodel.ResidenceViewModel import com.example.casera.viewmodel.ResidenceViewModel
import com.example.casera.models.* import com.example.casera.models.*
@@ -50,7 +51,7 @@ fun DocumentFormScreen(
var tags by remember { mutableStateOf("") } var tags by remember { mutableStateOf("") }
var isActive by remember { mutableStateOf(true) } var isActive by remember { mutableStateOf(true) }
var selectedImages by remember { mutableStateOf<List<ImageData>>(emptyList()) } 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 // Warranty-specific fields
var itemName by remember { mutableStateOf("") } var itemName by remember { mutableStateOf("") }
@@ -127,7 +128,7 @@ fun DocumentFormScreen(
tags = document.tags ?: "" tags = document.tags ?: ""
notes = document.notes ?: "" notes = document.notes ?: ""
isActive = document.isActive isActive = document.isActive
existingImageUrls = document.images.map { it.imageUrl } existingImages = document.images
// Warranty fields // Warranty fields
itemName = document.itemName ?: "" itemName = document.itemName ?: ""
@@ -472,7 +473,7 @@ fun DocumentFormScreen(
} }
// Existing images (edit mode only) // Existing images (edit mode only)
if (isEditMode && existingImageUrls.isNotEmpty()) { if (isEditMode && existingImages.isNotEmpty()) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
@@ -484,14 +485,14 @@ fun DocumentFormScreen(
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Text( Text(
"Existing Photos (${existingImageUrls.size})", "Existing Photos (${existingImages.size})",
style = MaterialTheme.typography.titleSmall style = MaterialTheme.typography.titleSmall
) )
existingImageUrls.forEach { url -> existingImages.forEach { image ->
AsyncImage( AuthenticatedImage(
model = url, mediaUrl = image.mediaUrl,
contentDescription = null, contentDescription = image.caption,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(200.dp) .height(200.dp)

View File

@@ -0,0 +1,190 @@
import SwiftUI
import ComposeApp
/// A SwiftUI view that loads images from authenticated API endpoints.
/// Use this for media that requires auth token (documents, completions, etc.)
///
/// Example usage:
/// ```swift
/// AuthenticatedImage(mediaURL: document.mediaUrl)
/// AuthenticatedImage(mediaURL: completion.images[0].mediaUrl, contentMode: .fill)
/// ```
struct AuthenticatedImage: View {
let mediaURL: String?
var contentMode: ContentMode = .fit
var placeholder: AnyView = AnyView(
ProgressView()
.tint(Color.appPrimary)
)
var errorView: AnyView = AnyView(
VStack(spacing: 8) {
Image(systemName: "photo")
.font(.system(size: 40))
.foregroundColor(Color.appTextSecondary)
Text("Failed to load")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
)
@StateObject private var loader = AuthenticatedImageLoader()
var body: some View {
Group {
switch loader.state {
case .idle, .loading:
placeholder
case .success(let image):
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: contentMode)
case .failure:
errorView
}
}
.onAppear {
loader.load(mediaURL: mediaURL)
}
.onChange(of: mediaURL) { _, newURL in
loader.load(mediaURL: newURL)
}
}
}
// MARK: - Convenience initializers
extension AuthenticatedImage {
/// Initialize with just the media URL path
init(mediaURL: String?) {
self.mediaURL = mediaURL
}
/// Initialize with media URL and content mode
init(mediaURL: String?, contentMode: ContentMode) {
self.mediaURL = mediaURL
self.contentMode = contentMode
}
}
// MARK: - Image Loader
private enum ImageLoadState {
case idle
case loading
case success(UIImage)
case failure(Error)
}
@MainActor
private class AuthenticatedImageLoader: ObservableObject {
@Published private(set) var state: ImageLoadState = .idle
private var currentTask: Task<Void, Never>?
private var currentURL: String?
// In-memory cache for loaded images
private static var imageCache = NSCache<NSString, UIImage>()
func load(mediaURL: String?) {
guard let mediaURL = mediaURL, !mediaURL.isEmpty else {
state = .failure(NSError(domain: "AuthenticatedImage", code: -1, userInfo: [NSLocalizedDescriptionKey: "No URL provided"]))
return
}
// Skip if already loading the same URL
if currentURL == mediaURL {
return
}
currentURL = mediaURL
// Check cache first
if let cachedImage = Self.imageCache.object(forKey: mediaURL as NSString) {
state = .success(cachedImage)
return
}
// Cancel any existing task
currentTask?.cancel()
state = .loading
currentTask = Task {
await loadImage(mediaURL: mediaURL)
}
}
private func loadImage(mediaURL: String) async {
// Get auth token
guard let token = TokenStorage.shared.getToken() else {
state = .failure(NSError(domain: "AuthenticatedImage", code: 401, userInfo: [NSLocalizedDescriptionKey: "Not authenticated"]))
return
}
// Build full URL using the media base URL (without /api suffix)
// Media URLs from the API are like "/api/media/document/123"
let baseURL = ApiClient.shared.getMediaBaseUrl()
let fullURL = baseURL + mediaURL
guard let url = URL(string: fullURL) else {
state = .failure(NSError(domain: "AuthenticatedImage", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL: \(fullURL)"]))
return
}
// Create request with auth header
var request = URLRequest(url: url)
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
request.cachePolicy = .returnCacheDataElseLoad
do {
let (data, response) = try await URLSession.shared.data(for: request)
// Check for task cancellation
if Task.isCancelled { return }
// Validate response
if let httpResponse = response as? HTTPURLResponse {
guard (200...299).contains(httpResponse.statusCode) else {
state = .failure(NSError(domain: "AuthenticatedImage", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "HTTP \(httpResponse.statusCode)"]))
return
}
}
// Convert to UIImage
guard let image = UIImage(data: data) else {
state = .failure(NSError(domain: "AuthenticatedImage", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid image data"]))
return
}
// Cache the image
Self.imageCache.setObject(image, forKey: mediaURL as NSString)
state = .success(image)
} catch {
if !Task.isCancelled {
state = .failure(error)
}
}
}
/// Clear the image cache (call on logout)
static func clearCache() {
imageCache.removeAllObjects()
}
}
// MARK: - Preview
#Preview {
VStack(spacing: 20) {
// Loading state
AuthenticatedImage(mediaURL: nil)
.frame(width: 200, height: 200)
.background(Color.appBackgroundSecondary)
.cornerRadius(12)
Text("AuthenticatedImage Preview")
.font(.headline)
}
.padding()
}

View File

@@ -13,27 +13,7 @@ struct ImageViewerSheet: View {
ZStack { ZStack {
Color.black.ignoresSafeArea() Color.black.ignoresSafeArea()
AsyncImage(url: URL(string: image.imageUrl)) { phase in AuthenticatedImage(mediaURL: image.mediaUrl)
switch phase {
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fit)
case .failure:
VStack {
Image(systemName: "photo")
.font(.system(size: 48))
.foregroundColor(.gray)
Text("Failed to load image")
.foregroundColor(.gray)
}
case .empty:
ProgressView()
.tint(.white)
@unknown default:
EmptyView()
}
}
} }
.tag(index) .tag(index)
} }

View File

@@ -212,28 +212,14 @@ struct DocumentDetailView: View {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
ForEach(Array(document.images.prefix(6).enumerated()), id: \.element.id) { index, image in ForEach(Array(document.images.prefix(6).enumerated()), id: \.element.id) { index, image in
ZStack(alignment: .center) { ZStack(alignment: .center) {
AsyncImage(url: URL(string: image.imageUrl)) { phase in AuthenticatedImage(mediaURL: image.mediaUrl, contentMode: .fill)
switch phase { .frame(height: 100)
case .success(let image): .clipped()
image .cornerRadius(8)
.resizable() .onTapGesture {
.aspectRatio(contentMode: .fill) selectedImageIndex = index
case .failure: showImageViewer = true
Image(systemName: "photo")
.foregroundColor(.gray)
case .empty:
ProgressView()
@unknown default:
EmptyView()
} }
}
.frame(height: 100)
.clipped()
.cornerRadius(8)
.onTapGesture {
selectedImageIndex = index
showImageViewer = true
}
if index == 5 && document.images.count > 6 { if index == 5 && document.images.count > 6 {
Rectangle() Rectangle()

View File

@@ -166,22 +166,8 @@ struct DocumentFormView: View {
if isEditMode && !existingImages.isEmpty { if isEditMode && !existingImages.isEmpty {
Section(L10n.Documents.existingPhotos) { Section(L10n.Documents.existingPhotos) {
ForEach(existingImages, id: \.id) { image in ForEach(existingImages, id: \.id) { image in
AsyncImage(url: URL(string: image.imageUrl)) { phase in AuthenticatedImage(mediaURL: image.mediaUrl)
switch phase { .frame(height: 200)
case .empty:
ProgressView()
case .success(let image):
image
.resizable()
.scaledToFit()
case .failure:
Image(systemName: "photo")
.foregroundColor(.secondary)
@unknown default:
EmptyView()
}
}
.frame(height: 200)
} }
} }
.listRowBackground(Color.appBackgroundSecondary) .listRowBackground(Color.appBackgroundSecondary)

View File

@@ -6,12 +6,6 @@ struct PhotoViewerSheet: View {
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@State private var selectedImage: TaskCompletionImage? @State private var selectedImage: TaskCompletionImage?
private let baseUrl = ApiClient.shared.getMediaBaseUrl()
private func fullImageUrl(_ imagePath: String) -> URL? {
return URL(string: baseUrl + imagePath)
}
var body: some View { var body: some View {
NavigationView { NavigationView {
Group { Group {
@@ -19,28 +13,8 @@ struct PhotoViewerSheet: View {
// Single image view // Single image view
ScrollView { ScrollView {
VStack(spacing: 16) { VStack(spacing: 16) {
AsyncImage(url: fullImageUrl(selectedImage.image)) { phase in AuthenticatedImage(mediaURL: selectedImage.mediaUrl)
switch phase { .frame(minHeight: 300)
case .empty:
ProgressView()
.frame(height: 300)
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fit)
case .failure:
VStack {
Image(systemName: "photo")
.font(.system(size: 60))
.foregroundColor(Color.appTextSecondary)
Text("Failed to load image")
.foregroundColor(Color.appTextSecondary)
}
.frame(height: 300)
@unknown default:
EmptyView()
}
}
if let caption = selectedImage.caption { if let caption = selectedImage.caption {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
@@ -91,32 +65,10 @@ struct PhotoViewerSheet: View {
selectedImage = image selectedImage = image
}) { }) {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
AsyncImage(url: fullImageUrl(image.image)) { phase in AuthenticatedImage(mediaURL: image.mediaUrl, contentMode: .fill)
switch phase { .frame(height: 150)
case .empty: .clipped()
ProgressView() .cornerRadius(8)
.frame(height: 150)
case .success(let img):
img
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 150)
.clipped()
case .failure:
VStack {
Image(systemName: "photo")
.font(.system(size: 40))
.foregroundColor(Color.appTextSecondary)
Text("Failed to load")
.font(.caption2)
.foregroundColor(Color.appTextSecondary)
}
.frame(height: 150)
@unknown default:
EmptyView()
}
}
.cornerRadius(8)
if let caption = image.caption { if let caption = image.caption {
Text(caption) Text(caption)