forked from sagnik/Project_Velocity
I have attached the screenshots of the native SwiftUI app. <img width="1705" alt="image.png" src="attachments/59fec2f3-0ae2-4b58-9349-457618ea0678"> <img width="1699" alt="image.png" src="attachments/0bf7c4f9-c883-4929-be36-774685b82fc4"> <img width="1698" alt="image.png" src="attachments/e3407e84-aaf2-45c0-9325-247d4020bace"> <img width="1694" alt="image.png" src="attachments/ee2cd47d-800d-4a40-855c-d54856680e79"> <img width="1694" alt="image.png" src="attachments/a2c902f1-9bc9-4427-8cae-b5801527c1ff"> Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: sagnik/Project_Velocity#2 Co-authored-by: sayan <sayan@desineuron.in> Co-committed-by: sayan <sayan@desineuron.in>
440 lines
16 KiB
Swift
440 lines
16 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(alignment: .leading, spacing: 16) {
|
|
// Page header
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Inventory")
|
|
.font(.system(size: 28, weight: .bold))
|
|
.foregroundStyle(VelocityTheme.foreground)
|
|
Text("Sunseeker · Dream Weaver · Dollhouse")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 20)
|
|
|
|
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:
|
|
#if targetEnvironment(simulator)
|
|
ZStack {
|
|
VStack(spacing: 14) {
|
|
Image(systemName: "camera.metering.unknown")
|
|
.font(.system(size: 40))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
Text("AR Not Available in Simulator")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(VelocityTheme.foreground)
|
|
Text("Sunseeker requires a real device with a camera and compass. Run on iPhone or iPad to use this feature.")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.padding(24)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.stroke(VelocityTheme.borderAccent, lineWidth: 1)
|
|
)
|
|
)
|
|
#else
|
|
SunseekerPanel(sunNodesReady: $store.sunNodesReady)
|
|
#endif
|
|
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)
|
|
}
|
|
.background(VelocityTheme.background)
|
|
.onAppear {
|
|
// Dark-theme the segmented control
|
|
UISegmentedControl.appearance().selectedSegmentTintColor = UIColor(
|
|
red: 0.231, green: 0.510, blue: 0.965, alpha: 0.85)
|
|
UISegmentedControl.appearance().setTitleTextAttributes(
|
|
[.foregroundColor: UIColor.white], for: .selected)
|
|
UISegmentedControl.appearance().setTitleTextAttributes(
|
|
[.foregroundColor: UIColor(white: 0.62, alpha: 1)], for: .normal)
|
|
UISegmentedControl.appearance().backgroundColor = UIColor(
|
|
red: 0.031, green: 0.039, blue: 0.071, alpha: 1)
|
|
}
|
|
.sheet(isPresented: $showCamera) {
|
|
CameraPicker(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
|
|
}
|
|
}
|
|
}
|