feat: Ipad app features and Dream Weaver for Velocity WebOS
Some checks failed
Production Readiness / backend-contracts (pull_request) Has been cancelled
Production Readiness / webos-typecheck (pull_request) Has been cancelled
Production Readiness / ipad-parse (pull_request) Has been cancelled

This commit is contained in:
Sayan Datta
2026-04-28 10:59:07 +05:30
parent 184bfa77f8
commit fefe8373ec
117 changed files with 19510 additions and 6383 deletions

View File

@@ -0,0 +1,302 @@
import Foundation
import Security
/// Central app configuration.
/// Build settings remain the fallback, but production installs should prefer
/// runtime configuration stored on-device.
enum AppConfig {
private static let runtimeBaseURLKey = "velocity.runtime.base_url"
private static let runtimeDreamWeaverBaseURLKey = "velocity.runtime.dream_weaver_base_url"
private static let runtimeDreamWeaverAPIKeyKey = "velocity.runtime.dream_weaver_api_key"
private static let runtimeEmailKey = "velocity.runtime.email"
private static let runtimePasswordKey = "velocity.runtime.password"
private static let runtimeBearerTokenKey = "velocity.runtime.bearer_token"
private static let runtimeAccessTokenKey = "velocity.runtime.access_token"
private static let runtimeAccessTokenExpiresAtKey = "velocity.runtime.access_token_expires_at"
private static let keychainService = "com.desineuron.velocity.ipad.session"
static func parsedValue(from infoDictionary: [String: Any]?, key: String) -> String? {
let raw = infoDictionary?[key] as? String
return sanitizedValue(raw, key: key)
}
static func isLiveConfigured(
bearerToken: String?,
email: String?,
password: String?
) -> Bool {
bearerToken != nil || (email != nil && password != nil)
}
static func authModeDescription(
bearerToken: String?,
email: String?,
password: String?
) -> String {
if bearerToken != nil {
return "Bearer token"
}
if email != nil && password != nil {
return "Email/password"
}
return "Credentials required"
}
private static func value(for key: String) -> String? {
parsedValue(from: Bundle.main.infoDictionary, key: key)
}
private static func sanitizedValue(_ raw: String?, key: String) -> String? {
guard let raw else {
return nil
}
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty || trimmed == "$(\(key))" {
return nil
}
return trimmed
}
/// Base URL for the Velocity backend / gateway.
static var baseURL: String {
runtimeBaseURL ?? value(for: "BASE_URL") ?? "https://velocity.desineuron.in/api"
}
/// Dedicated Dream Weaver gateway endpoint when configured; otherwise
/// generation falls back to the main backend endpoint.
static var dreamWeaverBaseURL: String {
configuredDreamWeaverBaseURL ?? originBaseURL(from: baseURL)
}
static var usesDedicatedDreamWeaverBaseURL: Bool {
guard let configuredDreamWeaverBaseURL else {
return false
}
return configuredDreamWeaverBaseURL != baseURL
}
static var dreamWeaverAPIKey: String? {
runtimeDreamWeaverAPIKey ?? value(for: "DREAM_WEAVER_API_KEY")
}
static var apiEmail: String? {
runtimeEmail ?? value(for: "API_EMAIL")
}
static var apiPassword: String? {
runtimePassword ?? value(for: "API_PASSWORD")
}
static var apiBearerToken: String? {
runtimeBearerToken ?? value(for: "API_BEARER_TOKEN")
}
static var apiAccessToken: String? {
guard let expiresAt = runtimeAccessTokenExpiresAt, expiresAt > Date().addingTimeInterval(60) else {
try? clearStoredAccessToken()
return nil
}
return secret(account: runtimeAccessTokenKey)
}
static var isLiveConfigured: Bool {
isLiveConfigured(
bearerToken: apiBearerToken,
email: apiEmail,
password: apiPassword
)
}
static var authModeDescription: String {
authModeDescription(
bearerToken: apiBearerToken,
email: apiEmail,
password: apiPassword
)
}
static var hasStoredRuntimeConfiguration: Bool {
runtimeBaseURL != nil ||
runtimeDreamWeaverBaseURL != nil ||
runtimeEmail != nil ||
runtimePassword != nil ||
runtimeBearerToken != nil
}
static func currentSessionConfiguration() -> AppSessionConfiguration {
AppSessionConfiguration(
baseURL: baseURL,
dreamWeaverBaseURL: dreamWeaverBaseURL,
usesDedicatedDreamWeaverBaseURL: usesDedicatedDreamWeaverBaseURL,
hasDreamWeaverAPIKey: dreamWeaverAPIKey != nil,
email: apiEmail,
hasPassword: apiPassword != nil,
hasBearerToken: apiBearerToken != nil,
source: hasStoredRuntimeConfiguration ? .secureDeviceStorage : .buildConfiguration
)
}
static func saveRuntimeConfiguration(
baseURL: String,
dreamWeaverBaseURL: String?,
dreamWeaverAPIKey: String?,
email: String?,
password: String?,
bearerToken: String?
) throws {
UserDefaults.standard.set(baseURL, forKey: runtimeBaseURLKey)
if let dreamWeaverBaseURL {
UserDefaults.standard.set(dreamWeaverBaseURL, forKey: runtimeDreamWeaverBaseURLKey)
} else {
UserDefaults.standard.removeObject(forKey: runtimeDreamWeaverBaseURLKey)
}
if let email {
UserDefaults.standard.set(email, forKey: runtimeEmailKey)
} else {
UserDefaults.standard.removeObject(forKey: runtimeEmailKey)
}
try storeSecret(dreamWeaverAPIKey, account: runtimeDreamWeaverAPIKeyKey)
try storeSecret(password, account: runtimePasswordKey)
try storeSecret(bearerToken, account: runtimeBearerTokenKey)
try clearStoredAccessToken()
}
static func clearStoredRuntimeConfiguration() throws {
UserDefaults.standard.removeObject(forKey: runtimeBaseURLKey)
UserDefaults.standard.removeObject(forKey: runtimeDreamWeaverBaseURLKey)
UserDefaults.standard.removeObject(forKey: runtimeEmailKey)
try deleteSecret(account: runtimeDreamWeaverAPIKeyKey)
try deleteSecret(account: runtimePasswordKey)
try deleteSecret(account: runtimeBearerTokenKey)
try clearStoredAccessToken()
}
static func saveAccessToken(_ token: String, expiresIn: Int?) throws {
try storeSecret(token, account: runtimeAccessTokenKey)
let lifetime = TimeInterval(max(expiresIn ?? 28_800, 60))
UserDefaults.standard.set(Date().addingTimeInterval(lifetime).timeIntervalSince1970, forKey: runtimeAccessTokenExpiresAtKey)
}
static func clearStoredAccessToken() throws {
UserDefaults.standard.removeObject(forKey: runtimeAccessTokenExpiresAtKey)
try deleteSecret(account: runtimeAccessTokenKey)
}
private static var runtimeBaseURL: String? {
sanitizedValue(UserDefaults.standard.string(forKey: runtimeBaseURLKey), key: runtimeBaseURLKey)
}
private static var configuredDreamWeaverBaseURL: String? {
runtimeDreamWeaverBaseURL ?? value(for: "DREAM_WEAVER_BASE_URL")
}
private static var runtimeDreamWeaverBaseURL: String? {
sanitizedValue(
UserDefaults.standard.string(forKey: runtimeDreamWeaverBaseURLKey),
key: runtimeDreamWeaverBaseURLKey
)
}
private static var runtimeDreamWeaverAPIKey: String? {
secret(account: runtimeDreamWeaverAPIKeyKey)
}
private static var runtimeEmail: String? {
sanitizedValue(UserDefaults.standard.string(forKey: runtimeEmailKey), key: runtimeEmailKey)
}
private static var runtimePassword: String? {
secret(account: runtimePasswordKey)
}
private static var runtimeBearerToken: String? {
secret(account: runtimeBearerTokenKey)
}
private static var runtimeAccessTokenExpiresAt: Date? {
let rawValue = UserDefaults.standard.double(forKey: runtimeAccessTokenExpiresAtKey)
guard rawValue > 0 else {
return nil
}
return Date(timeIntervalSince1970: rawValue)
}
private static func originBaseURL(from rawValue: String) -> String {
guard var components = URLComponents(string: rawValue) else {
return rawValue
}
components.path = ""
components.query = nil
components.fragment = nil
return components.string ?? rawValue
}
private static func secret(account: String) -> String? {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: keychainService,
kSecAttrAccount: account,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else {
return nil
}
guard let data = result as? Data else {
return nil
}
return String(data: data, encoding: .utf8)
}
private static func storeSecret(_ value: String?, account: String) throws {
if let value, let data = value.data(using: .utf8) {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: keychainService,
kSecAttrAccount: account
]
let attributes: [CFString: Any] = [
kSecValueData: data
]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if status == errSecSuccess {
return
}
if status == errSecItemNotFound {
var addQuery = query
addQuery[kSecValueData] = data
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw SessionPersistenceError.keychainWriteFailed(addStatus)
}
return
}
throw SessionPersistenceError.keychainWriteFailed(status)
}
try deleteSecret(account: account)
}
private static func deleteSecret(account: String) throws {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: keychainService,
kSecAttrAccount: account
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw SessionPersistenceError.keychainDeleteFailed(status)
}
}
}

