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 <noreply@anthropic.com>
This commit is contained in:
2026-01-23 23:30:54 -05:00
parent daa8ba82bb
commit eec086e727
3 changed files with 274 additions and 3 deletions

View File

@@ -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 <noreply@anthropic.com>\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 <noreply@anthropic.com>\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 <noreply@anthropic.com>\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 <noreply@anthropic.com>\nEOF\n\\)\")"
]
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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)
}
}