import AVFoundation import ARKit import MapKit 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 = { #if targetEnvironment(simulator) return .dreamWeaver #else return .sunseeker #endif }() var sourceImage: UIImage? var generatedImage: UIImage? var generatedImageResultURL: URL? var shouldAutoGenerateDreamWeaver = false var isProcessing: Bool = false var sunNodesReady: Bool = false var dollhouseHour: Double = 12 var mapDollhouseZoom: CGFloat = 1.0 // Error message shown in the DreamWeaver panel var errorMessage: String? } private struct InventoryContextTheme { let property: VelocityPropertyDTO? var background: LinearGradient { LinearGradient( colors: colors, startPoint: .topLeading, endPoint: .bottomTrailing ) } var shadowColor: Color { switch archetype { case .desertVilla: return Color(red: 0.85, green: 0.56, blue: 0.28).opacity(0.18) case .skylinePenthouse: return Color(red: 0.35, green: 0.55, blue: 0.95).opacity(0.18) case .neutral: return VelocityTheme.accent.opacity(0.10) } } private var colors: [Color] { switch archetype { case .desertVilla: return [ Color(red: 0.055, green: 0.044, blue: 0.035), Color(red: 0.125, green: 0.085, blue: 0.055), Color.black, ] case .skylinePenthouse: return [ Color.black, Color(red: 0.018, green: 0.025, blue: 0.052), Color(red: 0.006, green: 0.008, blue: 0.014), ] case .neutral: return [ VelocityTheme.background, Color(red: 0.015, green: 0.018, blue: 0.030), ] } } private var archetype: Archetype { let text = [ property?.projectName, property?.propertyType, property?.locationSummary, property?.location?["description"]?.stringValue, property?.location?["district"]?.stringValue, ] .compactMap { $0 } .joined(separator: " ") .lowercased() if text.contains("desert") || text.contains("villa") { return .desertVilla } if text.contains("skyline") || text.contains("penthouse") || text.contains("tower") { return .skylinePenthouse } return .neutral } private enum Archetype { case desertVilla case skylinePenthouse case neutral } } struct InventoryView: View { @State private var store = InventoryStore() @State private var appStore = AppStore.shared @State private var showCamera = false @State private var showLiDARScan = false @State private var showARStaging = false @State private var sliderTickHour = 12 @State private var showShareSheet = false @State private var shareImage: UIImage? = nil @State private var selectedPropertyID: String? 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 ) } ) } private var inventoryTheme: InventoryContextTheme { InventoryContextTheme(property: selectedProperty ?? appStore.properties.first) } private var selectedProperty: VelocityPropertyDTO? { guard let selectedPropertyID else { return nil } return appStore.properties.first { $0.propertyId == selectedPropertyID } } private var orderedProperties: [VelocityPropertyDTO] { guard let selectedPropertyID else { return appStore.properties } return appStore.properties.sorted { lhs, rhs in if lhs.propertyId == selectedPropertyID { return true } if rhs.propertyId == selectedPropertyID { return false } return lhs.projectName.localizedCaseInsensitiveCompare(rhs.projectName) == .orderedAscending } } private var hasMappedInventory: Bool { appStore.properties.contains { $0.coordinate != nil } } var body: some View { ScrollView(.vertical, showsIndicators: true) { 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("Property media, sun-path inspection, AI staging, and showroom-ready 3D inventory.") .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, 28) 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: "3D model pending for this build", message: "Dollhouse appears only after a verified Building.usdz or Building.scn asset ships in the app bundle." ) .padding(.horizontal, 20) } propertyMediaSection .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, generatedImageResultURL: $store.generatedImageResultURL, shouldAutoGenerate: $store.shouldAutoGenerateDreamWeaver, isProcessing: $store.isProcessing, errorMessage: $store.errorMessage, showCamera: $showCamera, showLiDARScan: $showLiDARScan, showARStaging: $showARStaging, roomTypes: appStore.crmVocabularies.dreamWeaverRoomTypes ) case .dollhouse: EmptyView() } } .padding(.horizontal, 20) .padding(.bottom, 20) .animation(.easeInOut(duration: 0.25), value: store.mode) if hasDollhouseAsset { Group { if hasMappedInventory { MapToDollhouseTransitionPanel( properties: orderedProperties, zoomProgress: $store.mapDollhouseZoom, hour: $store.dollhouseHour, tickHour: $sliderTickHour, haptics: haptics ) } else { MappedInventorySetupCard(propertyCount: appStore.properties.count) } } .padding(.horizontal, 20) .padding(.bottom, 20) } } .padding(.bottom, 176) .frame(maxWidth: .infinity, alignment: .topLeading) } .scrollContentBackground(.hidden) .background(inventoryTheme.background.ignoresSafeArea()) .simultaneousGesture( TapGesture().onEnded { UIApplication.shared.sendAction( #selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil ) } ) .onAppear { store.mode = selectedMode consumeRequestedInventoryProperty() 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) } .task { await appStore.refresh(silent: true) } .onChange(of: appStore.requestedInventoryPropertyID) { _, _ in consumeRequestedInventoryProperty() } .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.generatedImageResultURL = nil store.errorMessage = nil store.shouldAutoGenerateDreamWeaver = false } } .sheet(isPresented: $showLiDARScan) { ARDreamWeaverCaptureView { captured in store.sourceImage = captured.fixedOrientation() store.generatedImage = nil store.generatedImageResultURL = nil store.errorMessage = nil store.shouldAutoGenerateDreamWeaver = true showLiDARScan = false } .ignoresSafeArea() } .sheet(isPresented: $showARStaging) { if let generatedImage = store.generatedImage { ARStagingProjectionView(stagingImage: generatedImage) .ignoresSafeArea() } } .sheet(item: $shareImage) { img in ShareSheet(image: img) .ignoresSafeArea() } .onChange(of: store.generatedImage) { _, image in guard let image else { return } ExternalShowroomPresenter.shared.present(image: image) } } private func consumeRequestedInventoryProperty() { guard let propertyID = appStore.requestedInventoryPropertyID else { return } appStore.requestedInventoryPropertyID = nil guard appStore.properties.contains(where: { $0.propertyId == propertyID }) else { return } withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.84)) { selectedPropertyID = propertyID } } private var propertyMediaSection: some View { VStack(alignment: .leading, spacing: 12) { propertyMediaHeader propertyMediaContent } .padding(16) .glassCard(cornerRadius: 18) .shadow(color: inventoryTheme.shadowColor, radius: 18, y: 10) } private var propertyMediaHeader: some View { HStack { VStack(alignment: .leading, spacing: 4) { Text("Property Media Library") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Text("Photos, floor plans, blueprints, and visual references linked to live inventory.") .font(.system(size: 11)) .foregroundStyle(VelocityTheme.mutedFg) } Spacer() Text(propertyMediaStatusText) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(backendMediaAssetCount == 0 ? VelocityTheme.warning : VelocityTheme.accent) } } @ViewBuilder private var propertyMediaContent: some View { if appStore.properties.isEmpty { mediaSetupState( icon: "building.2", title: "Inventory sync needed", message: "No live inventory properties have loaded yet. Refresh the app or verify the inventory backend before demo." ) } else if backendMediaAssetCount == 0 { VStack(alignment: .leading, spacing: 12) { mediaSetupState( icon: "photo.badge.exclamationmark", title: "Media assets needed", message: "\(appStore.properties.count) properties are loaded, but no photos, floor plans, blueprints, or renders are attached yet." ) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(Array(orderedProperties.prefix(10))) { property in compactPropertyButton(property) } } .padding(.vertical, 2) } } } else { ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: 12) { ForEach(Array(orderedProperties.prefix(8))) { property in propertyMediaCard(property) } } .padding(.vertical, 2) } } } private var backendMediaAssetCount: Int { appStore.propertyMedia.values.map(\.count).reduce(0, +) } private var propertyMediaStatusText: String { backendMediaAssetCount == 0 ? "Media needed" : "\(backendMediaAssetCount) assets" } private func mediaSetupState(icon: String, title: String, message: String) -> some View { HStack(alignment: .top, spacing: 12) { Image(systemName: icon) .font(.system(size: 20, weight: .semibold)) .foregroundStyle(VelocityTheme.warning) .frame(width: 34, height: 34) .background(Circle().fill(VelocityTheme.warning.opacity(0.14))) VStack(alignment: .leading, spacing: 5) { Text(title) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Text(message) .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 0) } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background(mediaCardBackground) } private func compactPropertyButton(_ property: VelocityPropertyDTO) -> some View { let isSelected = selectedPropertyID == property.propertyId let hasCoordinate = property.coordinate != nil return Button { withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.84)) { selectedPropertyID = property.propertyId } } label: { HStack(spacing: 8) { Image(systemName: hasCoordinate ? "mappin.and.ellipse" : "building.2") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(hasCoordinate ? VelocityTheme.accent : VelocityTheme.mutedFg) VStack(alignment: .leading, spacing: 2) { Text(property.projectName) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) .lineLimit(1) Text(property.locationSummary) .font(.system(size: 10)) .foregroundStyle(VelocityTheme.mutedFg) .lineLimit(1) } } .padding(.horizontal, 12) .padding(.vertical, 9) .frame(width: 210, alignment: .leading) .background( RoundedRectangle(cornerRadius: 12) .fill(isSelected ? VelocityTheme.warning.opacity(0.16) : VelocityTheme.surface) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(isSelected ? VelocityTheme.warning.opacity(0.55) : VelocityTheme.borderSubtle, lineWidth: 1) ) ) } .buttonStyle(.plain) } private func propertyMediaCard(_ property: VelocityPropertyDTO) -> some View { let media = appStore.propertyMedia[property.propertyId] ?? [] let sortedMedia = media.sorted { $0.sortOrder < $1.sortOrder } let previewItems = Array(sortedMedia.prefix(3)) let isSelected = selectedPropertyID == property.propertyId return VStack(alignment: .leading, spacing: 10) { if let hero = sortedMedia.first { mediaPreview(hero) .frame(width: 220, height: 124) .clipShape(RoundedRectangle(cornerRadius: 12)) .vaultSwipeToShare(asset: vaultAsset(for: hero, property: property)) } else { ZStack { RoundedRectangle(cornerRadius: 12) .fill(VelocityTheme.surface) Image(systemName: "photo.on.rectangle.angled") .font(.system(size: 24, weight: .medium)) .foregroundStyle(VelocityTheme.mutedFg) } .frame(width: 220, height: 124) } Text(property.projectName) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) .lineLimit(1) Text(property.locationSummary) .font(.system(size: 11)) .foregroundStyle(VelocityTheme.mutedFg) .lineLimit(1) HStack(spacing: 6) { ForEach(previewItems) { item in Label(mediaLabel(item.mediaType), systemImage: mediaIcon(item.mediaType)) .font(.system(size: 9, weight: .medium)) .foregroundStyle(VelocityTheme.accent) .lineLimit(1) } if media.count > 3 { Text("+\(media.count - 3)") .font(.system(size: 9, weight: .semibold)) .foregroundStyle(VelocityTheme.mutedFg) } } } .padding(12) .frame(width: 244, alignment: .leading) .background(mediaCardBackground) .overlay( RoundedRectangle(cornerRadius: 16) .stroke(isSelected ? VelocityTheme.warning.opacity(0.75) : Color.clear, lineWidth: 1.4) ) .contentShape(RoundedRectangle(cornerRadius: 16)) .onTapGesture { withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.84)) { selectedPropertyID = property.propertyId } } } private func mediaPreview(_ media: VelocityPropertyMediaDTO) -> some View { let candidate = media.thumbnailUrl ?? media.url let url = URL(string: candidate) let isPreviewable = isPreviewableMedia(media.mediaType) return ZStack { RoundedRectangle(cornerRadius: 12) .fill(VelocityTheme.surface) if let url, isPreviewable { AsyncImage(url: url) { phase in switch phase { case .success(let image): image .resizable() .scaledToFill() case .failure: mediaPlaceholder(media) case .empty: ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent)) @unknown default: mediaPlaceholder(media) } } } else { mediaPlaceholder(media) } } } private func mediaPlaceholder(_ media: VelocityPropertyMediaDTO) -> some View { VStack(spacing: 6) { Image(systemName: mediaIcon(media.mediaType)) .font(.system(size: 22, weight: .medium)) Text(mediaLabel(media.mediaType)) .font(.system(size: 10, weight: .semibold)) } .foregroundStyle(VelocityTheme.mutedFg) } private func isPreviewableMedia(_ mediaType: String) -> Bool { ["image", "photo", "floorplan", "floor_plan", "blueprint", "render"].contains(mediaType.lowercased()) } private func vaultAsset(for media: VelocityPropertyMediaDTO, property: VelocityPropertyDTO) -> VelocityVaultShareAsset? { guard ["floorplan", "floor_plan", "blueprint", "image", "photo", "render"].contains(media.mediaType.lowercased()) else { return nil } let leadId = media.metadata["lead_id"]?.stringValue ?? appStore.activeCommunicationsLeadID let storagePath = URL(string: media.url)?.velocityStoragePath ?? media.url.trimmedNonEmpty return VelocityVaultShareAsset( leadId: leadId, assetName: "\(property.projectName) \(mediaLabel(media.mediaType))", assetType: media.mediaType.lowercased().contains("video") ? "video" : "image", storagePath: storagePath ) } private func mediaLabel(_ mediaType: String) -> String { mediaType.replacingOccurrences(of: "_", with: " ").capitalized } private func mediaIcon(_ mediaType: String) -> String { switch mediaType.lowercased() { case "floorplan", "floor_plan", "blueprint": return "map" case "video", "tour": return "play.rectangle" case "ar", "dollhouse", "model": return "cube.transparent" default: return "photo" } } private var mediaCardBackground: some View { RoundedRectangle(cornerRadius: 14) .fill(VelocityTheme.surface) .overlay( RoundedRectangle(cornerRadius: 14) .stroke(VelocityTheme.borderSubtle, lineWidth: 1) ) } } private struct MapToDollhouseTransitionPanel: View { let properties: [VelocityPropertyDTO] @Binding var zoomProgress: CGFloat @Binding var hour: Double @Binding var tickHour: Int let haptics: UIImpactFeedbackGenerator @State private var mapPosition: MapCameraPosition = .automatic private var selectedProperty: VelocityPropertyDTO? { properties.first { $0.coordinate != nil } } private var isDollhouseActive: Bool { zoomProgress >= 1.8 } var body: some View { VStack(alignment: .leading, spacing: 12) { HStack { VStack(alignment: .leading, spacing: 4) { Text("Map-to-Dollhouse") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Text("Pinch a mapped property into the 3D model, then inspect light with the time slider.") .font(.system(size: 11)) .foregroundStyle(VelocityTheme.mutedFg) } Spacer() Text(isDollhouseActive ? "3D Model" : "Map View") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(isDollhouseActive ? VelocityTheme.warning : VelocityTheme.accent) } ZStack { if let property = selectedProperty, let coordinate = property.coordinate { Map(position: $mapPosition) { Marker(property.projectName, coordinate: coordinate) } .clipShape(RoundedRectangle(cornerRadius: 18)) .opacity(isDollhouseActive ? 0 : 1) .scaleEffect(isDollhouseActive ? 1.08 : 1.0) .rotation3DEffect( .degrees(isDollhouseActive ? 62 : 0), axis: (x: 1, y: 0, z: 0), perspective: 0.55 ) .onAppear { mapPosition = .region( MKCoordinateRegion( center: coordinate, span: MKCoordinateSpan(latitudeDelta: 0.025, longitudeDelta: 0.025) ) ) } } else { ContentUnavailableView( "No mapped inventory", systemImage: "map", description: Text("Add map coordinates to a property to unlock Map-to-Dollhouse.") ) .foregroundStyle(VelocityTheme.mutedFg) .opacity(isDollhouseActive ? 0 : 1) } DollhousePanel(hour: $hour, tickHour: $tickHour, haptics: haptics) .opacity(isDollhouseActive ? 1 : 0) .scaleEffect(isDollhouseActive ? 1 : 0.96) .offset(y: isDollhouseActive ? 0 : 28) .allowsHitTesting(isDollhouseActive) } .frame(minHeight: 420) .gesture( MagnificationGesture() .onChanged { value in withAnimation(.interactiveSpring(response: 0.42, dampingFraction: 0.84)) { zoomProgress = min(max(value, 1.0), 2.4) } } .onEnded { value in withAnimation(.interactiveSpring(response: 0.58, dampingFraction: 0.82)) { zoomProgress = value >= 1.45 ? 1.9 : 1.0 } } ) .animation(.interactiveSpring(response: 0.58, dampingFraction: 0.82), value: isDollhouseActive) .onChange(of: isDollhouseActive) { _, active in guard active else { return } ExternalShowroomPresenter.shared.presentDollhouse() } } .padding(16) .glassCard(cornerRadius: 20) } } private struct MappedInventorySetupCard: View { let propertyCount: Int var body: some View { HStack(alignment: .top, spacing: 14) { Image(systemName: "map") .font(.system(size: 22, weight: .semibold)) .foregroundStyle(VelocityTheme.accent) .frame(width: 42, height: 42) .background(Circle().fill(VelocityTheme.accent.opacity(0.14))) VStack(alignment: .leading, spacing: 6) { Text("Map-to-Dollhouse needs mapped inventory") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Text(setupMessage) .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 0) Text("\(propertyCount) properties") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(propertyCount == 0 ? VelocityTheme.warning : VelocityTheme.accent) .padding(.horizontal, 10) .padding(.vertical, 6) .background( Capsule() .fill((propertyCount == 0 ? VelocityTheme.warning : VelocityTheme.accent).opacity(0.12)) ) } .padding(16) .glassCard(cornerRadius: 18) } private var setupMessage: String { if propertyCount == 0 { return "Load live inventory first. The 3D transition appears after at least one property includes map coordinates." } return "Add map coordinates to at least one property to unlock the map-to-3D transition. This keeps the production page from showing a full empty map panel." } } private extension VelocityPropertyDTO { var coordinate: CLLocationCoordinate2D? { let latitude = location?["latitude"]?.numberValue ?? location?["lat"]?.numberValue let longitude = location?["longitude"]?.numberValue ?? location?["lng"]?.numberValue ?? location?["lon"]?.numberValue guard let latitude, let longitude else { return nil } return CLLocationCoordinate2D(latitude: latitude, longitude: longitude) } } @MainActor private final class ExternalShowroomPresenter { static let shared = ExternalShowroomPresenter() private var window: UIWindow? func present(image: UIImage) { guard let windowScene = externalWindowScene else { return } let view = Image(uiImage: image) .resizable() .scaledToFit() .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.black) .ignoresSafeArea() present(rootViewController: UIHostingController(rootView: view), in: windowScene) } func presentDollhouse() { guard let windowScene = externalWindowScene else { return } let viewController = UIViewController() viewController.view.backgroundColor = .black let sceneView = SCNView(frame: windowScene.screen.bounds) sceneView.autoresizingMask = [.flexibleWidth, .flexibleHeight] sceneView.backgroundColor = .black sceneView.allowsCameraControl = true sceneView.autoenablesDefaultLighting = true sceneView.scene = Self.loadDollhouseScene() viewController.view.addSubview(sceneView) present(rootViewController: viewController, in: windowScene) } private func present(rootViewController: UIViewController, in windowScene: UIWindowScene) { let window = UIWindow(windowScene: windowScene) window.frame = windowScene.coordinateSpace.bounds window.rootViewController = rootViewController window.isHidden = false self.window = window } private var externalWindowScene: UIWindowScene? { UIApplication.shared.openSessions .compactMap { $0.scene as? UIWindowScene } .first { $0.screen != UIScreen.main } } private static func loadDollhouseScene() -> SCNScene { for candidate in InventoryModeAvailability.dollhouseAssetCandidates { if let url = Bundle.main.url(forResource: candidate.name, withExtension: candidate.ext), let scene = try? SCNScene(url: url) { return scene } } return SCNScene() } } // 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(22) .frame(maxWidth: .infinity, minHeight: 138) } .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 private struct DreamWeaverPanel: View { @Binding var sourceImage: UIImage? @Binding var generatedImage: UIImage? @Binding var generatedImageResultURL: URL? @Binding var shouldAutoGenerate: Bool @Binding var isProcessing: Bool @Binding var errorMessage: String? @Binding var showCamera: Bool @Binding var showLiDARScan: Bool @Binding var showARStaging: Bool let roomTypes: [VelocityVocabularyOptionDTO] /// 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 = "" @State private var readiness: DreamWeaverReadiness? private var previewHeight: CGFloat { sourceImage == nil && generatedImage == nil ? 320 : 380 } var body: some View { VStack(spacing: 14) { previewCard // ── Error banner ────────────────────────────────────────────── errorBanner readinessBanner // ── Room Type picker ─────────────────────────────────────── roomTypePicker // ── Keywords input ─────────────────────────────────────────── PromptInputBar( text: $keywords, isDisabled: sourceImage == nil || isProcessing || readiness?.isReady != true || selectedBackendRoomType == nil ) { Task { await generate() } } // ── Capture / Retake ───────────────────────────────────────── HStack(spacing: 10) { Button(sourceImage == nil ? "Capture" : "Retake") { showCamera = true } .buttonStyle(.borderedProminent) .disabled(isProcessing) Button { showLiDARScan = true } label: { Label("LiDAR Scan", systemImage: "arkit") } .buttonStyle(.bordered) .disabled(isProcessing) if generatedImage != nil { Button { showARStaging = true } label: { Label("Project", systemImage: "rectangle.3.group.bubble") } .buttonStyle(.bordered) .transition(.scale.combined(with: .opacity)) } } .frame(maxWidth: .infinity) } .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) .animation(.interactiveSpring(response: 0.42, dampingFraction: 0.86), value: generatedImage != nil) .task { readiness = await ComfyClient.shared.checkReadiness() } .onChange(of: shouldAutoGenerate) { _, shouldGenerate in guard shouldGenerate else { return } Task { await generate() await MainActor.run { shouldAutoGenerate = false } } } } private var previewCard: some View { ZStack { RoundedRectangle(cornerRadius: 20) .fill(Color.black.opacity(0.9)) sourceImageLayer generatedImageLayer if isProcessing { ProcessingOverlay() } readinessBadge } .frame(maxWidth: .infinity) .frame(height: previewHeight) .animation(.easeInOut(duration: 0.35), value: generatedImage) } @ViewBuilder private var sourceImageLayer: some View { 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) } } @ViewBuilder private var generatedImageLayer: some View { if let generatedImage { Image(uiImage: generatedImage) .resizable() .scaledToFit() .clipShape(RoundedRectangle(cornerRadius: 16)) .padding(12) .transition(.opacity) .vaultSwipeToShare(asset: dreamWeaverShareAsset) } } private var dreamWeaverShareAsset: VelocityVaultShareAsset? { guard let generatedImageResultURL else { return nil } return VelocityVaultShareAsset( leadId: AppStore.shared.activeCommunicationsLeadID, assetName: "Dream Weaver interior render", assetType: "image", storagePath: generatedImageResultURL.velocityStoragePath ) } private var readinessBadge: some View { VStack { HStack { Spacer() HStack(spacing: 5) { Circle() .fill(readinessBadgeColor) .frame(width: 7, height: 7) Text(readiness?.label ?? "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() } } private var readinessBadgeColor: Color { if readiness?.isReady == true { return .green } return readiness == nil ? .gray : .red } @ViewBuilder private var errorBanner: some View { 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)) } } @ViewBuilder private var readinessBanner: some View { if let currentReadiness = readiness, !currentReadiness.isReady { HStack(spacing: 10) { Image(systemName: "bolt.horizontal.circle") .foregroundStyle(VelocityTheme.warning) VStack(alignment: .leading, spacing: 3) { Text(currentReadiness.label) .font(.footnote.weight(.semibold)) .foregroundStyle(VelocityTheme.foreground) Text(currentReadiness.detail) .font(.caption) .foregroundStyle(VelocityTheme.mutedFg) .fixedSize(horizontal: false, vertical: true) } Spacer() Button("Retry") { Task { readiness = await ComfyClient.shared.checkReadiness() } } .font(.caption.weight(.semibold)) .buttonStyle(.bordered) .tint(VelocityTheme.accent) } .padding(.horizontal, 14) .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: 12) .fill(VelocityTheme.warning.opacity(0.12)) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(VelocityTheme.warning.opacity(0.30), lineWidth: 1) ) ) } } private var roomTypePicker: some View { Group { if roomTypes.isEmpty { Text("Dream Weaver room vocabulary is unavailable from the backend.") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.danger) .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background(RoundedRectangle(cornerRadius: 12).fill(VelocityTheme.danger.opacity(0.12))) } else { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(roomTypes) { room in roomTypeButton(room) } } .padding(.horizontal, 2) } } } } private var selectedBackendRoomType: String? { if let selectedRoomType, roomTypes.contains(where: { $0.value == selectedRoomType }) { return selectedRoomType } return roomTypes.first?.value } private func roomTypeButton(_ room: VelocityVocabularyOptionDTO) -> some View { Button { withAnimation(.spring(response: 0.3)) { selectedRoomType = selectedRoomType == room.value ? nil : room.value } } label: { HStack(spacing: 6) { Image(systemName: room.icon ?? "square.grid.2x2") .font(.system(size: 11, weight: .medium)) Text(room.label) .font(.system(size: 13, weight: .medium)) } .padding(.horizontal, 12) .padding(.vertical, 7) .background(roomTypeButtonBackground(for: room)) .foregroundStyle(selectedRoomType == room.value ? .white : .white.opacity(0.6)) .overlay(roomTypeButtonBorder(for: room)) } .buttonStyle(.plain) } private func roomTypeButtonBackground(for room: VelocityVocabularyOptionDTO) -> some View { let fill = selectedRoomType == room.value ? Color(red: 0.231, green: 0.510, blue: 0.965) : Color.white.opacity(0.08) return Capsule().fill(fill) } private func roomTypeButtonBorder(for room: VelocityVocabularyOptionDTO) -> some View { let stroke = selectedRoomType == room.value ? Color.clear : Color.white.opacity(0.12) return Capsule().stroke(stroke, lineWidth: 1) } @MainActor private func generate() async { guard let sourceImage, !isProcessing else { return } let currentReadiness: DreamWeaverReadiness if let readiness { currentReadiness = readiness } else { currentReadiness = await ComfyClient.shared.checkReadiness() } readiness = currentReadiness guard currentReadiness.isReady else { errorMessage = "\(currentReadiness.label): \(currentReadiness.detail)" return } guard let roomType = selectedBackendRoomType else { errorMessage = "Dream Weaver room vocabulary is unavailable from the backend." return } isProcessing = true errorMessage = nil do { let result = try await ComfyClient.shared.generateImageResult( source: sourceImage, roomType: roomType, keywords: keywords.trimmingCharacters(in: .whitespaces) ) withAnimation(.easeInOut(duration: 0.4)) { generatedImage = result.image generatedImageResultURL = result.resultURL } } catch { errorMessage = error.localizedDescription } isProcessing = false } } private struct ARDreamWeaverCaptureView: UIViewRepresentable { let onCapture: (UIImage) -> Void func makeCoordinator() -> Coordinator { Coordinator(onCapture: onCapture) } func makeUIView(context: Context) -> ARSCNView { let view = ARSCNView(frame: .zero) view.delegate = context.coordinator view.automaticallyUpdatesLighting = true view.scene = SCNScene() context.coordinator.sceneView = view let configuration = ARWorldTrackingConfiguration() configuration.planeDetection = [.horizontal, .vertical] if ARWorldTrackingConfiguration.supportsSceneReconstruction(.mesh) { configuration.sceneReconstruction = .mesh } if ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth) { configuration.frameSemantics.insert(.sceneDepth) } view.session.run(configuration) let button = UIButton(type: .system) var buttonConfiguration = UIButton.Configuration.filled() buttonConfiguration.title = "Capture Room" buttonConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in var outgoing = incoming outgoing.font = .systemFont(ofSize: 17, weight: .semibold) return outgoing } buttonConfiguration.baseForegroundColor = .white buttonConfiguration.baseBackgroundColor = UIColor.black.withAlphaComponent(0.45) buttonConfiguration.cornerStyle = .capsule buttonConfiguration.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 18, bottom: 10, trailing: 18) button.configuration = buttonConfiguration button.translatesAutoresizingMaskIntoConstraints = false button.addTarget(context.coordinator, action: #selector(Coordinator.capture), for: .touchUpInside) view.addSubview(button) NSLayoutConstraint.activate([ button.centerXAnchor.constraint(equalTo: view.centerXAnchor), button.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -24), ]) return view } func updateUIView(_ uiView: ARSCNView, context: Context) {} static func dismantleUIView(_ uiView: ARSCNView, coordinator: Coordinator) { uiView.session.pause() } final class Coordinator: NSObject, ARSCNViewDelegate { weak var sceneView: ARSCNView? let onCapture: (UIImage) -> Void init(onCapture: @escaping (UIImage) -> Void) { self.onCapture = onCapture } @objc func capture() { guard let sceneView else { return } UIImpactFeedbackGenerator(style: .medium).impactOccurred() onCapture(sceneView.snapshot()) } } } private struct ARStagingProjectionView: UIViewRepresentable { let stagingImage: UIImage func makeCoordinator() -> Coordinator { Coordinator(stagingImage: stagingImage) } func makeUIView(context: Context) -> ARSCNView { let view = ARSCNView(frame: .zero) view.delegate = context.coordinator view.automaticallyUpdatesLighting = true view.scene = SCNScene() context.coordinator.sceneView = view let configuration = ARWorldTrackingConfiguration() configuration.planeDetection = [.horizontal, .vertical] if ARWorldTrackingConfiguration.supportsSceneReconstruction(.mesh) { configuration.sceneReconstruction = .mesh } if ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth) { configuration.frameSemantics.insert(.sceneDepth) } view.session.run(configuration) let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.placeStaging(_:))) view.addGestureRecognizer(tap) return view } func updateUIView(_ uiView: ARSCNView, context: Context) { context.coordinator.stagingImage = stagingImage } static func dismantleUIView(_ uiView: ARSCNView, coordinator: Coordinator) { uiView.session.pause() } final class Coordinator: NSObject, ARSCNViewDelegate { weak var sceneView: ARSCNView? var stagingImage: UIImage private var stagingNode: SCNNode? init(stagingImage: UIImage) { self.stagingImage = stagingImage } @objc func placeStaging(_ recognizer: UITapGestureRecognizer) { guard let sceneView else { return } let point = recognizer.location(in: sceneView) guard let query = sceneView.raycastQuery( from: point, allowing: .estimatedPlane, alignment: .any ), let hit = sceneView.session.raycast(query).first else { return } stagingNode?.removeFromParentNode() let plane = SCNPlane(width: 1.8, height: 1.15) plane.firstMaterial?.diffuse.contents = stagingImage plane.firstMaterial?.isDoubleSided = true plane.cornerRadius = 0.02 let node = SCNNode(geometry: plane) node.simdTransform = hit.worldTransform node.eulerAngles.x -= .pi / 2 sceneView.scene.rootNode.addChildNode(node) stagingNode = node UIImpactFeedbackGenerator(style: .rigid).impactOccurred() } } } // 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)) } } } } private extension String { var trimmedNonEmpty: String? { let value = trimmingCharacters(in: .whitespacesAndNewlines) return value.isEmpty ? nil : value } } // 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 { scene.rootNode.addChildNode(Self.missingAssetNode()) } 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)) } private static func missingAssetNode() -> SCNNode { let root = SCNNode() let text = SCNText(string: "Dollhouse asset unavailable", extrusionDepth: 0.02) text.font = UIFont.systemFont(ofSize: 0.32, weight: .semibold) text.firstMaterial?.diffuse.contents = UIColor.systemRed let textNode = SCNNode(geometry: text) textNode.position = SCNVector3(-2.5, 0, 0) root.addChildNode(textNode) return root } } } // MARK: - ProcessingOverlay private struct ProcessingOverlay: View { @State private var animate = false var body: some View { ZStack { RoundedRectangle(cornerRadius: 16) .fill(.black.opacity(0.66)) .background { GlassBlurView(style: .systemUltraThinMaterialDark) .clipShape(RoundedRectangle(cornerRadius: 16)) } VStack(spacing: 18) { ZStack { ForEach(0..<14, id: \.self) { index in Circle() .fill(Color.white.opacity(index.isMultiple(of: 3) ? 0.72 : 0.32)) .frame(width: CGFloat(4 + (index % 4) * 2), height: CGFloat(4 + (index % 4) * 2)) .offset( x: animate ? CGFloat((index % 7) - 3) * 22 : CGFloat((index % 5) - 2) * 10, y: animate ? CGFloat((index / 2) - 3) * 16 : CGFloat((index % 6) - 3) * 7 ) .blur(radius: index.isMultiple(of: 4) ? 0 : 1) .animation( .interactiveSpring(response: 1.35 + Double(index) * 0.03, dampingFraction: 0.78) .repeatForever(autoreverses: true), value: animate ) } Circle() .stroke(Color.white.opacity(0.18), lineWidth: 1) .frame(width: 112, height: 112) .scaleEffect(animate ? 1.08 : 0.92) .animation(.interactiveSpring(response: 1.1, dampingFraction: 0.82).repeatForever(autoreverses: true), value: animate) Image(systemName: "sparkles") .font(.system(size: 34, weight: .semibold)) .foregroundStyle(.white) .shadow(color: VelocityTheme.accent.opacity(0.7), radius: 14) } .frame(width: 150, height: 118) VStack(spacing: 5) { Text("AI Theater") .font(.system(size: 20, weight: .semibold)) .foregroundStyle(.white) Text("Composing the Dream Weaver scene") .font(.system(size: 12, weight: .medium)) .foregroundStyle(.white.opacity(0.72)) } } } .padding(12) .onAppear { withAnimation(.interactiveSpring(response: 0.7, dampingFraction: 0.86)) { 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) } }