Files
Project_Velocity/iOS/Features/Inventory/InventoryView.swift

391 lines
13 KiB
Swift

import AVFoundation
import Observation
import SceneKit
import SwiftUI
import UIKit
@Observable
final class InventoryStore {
enum Mode: String, CaseIterable, Identifiable {
case sunseeker = "Sunseeker"
case dreamWeaver = "Dream Weaver"
case dollhouse = "Dollhouse"
var id: String { rawValue }
}
var mode: Mode = .sunseeker
var selectedPrompt: String = "Modern Islamic"
var sourceImage: UIImage?
var generatedImage: UIImage?
var isProcessing: Bool = false
var sunNodesReady: Bool = false
var dollhouseHour: Double = 12
let prompts = ["Modern Islamic", "Minimalist", "Night Mode"]
}
struct InventoryView: View {
@State private var store = InventoryStore()
@State private var showCamera = false
@State private var sliderTickHour = 12
private let haptics = UIImpactFeedbackGenerator(style: .light)
var body: some View {
VStack(spacing: 16) {
Picker("Mode", selection: $store.mode) {
ForEach(InventoryStore.Mode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, 20)
.padding(.top, 12)
Group {
switch store.mode {
case .sunseeker:
SunseekerPanel(sunNodesReady: $store.sunNodesReady)
case .dreamWeaver:
DreamWeaverPanel(
sourceImage: $store.sourceImage,
generatedImage: $store.generatedImage,
selectedPrompt: $store.selectedPrompt,
isProcessing: $store.isProcessing,
prompts: store.prompts,
showCamera: $showCamera
)
case .dollhouse:
DollhousePanel(hour: $store.dollhouseHour, tickHour: $sliderTickHour, haptics: haptics)
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
.animation(.easeInOut(duration: 0.25), value: store.mode)
}
.navigationTitle("Inventory")
.background(Color(uiColor: .systemGroupedBackground))
.sheet(isPresented: $showCamera) {
CameraPicker(image: $store.sourceImage, isPresented: $showCamera)
}
}
}
private struct SunseekerPanel: View {
@Binding var sunNodesReady: Bool
var body: some View {
ZStack(alignment: .topLeading) {
ARSunOverlayView(sunNodesReady: $sunNodesReady)
.clipShape(RoundedRectangle(cornerRadius: 20))
DashedSunLine()
.stroke(Color.yellow.opacity(0.9), style: StrokeStyle(lineWidth: 3, dash: [10, 8]))
.padding(.horizontal, 24)
.padding(.vertical, 80)
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))
}
.padding(20)
}
}
}
private struct DreamWeaverPanel: View {
@Binding var sourceImage: UIImage?
@Binding var generatedImage: UIImage?
@Binding var selectedPrompt: String
@Binding var isProcessing: Bool
let prompts: [String]
@Binding var showCamera: Bool
var body: some View {
VStack(spacing: 14) {
ZStack {
RoundedRectangle(cornerRadius: 20)
.fill(Color.black.opacity(0.9))
if let sourceImage {
Image(uiImage: sourceImage)
.resizable()
.scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(12)
} else {
ContentUnavailableView("No Capture", systemImage: "camera.viewfinder", description: Text("Tap Capture to snap a room."))
.foregroundStyle(.white)
}
if let generatedImage {
Image(uiImage: generatedImage)
.resizable()
.scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(12)
.transition(.opacity)
}
if isProcessing {
ProcessingOverlay()
}
}
.frame(maxWidth: .infinity, minHeight: 420)
.animation(.easeInOut(duration: 0.35), value: generatedImage)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(prompts, id: \.self) { prompt in
Text(prompt)
.font(.subheadline.weight(.semibold))
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(prompt == selectedPrompt ? Color.yellow.opacity(0.85) : Color.white.opacity(0.12))
)
.onTapGesture { selectedPrompt = prompt }
}
}
}
HStack(spacing: 12) {
Button("Capture") {
showCamera = true
}
.buttonStyle(.borderedProminent)
Button("Reimagine") {
Task { await generate() }
}
.buttonStyle(.bordered)
.disabled(sourceImage == nil || isProcessing)
}
}
.padding(16)
.background {
GlassBlurView(style: .systemThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 18))
}
}
@MainActor
private func generate() async {
guard let sourceImage, !isProcessing else { return }
isProcessing = true
do {
let result = try await ComfyClient.shared.generateImage(source: sourceImage, prompt: selectedPrompt)
withAnimation(.easeInOut(duration: 0.4)) {
generatedImage = result
}
} catch {
print("Dream Weaver error: \(error)")
}
isProcessing = false
}
}
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 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() {
if let modelScene = SCNScene(named: "Building.usdz") ?? SCNScene(named: "Building.scn") {
let container = SCNNode()
for child in modelScene.rootNode.childNodes {
container.addChildNode(child.clone())
}
scene.rootNode.addChildNode(container)
} else {
let fallback = SCNFloor()
fallback.firstMaterial?.diffuse.contents = UIColor.secondarySystemBackground
scene.rootNode.addChildNode(SCNNode(geometry: fallback))
}
let camera = SCNCamera()
let cameraNode = SCNNode()
cameraNode.camera = camera
cameraNode.position = SCNVector3(0, 4, 10)
scene.rootNode.addChildNode(cameraNode)
let light = SCNLight()
light.type = .directional
light.intensity = 1_200
light.castsShadow = true
sunNode.light = light
scene.rootNode.addChildNode(sunNode)
let ambient = SCNLight()
ambient.type = .ambient
ambient.intensity = 200
let ambientNode = SCNNode()
ambientNode.light = ambient
scene.rootNode.addChildNode(ambientNode)
}
func updateSunLight(hour: Double) {
let normalized = (hour / 24.0) * (2 * Double.pi)
let x = Float(cos(normalized) * 8.0)
let y = Float(max(sin(normalized) * 8.0, 1.0))
let z = Float(sin(normalized + .pi / 3) * 6.0)
sunNode.position = SCNVector3(x, y, z)
sunNode.look(at: SCNVector3(0, 0, 0))
}
}
}
private struct ProcessingOverlay: View {
@State private var animate = false
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 16)
.fill(.black.opacity(0.45))
Text("AI Processing...")
.font(.headline.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background {
GlassBlurView(style: .systemUltraThinMaterialDark)
.clipShape(Capsule())
}
.overlay(
Rectangle()
.fill(
LinearGradient(
colors: [.clear, .white.opacity(0.6), .clear],
startPoint: .leading,
endPoint: .trailing
)
)
.rotationEffect(.degrees(18))
.offset(x: animate ? 160 : -160)
.animation(.linear(duration: 1.2).repeatForever(autoreverses: false), value: animate)
.blendMode(.screen)
.mask(Capsule().frame(height: 44))
)
}
.padding(12)
.onAppear { animate = true }
}
}
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
}
}
private struct CameraPicker: UIViewControllerRepresentable {
@Binding var image: UIImage?
@Binding var isPresented: Bool
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
if UIImagePickerController.isSourceTypeAvailable(.camera) {
picker.sourceType = .camera
picker.cameraCaptureMode = .photo
} else {
picker.sourceType = .photoLibrary
}
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.image = captured
}
parent.isPresented = false
}
}
}