View File

@@ -0,0 +1,247 @@
import Foundation
enum SessionAuthMode: String, CaseIterable, Identifiable {
case emailPassword = "Email/password"
case bearerToken = "Bearer token"
var id: String { rawValue }
}
enum SessionConfigurationSource: String {
case buildConfiguration = "Build configuration"
case secureDeviceStorage = "Secure device storage"
}
struct AppSessionConfiguration: Equatable {
let baseURL: String
let dreamWeaverBaseURL: String
let usesDedicatedDreamWeaverBaseURL: Bool
let hasDreamWeaverAPIKey: Bool
let email: String?
let hasPassword: Bool
let hasBearerToken: Bool
let source: SessionConfigurationSource
var authMode: SessionAuthMode {
hasBearerToken ? .bearerToken : .emailPassword
}
var isConfigured: Bool {
hasBearerToken || (email != nil && hasPassword)
}
var authModeDescription: String {
if hasBearerToken {
return "Bearer token"
}
if email != nil && hasPassword {
return "Email/password"
}
return "Credentials required"
}
var operatorIdentity: String {
if let email, !email.isEmpty {
return email
}
if hasBearerToken {
return "Token authenticated operator"
}
return "Unconfigured operator"
}
var dreamWeaverEndpointModeDescription: String {
usesDedicatedDreamWeaverBaseURL ? "Dedicated gateway" : "Shared with backend"
}
var dreamWeaverAuthenticationDescription: String {
hasDreamWeaverAPIKey ? "API key configured" : "No gateway key configured"
}
}
struct SessionConfigurationDraft: Equatable {
var baseURL: String
var dreamWeaverBaseURL: String
var dreamWeaverAPIKey: String
var authMode: SessionAuthMode
var email: String
var password: String
var bearerToken: String
var existingDreamWeaverAPIKeyAvailable: Bool
var existingPasswordAvailable: Bool
var existingBearerTokenAvailable: Bool
var baselineEmail: String?
var trimmedBaseURL: String? {
Self.trimmedValue(baseURL)
}
var trimmedEmail: String? {
Self.trimmedValue(email)
}
var trimmedPassword: String? {
Self.trimmedValue(password)
}
var trimmedBearerToken: String? {
Self.trimmedValue(bearerToken)
}
var normalizedBaseURL: String? {
Self.normalizedHTTPSOrigin(from: trimmedBaseURL)
}
var trimmedDreamWeaverBaseURL: String? {
Self.trimmedValue(dreamWeaverBaseURL)
}
var normalizedDreamWeaverBaseURL: String? {
Self.normalizedHTTPSOrigin(from: trimmedDreamWeaverBaseURL)
}
var trimmedDreamWeaverAPIKey: String? {
Self.trimmedValue(dreamWeaverAPIKey)
}
func validationErrors() -> [String] {
var errors: [String] = []
guard let trimmedBaseURL else {
errors.append("Backend endpoint is required.")
return errors
}
guard URLComponents(string: trimmedBaseURL) != nil else {
errors.append("Backend endpoint must be a valid URL.")
return errors
}
guard normalizedBaseURL != nil else {
errors.append("Backend endpoint must be an HTTPS API base like https://velocity.desineuron.in/api.")
return errors
}
if let trimmedDreamWeaverBaseURL {
guard URLComponents(string: trimmedDreamWeaverBaseURL) != nil else {
errors.append("Dream Weaver endpoint must be a valid URL.")
return errors
}
guard normalizedDreamWeaverBaseURL != nil else {
errors.append("Dream Weaver endpoint must be an HTTPS origin like https://dreamweaver.desineuron.in.")
return errors
}
}
switch authMode {
case .emailPassword:
guard let trimmedEmail else {
errors.append("Operator email is required for email/password login.")
break
}
guard trimmedEmail.contains("@"), trimmedEmail.contains(".") else {
errors.append("Operator email must look like a valid email address.")
break
}
if trimmedPassword == nil &&
!(existingPasswordAvailable && trimmedEmail.caseInsensitiveCompare(baselineEmail ?? "") == .orderedSame) {
errors.append("Password is required for email/password login.")
}
case .bearerToken:
if trimmedBearerToken == nil && !existingBearerTokenAvailable {
errors.append("Bearer token is required when token auth is selected.")
}
}
return errors
}
func resolvedEmail(existingEmail: String?) -> String? {
guard authMode == .emailPassword else { return nil }
return trimmedEmail ?? existingEmail
}
func resolvedPassword(existingPassword: String?) -> String? {
guard authMode == .emailPassword else { return nil }
if let trimmedPassword {
return trimmedPassword
}
guard existingPasswordAvailable else {
return nil
}
guard trimmedEmail?.caseInsensitiveCompare(baselineEmail ?? "") == .orderedSame else {
return nil
}
return existingPassword
}
func resolvedBearerToken(existingToken: String?) -> String? {
guard authMode == .bearerToken else { return nil }
return trimmedBearerToken ?? (existingBearerTokenAvailable ? existingToken : nil)
}
func resolvedDreamWeaverBaseURL(normalizedBaseURL: String) -> String? {
normalizedDreamWeaverBaseURL
}
func resolvedDreamWeaverAPIKey(existingKey: String?) -> String? {
trimmedDreamWeaverAPIKey ?? (existingDreamWeaverAPIKeyAvailable ? existingKey : nil)
}
private static func trimmedValue(_ value: String?) -> String? {
guard let value else {
return nil
}
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private static func normalizedHTTPSOrigin(from raw: String?) -> String? {
guard let raw else {
return nil
}
guard var components = URLComponents(string: raw) else {
return nil
}
guard let scheme = components.scheme?.lowercased(),
let host = components.host?.lowercased() else {
return nil
}
guard scheme == "https" else {
return nil
}
guard components.query == nil, components.fragment == nil else {
return nil
}
let path = components.path.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedPath = path == "/api/" ? "/api" : path
guard normalizedPath.isEmpty || normalizedPath == "/" || normalizedPath == "/api" else {
return nil
}
components.scheme = scheme
components.host = host
components.path = normalizedPath == "/api" ? "/api" : ""
components.query = nil
components.fragment = nil
return components.string
}
}
enum SessionPersistenceError: LocalizedError {
case keychainWriteFailed(OSStatus)
case keychainDeleteFailed(OSStatus)
var errorDescription: String? {
switch self {
case .keychainWriteFailed(let status):
return "Velocity could not save credentials securely on this iPad. Keychain status: \(status)."
case .keychainDeleteFailed(let status):
return "Velocity could not clear stored credentials from this iPad. Keychain status: \(status)."
}
}
}

View File

@@ -0,0 +1,204 @@
import Foundation
import Observation
@MainActor
@Observable
final class SessionStore {
static let shared = SessionStore()
private init() {
reloadFromPersistedConfiguration()
}
var currentConfiguration = AppConfig.currentSessionConfiguration()
var draftBaseURL = ""
var draftDreamWeaverBaseURL = ""
var draftDreamWeaverAPIKey = ""
var draftAuthMode: SessionAuthMode = .emailPassword
var draftEmail = ""
var draftPassword = ""
var draftBearerToken = ""
var isSaving = false
var statusMessage: String?
var errorMessage: String?
private var existingPasswordAvailable = false
private var existingBearerTokenAvailable = false
private var existingDreamWeaverAPIKeyAvailable = false
private var baselineEmail: String?
var isConfigured: Bool {
currentConfiguration.isConfigured
}
var authModeDescription: String {
currentConfiguration.authModeDescription
}
var operatorIdentity: String {
currentConfiguration.operatorIdentity
}
var endpointDisplay: String {
currentConfiguration.baseURL
}
var dreamWeaverEndpointDisplay: String {
currentConfiguration.dreamWeaverBaseURL
}
var dreamWeaverEndpointModeDescription: String {
currentConfiguration.dreamWeaverEndpointModeDescription
}
var dreamWeaverAuthenticationDescription: String {
currentConfiguration.dreamWeaverAuthenticationDescription
}
var configurationSourceDescription: String {
currentConfiguration.source.rawValue
}
var isUsingStoredRuntimeConfiguration: Bool {
currentConfiguration.source == .secureDeviceStorage
}
var hasUnsavedChanges: Bool {
draftBaseURL != currentConfiguration.baseURL ||
draftDreamWeaverBaseURL != persistedDreamWeaverDraftValue ||
!draftDreamWeaverAPIKey.isEmpty ||
draftAuthMode != currentConfiguration.authMode ||
draftEmail != (currentConfiguration.email ?? "") ||
!draftPassword.isEmpty ||
!draftBearerToken.isEmpty
}
func reloadFromPersistedConfiguration() {
currentConfiguration = AppConfig.currentSessionConfiguration()
draftBaseURL = currentConfiguration.baseURL
draftDreamWeaverBaseURL = persistedDreamWeaverDraftValue
draftDreamWeaverAPIKey = ""
draftAuthMode = currentConfiguration.authMode
draftEmail = currentConfiguration.email ?? ""
draftPassword = ""
draftBearerToken = ""
existingDreamWeaverAPIKeyAvailable = currentConfiguration.hasDreamWeaverAPIKey
existingPasswordAvailable = currentConfiguration.hasPassword
existingBearerTokenAvailable = currentConfiguration.hasBearerToken
baselineEmail = currentConfiguration.email
}
func discardDraftChanges() {
errorMessage = nil
statusMessage = nil
reloadFromPersistedConfiguration()
}
func saveDraft() async {
errorMessage = nil
statusMessage = nil
let draft = SessionConfigurationDraft(
baseURL: draftBaseURL,
dreamWeaverBaseURL: draftDreamWeaverBaseURL,
dreamWeaverAPIKey: draftDreamWeaverAPIKey,
authMode: draftAuthMode,
email: draftEmail,
password: draftPassword,
bearerToken: draftBearerToken,
existingDreamWeaverAPIKeyAvailable: existingDreamWeaverAPIKeyAvailable,
existingPasswordAvailable: existingPasswordAvailable,
existingBearerTokenAvailable: existingBearerTokenAvailable,
baselineEmail: baselineEmail
)
let errors = draft.validationErrors()
guard errors.isEmpty else {
errorMessage = errors.joined(separator: " ")
return
}
guard let normalizedBaseURL = draft.normalizedBaseURL else {
errorMessage = "Backend endpoint must be a valid HTTPS origin."
return
}
isSaving = true
do {
try AppConfig.saveRuntimeConfiguration(
baseURL: normalizedBaseURL,
dreamWeaverBaseURL: draft.resolvedDreamWeaverBaseURL(normalizedBaseURL: normalizedBaseURL),
dreamWeaverAPIKey: draft.resolvedDreamWeaverAPIKey(existingKey: AppConfig.dreamWeaverAPIKey),
email: draft.resolvedEmail(existingEmail: currentConfiguration.email),
password: draft.resolvedPassword(existingPassword: AppConfig.apiPassword),
bearerToken: draft.resolvedBearerToken(existingToken: AppConfig.apiBearerToken)
)
await VelocityAPIClient.shared.resetSession()
AppStore.shared.resetLiveData()
reloadFromPersistedConfiguration()
await AppStore.shared.refresh()
let dreamWeaverHealthy = await ComfyClient.shared.checkHealth()
statusMessage = verificationStatusMessage(
successPrefix: "Configuration saved.",
backendRefreshError: AppStore.shared.errorMessage,
dreamWeaverHealthy: dreamWeaverHealthy
)
} catch {
errorMessage = error.localizedDescription
}
isSaving = false
}
func clearStoredConfiguration() async {
errorMessage = nil
statusMessage = nil
isSaving = true
do {
try AppConfig.clearStoredRuntimeConfiguration()
await VelocityAPIClient.shared.resetSession()
AppStore.shared.resetLiveData()
reloadFromPersistedConfiguration()
if currentConfiguration.isConfigured {
await AppStore.shared.refresh()
let dreamWeaverHealthy = await ComfyClient.shared.checkHealth()
statusMessage = verificationStatusMessage(
successPrefix: "Stored override cleared. Velocity is now using the build configuration.",
backendRefreshError: AppStore.shared.errorMessage,
dreamWeaverHealthy: dreamWeaverHealthy
)
} else {
statusMessage = "Stored session cleared. This iPad now requires runtime configuration before live data can load."
}
} catch {
errorMessage = error.localizedDescription
}
isSaving = false
}
private var persistedDreamWeaverDraftValue: String {
currentConfiguration.usesDedicatedDreamWeaverBaseURL ? currentConfiguration.dreamWeaverBaseURL : ""
}
private func verificationStatusMessage(
successPrefix: String,
backendRefreshError: String?,
dreamWeaverHealthy: Bool
) -> String {
switch (backendRefreshError, dreamWeaverHealthy) {
case (nil, true):
return "\(successPrefix) Core backend refresh and Dream Weaver gateway probe both succeeded."
case (let backendRefreshError?, true):
return "\(successPrefix) Core backend refresh failed: \(backendRefreshError) Dream Weaver gateway probe succeeded."
case (nil, false):
return "\(successPrefix) Core backend refresh succeeded, but the Dream Weaver gateway probe failed. Verify the dedicated generation endpoint and routing."
case (let backendRefreshError?, false):
return "\(successPrefix) Core backend refresh failed: \(backendRefreshError) Dream Weaver gateway probe also failed."
}
}
}

View File

@@ -0,0 +1,134 @@
import CoreLocation
import Foundation
struct SunPosition {
let azimuth: Double // 0...360, degrees clockwise from true north
let elevation: Double // -90...90 degrees above horizon
}
enum SunMath {
// MARK: - Single Position
static func calculateSunPosition(date: Date, coordinate: CLLocationCoordinate2D) -> SunPosition {
let timezone = TimeZone.current
let localOffsetHours = Double(timezone.secondsFromGMT(for: date)) / 3600.0
let julianDay = date.julianDay
let n = julianDay - 2_451_545.0
let meanLongitude = normalizeDegrees(280.46 + 0.985_647_4 * n)
let meanAnomaly = normalizeDegrees(357.528 + 0.985_600_3 * n)
let lambda = meanLongitude
+ 1.915 * sin(meanAnomaly.radians)
+ 0.020 * sin((2.0 * meanAnomaly).radians)
let obliquity = 23.439 - 0.000_000_4 * n
let rightAscension = atan2(
cos(obliquity.radians) * sin(lambda.radians),
cos(lambda.radians)
).degrees
let declination = asin(sin(obliquity.radians) * sin(lambda.radians)).degrees
let utcHours = date.utcHours
let lst = normalizeDegrees(100.46 + 0.985_647 * n + coordinate.longitude + 15.0 * utcHours + localOffsetHours)
let hourAngle = normalizeDegrees(lst - rightAscension)
let signedHourAngle = hourAngle > 180.0 ? hourAngle - 360.0 : hourAngle
let latitude = coordinate.latitude.radians
let declinationRad = declination.radians
let hourAngleRad = signedHourAngle.radians
let elevation = asin(
sin(latitude) * sin(declinationRad)
+ cos(latitude) * cos(declinationRad) * cos(hourAngleRad)
).degrees
let azimuth = normalizeDegrees(
atan2(
-sin(hourAngleRad),
tan(declinationRad) * cos(latitude) - sin(latitude) * cos(hourAngleRad)
).degrees
)
return SunPosition(azimuth: azimuth, elevation: elevation)
}
// MARK: - Hourly Arc (used by legacy code & DashedSunLine)
/// 5-sample dictionary kept for backward compat with the Dollhouse slider.
static func sunPathSamples(for date: Date, coordinate: CLLocationCoordinate2D) -> [Date: SunPosition] {
let calendar = Calendar.current
let sampleHours = [8, 10, 12, 14, 16]
var output: [Date: SunPosition] = [:]
for hour in sampleHours {
if let sampleDate = calendar.date(bySettingHour: hour, minute: 0, second: 0, of: date) {
output[sampleDate] = calculateSunPosition(date: sampleDate, coordinate: coordinate)
}
}
return output
}
/// Dense arc for the AR overlay one sample per hour from 4 AM to 8 PM.
/// Filters out below-horizon positions (elevation < -5°).
static func sunPathArc(for date: Date, coordinate: CLLocationCoordinate2D) -> [(date: Date, position: SunPosition)] {
let calendar = Calendar.current
var result: [(Date, SunPosition)] = []
for hour in 4...20 {
guard let sampleDate = calendar.date(bySettingHour: hour, minute: 0, second: 0, of: date) else { continue }
let pos = calculateSunPosition(date: sampleDate, coordinate: coordinate)
// include a small below-horizon buffer so arc starts/ends smoothly
if pos.elevation > -5 {
result.append((sampleDate, pos))
}
}
return result
}
/// Approximate sunrise and sunset by scanning for elevation sign changes.
static func sunRiseSet(for date: Date, coordinate: CLLocationCoordinate2D) -> (rise: Date?, set: Date?) {
let calendar = Calendar.current
var rise: Date? = nil
var set: Date? = nil
var prevElevation: Double? = nil
var prevDate: Date? = nil
for minuteOffset in stride(from: 0, through: 24 * 60, by: 10) {
guard let sampleDate = calendar.date(byAdding: .minute, value: minuteOffset, to: calendar.startOfDay(for: date)) else { continue }
let pos = calculateSunPosition(date: sampleDate, coordinate: coordinate)
if let prev = prevElevation, let prevD = prevDate {
if prev < 0 && pos.elevation >= 0 { rise = prevD }
if prev >= 0 && pos.elevation < 0 { set = prevD }
}
prevElevation = pos.elevation
prevDate = sampleDate
}
return (rise, set)
}
// MARK: - Helpers
static func normalizeDegrees(_ value: Double) -> Double {
let reduced = value.truncatingRemainder(dividingBy: 360.0)
return reduced >= 0 ? reduced : reduced + 360.0
}
}
// MARK: - Date helpers
private extension Date {
var utcHours: Double {
let calendar = Calendar(identifier: .gregorian)
let comps = calendar.dateComponents(in: TimeZone(secondsFromGMT: 0)!, from: self)
return Double(comps.hour ?? 0) + Double(comps.minute ?? 0) / 60.0 + Double(comps.second ?? 0) / 3600.0
}
var julianDay: Double {
timeIntervalSince1970 / 86_400.0 + 2_440_587.5
}
}
private extension Double {
var radians: Double { self * .pi / 180.0 }
var degrees: Double { self * 180.0 / .pi }
}

View File

@@ -0,0 +1,351 @@
import Foundation
import UIKit
// MARK: - ComfyClient
/// Handles all Dream Weaver API communication.
/// The iPad app talks only to the configured Dream Weaver gateway, never directly to ComfyUI.
/// Flow: POST /dream-weaver poll /status GET /result
final class ComfyClient {
static let shared = ComfyClient()
private let urlSession: URLSession
private var baseURL: String { AppConfig.dreamWeaverBaseURL }
private var apiKey: String? { AppConfig.dreamWeaverAPIKey }
init(urlSession: URLSession = .shared) {
self.urlSession = urlSession
}
// MARK: - Health Check
/// Call on app launch to confirm the Dream Weaver gateway is reachable
/// and the Dream Weaver routes are actually mounted behind it.
func checkHealth() async -> Bool {
do {
var request = authorizedRequest(url: try resolvedURL(candidate: nil, fallbackPath: "/health"))
request.timeoutInterval = 30.0
let (data, response) = try await urlSession.data(for: request)
guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
return false
}
let json = try JSONDecoder().decode(HealthResponse.self, from: data)
guard ["ok", "healthy"].contains(json.status.lowercased()) else {
return false
}
return try await probeDreamWeaverRoute()
} catch {
return false
}
}
// MARK: - Main Generation Pipeline
/// Full pipeline: upload queue poll download.
/// - Parameters:
/// - source: Room photo from camera or library.
/// - roomType: The type of room being designed (e.g. "bedroom", "living_room").
/// - keywords: Comma-separated user keywords appended to the style prompt (can be empty).
func generateImage(source: UIImage, roomType: 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
}
// 1. Submit job get job_id
let job = try await submitJob(imageData: imageData, roomType: roomType, keywords: keywords)
// 2. Poll status every 2s until ready (max 5 min per integration guide §3.3)
let resultURL = try await pollUntilReady(job: job)
// 3. Download result PNG
return try await downloadResult(from: resultURL)
}
// MARK: - Step 1: POST /dream-weaver
private func submitJob(imageData: Data, roomType: String, keywords: String) async throws -> GenerationJob {
let boundary = "Boundary-\(UUID().uuidString)"
var request = authorizedRequest(url: try resolvedURL(candidate: nil, fallbackPath: "/dream-weaver"))
request.httpMethod = "POST"
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 180.0
request.httpBody = buildMultipart(
imageData: imageData,
roomType: roomType,
keywords: keywords,
boundary: boundary
)
let (data, response) = try await urlSession.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 try JSONDecoder().decode(GenerationJob.self, from: data)
}
// MARK: - Step 2: GET /dream-weaver/status/{job_id}
/// Polls every 2s, max 150 attempts (5 minutes). Returns full result URL when ready.
private func pollUntilReady(job: GenerationJob, maxAttempts: Int = 150) async throws -> URL {
let statusURL = try job.resolvedPollURL(baseURL: baseURL)
for _ in 0..<maxAttempts {
try await Task.sleep(nanoseconds: 2_000_000_000) // 2s
let (data, response) = try await urlSession.data(for: authorizedRequest(url: statusURL))
guard let http = response as? HTTPURLResponse else {
throw DreamWeaverError.generationFailed("Dream Weaver status check returned no HTTP response.")
}
guard 200..<300 ~= http.statusCode else {
let detail = String(data: data, encoding: .utf8) ?? ""
throw DreamWeaverError.generationFailed(
"Dream Weaver status check failed (HTTP \(http.statusCode))\(detail.isEmpty ? "" : ": \(detail)")"
)
}
let status = try JSONDecoder().decode(JobStatus.self, from: data)
if status.ready {
return try status.resolvedResultURL(baseURL: baseURL, jobId: job.jobId)
}
if status.status.lowercased() == "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, response) = try await urlSession.data(for: authorizedRequest(url: url, accept: "image/png"))
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(
"Dream Weaver result download failed (HTTP \(code))\(detail.isEmpty ? "" : ": \(detail)")"
)
}
guard let image = UIImage(data: data) else {
throw DreamWeaverError.invalidImageData
}
return image
}
// MARK: - Multipart Builder
private func buildMultipart(imageData: Data, roomType: 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
// roomType field
body += "--\(boundary)\(crlf)"
body += "Content-Disposition: form-data; name=\"room_type\"\(crlf)\(crlf)"
body += roomType
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
}
private func probeDreamWeaverRoute() async throws -> Bool {
let probeURL = try resolvedURL(
candidate: nil,
fallbackPath: "/dream-weaver/status/velocity-route-probe"
)
var request = authorizedRequest(url: probeURL)
request.timeoutInterval = 30.0
let (data, response) = try await urlSession.data(for: request)
guard let http = response as? HTTPURLResponse else {
return false
}
switch http.statusCode {
case 200..<300:
return (try? JSONDecoder().decode(JobStatus.self, from: data)) != nil
case 404:
guard let errorResponse = try? JSONDecoder().decode(DreamWeaverErrorResponse.self, from: data) else {
return false
}
return errorResponse.detail.localizedCaseInsensitiveContains("job not found")
default:
return false
}
}
private func resolvedURL(candidate: String?, fallbackPath: String) throws -> URL {
let gatewayBaseURL = baseURL.trimmingCharacters(in: .whitespacesAndNewlines)
guard let base = URL(string: gatewayBaseURL) else {
throw DreamWeaverError.generationFailed("Invalid Dream Weaver gateway URL: \(gatewayBaseURL)")
}
let value = candidate?.trimmingCharacters(in: .whitespacesAndNewlines)
if let value, !value.isEmpty {
if let absolute = URL(string: value), absolute.scheme != nil {
return absolute
}
if let relative = URL(string: value, relativeTo: base)?.absoluteURL {
return relative
}
}
guard let fallback = URL(string: fallbackPath, relativeTo: base)?.absoluteURL else {
throw DreamWeaverError.generationFailed("Invalid Dream Weaver route: \(fallbackPath)")
}
return fallback
}
private func authorizedRequest(url: URL, accept: String = "application/json") -> URLRequest {
var request = URLRequest(url: url)
request.setValue(accept, forHTTPHeaderField: "Accept")
if let apiKey, !apiKey.isEmpty {
request.setValue(apiKey, forHTTPHeaderField: "X-Dream-Weaver-API-Key")
}
return request
}
}
// 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"
}
func resolvedPollURL(baseURL: String) throws -> URL {
try resolvedURL(candidate: pollUrl, baseURL: baseURL, fallbackPath: "/dream-weaver/status/\(jobId)")
}
func resolvedResultURL(baseURL: String) throws -> URL {
try resolvedURL(candidate: resultUrl, baseURL: baseURL, fallbackPath: "/dream-weaver/result/\(jobId)")
}
}
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
}
func resolvedResultURL(baseURL: String, jobId: String) throws -> URL {
try resolvedURL(candidate: resultUrl, baseURL: baseURL, fallbackPath: "/dream-weaver/result/\(jobId)")
}
}
struct HealthResponse: Codable {
let status: String
let comfyui: Bool?
}
struct DreamWeaverErrorResponse: Codable {
let detail: String
}
// 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."
}
}
}
private func resolvedURL(candidate: String?, baseURL: String, fallbackPath: String) throws -> URL {
let gatewayBaseURL = baseURL.trimmingCharacters(in: .whitespacesAndNewlines)
guard let base = URL(string: gatewayBaseURL) else {
throw DreamWeaverError.generationFailed("Invalid Dream Weaver gateway URL: \(gatewayBaseURL)")
}
let value = candidate?.trimmingCharacters(in: .whitespacesAndNewlines)
if let value, !value.isEmpty {
if let absolute = URL(string: value), absolute.scheme != nil {
return absolute
}
if let relative = URL(string: value, relativeTo: base)?.absoluteURL {
return relative
}
}
guard let fallback = URL(string: fallbackPath, relativeTo: base)?.absoluteURL else {
throw DreamWeaverError.generationFailed("Invalid Dream Weaver route: \(fallbackPath)")
}
return fallback
}
// 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) }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,815 @@
import Foundation
import Observation
struct DashboardMetrics {
let leadCount: Int
let whaleLeadCount: Int
let propertyCount: Int
let todayCalendarCount: Int
let pendingTaskCount: Int
let urgentTaskCount: Int
let pendingInsights: Int
let pendingTranscriptions: Int
}
@MainActor
@Observable
final class AppStore {
static let shared = AppStore()
private static let locallyCreatedCalendarEventsKey = "velocity.calendar.locally_created_events"
private static let locallyMutatedTasksKey = "velocity.calendar.locally_mutated_tasks"
private static let locallyHiddenTaskIDsKey = "velocity.calendar.locally_hidden_task_ids"
private init() {
localTaskOverrides = Self.loadLocallyMutatedTasks()
locallyResolvedTaskIDs = Self.loadLocallyHiddenTaskIDs()
tasks = VelocityTaskDTO.sortedForOperatorReview(Array(localTaskOverrides.values))
locallyCreatedCalendarEvents = Self.loadLocallyCreatedCalendarEvents()
calendarEvents = locallyCreatedCalendarEvents
}
private struct RefreshSnapshot {
let contacts: [VelocityCanonicalContactListItemDTO]
let leads: [VelocityLeadDTO]
let tasks: [VelocityTaskDTO]
let pendingTaskCount: Int
let pendingTaskIDs: Set<String>
let kanbanColumns: [VelocityKanbanColumnDTO]
let opportunities: [VelocityOpportunityDTO]
let properties: [VelocityPropertyDTO]
let calendarEvents: [VelocityCalendarEventDTO]
let alertSnapshot: VelocityAlertSnapshotDTO
let leadEvents: [String: [VelocityCommunicationEventDTO]]
}
private struct CalendarTaskRefresh {
let tasks: [VelocityTaskDTO]
let pendingTaskCount: Int
let pendingTaskIDs: Set<String>
}
private struct PersistedCalendarEvent: Codable {
let calendarEventId: String
let leadId: String?
let title: String
let description: String?
let startAt: String
let endAt: String
let allDay: Bool
let status: String
let reminderMinutes: [Int]
let createdBy: String
let location: String?
let createdAt: String
init(event: VelocityCalendarEventDTO) {
calendarEventId = event.calendarEventId
leadId = event.leadId
title = event.title
description = event.description
startAt = event.startAt
endAt = event.endAt
allDay = event.allDay
status = event.status
reminderMinutes = event.reminderMinutes
createdBy = event.createdBy
location = event.location
createdAt = event.createdAt
}
var event: VelocityCalendarEventDTO {
VelocityCalendarEventDTO(
calendarEventId: calendarEventId,
leadId: leadId,
title: title,
description: description,
startAt: startAt,
endAt: endAt,
allDay: allDay,
status: status,
reminderMinutes: reminderMinutes,
createdBy: createdBy,
location: location,
createdAt: createdAt
)
}
}
private struct PersistedTask: Codable {
let reminderId: String
let reminderType: String
let title: String
let notes: String?
let dueAt: String?
let status: String
let priority: String
let personId: String?
let clientName: String?
let clientPhone: String?
init(task: VelocityTaskDTO) {
reminderId = task.reminderId
reminderType = task.reminderType
title = task.title
notes = task.notes
dueAt = task.dueAt
status = task.status
priority = task.priority
personId = task.personId
clientName = task.clientName
clientPhone = task.clientPhone
}
var task: VelocityTaskDTO {
VelocityTaskDTO(
reminderId: reminderId,
reminderType: reminderType,
title: title,
notes: notes,
dueAt: dueAt,
status: status,
priority: priority,
personId: personId,
clientName: clientName,
clientPhone: clientPhone
)
}
}
var contacts: [VelocityCanonicalContactListItemDTO] = []
var leads: [VelocityLeadDTO] = []
var tasks: [VelocityTaskDTO] = []
var kanbanColumns: [VelocityKanbanColumnDTO] = []
var opportunities: [VelocityOpportunityDTO] = []
var properties: [VelocityPropertyDTO] = []
var calendarEvents: [VelocityCalendarEventDTO] = []
var leadEvents: [String: [VelocityCommunicationEventDTO]] = [:]
var alertSnapshot: VelocityAlertSnapshotDTO?
var pendingTaskMetricCount = 0
var isLoading = false
var errorMessage: String?
var lastRefreshAt: Date?
private var activeRefreshTask: Task<RefreshSnapshot, Error>?
private var canonicalPendingTaskCount = 0
private var canonicalPendingTaskIDs: Set<String> = []
private var locallyResolvedTaskIDs: Set<String> = []
private var localTaskOverrides: [String: VelocityTaskDTO] = [:]
private var locallyCreatedCalendarEvents: [VelocityCalendarEventDTO] = []
var operatorIdentity: String {
if let email = AppConfig.apiEmail, !email.isEmpty {
return email
}
if let token = AppConfig.apiBearerToken, !token.isEmpty {
return "Token authenticated operator"
}
return "Unconfigured operator"
}
var authDescription: String {
if let _ = AppConfig.apiBearerToken {
return "Bearer token"
}
if AppConfig.apiEmail != nil, AppConfig.apiPassword != nil {
return "Email/password login"
}
return "Credentials required"
}
var isConfigured: Bool {
AppConfig.isLiveConfigured
}
var metrics: DashboardMetrics {
DashboardMetrics(
leadCount: leads.count,
whaleLeadCount: leads.filter { $0.score >= 90 || $0.qualification.lowercased() == "whale" }.count,
propertyCount: properties.count,
todayCalendarCount: calendarEvents.filter { $0.startsToday }.count,
pendingTaskCount: pendingTaskMetricCount,
urgentTaskCount: tasks.filter {
$0.status.lowercased() == "pending" && ["urgent", "high"].contains($0.priority.lowercased())
}.count,
pendingInsights: alertSnapshot?.pendingInsights ?? 0,
pendingTranscriptions: alertSnapshot?.pendingTranscriptions ?? 0
)
}
var highlightedLeads: [VelocityLeadDTO] {
Array(leads.sorted(by: { $0.score > $1.score }).prefix(5))
}
var highlightedContacts: [VelocityCanonicalContactListItemDTO] {
Array(contacts.prefix(12))
}
var timelineEvents: [TimelineEvent] {
leadEvents
.flatMap { leadId, events in
events.map { TimelineEvent(leadId: leadId, event: $0, leadName: leadName(for: leadId)) }
}
.sorted(by: { $0.date > $1.date })
}
var prioritizedTasks: [VelocityTaskDTO] {
VelocityTaskDTO.sortedForOperatorReview(tasks)
}
func resetLiveData() {
contacts = []
leads = []
tasks = []
kanbanColumns = []
opportunities = []
properties = []
calendarEvents = []
leadEvents = [:]
alertSnapshot = nil
pendingTaskMetricCount = 0
canonicalPendingTaskCount = 0
canonicalPendingTaskIDs = []
isLoading = false
errorMessage = nil
lastRefreshAt = nil
canonicalPendingTaskCount = 0
canonicalPendingTaskIDs = []
locallyResolvedTaskIDs = []
localTaskOverrides = [:]
locallyCreatedCalendarEvents = []
Self.saveLocallyHiddenTaskIDs([])
Self.saveLocallyMutatedTasks([])
Self.saveLocallyCreatedCalendarEvents([])
}
func refresh(silent: Bool = false) async {
if !silent {
isLoading = true
}
do {
let task = activeRefreshTask ?? makeRefreshTask()
activeRefreshTask = task
let snapshot = try await task.value
activeRefreshTask = nil
contacts = snapshot.contacts
leads = snapshot.leads
tasks = mergedTasks(with: snapshot.tasks)
canonicalPendingTaskCount = snapshot.pendingTaskCount
canonicalPendingTaskIDs = snapshot.pendingTaskIDs
kanbanColumns = snapshot.kanbanColumns
opportunities = snapshot.opportunities
properties = snapshot.properties
calendarEvents = mergedCalendarEvents(with: snapshot.calendarEvents)
refreshPendingTaskMetricCount()
alertSnapshot = snapshot.alertSnapshot
leadEvents = snapshot.leadEvents
lastRefreshAt = Date()
errorMessage = nil
isLoading = false
} catch {
activeRefreshTask = nil
if !silent || lastRefreshAt == nil {
errorMessage = error.localizedDescription
}
if !silent {
contacts = []
leads = []
tasks = []
kanbanColumns = []
opportunities = []
properties = []
calendarEvents = []
alertSnapshot = nil
pendingTaskMetricCount = 0
canonicalPendingTaskCount = 0
canonicalPendingTaskIDs = []
leadEvents = [:]
}
isLoading = false
}
}
func leadName(for leadId: String) -> String {
leads.first(where: { $0.id == leadId })?.name ?? "Unknown lead"
}
func updateTaskStatus(
reminderId: String,
status: String,
dueAt: String? = nil,
notes: String? = nil
) async throws -> VelocityTaskDTO {
let serverTask: VelocityTaskDTO
do {
serverTask = try await VelocityAPIClient.shared.updateTask(
reminderId: reminderId,
status: status,
dueAt: dueAt,
notes: notes
)
} catch let error as VelocityAPIError where error.statusCode == 404 {
serverTask = locallyResolveMissingTask(
reminderId: reminderId,
status: status,
dueAt: dueAt
)
}
let updatedTask = locallyMutatedTask(from: serverTask, status: status, dueAt: dueAt)
if updatedTask.status.lowercased() == "cancelled" {
localTaskOverrides.removeValue(forKey: reminderId)
locallyResolvedTaskIDs.insert(reminderId)
Self.saveLocallyMutatedTasks(Array(localTaskOverrides.values))
Self.saveLocallyHiddenTaskIDs(Array(locallyResolvedTaskIDs))
tasks.removeAll { $0.reminderId == reminderId }
} else {
locallyResolvedTaskIDs.remove(reminderId)
upsertLocalTaskOverride(updatedTask)
if let index = tasks.firstIndex(where: { $0.reminderId == reminderId }) {
tasks[index] = updatedTask
} else {
tasks.append(updatedTask)
}
tasks = VelocityTaskDTO.sortedForOperatorReview(tasks)
}
refreshPendingTaskMetricCount()
errorMessage = nil
await refresh(silent: true)
return updatedTask
}
func createCalendarEvent(
leadId: String?,
title: String,
description: String?,
startAt: String,
endAt: String,
allDay: Bool,
status: String,
reminderMinutes: [Int],
location: String?,
metadata: [String: String] = [:]
) async throws -> VelocityCalendarEventCreateResultDTO {
let createdEvent: VelocityCalendarEventCreateResultDTO
var shouldPersistLocalFallback = false
do {
createdEvent = try await VelocityAPIClient.shared.createCalendarEvent(
leadId: leadId,
title: title,
description: description,
startAt: startAt,
endAt: endAt,
allDay: allDay,
status: status,
reminderMinutes: reminderMinutes,
location: location,
metadata: metadata
)
} catch let error as VelocityAPIError where error.isRecoverableCalendarCreateFailure {
createdEvent = VelocityCalendarEventCreateResultDTO(
calendarEventId: "local-\(UUID().uuidString)",
createdAt: ISO8601DateFormatter().string(from: Date())
)
shouldPersistLocalFallback = true
}
let optimisticEvent = VelocityCalendarEventDTO(
calendarEventId: createdEvent.calendarEventId,
leadId: leadId,
title: title,
description: description,
startAt: startAt,
endAt: endAt,
allDay: allDay,
status: status,
reminderMinutes: reminderMinutes,
createdBy: "user",
location: location,
createdAt: createdEvent.createdAt
)
upsertLocalCalendarEvent(optimisticEvent, persist: shouldPersistLocalFallback)
calendarEvents = mergedCalendarEvents(with: calendarEvents)
refreshPendingTaskMetricCount()
errorMessage = nil
await refresh(silent: true)
return createdEvent
}
func updateCalendarEvent(
_ event: VelocityCalendarEventDTO,
status: String? = nil,
startAt: String? = nil,
endAt: String? = nil
) async throws -> VelocityCalendarEventDTO {
let shouldPersistFallback: Bool
do {
try await VelocityAPIClient.shared.updateCalendarEvent(
calendarEventId: event.calendarEventId,
startAt: startAt,
endAt: endAt,
status: status
)
shouldPersistFallback = event.calendarEventId.hasPrefix("local-")
} catch let error as VelocityAPIError where error.isRecoverableCalendarCreateFailure {
shouldPersistFallback = true
}
let updatedEvent = VelocityCalendarEventDTO(
calendarEventId: event.calendarEventId,
leadId: event.leadId,
title: event.title,
description: event.description,
startAt: startAt ?? event.startAt,
endAt: endAt ?? event.endAt,
allDay: event.allDay,
status: status ?? event.status,
reminderMinutes: event.reminderMinutes,
createdBy: event.createdBy,
location: event.location,
createdAt: event.createdAt
)
if updatedEvent.status == "cancelled" {
calendarEvents.removeAll { $0.calendarEventId == event.calendarEventId }
locallyCreatedCalendarEvents.removeAll { $0.calendarEventId == event.calendarEventId }
Self.saveLocallyCreatedCalendarEvents(locallyCreatedCalendarEvents.filter { $0.calendarEventId.hasPrefix("local-") })
} else {
upsertLocalCalendarEvent(updatedEvent, persist: shouldPersistFallback)
calendarEvents = mergedCalendarEvents(with: calendarEvents)
}
refreshPendingTaskMetricCount()
errorMessage = nil
await refresh(silent: true)
return updatedEvent
}
func cancelCalendarEvent(_ event: VelocityCalendarEventDTO) async throws {
var shouldPersistFallback = event.calendarEventId.hasPrefix("local-")
do {
try await VelocityAPIClient.shared.cancelCalendarEvent(calendarEventId: event.calendarEventId)
} catch let error as VelocityAPIError where error.isRecoverableCalendarCreateFailure {
shouldPersistFallback = true
}
let cancelledEvent = VelocityCalendarEventDTO(
calendarEventId: event.calendarEventId,
leadId: event.leadId,
title: event.title,
description: event.description,
startAt: event.startAt,
endAt: event.endAt,
allDay: event.allDay,
status: "cancelled",
reminderMinutes: event.reminderMinutes,
createdBy: event.createdBy,
location: event.location,
createdAt: event.createdAt
)
upsertLocalCalendarEvent(cancelledEvent, persist: shouldPersistFallback)
calendarEvents.removeAll { $0.calendarEventId == event.calendarEventId }
refreshPendingTaskMetricCount()
errorMessage = nil
await refresh(silent: true)
}
func updateLeadStage(
leadId: String,
status: String,
notes: String? = nil
) async throws -> VelocityLeadStageUpdateDTO {
let updatedLead = try await VelocityAPIClient.shared.updateLeadStage(
leadId: leadId,
status: status,
notes: notes
)
await refresh(silent: true)
return updatedLead
}
func updateOpportunity(
opportunityId: String,
stage: String? = nil,
probability: Int? = nil,
nextAction: String? = nil,
notes: String? = nil
) async throws -> VelocityOpportunityDTO {
let updatedOpportunity = try await VelocityAPIClient.shared.updateOpportunity(
opportunityId: opportunityId,
stage: stage,
probability: probability,
nextAction: nextAction,
notes: notes
)
await refresh(silent: true)
return updatedOpportunity
}
private func locallyResolveMissingTask(
reminderId: String,
status: String,
dueAt: String?
) -> VelocityTaskDTO {
if status.lowercased() == "cancelled" {
locallyResolvedTaskIDs.insert(reminderId)
}
let existing = tasks.first { $0.reminderId == reminderId }
return VelocityTaskDTO(
reminderId: reminderId,
reminderType: existing?.reminderType ?? "follow_up",
title: existing?.title ?? "Calendar task",
notes: existing?.notes,
dueAt: dueAt ?? existing?.dueAt,
status: status,
priority: existing?.priority ?? "normal",
personId: existing?.personId,
clientName: existing?.clientName,
clientPhone: existing?.clientPhone
)
}
private func locallyMutatedTask(
from task: VelocityTaskDTO,
status: String,
dueAt: String?
) -> VelocityTaskDTO {
VelocityTaskDTO(
reminderId: task.reminderId,
reminderType: task.reminderType,
title: task.title,
notes: task.notes,
dueAt: dueAt ?? task.dueAt,
status: status,
priority: task.priority,
personId: task.personId,
clientName: task.clientName,
clientPhone: task.clientPhone
)
}
private func upsertLocalTaskOverride(_ task: VelocityTaskDTO) {
localTaskOverrides[task.reminderId] = task
Self.saveLocallyMutatedTasks(Array(localTaskOverrides.values))
Self.saveLocallyHiddenTaskIDs(Array(locallyResolvedTaskIDs))
}
private func mergedTasks(with fetchedTasks: [VelocityTaskDTO]) -> [VelocityTaskDTO] {
var taskByID = Dictionary(uniqueKeysWithValues: fetchedTasks.map { ($0.reminderId, $0) })
for task in localTaskOverrides.values {
taskByID[task.reminderId] = task
}
let visibleTasks = taskByID.values.filter { !locallyResolvedTaskIDs.contains($0.reminderId) }
return VelocityTaskDTO.sortedForOperatorReview(Array(visibleTasks))
}
private func refreshPendingTaskMetricCount() {
var localDelta = 0
for task in localTaskOverrides.values {
let isCanonicalPending = canonicalPendingTaskIDs.contains(task.reminderId)
let isLocallyPending = task.status.lowercased() == "pending"
if isCanonicalPending && !isLocallyPending {
localDelta -= 1
} else if !isCanonicalPending && isLocallyPending {
localDelta += 1
}
}
let locallyHiddenPendingCount = locallyResolvedTaskIDs
.filter { canonicalPendingTaskIDs.contains($0) }
.count
let normalCalendarTaskCount = calendarEvents.filter { event in
event.status.lowercased() == "tentative"
}.count
pendingTaskMetricCount = max(
0,
canonicalPendingTaskCount + localDelta - locallyHiddenPendingCount + normalCalendarTaskCount
)
}
private static func loadLocallyMutatedTasks() -> [String: VelocityTaskDTO] {
guard let data = UserDefaults.standard.data(forKey: locallyMutatedTasksKey),
let persistedTasks = try? JSONDecoder().decode([PersistedTask].self, from: data)
else {
return [:]
}
return Dictionary(uniqueKeysWithValues: persistedTasks.map { ($0.reminderId, $0.task) })
}
private static func saveLocallyMutatedTasks(_ tasks: [VelocityTaskDTO]) {
let persistedTasks = tasks.map(PersistedTask.init(task:))
if let data = try? JSONEncoder().encode(persistedTasks) {
UserDefaults.standard.set(data, forKey: locallyMutatedTasksKey)
}
}
private static func loadLocallyHiddenTaskIDs() -> Set<String> {
let ids = UserDefaults.standard.stringArray(forKey: locallyHiddenTaskIDsKey) ?? []
return Set(ids)
}
private static func saveLocallyHiddenTaskIDs(_ taskIDs: [String]) {
UserDefaults.standard.set(taskIDs, forKey: locallyHiddenTaskIDsKey)
}
private func upsertLocalCalendarEvent(_ event: VelocityCalendarEventDTO, persist: Bool) {
locallyCreatedCalendarEvents.removeAll { $0.calendarEventId == event.calendarEventId }
locallyCreatedCalendarEvents.append(event)
if persist {
Self.saveLocallyCreatedCalendarEvents(locallyCreatedCalendarEvents)
}
}
private func mergedCalendarEvents(with fetchedEvents: [VelocityCalendarEventDTO]) -> [VelocityCalendarEventDTO] {
var eventByID = Dictionary(uniqueKeysWithValues: fetchedEvents.map { ($0.calendarEventId, $0) })
for event in locallyCreatedCalendarEvents {
eventByID[event.calendarEventId] = event
}
return eventByID.values.filter { $0.status != "cancelled" }.sorted {
($0.startDate ?? .distantFuture) < ($1.startDate ?? .distantFuture)
}
}
private static func loadLocallyCreatedCalendarEvents() -> [VelocityCalendarEventDTO] {
guard let data = UserDefaults.standard.data(forKey: locallyCreatedCalendarEventsKey),
let persistedEvents = try? JSONDecoder().decode([PersistedCalendarEvent].self, from: data)
else {
return []
}
return persistedEvents.map(\.event)
}
private static func saveLocallyCreatedCalendarEvents(_ events: [VelocityCalendarEventDTO]) {
let persistedEvents = events.map(PersistedCalendarEvent.init(event:))
if let data = try? JSONEncoder().encode(persistedEvents) {
UserDefaults.standard.set(data, forKey: locallyCreatedCalendarEventsKey)
}
}
private func makeRefreshTask() -> Task<RefreshSnapshot, Error> {
let cachedContacts = contacts
return Task {
async let tasksTask = fetchCalendarTasks()
async let kanbanTask: [VelocityKanbanColumnDTO]? = try? await VelocityAPIClient.shared.fetchKanbanBoard()
async let opportunitiesTask: [VelocityOpportunityDTO]? = try? await VelocityAPIClient.shared.fetchOpportunities()
async let propertiesTask: [VelocityPropertyDTO]? = try? await VelocityAPIClient.shared.fetchProperties(
limit: AppStoreRefreshPolicy.inventoryPropertyLimit
)
async let calendarTask: [VelocityCalendarEventDTO]? = try? await VelocityAPIClient.shared.fetchCalendarEvents()
async let alertsTask: VelocityAlertSnapshotDTO? = try? await VelocityAPIClient.shared.fetchAlerts()
let fetchedContacts: [VelocityCanonicalContactListItemDTO]
do {
fetchedContacts = try await VelocityAPIClient.shared.fetchContacts()
} catch let error as VelocityAPIError where error.statusCode == 404 {
fetchedContacts = cachedContacts
}
let fetchedLeads = VelocityLeadDTO.activeLeadSummaries(from: fetchedContacts)
let taskRefresh = await tasksTask
let fetchedTasks = taskRefresh.tasks.filter { !locallyResolvedTaskIDs.contains($0.reminderId) }
let fetchedKanban = await kanbanTask ?? []
let fetchedOpportunities = await opportunitiesTask ?? []
let fetchedProperties = await propertiesTask ?? []
let fetchedCalendar = await calendarTask ?? []
let fetchedAlerts = await alertsTask ?? VelocityAlertSnapshotDTO.empty
let leadEvents = await fetchLeadEvents(for: fetchedLeads)
return RefreshSnapshot(
contacts: fetchedContacts,
leads: fetchedLeads,
tasks: fetchedTasks,
pendingTaskCount: taskRefresh.pendingTaskCount,
pendingTaskIDs: taskRefresh.pendingTaskIDs,
kanbanColumns: fetchedKanban,
opportunities: fetchedOpportunities,
properties: fetchedProperties,
calendarEvents: fetchedCalendar,
alertSnapshot: fetchedAlerts,
leadEvents: leadEvents
)
}
}
private func fetchCalendarTasks() async -> CalendarTaskRefresh {
async let allTasks: [VelocityTaskDTO]? = try? await VelocityAPIClient.shared.fetchTasks(status: "all")
async let pendingTasks: [VelocityTaskDTO]? = try? await VelocityAPIClient.shared.fetchTasks(status: "pending")
async let confirmedTasks: [VelocityTaskDTO]? = try? await VelocityAPIClient.shared.fetchTasks(status: "confirmed")
async let doneTasks: [VelocityTaskDTO]? = try? await VelocityAPIClient.shared.fetchTasks(status: "done")
async let snoozedTasks: [VelocityTaskDTO]? = try? await VelocityAPIClient.shared.fetchTasks(status: "snoozed")
let fetchedAllTasks = await allTasks ?? []
let pendingTaskResponse = await pendingTasks
let fetchedPendingTasks = pendingTaskResponse ?? []
let fetchedConfirmedTasks = await confirmedTasks ?? []
let fetchedDoneTasks = await doneTasks ?? []
let fetchedSnoozedTasks = await snoozedTasks ?? []
var taskByID: [String: VelocityTaskDTO] = [:]
for task in fetchedAllTasks {
taskByID[task.reminderId] = task
}
for task in fetchedPendingTasks {
taskByID[task.reminderId] = task
}
for task in fetchedConfirmedTasks {
taskByID[task.reminderId] = task
}
for task in fetchedDoneTasks {
taskByID[task.reminderId] = task
}
for task in fetchedSnoozedTasks {
taskByID[task.reminderId] = task
}
let pendingTaskCount = pendingTaskResponse?.count ?? fetchedAllTasks.filter { $0.status.lowercased() == "pending" }.count
let pendingTaskIDs = Set(fetchedPendingTasks.map(\.reminderId))
return CalendarTaskRefresh(
tasks: VelocityTaskDTO.sortedForOperatorReview(Array(taskByID.values)),
pendingTaskCount: pendingTaskCount,
pendingTaskIDs: pendingTaskIDs
)
}
private func fetchLeadEvents(
for leads: [VelocityLeadDTO]
) async -> [String: [VelocityCommunicationEventDTO]] {
let prioritizedLeadIDs = AppStoreRefreshPolicy.prioritizedLeadIDs(from: leads)
return await withTaskGroup(
of: (String, [VelocityCommunicationEventDTO]).self,
returning: [String: [VelocityCommunicationEventDTO]].self
) { group in
for leadID in prioritizedLeadIDs {
group.addTask {
do {
let events = try await VelocityAPIClient.shared.fetchEvents(
for: leadID,
limit: AppStoreRefreshPolicy.leadEventLimitPerLead
)
return (leadID, events)
} catch {
return (leadID, [])
}
}
}
var eventMap: [String: [VelocityCommunicationEventDTO]] = [:]
for await (leadID, events) in group {
eventMap[leadID] = events
}
return eventMap
}
}
}
struct TimelineEvent: Identifiable {
let leadId: String
let event: VelocityCommunicationEventDTO
let leadName: String
var id: String { event.id }
var date: Date { event.timestampDate ?? .distantPast }
}
extension VelocityCalendarEventDTO {
var startsToday: Bool {
guard let date = startDate else { return false }
return Calendar.current.isDateInToday(date)
}
}
extension Date {
var relativeShort: String {
let delta = Int(Date().timeIntervalSince(self))
if delta < 60 { return "now" }
if delta < 3600 { return "\(delta / 60)m ago" }
if delta < 86400 { return "\(delta / 3600)h ago" }
return "\(delta / 86400)d ago"
}
var taskDueLabel: String {
if Calendar.current.isDateInToday(self) {
let formatter = DateFormatter()
formatter.dateFormat = "'Today' · h:mm a"
return formatter.string(from: self)
}
if Calendar.current.isDateInTomorrow(self) {
let formatter = DateFormatter()
formatter.dateFormat = "'Tomorrow' · h:mm a"
return formatter.string(from: self)
}
let formatter = DateFormatter()
formatter.dateFormat = "EEE, MMM d · h:mm a"
return formatter.string(from: self)
}
}
private extension VelocityAPIError {
var isRecoverableCalendarCreateFailure: Bool {
if let statusCode {
return statusCode == 404 || (500...599).contains(statusCode)
}
if case .invalidResponse = self {
return true
}
return false
}
}

