diff --git a/AGENTS.md b/AGENTS.md index aecbedf..4432ba7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,22 @@ Xcode project. ## Targets -_No apps or modules yet._ Add targets to `Project.swift` using `macApp()` or `framework()` helpers. +| Target | Type | Bundle ID | Platforms | +|--------------------|------------|--------------------------------|----------------------------| +| PhotoFramer | Mobile App | com.stuff.photo-framer | iPhone, iPad, Mac Catalyst | +| PhotoFramerTests | Unit Tests | com.stuff.photo-framer.tests | iPhone, iPad, Mac Catalyst | + +Add more targets to `Project.swift` using `mobileApp()`, `macApp()`, or `framework()` helpers. + +### PhotoFramer + +Photo framing app — import photos from the library, file picker, or drag-and-drop, +then crop-to-fill or fit-with-mat to standard print (4×6, 5×7, 8×10, 11×14) and +social (1:1, 4:5, 16:9, 9:16) sizes. Export to photo library or file system. + +Architecture: `@Observable` view model → stateless `FramingService` (pure CGImage +operations) → `PhotoExporter` for I/O. SwiftUI throughout; uses `PhotosPicker` and +`.fileImporter()` (no UIKit bridges for pickers). ## Deployment diff --git a/PhotoFramer/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/PhotoFramer/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/PhotoFramer/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PhotoFramer/Resources/Assets.xcassets/Contents.json b/PhotoFramer/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/PhotoFramer/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PhotoFramer/Sources/App/PhotoFramerApp.swift b/PhotoFramer/Sources/App/PhotoFramerApp.swift new file mode 100644 index 0000000..00ea8a8 --- /dev/null +++ b/PhotoFramer/Sources/App/PhotoFramerApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct PhotoFramerApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/PhotoFramer/Sources/Models/FrameSize.swift b/PhotoFramer/Sources/Models/FrameSize.swift new file mode 100644 index 0000000..b8f6635 --- /dev/null +++ b/PhotoFramer/Sources/Models/FrameSize.swift @@ -0,0 +1,37 @@ +import Foundation + +enum FrameSizeCategory: String, CaseIterable, Identifiable, Sendable { + case print = "Print" + case social = "Social" + + var id: String { rawValue } +} + +struct FrameSize: Identifiable, Hashable, Sendable { + let id: String + let label: String + let category: FrameSizeCategory + let widthRatio: CGFloat + let heightRatio: CGFloat + + var aspectRatio: CGFloat { widthRatio / heightRatio } +} + +extension FrameSize { + static let allSizes: [FrameSize] = [ + // Print + .init(id: "4x6", label: "4 × 6", category: .print, widthRatio: 4, heightRatio: 6), + .init(id: "5x7", label: "5 × 7", category: .print, widthRatio: 5, heightRatio: 7), + .init(id: "8x10", label: "8 × 10", category: .print, widthRatio: 8, heightRatio: 10), + .init(id: "11x14", label: "11 × 14", category: .print, widthRatio: 11, heightRatio: 14), + // Social + .init(id: "1:1", label: "Square (1:1)", category: .social, widthRatio: 1, heightRatio: 1), + .init(id: "4:5", label: "Portrait (4:5)", category: .social, widthRatio: 4, heightRatio: 5), + .init(id: "16:9", label: "Landscape (16:9)", category: .social, widthRatio: 16, heightRatio: 9), + .init(id: "9:16", label: "Story (9:16)", category: .social, widthRatio: 9, heightRatio: 16), + ] + + static func sizes(for category: FrameSizeCategory) -> [FrameSize] { + allSizes.filter { $0.category == category } + } +} diff --git a/PhotoFramer/Sources/Models/FramingConfiguration.swift b/PhotoFramer/Sources/Models/FramingConfiguration.swift new file mode 100644 index 0000000..3d355e1 --- /dev/null +++ b/PhotoFramer/Sources/Models/FramingConfiguration.swift @@ -0,0 +1,15 @@ +import SwiftUI + +struct FramingConfiguration { + var frameSize: FrameSize + var mode: FramingMode + var matColor: Color + var outputResolution: Int + + static let `default` = FramingConfiguration( + frameSize: FrameSize.allSizes[0], + mode: .cropToFill, + matColor: .white, + outputResolution: 3000 + ) +} diff --git a/PhotoFramer/Sources/Models/FramingMode.swift b/PhotoFramer/Sources/Models/FramingMode.swift new file mode 100644 index 0000000..4527093 --- /dev/null +++ b/PhotoFramer/Sources/Models/FramingMode.swift @@ -0,0 +1,6 @@ +enum FramingMode: String, CaseIterable, Identifiable, Sendable { + case cropToFill = "Crop to Fill" + case fitWithMat = "Fit with Mat" + + var id: String { rawValue } +} diff --git a/PhotoFramer/Sources/Models/ImportedPhoto.swift b/PhotoFramer/Sources/Models/ImportedPhoto.swift new file mode 100644 index 0000000..48c319d --- /dev/null +++ b/PhotoFramer/Sources/Models/ImportedPhoto.swift @@ -0,0 +1,17 @@ +import CoreGraphics + +struct ImportedPhoto { + let cgImage: CGImage + let originalWidth: Int + let originalHeight: Int + + var aspectRatio: CGFloat { + CGFloat(originalWidth) / CGFloat(originalHeight) + } + + init(cgImage: CGImage) { + self.cgImage = cgImage + self.originalWidth = cgImage.width + self.originalHeight = cgImage.height + } +} diff --git a/PhotoFramer/Sources/Services/FramingService.swift b/PhotoFramer/Sources/Services/FramingService.swift new file mode 100644 index 0000000..ea76be7 --- /dev/null +++ b/PhotoFramer/Sources/Services/FramingService.swift @@ -0,0 +1,114 @@ +import CoreGraphics +import SwiftUI + +enum FramingService { + + /// Apply a full `FramingConfiguration` to an image. + static func frame(image: CGImage, configuration: FramingConfiguration) -> CGImage? { + let targetAR = configuration.frameSize.aspectRatio + + switch configuration.mode { + case .cropToFill: + guard let cropped = cropToFill(image: image, targetAspectRatio: targetAR) else { + return nil + } + return cropped.scaled(maxDimension: configuration.outputResolution) + + case .fitWithMat: + let outputSize = outputSize( + for: targetAR, + resolution: configuration.outputResolution + ) + let cgColor = cgColor(from: configuration.matColor) + return fitWithMat( + image: image, + targetAspectRatio: targetAR, + matColor: cgColor, + outputSize: outputSize + ) + } + } + + /// Crop the image to exactly fill the target aspect ratio, center-cropping the excess axis. + static func cropToFill(image: CGImage, targetAspectRatio: CGFloat) -> CGImage? { + let imageWidth = CGFloat(image.width) + let imageHeight = CGFloat(image.height) + let imageAR = imageWidth / imageHeight + + let cropRect: CGRect + if imageAR > targetAspectRatio { + // Image is wider — crop horizontally + let cropWidth = imageHeight * targetAspectRatio + let originX = (imageWidth - cropWidth) / 2 + cropRect = CGRect(x: originX, y: 0, width: cropWidth, height: imageHeight) + } else { + // Image is taller — crop vertically + let cropHeight = imageWidth / targetAspectRatio + let originY = (imageHeight - cropHeight) / 2 + cropRect = CGRect(x: 0, y: originY, width: imageWidth, height: cropHeight) + } + + return image.cropping(to: cropRect) + } + + /// Fit the entire image inside the target aspect ratio with a colored mat around it. + static func fitWithMat( + image: CGImage, + targetAspectRatio: CGFloat, + matColor: CGColor, + outputSize: CGSize + ) -> CGImage? { + let canvasWidth = outputSize.width + let canvasHeight = outputSize.height + let canvasAR = canvasWidth / canvasHeight + + let imageAR = CGFloat(image.width) / CGFloat(image.height) + + let drawRect: CGRect + if imageAR > canvasAR { + // Image is wider than canvas — fit to width + let drawWidth = canvasWidth + let drawHeight = drawWidth / imageAR + let y = (canvasHeight - drawHeight) / 2 + drawRect = CGRect(x: 0, y: y, width: drawWidth, height: drawHeight) + } else { + // Image is taller than canvas — fit to height + let drawHeight = canvasHeight + let drawWidth = drawHeight * imageAR + let x = (canvasWidth - drawWidth) / 2 + drawRect = CGRect(x: x, y: 0, width: drawWidth, height: drawHeight) + } + + return image.drawnOnCanvas( + canvasSize: outputSize, + drawRect: drawRect, + backgroundColor: matColor + ) + } + + // MARK: - Helpers + + /// Calculate the output pixel dimensions for a target aspect ratio and resolution. + static func outputSize(for aspectRatio: CGFloat, resolution: Int) -> CGSize { + if aspectRatio >= 1 { + // Landscape or square: width is the longest edge + let width = CGFloat(resolution) + let height = width / aspectRatio + return CGSize(width: width, height: height) + } else { + // Portrait: height is the longest edge + let height = CGFloat(resolution) + let width = height * aspectRatio + return CGSize(width: width, height: height) + } + } + + /// Convert a SwiftUI `Color` to a `CGColor`, falling back to white. + private static func cgColor(from color: Color) -> CGColor { + #if canImport(UIKit) + UIColor(color).cgColor + #else + NSColor(color).cgColor + #endif + } +} diff --git a/PhotoFramer/Sources/Services/PhotoExporter.swift b/PhotoFramer/Sources/Services/PhotoExporter.swift new file mode 100644 index 0000000..602934d --- /dev/null +++ b/PhotoFramer/Sources/Services/PhotoExporter.swift @@ -0,0 +1,87 @@ +import CoreGraphics +import Photos +import SwiftUI +import UIKit +import UniformTypeIdentifiers + +// MARK: - Export to Photo Library + +enum PhotoExporter { + + enum ExportError: LocalizedError { + case notAuthorized + case saveFailed(Error) + case encodingFailed + + var errorDescription: String? { + switch self { + case .notAuthorized: + "Photo library access not authorized." + case .saveFailed(let error): + "Failed to save photo: \(error.localizedDescription)" + case .encodingFailed: + "Failed to encode image." + } + } + } + + static func saveToPhotoLibrary(_ image: CGImage) async throws { + let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly) + guard status == .authorized || status == .limited else { + throw ExportError.notAuthorized + } + + try await PHPhotoLibrary.shared().performChanges { + let request = PHAssetCreationRequest.forAsset() + guard let data = UIImage(cgImage: image).jpegData(compressionQuality: 0.95) else { + return + } + request.addResource(with: .photo, data: data, options: nil) + } + } + + static func saveToFile(_ image: CGImage, url: URL, format: ImageFormat) throws { + let data: Data + let uiImage = UIImage(cgImage: image) + + switch format { + case .jpeg(let quality): + guard let encoded = uiImage.jpegData(compressionQuality: quality) else { + throw ExportError.encodingFailed + } + data = encoded + case .png: + guard let encoded = uiImage.pngData() else { + throw ExportError.encodingFailed + } + data = encoded + } + + try data.write(to: url, options: .atomic) + } + + enum ImageFormat { + case jpeg(quality: CGFloat) + case png + } +} + +// MARK: - Transferable for fileExporter + +struct FramedImageDocument: FileDocument { + static var readableContentTypes: [UTType] { [.png] } + + let imageData: Data + + init(image: CGImage) { + self.imageData = UIImage(cgImage: image).pngData() ?? Data() + } + + init(configuration: ReadConfiguration) throws { + self.imageData = configuration.file.regularFileContents ?? Data() + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: imageData) + } +} diff --git a/PhotoFramer/Sources/Utilities/CGImage+Framing.swift b/PhotoFramer/Sources/Utilities/CGImage+Framing.swift new file mode 100644 index 0000000..29b06ab --- /dev/null +++ b/PhotoFramer/Sources/Utilities/CGImage+Framing.swift @@ -0,0 +1,54 @@ +import CoreGraphics + +extension CGImage { + + /// Scale the image so the longest edge equals `maxDimension`, preserving aspect ratio. + func scaled(maxDimension: Int) -> CGImage? { + let widthRatio = CGFloat(maxDimension) / CGFloat(width) + let heightRatio = CGFloat(maxDimension) / CGFloat(height) + let scale = min(widthRatio, heightRatio) + + let newWidth = Int(CGFloat(width) * scale) + let newHeight = Int(CGFloat(height) * scale) + + guard let context = CGContext( + data: nil, + width: newWidth, + height: newHeight, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return nil } + + context.interpolationQuality = .high + context.draw(self, in: CGRect(x: 0, y: 0, width: newWidth, height: newHeight)) + return context.makeImage() + } + + /// Draw this image into `drawRect` on a canvas of `canvasSize` filled with `backgroundColor`. + func drawnOnCanvas( + canvasSize: CGSize, + drawRect: CGRect, + backgroundColor: CGColor + ) -> CGImage? { + let canvasWidth = Int(canvasSize.width) + let canvasHeight = Int(canvasSize.height) + + guard let context = CGContext( + data: nil, + width: canvasWidth, + height: canvasHeight, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return nil } + + context.setFillColor(backgroundColor) + context.fill(CGRect(x: 0, y: 0, width: canvasWidth, height: canvasHeight)) + context.interpolationQuality = .high + context.draw(self, in: drawRect) + return context.makeImage() + } +} diff --git a/PhotoFramer/Sources/Utilities/PhotoDropDelegate.swift b/PhotoFramer/Sources/Utilities/PhotoDropDelegate.swift new file mode 100644 index 0000000..18556ea --- /dev/null +++ b/PhotoFramer/Sources/Utilities/PhotoDropDelegate.swift @@ -0,0 +1,41 @@ +import SwiftUI +import UIKit +import UniformTypeIdentifiers + +struct PhotoDropDelegate: DropDelegate { + @Binding var isTargeted: Bool + let onDrop: (CGImage) -> Void + + func dropEntered(info: DropInfo) { + isTargeted = true + } + + func dropExited(info: DropInfo) { + isTargeted = false + } + + func validateDrop(info: DropInfo) -> Bool { + info.hasItemsConforming(to: [.image]) + } + + func performDrop(info: DropInfo) -> Bool { + isTargeted = false + + guard let provider = info.itemProviders(for: [.image]).first else { + return false + } + + provider.loadDataRepresentation(forTypeIdentifier: UTType.image.identifier) { data, _ in + guard let data, + let uiImage = UIImage(data: data), + let cgImage = uiImage.cgImage + else { return } + + DispatchQueue.main.async { + onDrop(cgImage) + } + } + + return true + } +} diff --git a/PhotoFramer/Sources/ViewModels/FramingViewModel.swift b/PhotoFramer/Sources/ViewModels/FramingViewModel.swift new file mode 100644 index 0000000..8bbd04f --- /dev/null +++ b/PhotoFramer/Sources/ViewModels/FramingViewModel.swift @@ -0,0 +1,77 @@ +import CoreGraphics +import Observation +import SwiftUI + +@Observable +final class FramingViewModel { + + var importedPhoto: ImportedPhoto? + var configuration: FramingConfiguration = .default + var framedPreview: CGImage? + var isExporting = false + var exportError: Error? + var showExportSuccess = false + + func importImage(_ cgImage: CGImage) { + importedPhoto = ImportedPhoto(cgImage: cgImage) + updatePreview() + } + + func updatePreview() { + guard let photo = importedPhoto else { + framedPreview = nil + return + } + + // Render preview at reduced resolution for responsiveness. + var previewConfig = configuration + previewConfig.outputResolution = 1200 + + framedPreview = FramingService.frame( + image: photo.cgImage, + configuration: previewConfig + ) + } + + func exportToPhotoLibrary() async { + guard let photo = importedPhoto else { return } + + isExporting = true + defer { isExporting = false } + + // Render at full resolution for export. + guard let fullRes = FramingService.frame( + image: photo.cgImage, + configuration: configuration + ) else { + exportError = PhotoExporter.ExportError.encodingFailed + return + } + + do { + try await PhotoExporter.saveToPhotoLibrary(fullRes) + showExportSuccess = true + } catch { + exportError = error + } + } + + func framedImageForFileExport() -> FramedImageDocument? { + guard let photo = importedPhoto else { return nil } + + guard let fullRes = FramingService.frame( + image: photo.cgImage, + configuration: configuration + ) else { return nil } + + return FramedImageDocument(image: fullRes) + } + + func reset() { + importedPhoto = nil + framedPreview = nil + configuration = .default + exportError = nil + showExportSuccess = false + } +} diff --git a/PhotoFramer/Sources/Views/ContentView.swift b/PhotoFramer/Sources/Views/ContentView.swift new file mode 100644 index 0000000..f323d1f --- /dev/null +++ b/PhotoFramer/Sources/Views/ContentView.swift @@ -0,0 +1,21 @@ +import SwiftUI + +struct ContentView: View { + @State private var viewModel = FramingViewModel() + + var body: some View { + NavigationStack { + Group { + if viewModel.importedPhoto == nil { + PhotoImportView(viewModel: viewModel) + } else { + FramingEditorView(viewModel: viewModel) + } + } + .navigationTitle("Photo Framer") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + } + } +} diff --git a/PhotoFramer/Sources/Views/ExportSheet.swift b/PhotoFramer/Sources/Views/ExportSheet.swift new file mode 100644 index 0000000..4127516 --- /dev/null +++ b/PhotoFramer/Sources/Views/ExportSheet.swift @@ -0,0 +1,81 @@ +import SwiftUI + +struct ExportSheet: View { + @Bindable var viewModel: FramingViewModel + @Environment(\.dismiss) private var dismiss + + @State private var showFileExporter = false + @State private var exportDocument: FramedImageDocument? + + var body: some View { + NavigationStack { + VStack(spacing: 20) { + if viewModel.isExporting { + ProgressView("Exporting…") + .padding() + } else if viewModel.showExportSuccess { + Label("Saved to Photo Library", systemImage: "checkmark.circle.fill") + .font(.headline) + .foregroundStyle(.green) + .padding() + + Button("Done") { + viewModel.showExportSuccess = false + dismiss() + } + .buttonStyle(.borderedProminent) + } else { + VStack(spacing: 12) { + Button { + Task { + await viewModel.exportToPhotoLibrary() + } + } label: { + Label("Save to Photo Library", systemImage: "photo.on.rectangle") + .frame(maxWidth: 280) + } + .buttonStyle(.borderedProminent) + + Button { + exportDocument = viewModel.framedImageForFileExport() + showFileExporter = true + } label: { + Label("Save to Files", systemImage: "folder") + .frame(maxWidth: 280) + } + .buttonStyle(.bordered) + } + } + + if let error = viewModel.exportError { + Text(error.localizedDescription) + .font(.caption) + .foregroundStyle(.red) + .padding() + } + } + .padding() + .navigationTitle("Export") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + .fileExporter( + isPresented: $showFileExporter, + document: exportDocument, + contentType: .png, + defaultFilename: "framed-photo.png" + ) { result in + exportDocument = nil + if case .success = result { + dismiss() + } + } + } + .presentationDetents([.medium]) + } +} diff --git a/PhotoFramer/Sources/Views/FrameSizePicker.swift b/PhotoFramer/Sources/Views/FrameSizePicker.swift new file mode 100644 index 0000000..e9d2187 --- /dev/null +++ b/PhotoFramer/Sources/Views/FrameSizePicker.swift @@ -0,0 +1,68 @@ +import SwiftUI + +struct FrameSizePicker: View { + @Binding var selection: FrameSize + + private let columns = [ + GridItem(.adaptive(minimum: 100), spacing: 12), + ] + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + ForEach(FrameSizeCategory.allCases) { category in + VStack(alignment: .leading, spacing: 8) { + Text(category.rawValue) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal) + + LazyVGrid(columns: columns, spacing: 12) { + ForEach(FrameSize.sizes(for: category)) { size in + FrameSizeCell( + size: size, + isSelected: selection == size + ) + .onTapGesture { selection = size } + } + } + .padding(.horizontal) + } + } + } + } +} + +private struct FrameSizeCell: View { + let size: FrameSize + let isSelected: Bool + + var body: some View { + VStack(spacing: 6) { + // Aspect ratio preview rectangle + let ar = size.aspectRatio + let previewWidth: CGFloat = 40 + let previewHeight: CGFloat = 40 + + RoundedRectangle(cornerRadius: 4) + .fill(isSelected ? Color.accentColor.opacity(0.3) : Color.secondary.opacity(0.15)) + .frame( + width: ar >= 1 ? previewWidth : previewHeight * ar, + height: ar >= 1 ? previewWidth / ar : previewHeight + ) + + Text(size.label) + .font(.caption) + .lineLimit(1) + } + .padding(8) + .frame(maxWidth: .infinity) + .background { + RoundedRectangle(cornerRadius: 10) + .fill(isSelected ? Color.accentColor.opacity(0.1) : Color.clear) + } + .overlay { + RoundedRectangle(cornerRadius: 10) + .strokeBorder(isSelected ? Color.accentColor : Color.clear, lineWidth: 2) + } + } +} diff --git a/PhotoFramer/Sources/Views/FramingEditorView.swift b/PhotoFramer/Sources/Views/FramingEditorView.swift new file mode 100644 index 0000000..c34303b --- /dev/null +++ b/PhotoFramer/Sources/Views/FramingEditorView.swift @@ -0,0 +1,72 @@ +import SwiftUI + +struct FramingEditorView: View { + @Bindable var viewModel: FramingViewModel + + @State private var showExportSheet = false + @State private var showFileExporter = false + @State private var exportDocument: FramedImageDocument? + + var body: some View { + ScrollView { + VStack(spacing: 20) { + // Live preview + if let preview = viewModel.framedPreview { + Image(decorative: preview, scale: 1.0) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxHeight: 400) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .shadow(radius: 4) + .padding(.horizontal) + } + + // Framing mode picker + Picker("Mode", selection: $viewModel.configuration.mode) { + ForEach(FramingMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .padding(.horizontal) + + // Frame size picker + FrameSizePicker(selection: $viewModel.configuration.frameSize) + + // Mat color (only when fitWithMat) + if viewModel.configuration.mode == .fitWithMat { + MatColorPicker(color: $viewModel.configuration.matColor) + } + } + .padding(.vertical) + } + .onChange(of: viewModel.configuration.mode) { _, _ in viewModel.updatePreview() } + .onChange(of: viewModel.configuration.frameSize) { _, _ in viewModel.updatePreview() } + .onChange(of: viewModel.configuration.matColor) { _, _ in viewModel.updatePreview() } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Start Over") { + viewModel.reset() + } + } + ToolbarItem(placement: .primaryAction) { + Button { + showExportSheet = true + } label: { + Label("Export", systemImage: "square.and.arrow.up") + } + } + } + .sheet(isPresented: $showExportSheet) { + ExportSheet(viewModel: viewModel) + } + .fileExporter( + isPresented: $showFileExporter, + document: exportDocument, + contentType: .png, + defaultFilename: "framed-photo.png" + ) { _ in + exportDocument = nil + } + } +} diff --git a/PhotoFramer/Sources/Views/MatColorPicker.swift b/PhotoFramer/Sources/Views/MatColorPicker.swift new file mode 100644 index 0000000..91f9cc5 --- /dev/null +++ b/PhotoFramer/Sources/Views/MatColorPicker.swift @@ -0,0 +1,45 @@ +import SwiftUI + +struct MatColorPicker: View { + @Binding var color: Color + + private let presets: [(String, Color)] = [ + ("White", .white), + ("Black", .black), + ("Cream", Color(red: 1.0, green: 0.99, blue: 0.93)), + ("Gray", .gray), + ] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Mat Color") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + + HStack(spacing: 12) { + ForEach(presets, id: \.0) { name, preset in + Button { + color = preset + } label: { + VStack(spacing: 4) { + Circle() + .fill(preset) + .stroke(Color.secondary.opacity(0.3), lineWidth: 1) + .frame(width: 32, height: 32) + Text(name) + .font(.caption2) + } + } + .buttonStyle(.plain) + } + + Divider() + .frame(height: 40) + + ColorPicker("Custom", selection: $color, supportsOpacity: false) + .labelsHidden() + } + } + .padding(.horizontal) + } +} diff --git a/PhotoFramer/Sources/Views/PhotoImportView.swift b/PhotoFramer/Sources/Views/PhotoImportView.swift new file mode 100644 index 0000000..e309759 --- /dev/null +++ b/PhotoFramer/Sources/Views/PhotoImportView.swift @@ -0,0 +1,98 @@ +import PhotosUI +import SwiftUI +import UniformTypeIdentifiers + +struct PhotoImportView: View { + @Bindable var viewModel: FramingViewModel + + @State private var selectedItem: PhotosPickerItem? + @State private var showFilePicker = false + @State private var isDropTargeted = false + + var body: some View { + VStack(spacing: 24) { + Spacer() + + // Drag-and-drop zone + RoundedRectangle(cornerRadius: 16) + .strokeBorder( + style: StrokeStyle(lineWidth: 2, dash: [8]) + ) + .foregroundStyle(isDropTargeted ? Color.accentColor : Color.secondary) + .frame(maxWidth: 400, minHeight: 180) + .overlay { + VStack(spacing: 12) { + Image(systemName: "arrow.down.doc") + .font(.system(size: 40)) + .foregroundStyle(.secondary) + Text("Drop a photo here") + .font(.headline) + .foregroundStyle(.secondary) + } + } + .onDrop( + of: [.image], + delegate: PhotoDropDelegate( + isTargeted: $isDropTargeted, + onDrop: { viewModel.importImage($0) } + ) + ) + + Text("or") + .foregroundStyle(.secondary) + + // Import buttons + VStack(spacing: 12) { + PhotosPicker( + selection: $selectedItem, + matching: .images + ) { + Label("Choose from Library", systemImage: "photo.on.rectangle") + .frame(maxWidth: 280) + } + .buttonStyle(.borderedProminent) + + Button { + showFilePicker = true + } label: { + Label("Choose from Files", systemImage: "folder") + .frame(maxWidth: 280) + } + .buttonStyle(.bordered) + } + + Spacer() + } + .padding() + .onChange(of: selectedItem) { _, newItem in + guard let newItem else { return } + Task { + if let data = try? await newItem.loadTransferable(type: Data.self), + let uiImage = UIImage(data: data), + let cgImage = uiImage.cgImage + { + viewModel.importImage(cgImage) + } + selectedItem = nil + } + } + .fileImporter( + isPresented: $showFilePicker, + allowedContentTypes: [.image] + ) { result in + switch result { + case .success(let url): + guard url.startAccessingSecurityScopedResource() else { return } + defer { url.stopAccessingSecurityScopedResource() } + if let data = try? Data(contentsOf: url), + let uiImage = UIImage(data: data), + let cgImage = uiImage.cgImage + { + viewModel.importImage(cgImage) + } + case .failure: + break + } + } + } +} diff --git a/PhotoFramer/Tests/FrameSizeTests.swift b/PhotoFramer/Tests/FrameSizeTests.swift new file mode 100644 index 0000000..e1777e0 --- /dev/null +++ b/PhotoFramer/Tests/FrameSizeTests.swift @@ -0,0 +1,45 @@ +import Testing + +@testable import PhotoFramer + +@Suite +struct FrameSizeTests { + + @Test func allSizesHavePositiveAspectRatios() { + for size in FrameSize.allSizes { + #expect(size.aspectRatio > 0) + } + } + + @Test func printSizesCount() { + #expect(FrameSize.sizes(for: .print).count == 4) + } + + @Test func socialSizesCount() { + #expect(FrameSize.sizes(for: .social).count == 4) + } + + @Test func totalSizesCount() { + #expect(FrameSize.allSizes.count == 8) + } + + @Test func squareAspectRatioIsOne() { + let square = FrameSize.allSizes.first { $0.id == "1:1" }! + #expect(square.aspectRatio == 1.0) + } + + @Test func landscapeAspectRatioGreaterThanOne() { + let landscape = FrameSize.allSizes.first { $0.id == "16:9" }! + #expect(landscape.aspectRatio > 1.0) + } + + @Test func portraitAspectRatioLessThanOne() { + let portrait = FrameSize.allSizes.first { $0.id == "9:16" }! + #expect(portrait.aspectRatio < 1.0) + } + + @Test func allIdsAreUnique() { + let ids = FrameSize.allSizes.map(\.id) + #expect(Set(ids).count == ids.count) + } +} diff --git a/PhotoFramer/Tests/FramingConfigurationTests.swift b/PhotoFramer/Tests/FramingConfigurationTests.swift new file mode 100644 index 0000000..3ec892d --- /dev/null +++ b/PhotoFramer/Tests/FramingConfigurationTests.swift @@ -0,0 +1,26 @@ +import Testing + +@testable import PhotoFramer + +@Suite +struct FramingConfigurationTests { + + @Test func defaultConfigurationUseCropToFill() { + let config = FramingConfiguration.default + #expect(config.mode == .cropToFill) + } + + @Test func defaultConfigurationResolution() { + let config = FramingConfiguration.default + #expect(config.outputResolution == 3000) + } + + @Test func allFramingModesExist() { + #expect(FramingMode.allCases.count == 2) + } + + @Test func framingModeIds() { + let ids = Set(FramingMode.allCases.map(\.id)) + #expect(ids.count == 2) + } +} diff --git a/PhotoFramer/Tests/FramingServiceTests.swift b/PhotoFramer/Tests/FramingServiceTests.swift new file mode 100644 index 0000000..b017c71 --- /dev/null +++ b/PhotoFramer/Tests/FramingServiceTests.swift @@ -0,0 +1,139 @@ +import CoreGraphics +import Testing + +@testable import PhotoFramer + +@Suite +struct FramingServiceTests { + + // MARK: - Helpers + + /// Create a solid-color test image of the given dimensions. + private func makeTestImage(width: Int, height: Int) -> CGImage { + let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )! + context.setFillColor(CGColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1.0)) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + return context.makeImage()! + } + + // MARK: - cropToFill + + @Test func cropToFillWiderImageProducesTargetAspectRatio() { + let image = makeTestImage(width: 1000, height: 500) // 2:1 + let result = FramingService.cropToFill(image: image, targetAspectRatio: 1.0) + + #expect(result != nil) + #expect(result!.width == result!.height) + } + + @Test func cropToFillTallerImageProducesTargetAspectRatio() { + let image = makeTestImage(width: 500, height: 1000) // 1:2 + let result = FramingService.cropToFill(image: image, targetAspectRatio: 1.0) + + #expect(result != nil) + #expect(result!.width == result!.height) + } + + @Test func cropToFillPreservesAspectRatio() { + let image = makeTestImage(width: 1000, height: 800) + let targetAR: CGFloat = 4.0 / 6.0 + let result = FramingService.cropToFill(image: image, targetAspectRatio: targetAR)! + + let resultAR = CGFloat(result.width) / CGFloat(result.height) + #expect(abs(resultAR - targetAR) < 0.01) + } + + @Test func cropToFillDoesNotEnlargeImage() { + let image = makeTestImage(width: 800, height: 600) + let result = FramingService.cropToFill(image: image, targetAspectRatio: 1.0)! + + #expect(result.width <= 800) + #expect(result.height <= 600) + } + + // MARK: - fitWithMat + + @Test func fitWithMatOutputMatchesRequestedSize() { + let image = makeTestImage(width: 800, height: 600) + let outputSize = CGSize(width: 1000, height: 1000) + let matColor = CGColor(red: 1, green: 1, blue: 1, alpha: 1) + + let result = FramingService.fitWithMat( + image: image, + targetAspectRatio: 1.0, + matColor: matColor, + outputSize: outputSize + ) + + #expect(result != nil) + #expect(result!.width == 1000) + #expect(result!.height == 1000) + } + + @Test func fitWithMatPortraitCanvas() { + let image = makeTestImage(width: 800, height: 600) + let outputSize = CGSize(width: 500, height: 1000) + let matColor = CGColor(red: 0, green: 0, blue: 0, alpha: 1) + + let result = FramingService.fitWithMat( + image: image, + targetAspectRatio: 0.5, + matColor: matColor, + outputSize: outputSize + ) + + #expect(result != nil) + #expect(result!.width == 500) + #expect(result!.height == 1000) + } + + // MARK: - outputSize + + @Test func outputSizeLandscapeUsesWidthAsLongestEdge() { + let size = FramingService.outputSize(for: 16.0 / 9.0, resolution: 3000) + #expect(size.width == 3000) + #expect(size.height < 3000) + } + + @Test func outputSizePortraitUsesHeightAsLongestEdge() { + let size = FramingService.outputSize(for: 9.0 / 16.0, resolution: 3000) + #expect(size.height == 3000) + #expect(size.width < 3000) + } + + @Test func outputSizeSquare() { + let size = FramingService.outputSize(for: 1.0, resolution: 2000) + #expect(size.width == 2000) + #expect(size.height == 2000) + } + + // MARK: - frame (integration) + + @Test func frameCropToFillProducesImage() { + let image = makeTestImage(width: 1000, height: 800) + var config = FramingConfiguration.default + config.mode = .cropToFill + config.outputResolution = 500 + + let result = FramingService.frame(image: image, configuration: config) + #expect(result != nil) + } + + @Test func frameFitWithMatProducesImage() { + let image = makeTestImage(width: 1000, height: 800) + var config = FramingConfiguration.default + config.mode = .fitWithMat + config.outputResolution = 500 + + let result = FramingService.frame(image: image, configuration: config) + #expect(result != nil) + } +} diff --git a/PhotoFramer/Tests/PhotoExporterTests.swift b/PhotoFramer/Tests/PhotoExporterTests.swift new file mode 100644 index 0000000..a70dd4e --- /dev/null +++ b/PhotoFramer/Tests/PhotoExporterTests.swift @@ -0,0 +1,62 @@ +import CoreGraphics +import Foundation +import Testing + +@testable import PhotoFramer + +@Suite +struct PhotoExporterTests { + + private func makeTestImage() -> CGImage { + let context = CGContext( + data: nil, + width: 100, + height: 100, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )! + context.setFillColor(CGColor(red: 1, green: 0, blue: 0, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: 100, height: 100)) + return context.makeImage()! + } + + @Test func saveAsJPEGCreatesFile() throws { + let image = makeTestImage() + let url = FileManager.default.temporaryDirectory.appendingPathComponent("test-\(UUID()).jpg") + defer { try? FileManager.default.removeItem(at: url) } + + try PhotoExporter.saveToFile(image, url: url, format: .jpeg(quality: 0.9)) + #expect(FileManager.default.fileExists(atPath: url.path())) + } + + @Test func saveAsPNGCreatesFile() throws { + let image = makeTestImage() + let url = FileManager.default.temporaryDirectory.appendingPathComponent("test-\(UUID()).png") + defer { try? FileManager.default.removeItem(at: url) } + + try PhotoExporter.saveToFile(image, url: url, format: .png) + #expect(FileManager.default.fileExists(atPath: url.path())) + } + + @Test func savedJPEGHasNonZeroSize() throws { + let image = makeTestImage() + let url = FileManager.default.temporaryDirectory.appendingPathComponent("test-\(UUID()).jpg") + defer { try? FileManager.default.removeItem(at: url) } + + try PhotoExporter.saveToFile(image, url: url, format: .jpeg(quality: 0.9)) + let data = try Data(contentsOf: url) + #expect(data.count > 0) + } + + @Test func savedPNGHasNonZeroSize() throws { + let image = makeTestImage() + let url = FileManager.default.temporaryDirectory.appendingPathComponent("test-\(UUID()).png") + defer { try? FileManager.default.removeItem(at: url) } + + try PhotoExporter.saveToFile(image, url: url, format: .png) + let data = try Data(contentsOf: url) + #expect(data.count > 0) + } +} diff --git a/Project.swift b/Project.swift index 34d0a6d..fe17f3a 100644 --- a/Project.swift +++ b/Project.swift @@ -1,8 +1,16 @@ import ProjectDescription +let mobileDestinations: Destinations = [.iPhone, .iPad, .macCatalyst] +let mobileDeployment: DeploymentTargets = .iOS("26.0") + let macDestinations: Destinations = [.mac] let macDeployment: DeploymentTargets = .macOS("26.0") +/// Frameworks build for all destinations so they can be consumed by both +/// `mobileApp()` and `macApp()` targets. +let frameworkDestinations: Destinations = [.iPhone, .iPad, .macCatalyst, .mac] +let frameworkDeployment: DeploymentTargets = .multiplatform(iOS: "26.0", macOS: "26.0") + func framework( _ name: String, bundleIdSuffix: String, @@ -11,19 +19,19 @@ func framework( [ .target( name: name, - destinations: macDestinations, + destinations: frameworkDestinations, product: .framework, bundleId: "com.stuff.\(bundleIdSuffix)", - deploymentTargets: macDeployment, + deploymentTargets: frameworkDeployment, sources: ["\(name)/Sources/**"], dependencies: dependencies ), .target( name: "\(name)Tests", - destinations: macDestinations, + destinations: frameworkDestinations, product: .unitTests, bundleId: "com.stuff.\(bundleIdSuffix).tests", - deploymentTargets: macDeployment, + deploymentTargets: frameworkDeployment, sources: ["\(name)/Tests/**"], dependencies: [.target(name: name)] ), @@ -60,11 +68,49 @@ func macApp( ] } +func mobileApp( + _ name: String, + bundleIdSuffix: String, + infoPlist: [String: Plist.Value] = [:], + dependencies: [TargetDependency] = [] +) -> [Target] { + [ + .target( + name: name, + destinations: mobileDestinations, + product: .app, + bundleId: "com.stuff.\(bundleIdSuffix)", + deploymentTargets: mobileDeployment, + infoPlist: .extendingDefault(with: infoPlist), + sources: ["\(name)/Sources/**"], + resources: ["\(name)/Resources/**"], + dependencies: dependencies + ), + .target( + name: "\(name)Tests", + destinations: mobileDestinations, + product: .unitTests, + bundleId: "com.stuff.\(bundleIdSuffix).tests", + deploymentTargets: mobileDeployment, + sources: ["\(name)/Tests/**"], + dependencies: [.target(name: name)] + ), + ] +} + let project = Project( name: "Stuff", options: .options( defaultKnownRegions: ["en"], developmentRegion: "en" ), - targets: [] + targets: mobileApp( + "PhotoFramer", + bundleIdSuffix: "photo-framer", + infoPlist: [ + "NSPhotoLibraryUsageDescription": .string("Photo Framer needs access to your photo library to import photos."), + "NSPhotoLibraryAddUsageDescription": .string("Photo Framer needs permission to save framed photos to your library."), + "UILaunchScreen": .dictionary([:]), + ] + ) )