Files
Project_Velocity/iOS/velocity-ipad/velocity/Features/Inventory/InventoryView.swift
sayan eeb684b46c
All checks were successful
Production Readiness / backend-contracts (push) Successful in 1m47s
Production Readiness / webos-typecheck (push) Successful in 1m50s
Production Readiness / ipad-parse (push) Successful in 1m34s
feat: Ipad app production readiness, Colony orchestration, Social posting (#44)
#38 Ipad app production readiness, Colony orchestration, Social posting

Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local>
Reviewed-on: #44
2026-05-03 18:30:38 +05:30

1835 lines
69 KiB
Swift

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<InventoryStore.Mode> {
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) }
}