View File

@@ -0,0 +1,31 @@
import Foundation
enum AppStoreRefreshPolicy {
/// Match the WebOS bootstrap so Inventory, Dashboard, and shared summaries
/// are based on the same production property slice by default.
static let inventoryPropertyLimit = 100
/// Keep the canonical CRM follow-up inbox bounded while still representing
/// the operator's active task load on iPad surfaces.
static let canonicalTaskLimit = 50
/// iPad surfaces only render a small operator-focused timeline, so keep the
/// lead-event hydration set intentionally narrower than WebOS.
static let leadTimelineHydrationLimit = 6
/// Fetch enough recent communication context for the visible iPad rails
/// without inflating each refresh unnecessarily.
static let leadEventLimitPerLead = 4
static func prioritizedLeadIDs(
from leads: [VelocityLeadDTO],
limit: Int = leadTimelineHydrationLimit
) -> [String] {
Array(
leads
.sorted(by: { $0.score > $1.score })
.prefix(limit)
.map(\.id)
)
}
}

View File

@@ -0,0 +1,17 @@
import SwiftUI
struct GlassBlurView: UIViewRepresentable {
let style: UIBlurEffect.Style
init(style: UIBlurEffect.Style = .systemUltraThinMaterial) {
self.style = style
}
func makeUIView(context: Context) -> UIVisualEffectView {
UIVisualEffectView(effect: UIBlurEffect(style: style))
}
func updateUIView(_ uiView: UIVisualEffectView, context: Context) {
uiView.effect = UIBlurEffect(style: style)
}
}

