Integrate services with UI via EditorViewModel
- Add EditorViewModel coordinating masking, contour detection, and inpainting - Connect PhotoEditorView to viewModel with mask confirmation bar - Add processing overlay and error toast UI - Update CanvasView with tap-to-mask functionality - Add coordinate conversion from view space to image space - Update ToolbarView to bind to viewModel state - Wire up undo/redo actions through viewModel Person, object, and wire removal now flow: tap -> detect -> preview mask -> confirm -> inpaint Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,11 @@
|
|||||||
"WebFetch(domain:github.com)",
|
"WebFetch(domain:github.com)",
|
||||||
"WebFetch(domain:raw.githubusercontent.com)",
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
"Bash(git ls-remote:*)",
|
"Bash(git ls-remote:*)",
|
||||||
"Bash(git add:*)"
|
"Bash(git add:*)",
|
||||||
|
"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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ import SwiftUI
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
struct CanvasView: View {
|
struct CanvasView: View {
|
||||||
let image: UIImage
|
@Bindable var viewModel: EditorViewModel
|
||||||
|
|
||||||
@State private var scale: CGFloat = 1.0
|
@State private var scale: CGFloat = 1.0
|
||||||
@State private var lastScale: CGFloat = 1.0
|
@State private var lastScale: CGFloat = 1.0
|
||||||
@State private var offset: CGSize = .zero
|
@State private var offset: CGSize = .zero
|
||||||
@State private var lastOffset: CGSize = .zero
|
@State private var lastOffset: CGSize = .zero
|
||||||
@State private var isShowingOriginal = false
|
@State private var isShowingOriginal = false
|
||||||
|
@State private var viewSize: CGSize = .zero
|
||||||
|
|
||||||
private let minScale: CGFloat = 1.0
|
private let minScale: CGFloat = 1.0
|
||||||
private let maxScale: CGFloat = 10.0
|
private let maxScale: CGFloat = 10.0
|
||||||
@@ -25,68 +26,170 @@ struct CanvasView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
Color.black
|
Color.black
|
||||||
|
|
||||||
Image(uiImage: image)
|
if let cgImage = isShowingOriginal ? viewModel.originalImage : viewModel.displayImage {
|
||||||
.resizable()
|
// Main image
|
||||||
.aspectRatio(contentMode: .fit)
|
Image(decorative: cgImage, scale: 1.0)
|
||||||
.scaleEffect(scale)
|
.resizable()
|
||||||
.offset(offset)
|
.aspectRatio(contentMode: .fit)
|
||||||
.gesture(
|
.scaleEffect(scale)
|
||||||
MagnificationGesture()
|
.offset(offset)
|
||||||
.onChanged { value in
|
|
||||||
let newScale = lastScale * value
|
// Mask overlay
|
||||||
scale = min(max(newScale, minScale), maxScale)
|
if let mask = viewModel.maskPreview {
|
||||||
}
|
Image(decorative: mask, scale: 1.0)
|
||||||
.onEnded { _ in
|
.resizable()
|
||||||
lastScale = scale
|
.aspectRatio(contentMode: .fit)
|
||||||
withAnimation(.spring(duration: 0.3)) {
|
.scaleEffect(scale)
|
||||||
clampOffset(in: geometry.size)
|
.offset(offset)
|
||||||
}
|
.blendMode(.multiply)
|
||||||
}
|
.colorMultiply(.red.opacity(0.5))
|
||||||
)
|
|
||||||
.simultaneousGesture(
|
|
||||||
DragGesture()
|
|
||||||
.onChanged { value in
|
|
||||||
if scale > 1.0 {
|
|
||||||
offset = CGSize(
|
|
||||||
width: lastOffset.width + value.translation.width,
|
|
||||||
height: lastOffset.height + value.translation.height
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onEnded { _ in
|
|
||||||
lastOffset = offset
|
|
||||||
withAnimation(.spring(duration: 0.3)) {
|
|
||||||
clampOffset(in: geometry.size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.simultaneousGesture(
|
|
||||||
LongPressGesture(minimumDuration: 0.2)
|
|
||||||
.onChanged { _ in
|
|
||||||
isShowingOriginal = true
|
|
||||||
}
|
|
||||||
.onEnded { _ in
|
|
||||||
isShowingOriginal = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.onTapGesture(count: 2) {
|
|
||||||
withAnimation(.spring(duration: 0.3)) {
|
|
||||||
if scale > 1.0 {
|
|
||||||
scale = 1.0
|
|
||||||
offset = .zero
|
|
||||||
lastScale = 1.0
|
|
||||||
lastOffset = .zero
|
|
||||||
} else {
|
|
||||||
scale = 2.5
|
|
||||||
lastScale = 2.5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.gesture(tapGesture(in: geometry))
|
||||||
|
.gesture(magnificationGesture(in: geometry))
|
||||||
|
.simultaneousGesture(dragGesture(in: geometry))
|
||||||
|
.simultaneousGesture(longPressGesture)
|
||||||
|
.onTapGesture(count: 2) {
|
||||||
|
doubleTapZoom()
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
viewSize = geometry.size
|
||||||
|
}
|
||||||
|
.onChange(of: geometry.size) { _, newSize in
|
||||||
|
viewSize = newSize
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.clipped()
|
.clipped()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Gestures
|
||||||
|
|
||||||
|
private func tapGesture(in geometry: GeometryProxy) -> some Gesture {
|
||||||
|
SpatialTapGesture()
|
||||||
|
.onEnded { value in
|
||||||
|
guard !viewModel.isProcessing,
|
||||||
|
!viewModel.showingMaskConfirmation,
|
||||||
|
viewModel.selectedTool != .brush else { return }
|
||||||
|
|
||||||
|
let imagePoint = convertViewPointToImagePoint(value.location, in: geometry.size)
|
||||||
|
Task {
|
||||||
|
await viewModel.handleTap(at: imagePoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func magnificationGesture(in geometry: GeometryProxy) -> some Gesture {
|
||||||
|
MagnificationGesture()
|
||||||
|
.onChanged { value in
|
||||||
|
let newScale = lastScale * value
|
||||||
|
scale = min(max(newScale, minScale), maxScale)
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
lastScale = scale
|
||||||
|
withAnimation(.spring(duration: 0.3)) {
|
||||||
|
clampOffset(in: geometry.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dragGesture(in geometry: GeometryProxy) -> some Gesture {
|
||||||
|
DragGesture()
|
||||||
|
.onChanged { value in
|
||||||
|
if scale > 1.0 {
|
||||||
|
offset = CGSize(
|
||||||
|
width: lastOffset.width + value.translation.width,
|
||||||
|
height: lastOffset.height + value.translation.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
lastOffset = offset
|
||||||
|
withAnimation(.spring(duration: 0.3)) {
|
||||||
|
clampOffset(in: geometry.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var longPressGesture: some Gesture {
|
||||||
|
LongPressGesture(minimumDuration: 0.3)
|
||||||
|
.onEnded { _ in
|
||||||
|
// Toggle to show original briefly
|
||||||
|
isShowingOriginal = true
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .seconds(0.5))
|
||||||
|
isShowingOriginal = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func doubleTapZoom() {
|
||||||
|
withAnimation(.spring(duration: 0.3)) {
|
||||||
|
if scale > 1.0 {
|
||||||
|
scale = 1.0
|
||||||
|
offset = .zero
|
||||||
|
lastScale = 1.0
|
||||||
|
lastOffset = .zero
|
||||||
|
} else {
|
||||||
|
scale = 2.5
|
||||||
|
lastScale = 2.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Coordinate Conversion
|
||||||
|
|
||||||
|
private func convertViewPointToImagePoint(_ viewPoint: CGPoint, in viewSize: CGSize) -> CGPoint {
|
||||||
|
let imageSize = viewModel.imageSize
|
||||||
|
guard imageSize.width > 0, imageSize.height > 0 else { return viewPoint }
|
||||||
|
|
||||||
|
// Calculate the displayed image frame
|
||||||
|
let imageAspect = imageSize.width / imageSize.height
|
||||||
|
let viewAspect = viewSize.width / viewSize.height
|
||||||
|
|
||||||
|
let displayedSize: CGSize
|
||||||
|
if imageAspect > viewAspect {
|
||||||
|
displayedSize = CGSize(
|
||||||
|
width: viewSize.width,
|
||||||
|
height: viewSize.width / imageAspect
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
displayedSize = CGSize(
|
||||||
|
width: viewSize.height * imageAspect,
|
||||||
|
height: viewSize.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account for centering
|
||||||
|
let displayedOrigin = CGPoint(
|
||||||
|
x: (viewSize.width - displayedSize.width) / 2,
|
||||||
|
y: (viewSize.height - displayedSize.height) / 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// Account for scale and offset
|
||||||
|
let scaledSize = CGSize(
|
||||||
|
width: displayedSize.width * scale,
|
||||||
|
height: displayedSize.height * scale
|
||||||
|
)
|
||||||
|
|
||||||
|
let scaledOrigin = CGPoint(
|
||||||
|
x: (viewSize.width - scaledSize.width) / 2 + offset.width,
|
||||||
|
y: (viewSize.height - scaledSize.height) / 2 + offset.height
|
||||||
|
)
|
||||||
|
|
||||||
|
// Convert view point to image coordinates
|
||||||
|
let relativeX = (viewPoint.x - scaledOrigin.x) / scaledSize.width
|
||||||
|
let relativeY = (viewPoint.y - scaledOrigin.y) / scaledSize.height
|
||||||
|
|
||||||
|
return CGPoint(
|
||||||
|
x: relativeX * imageSize.width,
|
||||||
|
y: relativeY * imageSize.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func clampOffset(in size: CGSize) {
|
private func clampOffset(in size: CGSize) {
|
||||||
guard scale > 1.0 else {
|
guard scale > 1.0 else {
|
||||||
offset = .zero
|
offset = .zero
|
||||||
@@ -94,7 +197,10 @@ struct CanvasView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let imageAspect = image.size.width / image.size.height
|
let imageSize = viewModel.imageSize
|
||||||
|
guard imageSize.width > 0, imageSize.height > 0 else { return }
|
||||||
|
|
||||||
|
let imageAspect = imageSize.width / imageSize.height
|
||||||
let viewAspect = size.width / size.height
|
let viewAspect = size.width / size.height
|
||||||
|
|
||||||
let imageDisplaySize: CGSize
|
let imageDisplaySize: CGSize
|
||||||
@@ -125,5 +231,6 @@ struct CanvasView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
CanvasView(image: UIImage(systemName: "photo")!)
|
let viewModel = EditorViewModel()
|
||||||
|
return CanvasView(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
|
|||||||
249
CheapRetouch/Features/Editor/EditorViewModel.swift
Normal file
249
CheapRetouch/Features/Editor/EditorViewModel.swift
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
//
|
||||||
|
// EditorViewModel.swift
|
||||||
|
// CheapRetouch
|
||||||
|
//
|
||||||
|
// View model coordinating edit operations between services and UI.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
import CoreGraphics
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class EditorViewModel {
|
||||||
|
|
||||||
|
// MARK: - State
|
||||||
|
|
||||||
|
var originalImage: CGImage?
|
||||||
|
var editedImage: CGImage?
|
||||||
|
var maskPreview: CGImage?
|
||||||
|
var selectedTool: EditTool = .person
|
||||||
|
var brushSize: CGFloat = 20
|
||||||
|
var featherAmount: CGFloat = 4
|
||||||
|
var wireWidth: CGFloat = 6
|
||||||
|
|
||||||
|
var isProcessing = false
|
||||||
|
var processingMessage = ""
|
||||||
|
var errorMessage: String?
|
||||||
|
var showingMaskConfirmation = false
|
||||||
|
|
||||||
|
private(set) var project: Project?
|
||||||
|
|
||||||
|
// MARK: - Services
|
||||||
|
|
||||||
|
private let maskingService = MaskingService()
|
||||||
|
private let contourService = ContourService()
|
||||||
|
private let inpaintEngine = InpaintEngine()
|
||||||
|
private let imagePipeline = ImagePipeline()
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
var displayImage: CGImage? {
|
||||||
|
editedImage ?? originalImage
|
||||||
|
}
|
||||||
|
|
||||||
|
var canUndo: Bool {
|
||||||
|
project?.canUndo ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
var canRedo: Bool {
|
||||||
|
project?.canRedo ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageSize: CGSize {
|
||||||
|
guard let image = originalImage else { return .zero }
|
||||||
|
return CGSize(width: image.width, height: image.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Image Loading
|
||||||
|
|
||||||
|
func loadImage(_ uiImage: UIImage) {
|
||||||
|
guard let cgImage = uiImage.cgImage else { return }
|
||||||
|
|
||||||
|
originalImage = cgImage
|
||||||
|
editedImage = nil
|
||||||
|
maskPreview = nil
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
// Create new project
|
||||||
|
let imageData = uiImage.jpegData(compressionQuality: 0.9) ?? Data()
|
||||||
|
project = Project(imageSource: .embedded(data: imageData))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tap Handling
|
||||||
|
|
||||||
|
func handleTap(at point: CGPoint) async {
|
||||||
|
guard let image = displayImage else { return }
|
||||||
|
|
||||||
|
isProcessing = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
switch selectedTool {
|
||||||
|
case .person:
|
||||||
|
processingMessage = "Detecting person..."
|
||||||
|
try await handlePersonTap(at: point, in: image)
|
||||||
|
|
||||||
|
case .object:
|
||||||
|
processingMessage = "Detecting object..."
|
||||||
|
try await handleObjectTap(at: point, in: image)
|
||||||
|
|
||||||
|
case .wire:
|
||||||
|
processingMessage = "Detecting wire..."
|
||||||
|
try await handleWireTap(at: point, in: image)
|
||||||
|
|
||||||
|
case .brush:
|
||||||
|
// Brush tool handles drawing directly in CanvasView
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
isProcessing = false
|
||||||
|
processingMessage = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handlePersonTap(at point: CGPoint, in image: CGImage) async throws {
|
||||||
|
let mask = try await maskingService.generatePersonMask(at: point, in: image)
|
||||||
|
|
||||||
|
guard let mask = mask else {
|
||||||
|
errorMessage = "No person found at tap location"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maskPreview = mask
|
||||||
|
showingMaskConfirmation = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleObjectTap(at point: CGPoint, in image: CGImage) async throws {
|
||||||
|
let mask = try await maskingService.generateForegroundMask(at: point, in: image)
|
||||||
|
|
||||||
|
guard let mask = mask else {
|
||||||
|
errorMessage = "Couldn't detect object. Try the brush tool to select manually."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maskPreview = mask
|
||||||
|
showingMaskConfirmation = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleWireTap(at point: CGPoint, in image: CGImage) async throws {
|
||||||
|
let contours = try await contourService.detectContours(in: image)
|
||||||
|
let bestContour = await contourService.findBestWireContour(
|
||||||
|
at: point,
|
||||||
|
from: contours,
|
||||||
|
imageSize: CGSize(width: image.width, height: image.height)
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let contour = bestContour else {
|
||||||
|
errorMessage = "No lines detected. Use the line brush to draw along the wire."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let mask = await contourService.contourToMask(
|
||||||
|
contour,
|
||||||
|
width: Int(wireWidth),
|
||||||
|
imageSize: CGSize(width: image.width, height: image.height)
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let mask = mask else {
|
||||||
|
errorMessage = "Failed to create mask from contour"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maskPreview = mask
|
||||||
|
showingMaskConfirmation = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mask Confirmation
|
||||||
|
|
||||||
|
func confirmMask() async {
|
||||||
|
guard let image = displayImage, let mask = maskPreview else { return }
|
||||||
|
|
||||||
|
isProcessing = true
|
||||||
|
processingMessage = "Removing..."
|
||||||
|
|
||||||
|
do {
|
||||||
|
let result = try await inpaintEngine.inpaint(image: image, mask: mask)
|
||||||
|
editedImage = result
|
||||||
|
|
||||||
|
// Add operation to project
|
||||||
|
if var project = project {
|
||||||
|
let maskData = MaskData(from: mask)?.data ?? Data()
|
||||||
|
let maskOp = MaskOperation(toolType: toolTypeForCurrentTool(), maskData: maskData)
|
||||||
|
let inpaintOp = InpaintOperation(maskOperationId: maskOp.id, featherAmount: Float(featherAmount))
|
||||||
|
|
||||||
|
project.addOperation(.mask(maskOp))
|
||||||
|
project.addOperation(.inpaint(inpaintOp))
|
||||||
|
self.project = project
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
maskPreview = nil
|
||||||
|
showingMaskConfirmation = false
|
||||||
|
isProcessing = false
|
||||||
|
processingMessage = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelMask() {
|
||||||
|
maskPreview = nil
|
||||||
|
showingMaskConfirmation = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Undo/Redo
|
||||||
|
|
||||||
|
func undo() async {
|
||||||
|
guard var project = project, project.undo() else { return }
|
||||||
|
self.project = project
|
||||||
|
await rebuildEditedImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
func redo() async {
|
||||||
|
guard var project = project, project.redo() else { return }
|
||||||
|
self.project = project
|
||||||
|
await rebuildEditedImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rebuildEditedImage() async {
|
||||||
|
guard let original = originalImage, let project = project else { return }
|
||||||
|
|
||||||
|
isProcessing = true
|
||||||
|
processingMessage = "Rebuilding..."
|
||||||
|
|
||||||
|
do {
|
||||||
|
let result = try await imagePipeline.renderPreview(
|
||||||
|
originalImage: original,
|
||||||
|
operations: project.activeOperations
|
||||||
|
)
|
||||||
|
editedImage = result
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
isProcessing = false
|
||||||
|
processingMessage = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Brush Tool
|
||||||
|
|
||||||
|
func applyBrushMask(_ mask: CGImage) async {
|
||||||
|
maskPreview = mask
|
||||||
|
await confirmMask()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func toolTypeForCurrentTool() -> ToolType {
|
||||||
|
switch selectedTool {
|
||||||
|
case .person: return .person
|
||||||
|
case .object: return .object
|
||||||
|
case .wire: return .wire
|
||||||
|
case .brush: return .brush
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,24 +10,39 @@ import PhotosUI
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
struct PhotoEditorView: View {
|
struct PhotoEditorView: View {
|
||||||
@State private var selectedImage: UIImage?
|
@State private var viewModel = EditorViewModel()
|
||||||
@State private var selectedItem: PhotosPickerItem?
|
@State private var selectedItem: PhotosPickerItem?
|
||||||
@State private var isShowingPicker = false
|
@State private var isShowingPicker = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(.systemBackground)
|
Color.black
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
if let image = selectedImage {
|
if viewModel.originalImage != nil {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
CanvasView(image: image)
|
CanvasView(viewModel: viewModel)
|
||||||
ToolbarView()
|
|
||||||
|
if viewModel.showingMaskConfirmation {
|
||||||
|
maskConfirmationBar
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarView(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
EmptyStateView(isShowingPicker: $isShowingPicker)
|
EmptyStateView(isShowingPicker: $isShowingPicker)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Processing overlay
|
||||||
|
if viewModel.isProcessing {
|
||||||
|
processingOverlay
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error toast
|
||||||
|
if let error = viewModel.errorMessage {
|
||||||
|
errorToast(message: error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("CheapRetouch")
|
.navigationTitle("CheapRetouch")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@@ -36,19 +51,98 @@ struct PhotoEditorView: View {
|
|||||||
PhotosPicker(selection: $selectedItem, matching: .images) {
|
PhotosPicker(selection: $selectedItem, matching: .images) {
|
||||||
Image(systemName: "photo.on.rectangle")
|
Image(systemName: "photo.on.rectangle")
|
||||||
}
|
}
|
||||||
|
.disabled(viewModel.isProcessing)
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.originalImage != nil {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
// Export action
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isProcessing || viewModel.editedImage == nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: selectedItem) { oldValue, newValue in
|
.onChange(of: selectedItem) { oldValue, newValue in
|
||||||
Task {
|
Task {
|
||||||
if let data = try? await newValue?.loadTransferable(type: Data.self),
|
if let data = try? await newValue?.loadTransferable(type: Data.self),
|
||||||
let uiImage = UIImage(data: data) {
|
let uiImage = UIImage(data: data) {
|
||||||
selectedImage = uiImage
|
viewModel.loadImage(uiImage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.photosPicker(isPresented: $isShowingPicker, selection: $selectedItem, matching: .images)
|
.photosPicker(isPresented: $isShowingPicker, selection: $selectedItem, matching: .images)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var maskConfirmationBar: some View {
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
Button {
|
||||||
|
viewModel.cancelMask()
|
||||||
|
} label: {
|
||||||
|
Label("Cancel", systemImage: "xmark")
|
||||||
|
.font(.headline)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await viewModel.confirmMask()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Remove", systemImage: "checkmark")
|
||||||
|
.font(.headline)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var processingOverlay: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(1.2)
|
||||||
|
|
||||||
|
if !viewModel.processingMessage.isEmpty {
|
||||||
|
Text(viewModel.processingMessage)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func errorToast(message: String) -> some View {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.yellow)
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
.padding()
|
||||||
|
.padding(.bottom, 80)
|
||||||
|
}
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
.onAppear {
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .seconds(3))
|
||||||
|
viewModel.errorMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct EmptyStateView: View {
|
struct EmptyStateView: View {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ enum EditTool: String, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var description: String {
|
var toolDescription: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .person: return "Tap to remove people"
|
case .person: return "Tap to remove people"
|
||||||
case .object: return "Tap to remove objects"
|
case .object: return "Tap to remove objects"
|
||||||
@@ -35,19 +35,14 @@ enum EditTool: String, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ToolbarView: View {
|
struct ToolbarView: View {
|
||||||
@State private var selectedTool: EditTool = .person
|
@Bindable var viewModel: EditorViewModel
|
||||||
@State private var brushSize: Double = 20
|
|
||||||
@State private var featherAmount: Double = 4
|
|
||||||
@State private var wireWidth: Double = 6
|
|
||||||
@State private var canUndo = false
|
|
||||||
@State private var canRedo = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
// Inspector panel (contextual)
|
// Inspector panel (contextual)
|
||||||
if selectedTool == .brush || selectedTool == .wire {
|
if viewModel.selectedTool == .brush || viewModel.selectedTool == .wire {
|
||||||
inspectorPanel
|
inspectorPanel
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
}
|
}
|
||||||
@@ -63,35 +58,39 @@ struct ToolbarView: View {
|
|||||||
|
|
||||||
// Undo/Redo
|
// Undo/Redo
|
||||||
Button {
|
Button {
|
||||||
// Undo action
|
Task {
|
||||||
|
await viewModel.undo()
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "arrow.uturn.backward")
|
Image(systemName: "arrow.uturn.backward")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.frame(width: 44, height: 44)
|
.frame(width: 44, height: 44)
|
||||||
}
|
}
|
||||||
.disabled(!canUndo)
|
.disabled(!viewModel.canUndo || viewModel.isProcessing)
|
||||||
.accessibilityLabel("Undo")
|
.accessibilityLabel("Undo")
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
// Redo action
|
Task {
|
||||||
|
await viewModel.redo()
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "arrow.uturn.forward")
|
Image(systemName: "arrow.uturn.forward")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.frame(width: 44, height: 44)
|
.frame(width: 44, height: 44)
|
||||||
}
|
}
|
||||||
.disabled(!canRedo)
|
.disabled(!viewModel.canRedo || viewModel.isProcessing)
|
||||||
.accessibilityLabel("Redo")
|
.accessibilityLabel("Redo")
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.background(Color(.systemBackground))
|
.background(Color(.systemBackground))
|
||||||
}
|
}
|
||||||
.animation(.easeInOut(duration: 0.2), value: selectedTool)
|
.animation(.easeInOut(duration: 0.2), value: viewModel.selectedTool)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toolButton(for tool: EditTool) -> some View {
|
private func toolButton(for tool: EditTool) -> some View {
|
||||||
Button {
|
Button {
|
||||||
selectedTool = tool
|
viewModel.selectedTool = tool
|
||||||
} label: {
|
} label: {
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
Image(systemName: tool.icon)
|
Image(systemName: tool.icon)
|
||||||
@@ -101,51 +100,52 @@ struct ToolbarView: View {
|
|||||||
Text(tool.rawValue)
|
Text(tool.rawValue)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
}
|
}
|
||||||
.foregroundStyle(selectedTool == tool ? Color.accentColor : Color.secondary)
|
.foregroundStyle(viewModel.selectedTool == tool ? Color.accentColor : Color.secondary)
|
||||||
.frame(width: 60)
|
.frame(width: 60)
|
||||||
}
|
}
|
||||||
|
.disabled(viewModel.isProcessing)
|
||||||
.accessibilityLabel("\(tool.rawValue) tool")
|
.accessibilityLabel("\(tool.rawValue) tool")
|
||||||
.accessibilityHint(tool.description)
|
.accessibilityHint(tool.toolDescription)
|
||||||
.accessibilityAddTraits(selectedTool == tool ? .isSelected : [])
|
.accessibilityAddTraits(viewModel.selectedTool == tool ? .isSelected : [])
|
||||||
}
|
}
|
||||||
|
|
||||||
private var inspectorPanel: some View {
|
private var inspectorPanel: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
if selectedTool == .brush {
|
if viewModel.selectedTool == .brush {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Brush Size")
|
Text("Brush Size")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(Int(brushSize))px")
|
Text("\(Int(viewModel.brushSize))px")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Slider(value: $brushSize, in: 1...100, step: 1)
|
Slider(value: $viewModel.brushSize, in: 1...100, step: 1)
|
||||||
.accessibilityLabel("Brush size slider")
|
.accessibilityLabel("Brush size slider")
|
||||||
|
|
||||||
Stepper("", value: $brushSize, in: 1...100, step: 1)
|
Stepper("", value: $viewModel.brushSize, in: 1...100, step: 1)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.accessibilityLabel("Brush size stepper")
|
.accessibilityLabel("Brush size stepper")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if selectedTool == .wire {
|
if viewModel.selectedTool == .wire {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Line Width")
|
Text("Line Width")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(Int(wireWidth))px")
|
Text("\(Int(viewModel.wireWidth))px")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
}
|
}
|
||||||
|
|
||||||
Slider(value: $wireWidth, in: 2...20, step: 1)
|
Slider(value: $viewModel.wireWidth, in: 2...20, step: 1)
|
||||||
.accessibilityLabel("Wire width slider")
|
.accessibilityLabel("Wire width slider")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,13 +153,13 @@ struct ToolbarView: View {
|
|||||||
Text("Feather")
|
Text("Feather")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(Int(featherAmount))px")
|
Text("\(Int(viewModel.featherAmount))px")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
}
|
}
|
||||||
|
|
||||||
Slider(value: $featherAmount, in: 0...20, step: 1)
|
Slider(value: $viewModel.featherAmount, in: 0...20, step: 1)
|
||||||
.accessibilityLabel("Feather amount slider")
|
.accessibilityLabel("Feather amount slider")
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
@@ -169,8 +169,9 @@ struct ToolbarView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
VStack {
|
let viewModel = EditorViewModel()
|
||||||
|
return VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
ToolbarView()
|
ToolbarView(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user