import AVFoundation import Observation import SceneKit import SwiftUI import UIKit @Observable final class InventoryStore { enum Mode: String, CaseIterable, Identifiable { case sunseeker = "Sunseeker" case dreamWeaver = "Dream Weaver" case dollhouse = "Dollhouse" var id: String { rawValue } } var mode: Mode = .sunseeker var sourceImage: UIImage? var generatedImage: UIImage? var isProcessing: Bool = false var sunNodesReady: Bool = false var dollhouseHour: Double = 12 // Error message shown in the DreamWeaver panel var errorMessage: String? } struct InventoryView: View { @State private var store = InventoryStore() @State private var showCamera = false @State private var sliderTickHour = 12 @State private var showShareSheet = false @State private var shareImage: UIImage? = nil private let hasDollhouseAsset = InventoryModeAvailability.hasShippedDollhouseAsset() private let haptics = UIImpactFeedbackGenerator(style: .light) private var visibleModes: [InventoryStore.Mode] { InventoryModeAvailability.productionVisibleModes(hasDollhouseAsset: hasDollhouseAsset) } private var selectedMode: InventoryStore.Mode { InventoryModeAvailability.sanitizedProductionSelection(store.mode, hasDollhouseAsset: hasDollhouseAsset) } private var modeSelection: Binding { Binding( get: { selectedMode }, set: { newValue in store.mode = InventoryModeAvailability.sanitizedProductionSelection( newValue, hasDollhouseAsset: hasDollhouseAsset ) } ) } var body: some View { VStack(alignment: .leading, spacing: 16) { // Page header — share button sits on the same baseline as the title HStack(alignment: .top) { VStack(alignment: .leading, spacing: 4) { Text("Inventory") .font(.system(size: 28, weight: .bold)) .foregroundStyle(VelocityTheme.foreground) Text(InventoryModeAvailability.modeSummaryText(hasDollhouseAsset: hasDollhouseAsset)) .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } Spacer() if let img = store.generatedImage { Button { shareImage = img showShareSheet = true } label: { Image(systemName: "square.and.arrow.up") .font(.system(size: 20, weight: .medium)) .foregroundStyle(VelocityTheme.foreground) .frame(width: 44, height: 44) .contentShape(Rectangle()) } .buttonStyle(.plain) .transition(.opacity.combined(with: .scale)) } } .animation(.easeInOut(duration: 0.2), value: store.generatedImage != nil) .padding(.horizontal, 20) .padding(.top, 20) Picker("Mode", selection: modeSelection) { ForEach(visibleModes) { mode in Text(mode.rawValue).tag(mode) } } .pickerStyle(.segmented) .padding(.horizontal, 20) .padding(.top, 12) if !hasDollhouseAsset { ProductionScopeCard( icon: "cube.transparent", title: "Dollhouse hidden in this production build", message: "Dollhouse stays out of the production iPad scope until a verified Building.usdz or Building.scn asset is shipped in the app bundle." ) .padding(.horizontal, 20) } Group { switch selectedMode { case .sunseeker: #if targetEnvironment(simulator) SimulatorUnavailableCard( icon: "arkit", title: "Sunseeker requires a real device", message: "The production build no longer renders a simulated AR sun path with fake location or heading data. Use a physical iPad to inspect the live camera-based overlay." ) #else SunseekerPanel(sunNodesReady: $store.sunNodesReady) #endif case .dreamWeaver: // No simulator guard here — CameraPicker automatically falls back // to the photo library when no camera is available (e.g. Simulator), // so the full Capture → Reimagine → API flow is testable without a device. DreamWeaverPanel( sourceImage: $store.sourceImage, generatedImage: $store.generatedImage, isProcessing: $store.isProcessing, errorMessage: $store.errorMessage, showCamera: $showCamera ) case .dollhouse: DollhousePanel(hour: $store.dollhouseHour, tickHour: $sliderTickHour, haptics: haptics) } } .padding(.horizontal, 20) .padding(.bottom, 20) .animation(.easeInOut(duration: 0.25), value: store.mode) } .background(VelocityTheme.background) .simultaneousGesture( TapGesture().onEnded { UIApplication.shared.sendAction( #selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil ) } ) .onAppear { store.mode = selectedMode UISegmentedControl.appearance().selectedSegmentTintColor = UIColor( red: 0.231, green: 0.510, blue: 0.965, alpha: 0.85) UISegmentedControl.appearance().setTitleTextAttributes( [.foregroundColor: UIColor.white], for: .selected) UISegmentedControl.appearance().setTitleTextAttributes( [.foregroundColor: UIColor(white: 0.62, alpha: 1)], for: .normal) UISegmentedControl.appearance().backgroundColor = UIColor( red: 0.031, green: 0.039, blue: 0.071, alpha: 1) } .sheet(isPresented: $showCamera) { CameraPicker(isPresented: $showCamera) { captured in // Normalise orientation immediately on capture store.sourceImage = captured.fixedOrientation() // Clear previous result and error when a new photo is taken store.generatedImage = nil store.errorMessage = nil } } .sheet(item: $shareImage) { img in ShareSheet(image: img) .ignoresSafeArea() } } } // MARK: - Shared simulator placeholder private struct SimulatorUnavailableCard: View { let icon: String let title: String let message: String var body: some View { ZStack { VStack(spacing: 14) { Image(systemName: icon) .font(.system(size: 40)) .foregroundStyle(VelocityTheme.mutedFg) Text(title) .font(.system(size: 16, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Text(message) .font(.system(size: 13)) .foregroundStyle(VelocityTheme.mutedFg) .multilineTextAlignment(.center) } .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity) } .background( RoundedRectangle(cornerRadius: 16) .fill(Color(red: 0.031, green: 0.039, blue: 0.071)) .overlay( RoundedRectangle(cornerRadius: 16) .stroke(VelocityTheme.borderAccent, lineWidth: 1) ) ) } } private struct ProductionScopeCard: View { let icon: String let title: String let message: String var body: some View { HStack(alignment: .top, spacing: 12) { Image(systemName: icon) .font(.system(size: 18, weight: .semibold)) .foregroundStyle(VelocityTheme.accent) .padding(.top, 2) VStack(alignment: .leading, spacing: 6) { Text(title) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Text(message) .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 0) } .padding(14) .background( RoundedRectangle(cornerRadius: 14) .fill(VelocityTheme.surface) .overlay( RoundedRectangle(cornerRadius: 14) .stroke(VelocityTheme.borderAccent, lineWidth: 1) ) ) } } // MARK: - Sunseeker private struct SunseekerPanel: View { @Binding var sunNodesReady: Bool @State private var vm = SunseekerViewModel() var body: some View { ZStack(alignment: .topLeading) { ARSunOverlayView(sunNodesReady: $sunNodesReady, vm: vm) .clipShape(RoundedRectangle(cornerRadius: 20)) // Retained as a stylistic design element framing the AR view DashedSunLine() .stroke(Color.yellow.opacity(0.9), style: StrokeStyle(lineWidth: 3, dash: [10, 8])) .padding(.horizontal, 24) .padding(.vertical, 80) VStack(alignment: .leading, spacing: 12) { // Info block VStack(alignment: .leading, spacing: 8) { Text("Sunseeker") .font(.headline) Text("Point the iPad toward windows to inspect yearly sun-entry path.") .font(.subheadline) .foregroundStyle(.secondary) } .padding(14) .background { GlassBlurView(style: .systemThinMaterial) .clipShape(RoundedRectangle(cornerRadius: 14)) } if !vm.isReady && vm.locationError == nil { // Loading state HStack(spacing: 8) { ProgressView().tint(.white) Text("Looking for the Sun...") .font(.footnote) .foregroundStyle(.white) } .padding(.horizontal, 14) .padding(.vertical, 10) .background(Color.black.opacity(0.6).clipShape(Capsule())) } // Error banner (e.g. Location Denied) if let error = vm.locationError { HStack(spacing: 12) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.yellow) Text(error) .font(.subheadline) .foregroundStyle(.white) Spacer() Button("Settings") { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } .buttonStyle(.borderedProminent) .tint(.white.opacity(0.2)) } .padding(14) .background { RoundedRectangle(cornerRadius: 14) .fill(Color.red.opacity(0.8)) } } } .padding(20) } } } // MARK: - Dream Weaver /// Available room types from integration guide §2 private struct RoomType: Identifiable { let id: String // sent as the `room_type` form field let displayName: String let icon: String // SF Symbol } private let roomTypes: [RoomType] = [ RoomType(id: "bedroom", displayName: "Bedroom", icon: "bed.double"), RoomType(id: "living_room", displayName: "Living Rm", icon: "sofa"), RoomType(id: "bathroom", displayName: "Bathroom", icon: "drop"), RoomType(id: "kitchen", displayName: "Kitchen", icon: "refrigerator"), RoomType(id: "dining_room", displayName: "Dining Rm", icon: "fork.knife"), RoomType(id: "home_office", displayName: "Office", icon: "desktopcomputer"), RoomType(id: "hallway", displayName: "Hallway", icon: "door.left.hand.open"), RoomType(id: "balcony", displayName: "Balcony", icon: "sun.max"), ] private struct DreamWeaverPanel: View { @Binding var sourceImage: UIImage? @Binding var generatedImage: UIImage? @Binding var isProcessing: Bool @Binding var errorMessage: String? @Binding var showCamera: Bool /// Selected room type ID — sent as `room_type` field. nil = none chosen yet. @State private var selectedRoomType: String? = nil /// Optional extra keywords — sent as `keywords` field (§3.2) @State private var keywords: String = "" /// Server health: nil = checking, true = online, false = offline @State private var serverOnline: Bool? = nil var body: some View { VStack(spacing: 14) { // ── Preview card ────────────────────────────────────────────── ZStack { RoundedRectangle(cornerRadius: 20) .fill(Color.black.opacity(0.9)) if let sourceImage { Image(uiImage: sourceImage) .resizable() .scaledToFit() .clipShape(RoundedRectangle(cornerRadius: 16)) .padding(12) } else { ContentUnavailableView( "No Capture", systemImage: "camera.viewfinder", description: Text("Tap Capture to snap a room.") ) .foregroundStyle(.white) } if let generatedImage { Image(uiImage: generatedImage) .resizable() .scaledToFit() .clipShape(RoundedRectangle(cornerRadius: 16)) .padding(12) .transition(.opacity) } if isProcessing { ProcessingOverlay() } // Server health badge — top-right corner VStack { HStack { Spacer() HStack(spacing: 5) { Circle() .fill(serverOnline == true ? Color.green : serverOnline == false ? Color.red : Color.gray) .frame(width: 7, height: 7) Text(serverOnline == true ? "Server Online" : serverOnline == false ? "Server Offline" : "Checking...") .font(.system(size: 10, weight: .medium)) .foregroundStyle(.white.opacity(0.8)) } .padding(.horizontal, 10) .padding(.vertical, 5) .background(.black.opacity(0.45)) .clipShape(Capsule()) .padding(14) } Spacer() } } .frame(maxWidth: .infinity, minHeight: 420) .animation(.easeInOut(duration: 0.35), value: generatedImage) // ── Error banner ────────────────────────────────────────────── if let errorMessage { HStack(spacing: 10) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.yellow) Text(errorMessage) .font(.footnote) .foregroundStyle(VelocityTheme.foreground) .fixedSize(horizontal: false, vertical: true) Spacer() } .padding(.horizontal, 14) .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: 12) .fill(Color.red.opacity(0.15)) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(Color.red.opacity(0.35), lineWidth: 1) ) ) .transition(.move(edge: .top).combined(with: .opacity)) } // ── Room Type picker ─────────────────────────────────────── ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(roomTypes) { room in Button { withAnimation(.spring(response: 0.3)) { // Tap again to deselect selectedRoomType = selectedRoomType == room.id ? nil : room.id } } label: { HStack(spacing: 6) { Image(systemName: room.icon) .font(.system(size: 11, weight: .medium)) Text(room.displayName) .font(.system(size: 13, weight: .medium)) } .padding(.horizontal, 12) .padding(.vertical, 7) .background( Capsule() .fill(selectedRoomType == room.id ? Color(red: 0.231, green: 0.510, blue: 0.965) : Color.white.opacity(0.08)) ) .foregroundStyle(selectedRoomType == room.id ? .white : .white.opacity(0.6)) .overlay( Capsule() .stroke(selectedRoomType == room.id ? Color.clear : Color.white.opacity(0.12), lineWidth: 1) ) } .buttonStyle(.plain) } } .padding(.horizontal, 2) } // ── Keywords input ─────────────────────────────────────────── PromptInputBar( text: $keywords, isDisabled: sourceImage == nil || isProcessing || serverOnline == false ) { Task { await generate() } } // ── Capture / Retake ───────────────────────────────────────── Button(sourceImage == nil ? "Capture" : "Retake") { showCamera = true } .buttonStyle(.borderedProminent) .frame(maxWidth: .infinity) .disabled(isProcessing) } .padding(16) .background { GlassBlurView(style: .systemThinMaterial) .clipShape(RoundedRectangle(cornerRadius: 18)) .contentShape(RoundedRectangle(cornerRadius: 18)) .onTapGesture { UIApplication.shared.sendAction( #selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil ) } } .animation(.easeInOut(duration: 0.3), value: errorMessage) .task { serverOnline = await ComfyClient.shared.checkHealth() } } @MainActor private func generate() async { guard let sourceImage, !isProcessing else { return } if serverOnline == false { errorMessage = "Server is currently offline. Please try again later." return } isProcessing = true errorMessage = nil do { let result = try await ComfyClient.shared.generateImage( source: sourceImage, roomType: selectedRoomType ?? roomTypes[0].id, // default: bedroom keywords: keywords.trimmingCharacters(in: .whitespaces) ) withAnimation(.easeInOut(duration: 0.4)) { generatedImage = result } } catch { errorMessage = error.localizedDescription } isProcessing = false } } // MARK: - Prompt Input Bar private struct PromptInputBar: View { @Binding var text: String let isDisabled: Bool let onSubmit: () -> Void @FocusState private var isFocused: Bool @State private var shimmer = false private let placeholder = "gold, marble, luxury, etc." var body: some View { HStack(spacing: 10) { ZStack(alignment: .leading) { if text.isEmpty { Text(placeholder) .font(.system(size: 14)) .foregroundStyle(Color.white.opacity(0.35)) .padding(.leading, 4) .allowsHitTesting(false) // let taps pass through to the gesture below } TextField("", text: $text, axis: .vertical) .font(.system(size: 14)) .foregroundStyle(Color.white) .lineLimit(1...3) .focused($isFocused) .submitLabel(.send) .onSubmit { guard !isDisabled, !text.trimmingCharacters(in: .whitespaces).isEmpty else { return } onSubmit() } .tint(Color(red: 0.231, green: 0.510, blue: 0.965)) } .contentShape(Rectangle()) // expand hit area to full ZStack bounds .onTapGesture { isFocused = true } // focus immediately on any tap // Send arrow button Button { isFocused = false onSubmit() } label: { ZStack { Circle() .fill( LinearGradient( colors: [ Color(red: 0.231, green: 0.510, blue: 0.965), Color(red: 0.40, green: 0.25, blue: 0.95) ], startPoint: .topLeading, endPoint: .bottomTrailing ) ) .frame(width: 36, height: 36) Image(systemName: "arrow.up") .font(.system(size: 14, weight: .bold)) .foregroundStyle(.white) } } .disabled(isDisabled || text.trimmingCharacters(in: .whitespaces).isEmpty) .opacity(isDisabled || text.trimmingCharacters(in: .whitespaces).isEmpty ? 0.4 : 1.0) .animation(.easeInOut(duration: 0.2), value: text.isEmpty) } .padding(.horizontal, 14) .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: 14) .fill(Color.white.opacity(0.06)) .overlay( RoundedRectangle(cornerRadius: 14) .stroke( isFocused ? Color(red: 0.231, green: 0.510, blue: 0.965).opacity(0.8) : Color.white.opacity(0.12), lineWidth: 1 ) ) ) .animation(.easeInOut(duration: 0.2), value: isFocused) } } // MARK: - Dollhouse private struct DollhousePanel: View { @Binding var hour: Double @Binding var tickHour: Int let haptics: UIImpactFeedbackGenerator var body: some View { VStack(spacing: 12) { SceneKitDollhouseView(hour: $hour) .clipShape(RoundedRectangle(cornerRadius: 20)) .frame(maxWidth: .infinity, minHeight: 460) VStack(alignment: .leading, spacing: 8) { Text(String(format: "Time: %02d:00", Int(hour.rounded()))) .font(.headline) Slider(value: $hour, in: 0...24, step: 0.25) .onChange(of: hour) { _, newValue in let rounded = Int(newValue.rounded()) if rounded != tickHour { tickHour = rounded haptics.impactOccurred(intensity: 0.7) } } } .padding(14) .background { GlassBlurView(style: .systemThinMaterial) .clipShape(RoundedRectangle(cornerRadius: 14)) } } } } // MARK: - SceneKit Dollhouse private struct SceneKitDollhouseView: UIViewRepresentable { @Binding var hour: Double func makeCoordinator() -> Coordinator { Coordinator() } func makeUIView(context: Context) -> SCNView { let view = SCNView() view.scene = context.coordinator.scene view.autoenablesDefaultLighting = false view.allowsCameraControl = true view.backgroundColor = UIColor.systemBackground context.coordinator.setupScene() context.coordinator.updateSunLight(hour: hour) return view } func updateUIView(_ uiView: SCNView, context: Context) { context.coordinator.updateSunLight(hour: hour) } final class Coordinator { let scene = SCNScene() private let sunNode = SCNNode() func setupScene() { let modelScene = InventoryModeAvailability.dollhouseAssetCandidates .compactMap { candidate in SCNScene(named: "\(candidate.name).\(candidate.ext)") } .first if let modelScene { let container = SCNNode() for child in modelScene.rootNode.childNodes { container.addChildNode(child.clone()) } scene.rootNode.addChildNode(container) } else { let fallback = SCNFloor() fallback.firstMaterial?.diffuse.contents = UIColor.secondarySystemBackground scene.rootNode.addChildNode(SCNNode(geometry: fallback)) } let camera = SCNCamera() let cameraNode = SCNNode() cameraNode.camera = camera cameraNode.position = SCNVector3(0, 4, 10) scene.rootNode.addChildNode(cameraNode) let light = SCNLight() light.type = .directional light.intensity = 1_200 light.castsShadow = true sunNode.light = light scene.rootNode.addChildNode(sunNode) let ambient = SCNLight() ambient.type = .ambient ambient.intensity = 200 let ambientNode = SCNNode() ambientNode.light = ambient scene.rootNode.addChildNode(ambientNode) } func updateSunLight(hour: Double) { let normalized = (hour / 24.0) * (2 * Double.pi) let x = Float(cos(normalized) * 8.0) let y = Float(max(sin(normalized) * 8.0, 1.0)) let z = Float(sin(normalized + .pi / 3) * 6.0) sunNode.position = SCNVector3(x, y, z) sunNode.look(at: SCNVector3(0, 0, 0)) } } } // MARK: - ProcessingOverlay private struct ProcessingOverlay: View { @State private var animate = false var body: some View { ZStack { RoundedRectangle(cornerRadius: 16) .fill(.black.opacity(0.45)) Text("AI Processing...") .font(.headline.weight(.bold)) .foregroundStyle(.white) .padding(.horizontal, 20) .padding(.vertical, 12) .background { GlassBlurView(style: .systemUltraThinMaterialDark) .clipShape(Capsule()) } .overlay( Rectangle() .fill( LinearGradient( colors: [.clear, .white.opacity(0.6), .clear], startPoint: .leading, endPoint: .trailing ) ) .rotationEffect(.degrees(18)) .offset(x: animate ? 160 : -160) .animation(.linear(duration: 1.2).repeatForever(autoreverses: false), value: animate) .blendMode(.screen) .mask(Capsule().frame(height: 44)) ) } .padding(12) .onAppear { animate = true } } } // MARK: - DashedSunLine private struct DashedSunLine: Shape { func path(in rect: CGRect) -> Path { var path = Path() path.move(to: CGPoint(x: rect.minX, y: rect.maxY * 0.75)) path.addQuadCurve( to: CGPoint(x: rect.maxX, y: rect.maxY * 0.25), control: CGPoint(x: rect.midX, y: rect.minY + 30) ) return path } } // MARK: - CameraPicker /// UIImagePickerController wrapper that delivers the captured image via a callback, /// triggering orientation fix and clearing stale state immediately on capture. private struct CameraPicker: UIViewControllerRepresentable { @Binding var isPresented: Bool let onCapture: (UIImage) -> Void func makeCoordinator() -> Coordinator { Coordinator(self) } func makeUIViewController(context: Context) -> UIImagePickerController { let picker = UIImagePickerController() picker.delegate = context.coordinator #if targetEnvironment(simulator) // Newer Simulators report camera as available but the shutter never // delivers an image. Force photo library so testing actually works. picker.sourceType = .photoLibrary #else if UIImagePickerController.isSourceTypeAvailable(.camera) { picker.sourceType = .camera picker.cameraCaptureMode = .photo } else { picker.sourceType = .photoLibrary } #endif return picker } func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { private let parent: CameraPicker init(_ parent: CameraPicker) { self.parent = parent } func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { parent.isPresented = false } func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { if let captured = info[.originalImage] as? UIImage { parent.onCapture(captured) } parent.isPresented = false } } } // MARK: - Share Sheet /// Wraps UIActivityViewController to match the native iOS Photos share experience. /// Natively includes: Save Image, AirDrop, Messages, Mail, Copy, and all installed share extensions. private struct ShareSheet: UIViewControllerRepresentable { let image: UIImage func makeUIViewController(context: Context) -> UIActivityViewController { UIActivityViewController(activityItems: [image], applicationActivities: nil) } func updateUIViewController(_ uvc: UIActivityViewController, context: Context) {} } // MARK: - UIImage + Identifiable // Required to use UIImage as the `item` in .sheet(item:) extension UIImage: @retroactive Identifiable { public var id: ObjectIdentifier { ObjectIdentifier(self) } }