From eec086e7271f10e70ffb1b18e170eecc9626fecf Mon Sep 17 00:00:00 2001 From: jared Date: Fri, 23 Jan 2026 23:30:54 -0500 Subject: [PATCH] Add export functionality with format options - Add ExportView with JPEG, PNG, HEIC format support - Include quality slider for JPEG compression - Show image dimensions and estimated file size - Implement save to Photo Library with proper permissions - Add share sheet for AirDrop, Messages, etc. - Wire export button in PhotoEditorView to show ExportView - Add HEIC encoding extension for UIImage Co-Authored-By: Claude Opus 4.5 --- .claude/settings.local.json | 4 +- .../Features/Editor/PhotoEditorView.swift | 10 +- CheapRetouch/Features/Export/ExportView.swift | 263 ++++++++++++++++++ 3 files changed, 274 insertions(+), 3 deletions(-) create mode 100644 CheapRetouch/Features/Export/ExportView.swift diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ad4c6fa..3ab8411 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -8,7 +8,9 @@ "Bash(git commit -m \"$\\(cat <<''EOF''\nAdd Ralph Wiggum agent setup and project specifications\n\n- Add project constitution with vision, principles, and autonomy settings\n- Add 15 feature specifications covering full app scope\n- Configure agent entry points \\(AGENTS.md, CLAUDE.md\\)\n- Add build prompt and speckit command for spec creation\n- Include comprehensive .gitignore for iOS development\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n\\)\")", "Bash(./check_build.sh:*)", "Bash(git commit -m \"$\\(cat <<''EOF''\nImplement core project structure and foundation components\n\nProject Setup \\(Spec 01\\):\n- Configure iOS 17.0 deployment target\n- Add photo library usage descriptions\n- Create proper folder structure \\(App, Features, Services, Models, Utilities\\)\n\nData Model \\(Spec 02\\):\n- Add EditOperation enum with mask, inpaint, adjustment cases\n- Add MaskOperation, InpaintOperation structs \\(Codable\\)\n- Add Project model with operation stack and undo/redo support\n- Add MaskData with dilation support via vImage\n\nServices \\(Specs 03-05\\):\n- Add InpaintEngine with Metal + Accelerate fallback\n- Add MaskingService wrapping Vision framework\n- Add ContourService for wire/line detection and scoring\n\nUI Components \\(Specs 06-08\\):\n- Add PhotoEditorView with photo picker integration\n- Add CanvasView with pinch-to-zoom and pan gestures\n- Add ToolbarView with tool selection and inspector panel\n\nUtilities:\n- Add ImagePipeline for preview/export rendering\n- Add EdgeRefinement for smart brush edge detection\n- Add check_build.sh for CI verification\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n\\)\")", - "Bash(git push)" + "Bash(git push)", + "Bash(git commit -m \"$\\(cat <<''EOF''\nIntegrate services with UI via EditorViewModel\n\n- Add EditorViewModel coordinating masking, contour detection, and inpainting\n- Connect PhotoEditorView to viewModel with mask confirmation bar\n- Add processing overlay and error toast UI\n- Update CanvasView with tap-to-mask functionality\n- Add coordinate conversion from view space to image space\n- Update ToolbarView to bind to viewModel state\n- Wire up undo/redo actions through viewModel\n\nPerson, object, and wire removal now flow:\ntap -> detect -> preview mask -> confirm -> inpaint\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n\\)\")", + "Bash(git commit -m \"$\\(cat <<''EOF''\nAdd export functionality with format options\n\n- Add ExportView with JPEG, PNG, HEIC format support\n- Include quality slider for JPEG compression\n- Show image dimensions and estimated file size\n- Implement save to Photo Library with proper permissions\n- Add share sheet for AirDrop, Messages, etc.\n- Wire export button in PhotoEditorView to show ExportView\n- Add HEIC encoding extension for UIImage\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n\\)\")" ] } } diff --git a/CheapRetouch/Features/Editor/PhotoEditorView.swift b/CheapRetouch/Features/Editor/PhotoEditorView.swift index 6c3bfcd..5933438 100644 --- a/CheapRetouch/Features/Editor/PhotoEditorView.swift +++ b/CheapRetouch/Features/Editor/PhotoEditorView.swift @@ -13,6 +13,7 @@ struct PhotoEditorView: View { @State private var viewModel = EditorViewModel() @State private var selectedItem: PhotosPickerItem? @State private var isShowingPicker = false + @State private var isShowingExport = false var body: some View { NavigationStack { @@ -57,11 +58,11 @@ struct PhotoEditorView: View { if viewModel.originalImage != nil { ToolbarItem(placement: .topBarTrailing) { Button { - // Export action + isShowingExport = true } label: { Image(systemName: "square.and.arrow.up") } - .disabled(viewModel.isProcessing || viewModel.editedImage == nil) + .disabled(viewModel.isProcessing || viewModel.displayImage == nil) } } } @@ -74,6 +75,11 @@ struct PhotoEditorView: View { } } .photosPicker(isPresented: $isShowingPicker, selection: $selectedItem, matching: .images) + .sheet(isPresented: $isShowingExport) { + if let image = viewModel.displayImage { + ExportView(image: image) + } + } } } diff --git a/CheapRetouch/Features/Export/ExportView.swift b/CheapRetouch/Features/Export/ExportView.swift new file mode 100644 index 0000000..d78ff2c --- /dev/null +++ b/CheapRetouch/Features/Export/ExportView.swift @@ -0,0 +1,263 @@ +// +// ExportView.swift +// CheapRetouch +// +// Export edited photos at full resolution. +// + +import SwiftUI +import UIKit +import Photos + +struct ExportView: View { + let image: CGImage + @Environment(\.dismiss) private var dismiss + + @State private var isExporting = false + @State private var exportProgress: Double = 0 + @State private var showingShareSheet = false + @State private var showingError = false + @State private var errorMessage = "" + @State private var showingSuccess = false + @State private var selectedFormat: ExportFormat = .jpeg + @State private var jpegQuality: Double = 0.9 + + enum ExportFormat: String, CaseIterable, Identifiable { + case jpeg = "JPEG" + case png = "PNG" + case heic = "HEIC" + + var id: String { rawValue } + + var utType: String { + switch self { + case .jpeg: return "public.jpeg" + case .png: return "public.png" + case .heic: return "public.heic" + } + } + } + + var body: some View { + NavigationStack { + Form { + Section { + // Preview + Image(decorative: image, scale: 1.0) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxHeight: 200) + .frame(maxWidth: .infinity) + .background(Color.black) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + Section("Format") { + Picker("Format", selection: $selectedFormat) { + ForEach(ExportFormat.allCases) { format in + Text(format.rawValue).tag(format) + } + } + .pickerStyle(.segmented) + + if selectedFormat == .jpeg { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Quality") + Spacer() + Text("\(Int(jpegQuality * 100))%") + .foregroundStyle(.secondary) + } + Slider(value: $jpegQuality, in: 0.5...1.0, step: 0.05) + } + } + } + + Section("Image Info") { + LabeledContent("Dimensions", value: "\(image.width) × \(image.height)") + LabeledContent("Estimated Size", value: estimatedFileSize) + } + + Section { + Button { + Task { + await saveToPhotoLibrary() + } + } label: { + Label("Save to Photos", systemImage: "photo.on.rectangle") + } + .disabled(isExporting) + + Button { + showingShareSheet = true + } label: { + Label("Share", systemImage: "square.and.arrow.up") + } + .disabled(isExporting) + } + } + .navigationTitle("Export") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + } + .overlay { + if isExporting { + exportingOverlay + } + } + .alert("Export Failed", isPresented: $showingError) { + Button("OK") {} + } message: { + Text(errorMessage) + } + .alert("Saved", isPresented: $showingSuccess) { + Button("OK") { + dismiss() + } + } message: { + Text("Photo saved to your library") + } + .sheet(isPresented: $showingShareSheet) { + if let data = exportedImageData { + ShareSheet(items: [data]) + } + } + } + } + + private var estimatedFileSize: String { + let pixels = image.width * image.height + let bytesPerPixel: Double + + switch selectedFormat { + case .jpeg: + bytesPerPixel = 0.5 * jpegQuality + 0.1 + case .png: + bytesPerPixel = 1.5 + case .heic: + bytesPerPixel = 0.3 + } + + let bytes = Double(pixels) * bytesPerPixel + return ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file) + } + + private var exportedImageData: Data? { + let uiImage = UIImage(cgImage: image) + + switch selectedFormat { + case .jpeg: + return uiImage.jpegData(compressionQuality: jpegQuality) + case .png: + return uiImage.pngData() + case .heic: + return uiImage.heicData() + } + } + + private var exportingOverlay: some View { + VStack(spacing: 12) { + ProgressView() + .scaleEffect(1.2) + Text("Exporting...") + .font(.subheadline) + } + .padding(24) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) + } + + private func saveToPhotoLibrary() async { + isExporting = true + + do { + // Request permission + let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly) + guard status == .authorized || status == .limited else { + throw ExportError.permissionDenied + } + + guard let data = exportedImageData else { + throw ExportError.encodingFailed + } + + try await PHPhotoLibrary.shared().performChanges { + let request = PHAssetCreationRequest.forAsset() + request.addResource(with: .photo, data: data, options: nil) + } + + showingSuccess = true + } catch { + errorMessage = error.localizedDescription + showingError = true + } + + isExporting = false + } + + enum ExportError: LocalizedError { + case permissionDenied + case encodingFailed + + var errorDescription: String? { + switch self { + case .permissionDenied: + return "Photo library access denied. Please enable it in Settings." + case .encodingFailed: + return "Failed to encode image" + } + } + } +} + +// MARK: - ShareSheet + +struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} + +// MARK: - UIImage HEIC Extension + +extension UIImage { + func heicData(compressionQuality: CGFloat = 0.9) -> Data? { + guard let cgImage = self.cgImage else { return nil } + + let mutableData = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + mutableData, + "public.heic" as CFString, + 1, + nil + ) else { + return nil + } + + let options: [CFString: Any] = [ + kCGImageDestinationLossyCompressionQuality: compressionQuality + ] + + CGImageDestinationAddImage(destination, cgImage, options as CFDictionary) + + guard CGImageDestinationFinalize(destination) else { + return nil + } + + return mutableData as Data + } +} + +#Preview { + if let image = UIImage(systemName: "photo")?.cgImage { + ExportView(image: image) + } +}