From 0ddd54208040ba4f2c455c3bf78a0c876d8fd63e Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 2 Dec 2025 19:47:48 -0600 Subject: [PATCH] Add AuthenticatedImage components for secure media access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../com/example/casera/models/CustomTask.kt | 1 + .../com/example/casera/models/Document.kt | 2 + .../ui/components/AuthenticatedImage.kt | 125 ++++++++++++ .../ui/components/task/PhotoViewerDialog.kt | 81 +------- .../casera/ui/screens/DocumentDetailScreen.kt | 79 +------- .../casera/ui/screens/DocumentFormScreen.kt | 17 +- .../Components/AuthenticatedImage.swift | 190 ++++++++++++++++++ .../Components/ImageViewerSheet.swift | 22 +- .../iosApp/Documents/DocumentDetailView.swift | 28 +-- .../iosApp/Documents/DocumentFormView.swift | 18 +- .../Subviews/Task/PhotoViewerSheet.swift | 60 +----- 11 files changed, 359 insertions(+), 264 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AuthenticatedImage.kt create mode 100644 iosApp/iosApp/Components/AuthenticatedImage.swift diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/models/CustomTask.kt b/composeApp/src/commonMain/kotlin/com/example/casera/models/CustomTask.kt index c0cf78f..8cf91eb 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/models/CustomTask.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/models/CustomTask.kt @@ -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 ) { diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/models/Document.kt b/composeApp/src/commonMain/kotlin/com/example/casera/models/Document.kt index 0ce385e..161f21b 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/models/Document.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/models/Document.kt @@ -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") diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AuthenticatedImage.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AuthenticatedImage.kt new file mode 100644 index 0000000..708f254 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AuthenticatedImage.kt @@ -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 + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/PhotoViewerDialog.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/PhotoViewerDialog.kt index ed1115c..ad01dbd 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/PhotoViewerDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/PhotoViewerDialog.kt @@ -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(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( diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentDetailScreen.kt index 1933e42..2388b17 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentDetailScreen.kt @@ -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( diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentFormScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentFormScreen.kt index c90c090..38cd987 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentFormScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentFormScreen.kt @@ -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>(emptyList()) } - var existingImageUrls by remember { mutableStateOf>(emptyList()) } + var existingImages by remember { mutableStateOf>(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) diff --git a/iosApp/iosApp/Components/AuthenticatedImage.swift b/iosApp/iosApp/Components/AuthenticatedImage.swift new file mode 100644 index 0000000..095044c --- /dev/null +++ b/iosApp/iosApp/Components/AuthenticatedImage.swift @@ -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? + private var currentURL: String? + + // In-memory cache for loaded images + private static var imageCache = NSCache() + + 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() +} diff --git a/iosApp/iosApp/Documents/Components/ImageViewerSheet.swift b/iosApp/iosApp/Documents/Components/ImageViewerSheet.swift index 06db110..fabfa1a 100644 --- a/iosApp/iosApp/Documents/Components/ImageViewerSheet.swift +++ b/iosApp/iosApp/Documents/Components/ImageViewerSheet.swift @@ -13,27 +13,7 @@ struct ImageViewerSheet: View { ZStack { Color.black.ignoresSafeArea() - AsyncImage(url: URL(string: image.imageUrl)) { phase in - 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() - } - } + AuthenticatedImage(mediaURL: image.mediaUrl) } .tag(index) } diff --git a/iosApp/iosApp/Documents/DocumentDetailView.swift b/iosApp/iosApp/Documents/DocumentDetailView.swift index f6a94d7..f90dc0d 100644 --- a/iosApp/iosApp/Documents/DocumentDetailView.swift +++ b/iosApp/iosApp/Documents/DocumentDetailView.swift @@ -212,28 +212,14 @@ struct DocumentDetailView: View { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { ForEach(Array(document.images.prefix(6).enumerated()), id: \.element.id) { index, image in ZStack(alignment: .center) { - AsyncImage(url: URL(string: image.imageUrl)) { phase in - switch phase { - case .success(let image): - image - .resizable() - .aspectRatio(contentMode: .fill) - case .failure: - Image(systemName: "photo") - .foregroundColor(.gray) - case .empty: - ProgressView() - @unknown default: - EmptyView() + AuthenticatedImage(mediaURL: image.mediaUrl, contentMode: .fill) + .frame(height: 100) + .clipped() + .cornerRadius(8) + .onTapGesture { + selectedImageIndex = index + showImageViewer = true } - } - .frame(height: 100) - .clipped() - .cornerRadius(8) - .onTapGesture { - selectedImageIndex = index - showImageViewer = true - } if index == 5 && document.images.count > 6 { Rectangle() diff --git a/iosApp/iosApp/Documents/DocumentFormView.swift b/iosApp/iosApp/Documents/DocumentFormView.swift index 7921716..777eaf5 100644 --- a/iosApp/iosApp/Documents/DocumentFormView.swift +++ b/iosApp/iosApp/Documents/DocumentFormView.swift @@ -166,22 +166,8 @@ struct DocumentFormView: View { if isEditMode && !existingImages.isEmpty { Section(L10n.Documents.existingPhotos) { ForEach(existingImages, id: \.id) { image in - AsyncImage(url: URL(string: image.imageUrl)) { phase in - switch phase { - case .empty: - ProgressView() - case .success(let image): - image - .resizable() - .scaledToFit() - case .failure: - Image(systemName: "photo") - .foregroundColor(.secondary) - @unknown default: - EmptyView() - } - } - .frame(height: 200) + AuthenticatedImage(mediaURL: image.mediaUrl) + .frame(height: 200) } } .listRowBackground(Color.appBackgroundSecondary) diff --git a/iosApp/iosApp/Subviews/Task/PhotoViewerSheet.swift b/iosApp/iosApp/Subviews/Task/PhotoViewerSheet.swift index e0eaf8a..da75dbd 100644 --- a/iosApp/iosApp/Subviews/Task/PhotoViewerSheet.swift +++ b/iosApp/iosApp/Subviews/Task/PhotoViewerSheet.swift @@ -6,12 +6,6 @@ struct PhotoViewerSheet: View { @Environment(\.dismiss) var dismiss @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 { NavigationView { Group { @@ -19,28 +13,8 @@ struct PhotoViewerSheet: View { // Single image view ScrollView { VStack(spacing: 16) { - AsyncImage(url: fullImageUrl(selectedImage.image)) { phase in - switch phase { - 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() - } - } + AuthenticatedImage(mediaURL: selectedImage.mediaUrl) + .frame(minHeight: 300) if let caption = selectedImage.caption { VStack(alignment: .leading, spacing: 8) { @@ -91,32 +65,10 @@ struct PhotoViewerSheet: View { selectedImage = image }) { VStack(alignment: .leading, spacing: 8) { - AsyncImage(url: fullImageUrl(image.image)) { phase in - switch phase { - case .empty: - ProgressView() - .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) + AuthenticatedImage(mediaURL: image.mediaUrl, contentMode: .fill) + .frame(height: 150) + .clipped() + .cornerRadius(8) if let caption = image.caption { Text(caption)