Applies verified fixes from deep audit (concurrency, performance, security, accessibility), standardizes CRUD form buttons to Add/Save pattern, removes .drawingGroup() that broke search bar TextFields, and converts vulnerable .sheet(isPresented:) + if-let patterns to safe presentation to prevent blank white modals. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
214 lines
6.4 KiB
Swift
214 lines
6.4 KiB
Swift
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
|
|
}
|
|
|
|
/// Clear the in-memory image cache (call on logout to free memory and avoid stale data)
|
|
static func clearCache() {
|
|
AuthenticatedImageLoader.clearCache()
|
|
}
|
|
}
|
|
|
|
// 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> = {
|
|
let cache = NSCache<NSString, UIImage>()
|
|
cache.countLimit = 100
|
|
cache.totalCostLimit = 50 * 1024 * 1024 // 50 MB
|
|
return cache
|
|
}()
|
|
|
|
private static let memoryWarningObserver: NSObjectProtocol = {
|
|
NotificationCenter.default.addObserver(
|
|
forName: UIApplication.didReceiveMemoryWarningNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { _ in
|
|
imageCache.removeAllObjects()
|
|
}
|
|
}()
|
|
|
|
func load(mediaURL: String?) {
|
|
_ = Self.memoryWarningObserver
|
|
|
|
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.timeoutInterval = 15
|
|
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()
|
|
}
|