feat: Ipad app features and Dream Weaver for Velocity WebOS
This commit is contained in:
302
iOS/velocity-ipad/velocity/Core/Config/AppConfig.swift
Normal file
302
iOS/velocity-ipad/velocity/Core/Config/AppConfig.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
204
iOS/velocity-ipad/velocity/Core/Config/SessionStore.swift
Normal file
204
iOS/velocity-ipad/velocity/Core/Config/SessionStore.swift
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
134
iOS/velocity-ipad/velocity/Core/Math/SunMath.swift
Normal file
134
iOS/velocity-ipad/velocity/Core/Math/SunMath.swift
Normal 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 }
|
||||
}
|
||||
351
iOS/velocity-ipad/velocity/Core/Networking/ComfyClient.swift
Normal file
351
iOS/velocity-ipad/velocity/Core/Networking/ComfyClient.swift
Normal 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) }
|
||||
2165
iOS/velocity-ipad/velocity/Core/Networking/VelocityAPIClient.swift
Normal file
2165
iOS/velocity-ipad/velocity/Core/Networking/VelocityAPIClient.swift
Normal file
File diff suppressed because it is too large
Load Diff
815
iOS/velocity-ipad/velocity/Core/State/AppStore.swift
Normal file
815
iOS/velocity-ipad/velocity/Core/State/AppStore.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
17
iOS/velocity-ipad/velocity/Core/UI/GlassBlurView.swift
Normal file
17
iOS/velocity-ipad/velocity/Core/UI/GlassBlurView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
60
iOS/velocity-ipad/velocity/Core/UI/VelocityTheme.swift
Normal file
60
iOS/velocity-ipad/velocity/Core/UI/VelocityTheme.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user