forked from sagnik/Project_Velocity
#38 Ipad app production readiness, Colony orchestration, Social posting Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: sagnik/Project_Velocity#44
1835 lines
69 KiB
Swift
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) }
|
|
}
|