Files
Reflect/Shared/Services/PhotoManager.swift
Trey T 1f040ab676 v1.1 polish: accessibility, error logging, localization, and code quality sweep
- Wrap 30+ production print() statements in #if DEBUG guards across 18 files
- Add VoiceOver labels, hints, and traits to Watch app, Live Activities, widgets
- Add .accessibilityAddTraits(.isButton) to 15+ onTapGesture views
- Add text alternatives for color-only indicators (progress dots, mood circles)
- Localize raw string literals in NoteEditorView, EntryDetailView, widgets
- Replace 25+ silent try? with do/catch + AppLogger error logging
- Replace hardcoded font sizes with semantic Dynamic Type fonts
- Fix FIXME in IconPickerView (log icon change errors)
- Extract magic animation delays to named constants across 8 files
- Add widget empty state "Log your first mood!" messaging
- Hide decorative images from VoiceOver, add labels to ColorPickers
- Remove stale TODO in Color+Codable (alpha change deferred for migration)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:09:14 -05:00

274 lines
8.3 KiB
Swift

//
// PhotoManager.swift
// Reflect
//
// Manages photo storage for mood entries.
// Photos are stored as JPEG files in the app group Documents directory.
//
import Foundation
import UIKit
import SwiftUI
import os.log
@MainActor
class PhotoManager: ObservableObject {
static let shared = PhotoManager()
// MARK: - Constants
private let compressionQuality: CGFloat = 0.8
private let thumbnailSize = CGSize(width: 200, height: 200)
// MARK: - Storage Location
private var photosDirectory: URL? {
guard let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: Constants.currentGroupShareId
) else {
AppLogger.photos.error("Failed to get app group container")
return nil
}
let photosURL = containerURL.appendingPathComponent("Photos", isDirectory: true)
// Create directory if it doesn't exist
if !FileManager.default.fileExists(atPath: photosURL.path) {
do {
try FileManager.default.createDirectory(at: photosURL, withIntermediateDirectories: true)
} catch {
AppLogger.photos.error("Failed to create photos directory: \(error.localizedDescription)")
return nil
}
}
return photosURL
}
private var thumbnailsDirectory: URL? {
guard let photosDir = photosDirectory else { return nil }
let thumbnailsURL = photosDir.appendingPathComponent("Thumbnails", isDirectory: true)
if !FileManager.default.fileExists(atPath: thumbnailsURL.path) {
do {
try FileManager.default.createDirectory(at: thumbnailsURL, withIntermediateDirectories: true)
} catch {
AppLogger.photos.error("Failed to create thumbnails directory: \(error.localizedDescription)")
return nil
}
}
return thumbnailsURL
}
// MARK: - Save Photo
func savePhoto(_ image: UIImage) -> UUID? {
guard let photosDir = photosDirectory,
let thumbnailsDir = thumbnailsDirectory else {
return nil
}
let photoID = UUID()
let filename = "\(photoID.uuidString).jpg"
// Save full resolution
let fullURL = photosDir.appendingPathComponent(filename)
guard let fullData = image.jpegData(compressionQuality: compressionQuality) else {
AppLogger.photos.error("Failed to create JPEG data")
return nil
}
do {
try fullData.write(to: fullURL)
} catch {
AppLogger.photos.error("Failed to save photo: \(error.localizedDescription)")
return nil
}
// Save thumbnail
let thumbnailURL = thumbnailsDir.appendingPathComponent(filename)
if let thumbnail = createThumbnail(from: image),
let thumbnailData = thumbnail.jpegData(compressionQuality: 0.6) {
do {
try thumbnailData.write(to: thumbnailURL)
} catch {
AppLogger.photos.error("Failed to save thumbnail: \(error)")
}
}
AnalyticsManager.shared.track(.photoAdded)
return photoID
}
// MARK: - Load Photo
func loadPhoto(id: UUID) -> UIImage? {
guard let photosDir = photosDirectory else { return nil }
let filename = "\(id.uuidString).jpg"
let fullURL = photosDir.appendingPathComponent(filename)
guard FileManager.default.fileExists(atPath: fullURL.path) else {
return nil
}
do {
let data = try Data(contentsOf: fullURL)
guard let image = UIImage(data: data) else {
AppLogger.photos.error("Failed to create UIImage from photo data: \(id)")
return nil
}
return image
} catch {
AppLogger.photos.error("Failed to read photo data for \(id): \(error)")
return nil
}
}
func loadThumbnail(id: UUID) -> UIImage? {
guard let thumbnailsDir = thumbnailsDirectory else { return nil }
let filename = "\(id.uuidString).jpg"
let thumbnailURL = thumbnailsDir.appendingPathComponent(filename)
// Try thumbnail first
if FileManager.default.fileExists(atPath: thumbnailURL.path) {
do {
let data = try Data(contentsOf: thumbnailURL)
if let image = UIImage(data: data) {
return image
}
} catch {
AppLogger.photos.error("Failed to read thumbnail data for \(id): \(error)")
}
}
// Fall back to full image if thumbnail doesn't exist
return loadPhoto(id: id)
}
// MARK: - Delete Photo
func deletePhoto(id: UUID) -> Bool {
guard let photosDir = photosDirectory,
let thumbnailsDir = thumbnailsDirectory else {
return false
}
let filename = "\(id.uuidString).jpg"
let fullURL = photosDir.appendingPathComponent(filename)
let thumbnailURL = thumbnailsDir.appendingPathComponent(filename)
var success = true
// Delete full image
if FileManager.default.fileExists(atPath: fullURL.path) {
do {
try FileManager.default.removeItem(at: fullURL)
} catch {
AppLogger.photos.error("Failed to delete photo: \(error.localizedDescription)")
success = false
}
}
// Delete thumbnail
if FileManager.default.fileExists(atPath: thumbnailURL.path) {
do {
try FileManager.default.removeItem(at: thumbnailURL)
} catch {
AppLogger.photos.error("Failed to delete thumbnail: \(error)")
}
}
if success {
AnalyticsManager.shared.track(.photoDeleted)
}
return success
}
// MARK: - Helpers
private func createThumbnail(from image: UIImage) -> UIImage? {
let size = thumbnailSize
let aspectRatio = image.size.width / image.size.height
var targetSize: CGSize
if aspectRatio > 1 {
// Landscape
targetSize = CGSize(width: size.width, height: size.width / aspectRatio)
} else {
// Portrait or square
targetSize = CGSize(width: size.height * aspectRatio, height: size.height)
}
UIGraphicsBeginImageContextWithOptions(targetSize, false, 1.0)
image.draw(in: CGRect(origin: .zero, size: targetSize))
let thumbnail = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return thumbnail
}
// MARK: - Storage Info
var totalPhotoCount: Int {
guard let photosDir = photosDirectory else { return 0 }
do {
let files = try FileManager.default.contentsOfDirectory(atPath: photosDir.path)
return files.filter { $0.hasSuffix(".jpg") }.count
} catch {
AppLogger.photos.error("Failed to list photos directory: \(error)")
return 0
}
}
var totalStorageUsed: Int64 {
guard let photosDir = photosDirectory else { return 0 }
var totalSize: Int64 = 0
let fileManager = FileManager.default
if let enumerator = fileManager.enumerator(at: photosDir, includingPropertiesForKeys: [.fileSizeKey]) {
for case let fileURL as URL in enumerator {
if let fileSize = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
totalSize += Int64(fileSize)
}
}
}
return totalSize
}
var formattedStorageUsed: String {
let bytes = totalStorageUsed
let formatter = ByteCountFormatter()
formatter.countStyle = .file
return formatter.string(fromByteCount: bytes)
}
}
// MARK: - SwiftUI Image Loading
extension PhotoManager {
func image(for id: UUID?) -> Image? {
guard let id = id,
let uiImage = loadPhoto(id: id) else {
return nil
}
return Image(uiImage: uiImage)
}
func thumbnail(for id: UUID?) -> Image? {
guard let id = id,
let uiImage = loadThumbnail(id: id) else {
return nil
}
return Image(uiImage: uiImage)
}
}