View File

@@ -0,0 +1,60 @@
import SwiftUI
// MARK: - Design Tokens matching the WebOS dark interface
enum VelocityTheme {
// Backgrounds
/// True black app background
static let background = Color(red: 0.00, green: 0.00, blue: 0.00)
/// Dark surface (#131418)
static let surface = Color(red: 0.074, green: 0.078, blue: 0.094)
/// Slightly lighter surface (#181b20)
static let surface2 = Color(red: 0.095, green: 0.106, blue: 0.125)
/// Card surface (#22262e)
static let surface3 = Color(red: 0.133, green: 0.149, blue: 0.180)
/// Sidebar background (#0B0D10)
static let sidebarBg = Color(red: 0.043, green: 0.051, blue: 0.063)
// Foreground
static let foreground = Color(white: 0.96)
static let mutedFg = Color(red: 0.580, green: 0.620, blue: 0.710)
static let subtleFg = Color(red: 0.35, green: 0.38, blue: 0.44)
// Accent: Blue (#3b82f6)
static let accent = Color(red: 0.231, green: 0.510, blue: 0.965)
static let accentDim = Color(red: 0.160, green: 0.388, blue: 0.820)
static let accentSubtle = Color(red: 0.231, green: 0.510, blue: 0.965).opacity(0.15)
// Semantic
static let success = Color(red: 0.290, green: 0.780, blue: 0.290)
static let warning = Color(red: 0.980, green: 0.745, blue: 0.141)
static let danger = Color(red: 0.973, green: 0.267, blue: 0.267)
// Borders
static let borderSubtle = Color.white.opacity(0.07)
static let borderAccent = Color(red: 0.231, green: 0.510, blue: 0.965).opacity(0.18)
}
// MARK: - Glass card modifier
struct GlassCard: ViewModifier {
var cornerRadius: CGFloat = 16
func body(content: Content) -> some View {
content
.background(
RoundedRectangle(cornerRadius: cornerRadius)
.fill(Color(red: 0.031, green: 0.039, blue: 0.071).opacity(0.82))
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.stroke(VelocityTheme.borderAccent, lineWidth: 1)
)
.shadow(color: .black.opacity(0.55), radius: 16, y: 4)
)
}
}
extension View {
func glassCard(cornerRadius: CGFloat = 16) -> some View {
self.modifier(GlassCard(cornerRadius: cornerRadius))
}
}