From daa8ba82bb94e679f86abfae115737cf983ee115 Mon Sep 17 00:00:00 2001 From: jared Date: Fri, 23 Jan 2026 23:29:21 -0500 Subject: [PATCH] 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 --- .claude/settings.local.json | 6 +- CheapRetouch/Features/Editor/CanvasView.swift | 225 +++++++++++----- .../Features/Editor/EditorViewModel.swift | 249 ++++++++++++++++++ .../Features/Editor/PhotoEditorView.swift | 106 +++++++- .../Features/Editor/ToolbarView.swift | 57 ++-- 5 files changed, 549 insertions(+), 94 deletions(-) create mode 100644 CheapRetouch/Features/Editor/EditorViewModel.swift diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e0265de..ad4c6fa 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,11 @@ "WebFetch(domain:github.com)", "WebFetch(domain:raw.githubusercontent.com)", "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 \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)" ] } } diff --git a/CheapRetouch/Features/Editor/CanvasView.swift b/CheapRetouch/Features/Editor/CanvasView.swift index a6bdc3d..388827f 100644 --- a/CheapRetouch/Features/Editor/CanvasView.swift +++ b/CheapRetouch/Features/Editor/CanvasView.swift @@ -9,13 +9,14 @@ import SwiftUI import UIKit struct CanvasView: View { - let image: UIImage + @Bindable var viewModel: EditorViewModel @State private var scale: CGFloat = 1.0 @State private var lastScale: CGFloat = 1.0 @State private var offset: CGSize = .zero @State private var lastOffset: CGSize = .zero @State private var isShowingOriginal = false + @State private var viewSize: CGSize = .zero private let minScale: CGFloat = 1.0 private let maxScale: CGFloat = 10.0 @@ -25,68 +26,170 @@ struct CanvasView: View { ZStack { Color.black - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .scaleEffect(scale) - .offset(offset) - .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) - } - } - ) - .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 - } - } + if let cgImage = isShowingOriginal ? viewModel.originalImage : viewModel.displayImage { + // Main image + Image(decorative: cgImage, scale: 1.0) + .resizable() + .aspectRatio(contentMode: .fit) + .scaleEffect(scale) + .offset(offset) + + // Mask overlay + if let mask = viewModel.maskPreview { + Image(decorative: mask, scale: 1.0) + .resizable() + .aspectRatio(contentMode: .fit) + .scaleEffect(scale) + .offset(offset) + .blendMode(.multiply) + .colorMultiply(.red.opacity(0.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() } + // 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) { guard scale > 1.0 else { offset = .zero @@ -94,7 +197,10 @@ struct CanvasView: View { 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 imageDisplaySize: CGSize @@ -125,5 +231,6 @@ struct CanvasView: View { } #Preview { - CanvasView(image: UIImage(systemName: "photo")!) + let viewModel = EditorViewModel() + return CanvasView(viewModel: viewModel) } diff --git a/CheapRetouch/Features/Editor/EditorViewModel.swift b/CheapRetouch/Features/Editor/EditorViewModel.swift new file mode 100644 index 0000000..96572cf --- /dev/null +++ b/CheapRetouch/Features/Editor/EditorViewModel.swift @@ -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 + } + } +} diff --git a/CheapRetouch/Features/Editor/PhotoEditorView.swift b/CheapRetouch/Features/Editor/PhotoEditorView.swift index a45eef6..6c3bfcd 100644 --- a/CheapRetouch/Features/Editor/PhotoEditorView.swift +++ b/CheapRetouch/Features/Editor/PhotoEditorView.swift @@ -10,24 +10,39 @@ import PhotosUI import UIKit struct PhotoEditorView: View { - @State private var selectedImage: UIImage? + @State private var viewModel = EditorViewModel() @State private var selectedItem: PhotosPickerItem? @State private var isShowingPicker = false var body: some View { NavigationStack { ZStack { - Color(.systemBackground) + Color.black .ignoresSafeArea() - if let image = selectedImage { + if viewModel.originalImage != nil { VStack(spacing: 0) { - CanvasView(image: image) - ToolbarView() + CanvasView(viewModel: viewModel) + + if viewModel.showingMaskConfirmation { + maskConfirmationBar + } + + ToolbarView(viewModel: viewModel) } } else { EmptyStateView(isShowingPicker: $isShowingPicker) } + + // Processing overlay + if viewModel.isProcessing { + processingOverlay + } + + // Error toast + if let error = viewModel.errorMessage { + errorToast(message: error) + } } .navigationTitle("CheapRetouch") .navigationBarTitleDisplayMode(.inline) @@ -36,19 +51,98 @@ struct PhotoEditorView: View { PhotosPicker(selection: $selectedItem, matching: .images) { 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 Task { if let data = try? await newValue?.loadTransferable(type: Data.self), let uiImage = UIImage(data: data) { - selectedImage = uiImage + viewModel.loadImage(uiImage) } } } .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 { diff --git a/CheapRetouch/Features/Editor/ToolbarView.swift b/CheapRetouch/Features/Editor/ToolbarView.swift index f803362..98b75d3 100644 --- a/CheapRetouch/Features/Editor/ToolbarView.swift +++ b/CheapRetouch/Features/Editor/ToolbarView.swift @@ -24,7 +24,7 @@ enum EditTool: String, CaseIterable, Identifiable { } } - var description: String { + var toolDescription: String { switch self { case .person: return "Tap to remove people" case .object: return "Tap to remove objects" @@ -35,19 +35,14 @@ enum EditTool: String, CaseIterable, Identifiable { } struct ToolbarView: View { - @State private var selectedTool: EditTool = .person - @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 + @Bindable var viewModel: EditorViewModel var body: some View { VStack(spacing: 0) { Divider() // Inspector panel (contextual) - if selectedTool == .brush || selectedTool == .wire { + if viewModel.selectedTool == .brush || viewModel.selectedTool == .wire { inspectorPanel .transition(.move(edge: .bottom).combined(with: .opacity)) } @@ -63,35 +58,39 @@ struct ToolbarView: View { // Undo/Redo Button { - // Undo action + Task { + await viewModel.undo() + } } label: { Image(systemName: "arrow.uturn.backward") .font(.title3) .frame(width: 44, height: 44) } - .disabled(!canUndo) + .disabled(!viewModel.canUndo || viewModel.isProcessing) .accessibilityLabel("Undo") Button { - // Redo action + Task { + await viewModel.redo() + } } label: { Image(systemName: "arrow.uturn.forward") .font(.title3) .frame(width: 44, height: 44) } - .disabled(!canRedo) + .disabled(!viewModel.canRedo || viewModel.isProcessing) .accessibilityLabel("Redo") } .padding(.horizontal) .padding(.vertical, 8) .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 { Button { - selectedTool = tool + viewModel.selectedTool = tool } label: { VStack(spacing: 4) { Image(systemName: tool.icon) @@ -101,51 +100,52 @@ struct ToolbarView: View { Text(tool.rawValue) .font(.caption2) } - .foregroundStyle(selectedTool == tool ? Color.accentColor : Color.secondary) + .foregroundStyle(viewModel.selectedTool == tool ? Color.accentColor : Color.secondary) .frame(width: 60) } + .disabled(viewModel.isProcessing) .accessibilityLabel("\(tool.rawValue) tool") - .accessibilityHint(tool.description) - .accessibilityAddTraits(selectedTool == tool ? .isSelected : []) + .accessibilityHint(tool.toolDescription) + .accessibilityAddTraits(viewModel.selectedTool == tool ? .isSelected : []) } private var inspectorPanel: some View { VStack(spacing: 12) { Divider() - if selectedTool == .brush { + if viewModel.selectedTool == .brush { HStack { Text("Brush Size") .font(.subheadline) Spacer() - Text("\(Int(brushSize))px") + Text("\(Int(viewModel.brushSize))px") .font(.subheadline) .foregroundStyle(.secondary) .monospacedDigit() } HStack { - Slider(value: $brushSize, in: 1...100, step: 1) + Slider(value: $viewModel.brushSize, in: 1...100, step: 1) .accessibilityLabel("Brush size slider") - Stepper("", value: $brushSize, in: 1...100, step: 1) + Stepper("", value: $viewModel.brushSize, in: 1...100, step: 1) .labelsHidden() .accessibilityLabel("Brush size stepper") } } - if selectedTool == .wire { + if viewModel.selectedTool == .wire { HStack { Text("Line Width") .font(.subheadline) Spacer() - Text("\(Int(wireWidth))px") + Text("\(Int(viewModel.wireWidth))px") .font(.subheadline) .foregroundStyle(.secondary) .monospacedDigit() } - Slider(value: $wireWidth, in: 2...20, step: 1) + Slider(value: $viewModel.wireWidth, in: 2...20, step: 1) .accessibilityLabel("Wire width slider") } @@ -153,13 +153,13 @@ struct ToolbarView: View { Text("Feather") .font(.subheadline) Spacer() - Text("\(Int(featherAmount))px") + Text("\(Int(viewModel.featherAmount))px") .font(.subheadline) .foregroundStyle(.secondary) .monospacedDigit() } - Slider(value: $featherAmount, in: 0...20, step: 1) + Slider(value: $viewModel.featherAmount, in: 0...20, step: 1) .accessibilityLabel("Feather amount slider") } .padding(.horizontal) @@ -169,8 +169,9 @@ struct ToolbarView: View { } #Preview { - VStack { + let viewModel = EditorViewModel() + return VStack { Spacer() - ToolbarView() + ToolbarView(viewModel: viewModel) } }