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:
2026-03-10 04:20:27 +05:30
parent 55bb5e5a90
commit 6c98affe53
7 changed files with 1928 additions and 144 deletions

View 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"
}()
}

View File

@@ -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) }

View File

@@ -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) }
}

View 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>