forked from sagnik/Project_Velocity
feat: Implement the camera capture feature to take photos of empty walls/rooms (#6)
#4 Implemented the feature completely. I have attached the screenshot as well. <img width="1697" alt="image.png" src="attachments/ad65d4f6-1c3b-4d2e-b1a5-0a2a02aa2dfd"> Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: sagnik/Project_Velocity#6
This commit is contained in:
@@ -14,9 +14,22 @@
|
||||
A27B23152F58D9C300A74A49 /* velocity.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = velocity.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
A2E3BF5F2F5CCA6800670166 /* Exceptions for "velocity" folder in "velocity" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = A27B23142F58D9C300A74A49 /* velocity */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
A27B23172F58D9C300A74A49 /* velocity */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
A2E3BF5F2F5CCA6800670166 /* Exceptions for "velocity" folder in "velocity" target */,
|
||||
);
|
||||
path = velocity;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -264,6 +277,7 @@
|
||||
DEVELOPMENT_TEAM = L29922NHD9;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = velocity/Info.plist;
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "\"Used to capture rooms for the DreamWeaver AI redesign feature.\"";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
@@ -299,6 +313,7 @@
|
||||
DEVELOPMENT_TEAM = L29922NHD9;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = velocity/Info.plist;
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "\"Used to capture rooms for the DreamWeaver AI redesign feature.\"";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
|
||||
16
iOS/velocity/velocity/Core/Config/AppConfig.swift
Normal file
16
iOS/velocity/velocity/Core/Config/AppConfig.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
import Foundation
|
||||
|
||||
/// Central app configuration.
|
||||
/// To override without touching source, add a `Config.xcconfig` (gitignored):
|
||||
/// BASE_URL = http://192.168.x.x:8080
|
||||
enum AppConfig {
|
||||
/// Base URL for the Dream Weaver gateway (port 8080).
|
||||
/// Swap this to an HTTPS domain once SSL is set up — §3 of integration guide.
|
||||
static let baseURL: String = {
|
||||
if let override = Bundle.main.infoDictionary?["BASE_URL"] as? String,
|
||||
!override.isEmpty, override != "$(BASE_URL)" {
|
||||
return override
|
||||
}
|
||||
return "http://54.172.172.2:8080"
|
||||
}()
|
||||
}
|
||||
@@ -1,100 +1,226 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
@preconcurrency import Alamofire
|
||||
|
||||
// MARK: - ComfyClient
|
||||
|
||||
/// Handles all Dream Weaver API communication.
|
||||
/// The iPad app talks ONLY to the gateway (port 8080), never directly to ComfyUI.
|
||||
/// Flow: POST /dream-weaver → poll /status → GET /result
|
||||
final class ComfyClient {
|
||||
static let shared = ComfyClient()
|
||||
private var baseURL: String { AppConfig.baseURL }
|
||||
private init() {}
|
||||
|
||||
private let endpoint = "http://192.168.x.x:8000/dream-weaver"
|
||||
private let session: Session
|
||||
// MARK: - Health Check
|
||||
|
||||
private init(session: Session = .default) {
|
||||
self.session = session
|
||||
/// Call on app launch to confirm gateway is reachable.
|
||||
/// Returns `true` if `{ "status": "ok" }`.
|
||||
func checkHealth() async -> Bool {
|
||||
guard let url = URL(string: "\(baseURL)/health") else { return false }
|
||||
guard let (data, _) = try? await URLSession.shared.data(from: url),
|
||||
let json = try? JSONDecoder().decode(HealthResponse.self, from: data) else {
|
||||
return false
|
||||
}
|
||||
// Server returns "healthy" (v2.0-FINAL gateway) — accept both variants
|
||||
return json.status == "ok" || json.status == "healthy"
|
||||
}
|
||||
|
||||
func generateImage(source: UIImage, prompt: String) async throws -> UIImage {
|
||||
let resized = source.resizedSquare(to: 1024)
|
||||
guard let imageData = resized.jpegData(compressionQuality: 0.9) else {
|
||||
throw ComfyClientError.encodingFailed
|
||||
// MARK: - Main Generation Pipeline
|
||||
|
||||
/// Full pipeline: upload → queue → poll → download.
|
||||
/// - Parameters:
|
||||
/// - source: Room photo from camera or library.
|
||||
/// - style: One of `scandinavian`, `art_deco`, `biophilic`, `cyberpunk`, `japandi`.
|
||||
/// - keywords: Comma-separated user keywords appended to the style prompt (can be empty).
|
||||
func generateImage(source: UIImage, style: String, keywords: String) async throws -> UIImage {
|
||||
let normalised = source.fixedOrientation()
|
||||
let resized = normalised.resizedSquare(to: 1024)
|
||||
guard let imageData = resized.jpegData(compressionQuality: 0.85) else {
|
||||
throw DreamWeaverError.encodingFailed
|
||||
}
|
||||
|
||||
let payload = DreamWeaverRequest(
|
||||
imageBase64: imageData.base64EncodedString(),
|
||||
prompt: prompt
|
||||
// 1. Submit job → get job_id
|
||||
let job = try await submitJob(imageData: imageData, style: style, keywords: keywords)
|
||||
|
||||
// 2. Poll status every 2s until ready (max 5 min per integration guide §3.3)
|
||||
let resultURL = try await pollUntilReady(jobId: job.jobId)
|
||||
|
||||
// 3. Download result PNG
|
||||
return try await downloadResult(from: resultURL)
|
||||
}
|
||||
|
||||
// MARK: - Step 1: POST /dream-weaver
|
||||
|
||||
private func submitJob(imageData: Data, style: String, keywords: String) async throws -> GenerationJob {
|
||||
guard let url = URL(string: "\(baseURL)/dream-weaver") else {
|
||||
throw DreamWeaverError.generationFailed("Invalid gateway URL")
|
||||
}
|
||||
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = buildMultipart(
|
||||
imageData: imageData,
|
||||
style: style,
|
||||
keywords: keywords,
|
||||
boundary: boundary
|
||||
)
|
||||
|
||||
let response = try await session.request(
|
||||
endpoint,
|
||||
method: .post,
|
||||
parameters: payload,
|
||||
encoder: JSONParameterEncoder.default,
|
||||
headers: [.contentType("application/json")]
|
||||
)
|
||||
.validate(statusCode: 200..<300)
|
||||
.serializingDecodable(DreamWeaverResponse.self)
|
||||
.value
|
||||
|
||||
guard
|
||||
let data = Data(base64Encoded: response.outputBase64),
|
||||
let generated = UIImage(data: data)
|
||||
else {
|
||||
throw ComfyClientError.decodingFailed
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
|
||||
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||
let detail = String(data: data, encoding: .utf8) ?? ""
|
||||
throw DreamWeaverError.generationFailed("Submission failed (HTTP \(code))\(detail.isEmpty ? "" : ": \(detail)")")
|
||||
}
|
||||
|
||||
return generated
|
||||
}
|
||||
}
|
||||
|
||||
private struct DreamWeaverRequest: Encodable, Sendable {
|
||||
let imageBase64: String
|
||||
let prompt: String
|
||||
}
|
||||
|
||||
private struct DreamWeaverResponse: Decodable, Sendable {
|
||||
let outputBase64: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case outputBase64 = "output_base64"
|
||||
case imageBase64 = "image_base64"
|
||||
case image
|
||||
return try JSONDecoder().decode(GenerationJob.self, from: data)
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
if let preferred = try container.decodeIfPresent(String.self, forKey: .outputBase64) {
|
||||
outputBase64 = preferred
|
||||
return
|
||||
}
|
||||
if let legacy = try container.decodeIfPresent(String.self, forKey: .imageBase64) {
|
||||
outputBase64 = legacy
|
||||
return
|
||||
}
|
||||
outputBase64 = try container.decode(String.self, forKey: .image)
|
||||
}
|
||||
}
|
||||
// MARK: - Step 2: GET /dream-weaver/status/{job_id}
|
||||
|
||||
enum ComfyClientError: Error {
|
||||
case encodingFailed
|
||||
case decodingFailed
|
||||
}
|
||||
/// Polls every 2s, max 150 attempts (5 minutes). Returns full result URL when ready.
|
||||
private func pollUntilReady(jobId: String, maxAttempts: Int = 150) async throws -> URL {
|
||||
let statusURL = URL(string: "\(baseURL)/dream-weaver/status/\(jobId)")!
|
||||
|
||||
private extension UIImage {
|
||||
func resizedSquare(to side: CGFloat) -> UIImage {
|
||||
let format = UIGraphicsImageRendererFormat.default()
|
||||
format.scale = 1
|
||||
let renderer = UIGraphicsImageRenderer(size: CGSize(width: side, height: side), format: format)
|
||||
for _ in 0..<maxAttempts {
|
||||
try await Task.sleep(nanoseconds: 2_000_000_000) // 2s
|
||||
let (data, _) = try await URLSession.shared.data(from: statusURL)
|
||||
let status = try JSONDecoder().decode(JobStatus.self, from: data)
|
||||
|
||||
return renderer.image { _ in
|
||||
let aspect = size.width / size.height
|
||||
let targetRect: CGRect
|
||||
if aspect > 1 {
|
||||
let width = side * aspect
|
||||
targetRect = CGRect(x: (side - width) / 2, y: 0, width: width, height: side)
|
||||
} else {
|
||||
let height = side / aspect
|
||||
targetRect = CGRect(x: 0, y: (side - height) / 2, width: side, height: height)
|
||||
if status.ready {
|
||||
return URL(string: "\(baseURL)/dream-weaver/result/\(jobId)")!
|
||||
}
|
||||
draw(in: targetRect)
|
||||
if status.status == "error" {
|
||||
throw DreamWeaverError.generationFailed(status.error ?? "Unknown server error")
|
||||
}
|
||||
}
|
||||
throw DreamWeaverError.timeout
|
||||
}
|
||||
|
||||
// MARK: - Step 3: GET /dream-weaver/result/{job_id}
|
||||
|
||||
private func downloadResult(from url: URL) async throws -> UIImage {
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
guard let image = UIImage(data: data) else {
|
||||
throw DreamWeaverError.invalidImageData
|
||||
}
|
||||
return image
|
||||
}
|
||||
|
||||
// MARK: - Multipart Builder
|
||||
|
||||
private func buildMultipart(imageData: Data, style: String, keywords: String, boundary: String) -> Data {
|
||||
var body = Data()
|
||||
let crlf = "\r\n"
|
||||
|
||||
// image field
|
||||
body += "--\(boundary)\(crlf)"
|
||||
body += "Content-Disposition: form-data; name=\"image\"; filename=\"room.jpg\"\(crlf)"
|
||||
body += "Content-Type: image/jpeg\(crlf)\(crlf)"
|
||||
body += imageData
|
||||
body += crlf
|
||||
|
||||
// style field — must be one of the 5 preset IDs
|
||||
body += "--\(boundary)\(crlf)"
|
||||
body += "Content-Disposition: form-data; name=\"style\"\(crlf)\(crlf)"
|
||||
body += style
|
||||
body += crlf
|
||||
|
||||
// keywords field — user's optional comma-separated additions
|
||||
if !keywords.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
body += "--\(boundary)\(crlf)"
|
||||
body += "Content-Disposition: form-data; name=\"keywords\"\(crlf)\(crlf)"
|
||||
body += keywords.trimmingCharacters(in: .whitespaces)
|
||||
body += crlf
|
||||
}
|
||||
|
||||
body += "--\(boundary)--\(crlf)"
|
||||
return body
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data Models (§5 of integration guide)
|
||||
|
||||
struct GenerationJob: Codable {
|
||||
let jobId: String
|
||||
let status: String
|
||||
let pollUrl: String
|
||||
let resultUrl: String
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case jobId = "job_id"
|
||||
case status
|
||||
case pollUrl = "poll_url"
|
||||
case resultUrl = "result_url"
|
||||
}
|
||||
}
|
||||
|
||||
struct JobStatus: Codable {
|
||||
let status: String
|
||||
let ready: Bool
|
||||
let resultUrl: String?
|
||||
let error: String?
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case status, ready
|
||||
case resultUrl = "result_url"
|
||||
case error
|
||||
}
|
||||
}
|
||||
|
||||
struct HealthResponse: Codable {
|
||||
let status: String
|
||||
let comfyui: Bool?
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum DreamWeaverError: LocalizedError {
|
||||
case encodingFailed
|
||||
case invalidImageData
|
||||
case generationFailed(String)
|
||||
case timeout
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .encodingFailed: return "Failed to encode the captured image."
|
||||
case .invalidImageData: return "The server returned an unreadable image."
|
||||
case .generationFailed(let msg): return msg
|
||||
case .timeout: return "The server is taking longer than expected. Please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIImage Helpers
|
||||
|
||||
extension UIImage {
|
||||
func fixedOrientation() -> UIImage {
|
||||
guard imageOrientation != .up else { return self }
|
||||
let fmt = UIGraphicsImageRendererFormat.default()
|
||||
fmt.scale = scale
|
||||
return UIGraphicsImageRenderer(size: size, format: fmt).image { _ in
|
||||
draw(in: CGRect(origin: .zero, size: size))
|
||||
}
|
||||
}
|
||||
|
||||
func resizedSquare(to side: CGFloat) -> UIImage {
|
||||
let fmt = UIGraphicsImageRendererFormat.default()
|
||||
fmt.scale = 1
|
||||
return UIGraphicsImageRenderer(size: CGSize(width: side, height: side), format: fmt).image { _ in
|
||||
let aspect = size.width / size.height
|
||||
let rect: CGRect
|
||||
if aspect > 1 {
|
||||
let w = side * aspect
|
||||
rect = CGRect(x: (side - w) / 2, y: 0, width: w, height: side)
|
||||
} else {
|
||||
let h = side / aspect
|
||||
rect = CGRect(x: 0, y: (side - h) / 2, width: side, height: h)
|
||||
}
|
||||
draw(in: rect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data Helpers
|
||||
|
||||
private func += (lhs: inout Data, rhs: String) { if let d = rhs.data(using: .utf8) { lhs.append(d) } }
|
||||
private func += (lhs: inout Data, rhs: Data) { lhs.append(rhs) }
|
||||
|
||||
@@ -15,33 +15,50 @@ final class InventoryStore {
|
||||
}
|
||||
|
||||
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"]
|
||||
// Error message shown in the DreamWeaver panel
|
||||
var errorMessage: String?
|
||||
}
|
||||
|
||||
struct InventoryView: View {
|
||||
@State private var store = InventoryStore()
|
||||
@State private var showCamera = false
|
||||
@State private var sliderTickHour = 12
|
||||
@State private var showShareSheet = false
|
||||
@State private var shareImage: UIImage? = nil
|
||||
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)
|
||||
// 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("Sunseeker · Dream Weaver · Dollhouse")
|
||||
.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)
|
||||
.padding(8)
|
||||
}
|
||||
.transition(.opacity.combined(with: .scale))
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: store.generatedImage != nil)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 20)
|
||||
|
||||
@@ -58,42 +75,27 @@ struct InventoryView: View {
|
||||
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)
|
||||
)
|
||||
SimulatorUnavailableCard(
|
||||
icon: "camera.metering.unknown",
|
||||
title: "AR Not Available in Simulator",
|
||||
message: "Sunseeker requires a real device with a camera and compass. Run on iPhone or iPad to use this feature."
|
||||
)
|
||||
#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,
|
||||
selectedPrompt: $store.selectedPrompt,
|
||||
isProcessing: $store.isProcessing,
|
||||
prompts: store.prompts,
|
||||
errorMessage: $store.errorMessage,
|
||||
showCamera: $showCamera
|
||||
)
|
||||
|
||||
case .dollhouse:
|
||||
DollhousePanel(hour: $store.dollhouseHour, tickHour: $sliderTickHour, haptics: haptics)
|
||||
}
|
||||
@@ -103,8 +105,15 @@ struct InventoryView: View {
|
||||
.animation(.easeInOut(duration: 0.25), value: store.mode)
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.simultaneousGesture(
|
||||
TapGesture().onEnded {
|
||||
UIApplication.shared.sendAction(
|
||||
#selector(UIResponder.resignFirstResponder),
|
||||
to: nil, from: nil, for: nil
|
||||
)
|
||||
}
|
||||
)
|
||||
.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(
|
||||
@@ -115,11 +124,58 @@ struct InventoryView: View {
|
||||
red: 0.031, green: 0.039, blue: 0.071, alpha: 1)
|
||||
}
|
||||
.sheet(isPresented: $showCamera) {
|
||||
CameraPicker(image: $store.sourceImage, 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.errorMessage = nil
|
||||
}
|
||||
}
|
||||
.sheet(item: $shareImage) { img in
|
||||
ShareSheet(image: img)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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(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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sunseeker
|
||||
|
||||
private struct SunseekerPanel: View {
|
||||
@Binding var sunNodesReady: Bool
|
||||
|
||||
@@ -150,16 +206,41 @@ private struct SunseekerPanel: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dream Weaver
|
||||
|
||||
/// Available interior styles from integration guide §2.3
|
||||
private struct InteriorStyle: Identifiable {
|
||||
let id: String // sent as the `style` form field
|
||||
let displayName: String
|
||||
let icon: String // SF Symbol
|
||||
}
|
||||
|
||||
private let dreamWeaverStyles: [InteriorStyle] = [
|
||||
InteriorStyle(id: "scandinavian", displayName: "Scandi", icon: "snowflake"),
|
||||
InteriorStyle(id: "art_deco", displayName: "Art Deco", icon: "sparkles"),
|
||||
InteriorStyle(id: "biophilic", displayName: "Biophilic",icon: "leaf"),
|
||||
InteriorStyle(id: "cyberpunk", displayName: "Cyberpunk",icon: "bolt"),
|
||||
InteriorStyle(id: "japandi", displayName: "Japandi", icon: "mountain.2"),
|
||||
]
|
||||
|
||||
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 errorMessage: String?
|
||||
@Binding var showCamera: Bool
|
||||
|
||||
/// Selected style ID — sent as `style` field (§3.2). nil = none chosen yet.
|
||||
@State private var selectedStyle: String? = nil
|
||||
/// Optional extra keywords — sent as `keywords` field (§3.2)
|
||||
@State private var keywords: String = ""
|
||||
/// Server health: nil = checking, true = online, false = offline
|
||||
@State private var serverOnline: Bool? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
|
||||
// ── Preview card ──────────────────────────────────────────────
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.fill(Color.black.opacity(0.9))
|
||||
@@ -171,8 +252,12 @@ private struct DreamWeaverPanel: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.padding(12)
|
||||
} else {
|
||||
ContentUnavailableView("No Capture", systemImage: "camera.viewfinder", description: Text("Tap Capture to snap a room."))
|
||||
.foregroundStyle(.white)
|
||||
ContentUnavailableView(
|
||||
"No Capture",
|
||||
systemImage: "camera.viewfinder",
|
||||
description: Text("Tap Capture to snap a room.")
|
||||
)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
if let generatedImage {
|
||||
@@ -184,65 +269,232 @@ private struct DreamWeaverPanel: View {
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
if isProcessing {
|
||||
ProcessingOverlay()
|
||||
if isProcessing { ProcessingOverlay() }
|
||||
|
||||
// Server health badge — top-right corner
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(serverOnline == true ? Color.green : serverOnline == false ? Color.red : Color.gray)
|
||||
.frame(width: 7, height: 7)
|
||||
Text(serverOnline == true ? "Server Online" : serverOnline == false ? "Server Offline" : "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()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 420)
|
||||
.animation(.easeInOut(duration: 0.35), value: generatedImage)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
// ── Error banner ──────────────────────────────────────────────
|
||||
if let errorMessage {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(prompts, id: \.self) { prompt in
|
||||
Text(prompt)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 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))
|
||||
}
|
||||
|
||||
// ── Style picker (§2.3) ───────────────────────────────────────
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(dreamWeaverStyles) { style in
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.3)) {
|
||||
// Tap again to deselect
|
||||
selectedStyle = selectedStyle == style.id ? nil : style.id
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: style.icon)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
Text(style.displayName)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(prompt == selectedPrompt ? Color.yellow.opacity(0.85) : Color.white.opacity(0.12))
|
||||
Capsule()
|
||||
.fill(selectedStyle == style.id
|
||||
? Color(red: 0.231, green: 0.510, blue: 0.965)
|
||||
: Color.white.opacity(0.08))
|
||||
)
|
||||
.onTapGesture { selectedPrompt = prompt }
|
||||
.foregroundStyle(selectedStyle == style.id ? .white : .white.opacity(0.6))
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(selectedStyle == style.id
|
||||
? Color.clear
|
||||
: Color.white.opacity(0.12), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button("Capture") {
|
||||
showCamera = true
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button("Reimagine") {
|
||||
Task { await generate() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(sourceImage == nil || isProcessing)
|
||||
// ── Keywords input ───────────────────────────────────────────
|
||||
PromptInputBar(
|
||||
text: $keywords,
|
||||
isDisabled: sourceImage == nil || isProcessing
|
||||
) {
|
||||
Task { await generate() }
|
||||
}
|
||||
|
||||
// ── Capture / Retake ─────────────────────────────────────────
|
||||
Button(sourceImage == nil ? "Capture" : "Retake") {
|
||||
showCamera = true
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.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)
|
||||
.task { serverOnline = await ComfyClient.shared.checkHealth() }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func generate() async {
|
||||
guard let sourceImage, !isProcessing else { return }
|
||||
isProcessing = true
|
||||
errorMessage = nil
|
||||
do {
|
||||
let result = try await ComfyClient.shared.generateImage(source: sourceImage, prompt: selectedPrompt)
|
||||
let result = try await ComfyClient.shared.generateImage(
|
||||
source: sourceImage,
|
||||
style: selectedStyle ?? dreamWeaverStyles[0].id, // default: scandinavian
|
||||
keywords: keywords.trimmingCharacters(in: .whitespaces)
|
||||
)
|
||||
withAnimation(.easeInOut(duration: 0.4)) {
|
||||
generatedImage = result
|
||||
}
|
||||
} catch {
|
||||
print("Dream Weaver error: \(error)")
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
isProcessing = false
|
||||
}
|
||||
}
|
||||
|
||||
// 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... (optional keywords)"
|
||||
|
||||
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
|
||||
@@ -275,6 +527,8 @@ private struct DollhousePanel: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SceneKit Dollhouse
|
||||
|
||||
private struct SceneKitDollhouseView: UIViewRepresentable {
|
||||
@Binding var hour: Double
|
||||
|
||||
@@ -346,6 +600,8 @@ private struct SceneKitDollhouseView: UIViewRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ProcessingOverlay
|
||||
|
||||
private struct ProcessingOverlay: View {
|
||||
@State private var animate = false
|
||||
|
||||
@@ -384,6 +640,8 @@ private struct ProcessingOverlay: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DashedSunLine
|
||||
|
||||
private struct DashedSunLine: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
@@ -396,9 +654,13 @@ private struct DashedSunLine: Shape {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 image: UIImage?
|
||||
@Binding var isPresented: Bool
|
||||
let onCapture: (UIImage) -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
@@ -407,12 +669,18 @@ private struct CameraPicker: UIViewControllerRepresentable {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -431,9 +699,29 @@ private struct CameraPicker: UIViewControllerRepresentable {
|
||||
|
||||
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
||||
if let captured = info[.originalImage] as? UIImage {
|
||||
parent.image = captured
|
||||
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) }
|
||||
}
|
||||
|
||||
17
iOS/velocity/velocity/Info.plist
Normal file
17
iOS/velocity/velocity/Info.plist
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Velocity needs access to your photo library to let you pick room photos for Dream Weaver.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>Velocity would like to save your AI-generated room design to your photo library.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Velocity needs camera access to capture room photos for Dream Weaver.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
Reference in New Issue
Block a user