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: #6
This commit was merged in pull request #6.
This commit is contained in:
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