Widget Extension Fixes: - Create standalone WidgetDataProvider for widget data isolation - Add WIDGET_EXTENSION compiler flag for conditional compilation - Fix DataController references in widget-shared files - Sync widget version numbers with main app (23, 1.0.2) - Add WidgetBackground color to asset catalog Warning Resolutions: - Fix UIScreen.main deprecation in BGView and SharingListView - Fix Text '+' concatenation deprecation in PurchaseButtonView and SettingsTabView - Fix exhaustive switch in BiometricAuthManager (add .none case) - Fix var to let in ExportService (3 instances) - Fix unused result warning in NoteEditorView - Fix ForEach duplicate ID warnings in MonthView and YearView Code Quality Improvements: - Wrap bypassSubscription in #if DEBUG for security - Rename StupidAssCustomWidgetObservableObject to CustomWidgetStateViewModel - Add @MainActor to IconViewModel - Replace fatalError with graceful fallback in SharedModelContainer - Add [weak self] to closures in DayViewViewModel - Add OSLog-based AppLogger for production logging - Add ImageCache with NSCache for memory efficiency - Add AccessibilityHelpers with Reduce Motion support - Create DataControllerProtocol for dependency injection - Update .gitignore with secrets exclusions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
248 lines
7.4 KiB
Swift
248 lines
7.4 KiB
Swift
//
|
|
// PhotoManager.swift
|
|
// Feels
|
|
//
|
|
// 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) {
|
|
try? thumbnailData.write(to: thumbnailURL)
|
|
}
|
|
|
|
EventLogger.log(event: "photo_saved")
|
|
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),
|
|
let data = try? Data(contentsOf: fullURL),
|
|
let image = UIImage(data: data) else {
|
|
return nil
|
|
}
|
|
|
|
return image
|
|
}
|
|
|
|
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),
|
|
let data = try? Data(contentsOf: thumbnailURL),
|
|
let image = UIImage(data: data) {
|
|
return image
|
|
}
|
|
|
|
// 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) {
|
|
try? FileManager.default.removeItem(at: thumbnailURL)
|
|
}
|
|
|
|
if success {
|
|
EventLogger.log(event: "photo_deleted")
|
|
}
|
|
|
|
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 }
|
|
|
|
let files = try? FileManager.default.contentsOfDirectory(atPath: photosDir.path)
|
|
return files?.filter { $0.hasSuffix(".jpg") }.count ?? 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)
|
|
}
|
|
}
|