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:
190
iosApp/iosApp/Components/AuthenticatedImage.swift
Normal file
190
iosApp/iosApp/Components/AuthenticatedImage.swift
Normal 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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user