Files
honeyDueKMP/iosApp/iosApp/Components/AuthenticatedImage.swift
Trey T af73f8861b iOS VoiceOver accessibility overhaul — 67 files
New framework:
- AccessibilityLabels.swift: centralized A11y struct with VoiceOver strings
- AccessibilityModifiers.swift: reusable .a11yHeader, .a11yDecorative,
  .a11yButton, .a11yCard, .a11yStatValue View extensions

Shared components: decorative elements hidden, stat views combined,
status/priority badges labeled, error views announced, empty states grouped

Cards: ResidenceCard, TaskCard, DynamicTaskCard, ContractorCard,
DocumentCard, WarrantyCard — all grouped with combined labels,
chevrons hidden, action buttons labeled

Main screens: Login, Register, Residences, Tasks, Contractors, Documents —
toolbar buttons labeled, section headers marked, form field hints added

Onboarding: all 10 views — header traits, button hints, task selection
state, progress indicator, decorative backgrounds hidden

Profile/Subscription: toggle hints, theme selection state, feature
comparison table accessibility, subscription button labels

iOS build verified: BUILD SUCCEEDED
2026-03-26 14:51:29 -05:00

215 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)
.accessibilityLabel("Image")
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()
}