feat/#28 (#29)

Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local>
Reviewed-on: #29
This commit was merged in pull request #29.
This commit is contained in:
2026-04-20 00:48:01 +05:30
parent 4e3ce623a6
commit 57144e1bd3
65 changed files with 7652 additions and 2202 deletions

View File

@@ -0,0 +1,5 @@
BASE_URL = https://api.desineuron.in
API_EMAIL =
API_PASSWORD =
API_BEARER_TOKEN =
APP_VERSION = 1.0.0

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>API_BEARER_TOKEN</key>
<string>$(API_BEARER_TOKEN)</string>
<key>API_EMAIL</key>
<string>$(API_EMAIL)</string>
<key>API_PASSWORD</key>
<string>$(API_PASSWORD)</string>
<key>APP_VERSION</key>
<string>$(APP_VERSION)</string>
<key>BASE_URL</key>
<string>$(BASE_URL)</string>
<key>NSCameraUsageDescription</key>
<string>Velocity needs camera access to capture live room imagery for Dream Weaver.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Velocity needs photo library access so you can select room images for Dream Weaver.</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
</dict>
</plist>

View File

@@ -0,0 +1,42 @@
# Velocity iPhone App
Dedicated iPhone source tree for the Velocity edge-phone surface.
Goals:
- preserve the visual language of the iPad Velocity app
- match the production feature scope of `android-edge-phone`
- use live backend data only
- register `iphone_edge` surface heartbeats against `/api/mobile-edge/session`
Contents:
- `VelocityIPhoneApp.swift` app entry
- `EdgeRootView.swift` tab shell
- `Core/` shared config, networking, state, and styling
- `Features/` Alerts, Lead Summary, Communications, Notes, Transcriptions, Settings
Configuration:
1. Open `velocity-iphone.xcodeproj` in Xcode.
2. If you want explicit per-build config values, copy `Config.xcconfig.example` to a local `Config.xcconfig` and attach it to the target build configurations.
3. Fill in either:
- `API_BEARER_TOKEN`
- or `API_EMAIL` and `API_PASSWORD`
4. Keep `BASE_URL` pointed at the live Velocity backend unless you intentionally override it.
Notes:
- This source tree is intended to supersede the earlier lightweight `velocity-edge-phone` scaffold.
- The backend routes already exist in `backend/api/routes_mobile_edge.py`; this app consumes them directly.
Xcode test flow:
1. Open `iOS/velocity-iphone/velocity-iphone.xcodeproj`.
2. Select the `velocity-iphone` scheme and an iPhone simulator such as `iPhone 16 Pro`.
3. In `Signing & Capabilities`, choose your Apple development team and let Xcode resolve the bundle signing settings.
4. In `Build Settings`, confirm `Info.plist File` points to `velocity-iphone/Info.plist`.
5. If you are using live credentials through build settings, set `BASE_URL`, plus either `API_BEARER_TOKEN` or `API_EMAIL` and `API_PASSWORD`.
6. Press `Cmd+B` to confirm the app builds.
7. Press `Cmd+R` to launch the simulator.
8. Verify the bottom-tab shell renders Alerts, Lead Summary, Communications, Notes, Transcriptions, and Settings.
9. In Settings, confirm the app reports the expected auth mode and live backend base URL.
10. Trigger a manual refresh or wait for auto-refresh, then verify Alerts and Lead Summary populate from the live backend.
11. Create a note and confirm it appears without mock fallback behavior.
12. Open Transcriptions and verify empty states are truthful when no live transcript exists.
13. Review backend logs or database state to confirm `/api/mobile-edge/session` heartbeats are updating the active `iphone_edge` session window.

View File

@@ -0,0 +1,339 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXFileReference section */
B31C10012F58D9C300A74A49 /* velocity-iphone.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "velocity-iphone.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
B31C10032F58D9C300A74A49 /* velocity-iphone */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "velocity-iphone";
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
B31C10062F58D9C300A74A49 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
B31C10072F58D9C300A74A49 = {
isa = PBXGroup;
children = (
B31C10032F58D9C300A74A49 /* velocity-iphone */,
B31C10082F58D9C300A74A49 /* Products */,
);
sourceTree = "<group>";
};
B31C10082F58D9C300A74A49 /* Products */ = {
isa = PBXGroup;
children = (
B31C10012F58D9C300A74A49 /* velocity-iphone.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
B31C10052F58D9C300A74A49 /* velocity-iphone */ = {
isa = PBXNativeTarget;
buildConfigurationList = B31C10112F58D9C400A74A49 /* Build configuration list for PBXNativeTarget "velocity-iphone" */;
buildPhases = (
B31C10092F58D9C300A74A49 /* Sources */,
B31C10062F58D9C300A74A49 /* Frameworks */,
B31C100A2F58D9C300A74A49 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
B31C10032F58D9C300A74A49 /* velocity-iphone */,
);
name = "velocity-iphone";
packageProductDependencies = (
);
productName = "velocity-iphone";
productReference = B31C10012F58D9C300A74A49 /* velocity-iphone.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
B31C100B2F58D9C300A74A49 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2630;
LastUpgradeCheck = 2630;
TargetAttributes = {
B31C10052F58D9C300A74A49 = {
CreatedOnToolsVersion = 26.3;
};
};
};
buildConfigurationList = B31C100C2F58D9C300A74A49 /* Build configuration list for PBXProject "velocity-iphone" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = B31C10072F58D9C300A74A49;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = B31C10082F58D9C300A74A49 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
B31C10052F58D9C300A74A49 /* velocity-iphone */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
B31C100A2F58D9C300A74A49 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
B31C10092F58D9C300A74A49 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
B31C100D2F58D9C400A74A49 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
B31C100E2F58D9C400A74A49 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
B31C100F2F58D9C400A74A49 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = L29922NHD9;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Velocity Iphone App";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = in.desineuron.velocity.iphone;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
B31C10102F58D9C400A74A49 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = L29922NHD9;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Velocity Iphone App";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = in.desineuron.velocity.iphone;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
B31C100C2F58D9C300A74A49 /* Build configuration list for PBXProject "velocity-iphone" */ = {
isa = XCConfigurationList;
buildConfigurations = (
B31C100D2F58D9C400A74A49 /* Debug */,
B31C100E2F58D9C400A74A49 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
B31C10112F58D9C400A74A49 /* Build configuration list for PBXNativeTarget "velocity-iphone" */ = {
isa = XCConfigurationList;
buildConfigurations = (
B31C100F2F58D9C400A74A49 /* Debug */,
B31C10102F58D9C400A74A49 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = B31C100B2F58D9C300A74A49 /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xFF",
"green" : "0x7F",
"red" : "0x3F"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,34 @@
import Foundation
enum EdgeAppConfig {
private static func value(for key: String) -> String? {
guard let raw = Bundle.main.infoDictionary?[key] as? String else {
return nil
}
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty || trimmed == "$(\(key))" {
return nil
}
return trimmed
}
static let baseURL: String = value(for: "BASE_URL") ?? "https://api.desineuron.in"
static let apiEmail: String? = value(for: "API_EMAIL")
static let apiPassword: String? = value(for: "API_PASSWORD")
static let apiBearerToken: String? = value(for: "API_BEARER_TOKEN")
static let appVersion: String = value(for: "APP_VERSION") ?? "1.0.0"
static var authModeDescription: String {
if apiBearerToken != nil {
return "Bearer token"
}
if apiEmail != nil && apiPassword != nil {
return "Email/password"
}
return "Credentials required"
}
static var isConfigured: Bool {
apiBearerToken != nil || (apiEmail != nil && apiPassword != nil)
}
}

View File

@@ -0,0 +1,319 @@
import Foundation
struct EdgeLeadDTO: Decodable, Identifiable {
let id: String
let name: String
let score: Int
let qualification: String
let unitInterest: String
let budget: String
enum CodingKeys: String, CodingKey {
case id
case name
case score
case qualification
case unitInterest = "unit_interest"
case budget
}
}
struct EdgeAlertSnapshotDTO: Decodable {
let pendingInsights: Int
let pendingTranscriptions: Int
let upcomingCalendarEvents24h: Int
let generatedAt: String
enum CodingKeys: String, CodingKey {
case pendingInsights = "pending_insights"
case pendingTranscriptions = "pending_transcriptions"
case upcomingCalendarEvents24h = "upcoming_calendar_events_24h"
case generatedAt = "generated_at"
}
}
struct EdgeCommunicationEventDTO: Decodable, Identifiable {
let eventId: String
let leadId: String
let channel: String
let summary: String?
let timestamp: String
let recordingRef: String?
var id: String { eventId }
enum CodingKeys: String, CodingKey {
case eventId = "event_id"
case leadId = "lead_id"
case channel
case summary
case timestamp
case recordingRef = "recording_ref"
}
}
struct EdgeMemoryFactDTO: Decodable, Identifiable {
let factId: String
let factType: String
let factText: String
let createdAt: String
var id: String { factId }
enum CodingKeys: String, CodingKey {
case factId = "fact_id"
case factType = "fact_type"
case factText = "fact_text"
case createdAt = "created_at"
}
}
struct EdgeTranscriptDTO {
let eventId: String
let status: String
let segmentCount: Int
}
enum VelocityEdgeAPIError: LocalizedError {
case notConfigured(String)
case invalidResponse
case api(String)
var errorDescription: String? {
switch self {
case .notConfigured(let message):
return message
case .invalidResponse:
return "Velocity edge backend returned an invalid response."
case .api(let message):
return message
}
}
}
actor VelocityEdgeAPIClient {
static let shared = VelocityEdgeAPIClient()
private struct LoginBody: Encodable {
let email: String
let password: String
}
private struct LoginResponse: Decodable {
let accessToken: String
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
}
}
private struct LeadsEnvelope: Decodable {
let data: [EdgeLeadDTO]
}
private struct EventsEnvelope: Decodable {
let events: [EdgeCommunicationEventDTO]
}
private struct FactsEnvelope: Decodable {
let facts: [EdgeMemoryFactDTO]
}
private struct NoteResponse: Decodable {
let factId: String?
enum CodingKeys: String, CodingKey {
case factId = "fact_id"
}
}
private struct TranscriptEnvelope: Decodable {
struct Job: Decodable {
let status: String
}
let job: Job
let segments: [TranscriptSegment]
}
private struct TranscriptSegment: Decodable {
let segmentId: String
enum CodingKeys: String, CodingKey {
case segmentId = "segment_id"
}
}
private struct HeartbeatBody: Encodable {
let surfaceType: String
let appVersion: String
let screen: String?
let metadata: [String: String]
enum CodingKeys: String, CodingKey {
case surfaceType = "surface_type"
case appVersion = "app_version"
case screen
case metadata
}
}
private let decoder = JSONDecoder()
private var cachedToken: String?
func fetchLeads() async throws -> [EdgeLeadDTO] {
let request = try await authorizedRequest(path: "/api/leads")
let response: LeadsEnvelope = try await perform(request)
return response.data
}
func fetchAlerts() async throws -> EdgeAlertSnapshotDTO {
let request = try await authorizedRequest(path: "/api/mobile-edge/alerts")
return try await perform(request)
}
func fetchEvents(for leadId: String, limit: Int = 4) async throws -> [EdgeCommunicationEventDTO] {
let request = try await authorizedRequest(
path: "/api/mobile-edge/events",
queryItems: [
URLQueryItem(name: "lead_id", value: leadId),
URLQueryItem(name: "limit", value: String(limit)),
]
)
let response: EventsEnvelope = try await perform(request)
return response.events
}
func fetchMemoryFacts(for leadId: String, limit: Int = 10) async throws -> [EdgeMemoryFactDTO] {
let request = try await authorizedRequest(
path: "/api/mobile-edge/memory",
queryItems: [
URLQueryItem(name: "lead_id", value: leadId),
URLQueryItem(name: "limit", value: String(limit)),
]
)
let response: FactsEnvelope = try await perform(request)
return response.facts
}
func createNote(leadId: String, noteText: String) async throws {
let payload = [
"lead_id": leadId,
"note_text": noteText,
"fact_type": "custom",
]
var request = try await authorizedRequest(path: "/api/mobile-edge/notes")
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: payload)
let _: NoteResponse = try await perform(request)
}
func fetchTranscript(for eventId: String) async throws -> EdgeTranscriptDTO {
let request = try await authorizedRequest(path: "/api/mobile-edge/transcripts/\(eventId)")
let response: TranscriptEnvelope = try await perform(request)
return EdgeTranscriptDTO(eventId: eventId, status: response.job.status, segmentCount: response.segments.count)
}
func registerHeartbeat(screen: String?) async throws {
var request = try await authorizedRequest(path: "/api/mobile-edge/session")
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(
HeartbeatBody(
surfaceType: "iphone_edge",
appVersion: EdgeAppConfig.appVersion,
screen: screen,
metadata: [
"client": "velocity-iphone",
"platform": "ios",
]
)
)
let _: EmptyResponse = try await perform(request)
}
private func authorizedRequest(path: String, queryItems: [URLQueryItem] = []) async throws -> URLRequest {
guard let url = buildURL(path: path, queryItems: queryItems) else {
throw VelocityEdgeAPIError.notConfigured("Velocity backend base URL is invalid.")
}
let token = try await getToken()
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.timeoutInterval = 30
return request
}
private func buildURL(path: String, queryItems: [URLQueryItem]) -> URL? {
guard var components = URLComponents(string: EdgeAppConfig.baseURL) else {
return nil
}
components.path = path
if !queryItems.isEmpty {
components.queryItems = queryItems
}
return components.url
}
private func getToken() async throws -> String {
if let token = EdgeAppConfig.apiBearerToken {
return token
}
if let token = cachedToken {
return token
}
guard let email = EdgeAppConfig.apiEmail, let password = EdgeAppConfig.apiPassword else {
throw VelocityEdgeAPIError.notConfigured(
"Set API_BEARER_TOKEN or API_EMAIL/API_PASSWORD in the iPhone app configuration."
)
}
guard let loginURL = buildURL(path: "/api/auth/login", queryItems: []) else {
throw VelocityEdgeAPIError.notConfigured("Velocity backend base URL is invalid.")
}
var request = URLRequest(url: loginURL)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = try JSONEncoder().encode(LoginBody(email: email, password: password))
request.timeoutInterval = 30
let response: LoginResponse = try await perform(request)
cachedToken = response.accessToken
return response.accessToken
}
private func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw VelocityEdgeAPIError.invalidResponse
}
guard 200..<300 ~= http.statusCode else {
if let apiError = try? decoder.decode(APIErrorPayload.self, from: data), let detail = apiError.detail {
throw VelocityEdgeAPIError.api(detail)
}
throw VelocityEdgeAPIError.api("Velocity edge request failed with HTTP \(http.statusCode).")
}
do {
return try decoder.decode(T.self, from: data)
} catch {
throw VelocityEdgeAPIError.invalidResponse
}
}
}
private struct EmptyResponse: Decodable {}
private struct APIErrorPayload: Decodable {
let detail: String?
}
private let edgeDateFormatter = ISO8601DateFormatter()
extension EdgeCommunicationEventDTO {
var timestampDate: Date? {
edgeDateFormatter.date(from: timestamp)
}
}

View File

@@ -0,0 +1,914 @@
import Foundation
import UIKit
struct VelocityAuthProfile: Decodable, Sendable {
let userId: String
let role: String
let fullName: String?
let email: String?
let avatarURL: String?
enum CodingKeys: String, CodingKey {
case userId = "user_id"
case role
case fullName = "full_name"
case email
case avatarURL = "avatar_url"
}
}
struct VelocityLoginResponse: Decodable, Sendable {
let accessToken: String
let tokenType: String
let expiresIn: Int
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case tokenType = "token_type"
case expiresIn = "expires_in"
}
}
struct VelocityLead: Decodable, Identifiable, Sendable {
let id: String
let name: String
let email: String?
let phone: String?
let source: String?
let notes: String?
let qualification: String
let score: Int
let kanbanStatus: String
let stage: String
let budget: String
let unitInterest: String
enum CodingKeys: String, CodingKey {
case id, name, email, phone, source, notes, qualification, score, stage, budget
case kanbanStatus = "kanban_status"
case unitInterest = "unit_interest"
}
}
struct VelocityLeadEnvelope: Decodable, Sendable {
let data: [VelocityLead]
}
struct VelocityAlertSnapshot: Decodable, Sendable {
let pendingInsights: Int
let upcomingCalendarEvents24h: Int
let pendingTranscriptions: Int
let generatedAt: String
enum CodingKeys: String, CodingKey {
case pendingInsights = "pending_insights"
case upcomingCalendarEvents24h = "upcoming_calendar_events_24h"
case pendingTranscriptions = "pending_transcriptions"
case generatedAt = "generated_at"
}
}
struct VelocityCommunicationEvent: Decodable, Identifiable, Sendable {
let eventId: String
let leadId: String
let channel: String
let direction: String?
let summary: String?
let timestamp: String
let recordingRef: String?
var id: String { eventId }
enum CodingKeys: String, CodingKey {
case eventId = "event_id"
case leadId = "lead_id"
case channel, direction, summary, timestamp
case recordingRef = "recording_ref"
}
}
struct VelocityCommunicationEnvelope: Decodable, Sendable {
let events: [VelocityCommunicationEvent]
}
struct VelocityMemoryFact: Decodable, Identifiable, Sendable {
let factId: String
let factType: String
let factText: String
let createdAt: String
var id: String { factId }
enum CodingKeys: String, CodingKey {
case factId = "fact_id"
case factType = "fact_type"
case factText = "fact_text"
case createdAt = "created_at"
}
}
struct VelocityMemoryEnvelope: Decodable, Sendable {
let facts: [VelocityMemoryFact]
}
struct VelocityTranscriptJob: Decodable, Sendable {
let status: String
let provider: String?
let speakerCount: Int?
let wordCount: Int?
let language: String?
enum CodingKeys: String, CodingKey {
case status, provider, language
case speakerCount = "speaker_count"
case wordCount = "word_count"
}
}
struct VelocityTranscriptSegment: Decodable, Identifiable, Sendable {
let segmentId: String
let speakerLabel: String?
let startMs: Int?
let endMs: Int?
let text: String
var id: String { segmentId }
enum CodingKeys: String, CodingKey {
case segmentId = "segment_id"
case speakerLabel = "speaker_label"
case startMs = "start_ms"
case endMs = "end_ms"
case text
}
}
struct VelocityTranscriptEnvelope: Decodable, Sendable {
let job: VelocityTranscriptJob
let segments: [VelocityTranscriptSegment]
}
struct VelocityCalendarEvent: Decodable, Identifiable, Sendable {
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 location: String?
var id: String { calendarEventId }
enum CodingKeys: String, CodingKey {
case calendarEventId = "calendar_event_id"
case leadId = "lead_id"
case title, description, status, location
case startAt = "start_at"
case endAt = "end_at"
case allDay = "all_day"
case reminderMinutes = "reminder_minutes"
}
}
struct VelocityCalendarEnvelope: Decodable, Sendable {
let events: [VelocityCalendarEvent]
}
struct VelocityInsight: Decodable, Identifiable, Sendable {
let recommendationId: String
let leadId: String
let recommendationType: String
let summary: String
let suggestedAction: String?
let targetSystem: String?
let status: String
let confidence: Double?
let createdAt: String
var id: String { recommendationId }
enum CodingKeys: String, CodingKey {
case recommendationId = "recommendation_id"
case leadId = "lead_id"
case recommendationType = "recommendation_type"
case summary
case suggestedAction = "suggested_action"
case targetSystem = "target_system"
case status, confidence
case createdAt = "created_at"
}
}
struct VelocityInsightEnvelope: Decodable, Sendable {
let insights: [VelocityInsight]
}
struct VelocityCampaign: Decodable, Identifiable, Sendable {
let id: String
let name: String
let platform: String
let status: String
let budget: Double
let spent: Double
let impressions: Int
let clicks: Int
let conversions: Int
let objective: String?
}
struct VelocityCampaignEnvelope: Decodable, Sendable {
let data: [VelocityCampaign]
}
struct VelocityCatalystInsightEnvelope: Decodable, Sendable {
let data: [VelocityCatalystInsight]
}
struct VelocityCatalystInsight: Decodable, Identifiable, Sendable {
let id = UUID()
let campaignId: String?
let platform: String?
let spend: Double?
let impressions: Int?
let clicks: Int?
let conversions: Int?
enum CodingKeys: String, CodingKey {
case campaignId = "campaign_id"
case platform, spend, impressions, clicks, conversions
}
}
struct VelocityInventoryProperty: Decodable, Identifiable, Sendable {
let propertyId: String
let projectName: String
let developerName: String
let propertyType: String
let status: String
let location: [String: JSONValue]
let priceBands: [JSONValue]
let unitMix: [JSONValue]
var id: String { propertyId }
enum CodingKeys: String, CodingKey {
case propertyId = "property_id"
case projectName = "project_name"
case developerName = "developer_name"
case propertyType = "property_type"
case status, location
case priceBands = "price_bands"
case unitMix = "unit_mix"
}
}
struct VelocityInventoryListEnvelope: Decodable, Sendable {
let properties: [VelocityInventoryProperty]
}
struct VelocityInventoryMedia: Decodable, Identifiable, Sendable {
let mediaAssetId: String
let mediaType: String
let url: String
let thumbnailURL: String?
var id: String { mediaAssetId }
enum CodingKeys: String, CodingKey {
case mediaAssetId = "media_asset_id"
case mediaType = "media_type"
case url
case thumbnailURL = "thumbnail_url"
}
}
struct VelocityInventoryMediaEnvelope: Decodable, Sendable {
let media: [VelocityInventoryMedia]
}
struct VelocityCRMContact: Decodable, Identifiable, Sendable {
let personId: String
let fullName: String
let primaryEmail: String?
let primaryPhone: String?
let buyerType: String?
let leadStatus: String?
let intentScore: Double?
let interactionCount: Int?
let pendingTasks: Int?
var id: String { personId }
enum CodingKeys: String, CodingKey {
case personId = "person_id"
case fullName = "full_name"
case primaryEmail = "primary_email"
case primaryPhone = "primary_phone"
case buyerType = "buyer_type"
case leadStatus = "lead_status"
case intentScore = "intent_score"
case interactionCount = "interaction_count"
case pendingTasks = "pending_tasks"
}
}
struct VelocityCRMContactsEnvelope: Decodable, Sendable {
struct Payload: Decodable, Sendable {
let contacts: [VelocityCRMContact]
}
let data: Payload
}
struct VelocityCRMOpportunity: Decodable, Identifiable, Sendable {
let opportunityId: String
let stage: String
let value: Double?
let nextAction: String?
let clientName: String
let projectName: String?
var id: String { opportunityId }
enum CodingKeys: String, CodingKey {
case opportunityId = "opportunity_id"
case stage, value
case nextAction = "next_action"
case clientName = "client_name"
case projectName = "project_name"
}
}
struct VelocityCRMOpportunitiesEnvelope: Decodable, Sendable {
let data: [VelocityCRMOpportunity]
}
struct VelocityCRMTask: Decodable, Identifiable, Sendable {
let reminderId: String
let title: String
let notes: String?
let dueAt: String?
let status: String
let priority: String?
let clientName: String?
var id: String { reminderId }
enum CodingKeys: String, CodingKey {
case reminderId = "reminder_id"
case title, notes, status, priority
case dueAt = "due_at"
case clientName = "client_name"
}
}
struct VelocityCRMTasksEnvelope: Decodable, Sendable {
let data: [VelocityCRMTask]
}
struct VelocityCRMKanbanColumn: Decodable, Identifiable, Sendable {
let status: String
let label: String
let count: Int
var id: String { status }
}
struct VelocityCRMKanbanEnvelope: Decodable, Sendable {
let data: [VelocityCRMKanbanColumn]
}
struct VelocityAdminHealth: Decodable, Sendable {
struct Database: Decodable, Sendable {
let connected: Bool
let latencyMs: Double
enum CodingKeys: String, CodingKey {
case connected
case latencyMs = "latency_ms"
}
}
struct Queues: Decodable, Sendable {
let pendingTranscriptions: Int
let pendingSyntheticJobs: Int
let pendingAdminActions: Int
let pendingInventoryBatches: Int
enum CodingKeys: String, CodingKey {
case pendingTranscriptions = "pending_transcriptions"
case pendingSyntheticJobs = "pending_synthetic_jobs"
case pendingAdminActions = "pending_admin_actions"
case pendingInventoryBatches = "pending_inventory_batches"
}
}
let status: String
let timestamp: String
let database: Database
let queues: Queues
}
struct VelocityMarketingVideo: Decodable, Identifiable, Sendable {
let id: String
let title: String
let propertyName: String
let unitNumber: String
let type: String
let videoURL: String
enum CodingKeys: String, CodingKey {
case id, title, type
case propertyName = "property_name"
case unitNumber = "unit_number"
case videoURL = "video_url"
}
}
struct VelocityMarketingVideosEnvelope: Decodable, Sendable {
let videos: [VelocityMarketingVideo]
}
struct VelocityClient360: Decodable, Sendable {
let data: JSONValue
}
struct VelocityEmptyResponse: Decodable, Sendable {}
enum JSONValue: Decodable, Sendable {
case string(String)
case number(Double)
case bool(Bool)
case object([String: JSONValue])
case array([JSONValue])
case null
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let value = try? container.decode(Bool.self) {
self = .bool(value)
} else if let value = try? container.decode(Double.self) {
self = .number(value)
} else if let value = try? container.decode(String.self) {
self = .string(value)
} else if let value = try? container.decode([String: JSONValue].self) {
self = .object(value)
} else if let value = try? container.decode([JSONValue].self) {
self = .array(value)
} else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value")
}
}
var stringValue: String? {
switch self {
case .string(let value): return value
case .number(let value): return value == floor(value) ? String(Int(value)) : String(value)
case .bool(let value): return value ? "True" : "False"
default: return nil
}
}
}
enum VelocityAPIError: LocalizedError {
case invalidURL
case unauthorized
case invalidResponse
case api(String)
var errorDescription: String? {
switch self {
case .invalidURL:
return "Velocity backend base URL is invalid."
case .unauthorized:
return "Your session has expired. Please sign in again."
case .invalidResponse:
return "Velocity returned an invalid response."
case .api(let message):
return message
}
}
}
actor VelocityLiveAPI {
static let shared = VelocityLiveAPI()
private let decoder: JSONDecoder = {
let decoder = JSONDecoder()
return decoder
}()
private func buildURL(path: String, queryItems: [URLQueryItem] = []) throws -> URL {
guard var components = URLComponents(string: EdgeAppConfig.baseURL) else {
throw VelocityAPIError.invalidURL
}
components.path = path
if queryItems.isEmpty == false {
components.queryItems = queryItems
}
guard let url = components.url else {
throw VelocityAPIError.invalidURL
}
return url
}
private func request<T: Decodable>(_ type: T.Type, path: String, method: String = "GET", token: String? = nil, queryItems: [URLQueryItem] = [], body: Data? = nil, contentType: String = "application/json") async throws -> T {
let url = try buildURL(path: path, queryItems: queryItems)
var request = URLRequest(url: url)
request.httpMethod = method
request.timeoutInterval = 45
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let token, token.isEmpty == false {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body {
request.httpBody = body
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
}
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw VelocityAPIError.invalidResponse
}
if http.statusCode == 401 {
throw VelocityAPIError.unauthorized
}
guard (200..<300).contains(http.statusCode) else {
let detail = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String
throw VelocityAPIError.api(detail ?? "Request failed with HTTP \(http.statusCode).")
}
return try decoder.decode(T.self, from: data)
}
func login(email: String, password: String) async throws -> VelocityLoginResponse {
let body = try JSONSerialization.data(withJSONObject: ["email": email, "password": password])
return try await request(VelocityLoginResponse.self, path: "/api/auth/login", method: "POST", body: body)
}
func me(token: String) async throws -> VelocityAuthProfile {
try await request(VelocityAuthProfile.self, path: "/api/auth/me", token: token)
}
func fetchLeads(token: String) async throws -> [VelocityLead] {
let envelope = try await request(VelocityLeadEnvelope.self, path: "/api/leads", token: token)
return envelope.data
}
func fetchAlerts(token: String) async throws -> VelocityAlertSnapshot {
try await request(VelocityAlertSnapshot.self, path: "/api/mobile-edge/alerts", token: token)
}
func fetchEvents(token: String, leadId: String, limit: Int = 8) async throws -> [VelocityCommunicationEvent] {
let envelope = try await request(
VelocityCommunicationEnvelope.self,
path: "/api/mobile-edge/events",
token: token,
queryItems: [
URLQueryItem(name: "lead_id", value: leadId),
URLQueryItem(name: "limit", value: String(limit)),
]
)
return envelope.events
}
func fetchMemoryFacts(token: String, leadId: String, limit: Int = 12) async throws -> [VelocityMemoryFact] {
let envelope = try await request(
VelocityMemoryEnvelope.self,
path: "/api/mobile-edge/memory",
token: token,
queryItems: [
URLQueryItem(name: "lead_id", value: leadId),
URLQueryItem(name: "limit", value: String(limit)),
]
)
return envelope.facts
}
func createNote(token: String, leadId: String, text: String) async throws {
let body = try JSONSerialization.data(withJSONObject: [
"lead_id": leadId,
"note_text": text,
"fact_type": "custom",
])
let _: VelocityEmptyResponse = try await request(VelocityEmptyResponse.self, path: "/api/mobile-edge/notes", method: "POST", token: token, body: body)
}
func fetchTranscript(token: String, eventId: String) async throws -> VelocityTranscriptEnvelope {
try await request(VelocityTranscriptEnvelope.self, path: "/api/mobile-edge/transcripts/\(eventId)", token: token)
}
func fetchCalendar(token: String, limit: Int = 12) async throws -> [VelocityCalendarEvent] {
let envelope = try await request(
VelocityCalendarEnvelope.self,
path: "/api/mobile-edge/calendar",
token: token,
queryItems: [URLQueryItem(name: "limit", value: String(limit))]
)
return envelope.events
}
func createCalendarEvent(token: String, leadId: String?, title: String, description: String?, startAt: Date, durationMinutes: Int) async throws {
let endAt = startAt.addingTimeInterval(TimeInterval(durationMinutes * 60))
let formatter = ISO8601DateFormatter()
var payload: [String: Any] = [
"title": title,
"start_at": formatter.string(from: startAt),
"end_at": formatter.string(from: endAt),
"all_day": false,
"reminder_minutes": [15],
"metadata": [:],
]
if let leadId {
payload["lead_id"] = leadId
}
if let description, description.isEmpty == false {
payload["description"] = description
}
let body = try JSONSerialization.data(withJSONObject: payload)
let _: VelocityEmptyResponse = try await request(VelocityEmptyResponse.self, path: "/api/mobile-edge/calendar", method: "POST", token: token, body: body)
}
func updateCalendarEvent(token: String, eventId: String, title: String, startAt: Date, durationMinutes: Int) async throws {
let formatter = ISO8601DateFormatter()
let body = try JSONSerialization.data(withJSONObject: [
"title": title,
"start_at": formatter.string(from: startAt),
"end_at": formatter.string(from: startAt.addingTimeInterval(TimeInterval(durationMinutes * 60))),
])
let _: VelocityEmptyResponse = try await request(VelocityEmptyResponse.self, path: "/api/mobile-edge/calendar/\(eventId)", method: "PATCH", token: token, body: body)
}
func cancelCalendarEvent(token: String, eventId: String) async throws {
let _: VelocityEmptyResponse = try await request(VelocityEmptyResponse.self, path: "/api/mobile-edge/calendar/\(eventId)", method: "DELETE", token: token)
}
func fetchInsights(token: String, leadId: String) async throws -> [VelocityInsight] {
let envelope = try await request(VelocityInsightEnvelope.self, path: "/api/mobile-edge/insights/\(leadId)", token: token)
return envelope.insights
}
func actInsight(token: String, recommendationId: String, action: String) async throws {
let body = try JSONSerialization.data(withJSONObject: ["action": action])
let _: VelocityEmptyResponse = try await request(VelocityEmptyResponse.self, path: "/api/mobile-edge/insights/\(recommendationId)/act", method: "POST", token: token, body: body)
}
func registerHeartbeat(token: String, module: String, screen: String?) async throws {
let body = try JSONSerialization.data(withJSONObject: [
"surface_type": "iphone_edge",
"app_version": EdgeAppConfig.appVersion,
"screen": screen ?? module,
"metadata": [
"client": "velocity-iphone",
"platform": "ios",
"module": module,
],
])
let _: VelocityEmptyResponse = try await request(VelocityEmptyResponse.self, path: "/api/mobile-edge/session", method: "POST", token: token, body: body)
}
func fetchCampaigns(token: String) async throws -> [VelocityCampaign] {
let envelope = try await request(VelocityCampaignEnvelope.self, path: "/api/catalyst/campaigns", token: token)
return envelope.data
}
func fetchCatalystInsights(token: String) async throws -> [VelocityCatalystInsight] {
let envelope = try await request(VelocityCatalystInsightEnvelope.self, path: "/api/catalyst/insights/realtime", token: token)
return envelope.data
}
func fetchProperties(token: String, limit: Int = 16) async throws -> [VelocityInventoryProperty] {
let envelope = try await request(
VelocityInventoryListEnvelope.self,
path: "/api/inventory/properties",
token: token,
queryItems: [
URLQueryItem(name: "limit", value: String(limit)),
URLQueryItem(name: "offset", value: "0"),
]
)
return envelope.properties
}
func fetchPropertyMedia(token: String, propertyId: String) async throws -> [VelocityInventoryMedia] {
let envelope = try await request(VelocityInventoryMediaEnvelope.self, path: "/api/inventory/properties/\(propertyId)/media", token: token)
return envelope.media
}
func fetchCRMContacts(token: String) async throws -> [VelocityCRMContact] {
let envelope = try await request(VelocityCRMContactsEnvelope.self, path: "/api/crm/contacts", token: token)
return envelope.data.contacts
}
func fetchClient360(token: String, personId: String) async throws -> JSONValue {
let dossier = try await request(VelocityClient360.self, path: "/api/crm/client-360/\(personId)", token: token)
return dossier.data
}
func fetchOpportunities(token: String) async throws -> [VelocityCRMOpportunity] {
let envelope = try await request(VelocityCRMOpportunitiesEnvelope.self, path: "/api/crm/opportunities", token: token)
return envelope.data
}
func fetchTasks(token: String) async throws -> [VelocityCRMTask] {
let envelope = try await request(VelocityCRMTasksEnvelope.self, path: "/api/crm/tasks", token: token)
return envelope.data
}
func createTask(token: String, personId: String, title: String, notes: String?) async throws {
var payload: [String: Any] = [
"person_id": personId,
"title": title,
"reminder_type": "follow_up",
"priority": "high",
]
if let notes, notes.isEmpty == false {
payload["notes"] = notes
}
let body = try JSONSerialization.data(withJSONObject: payload)
let _: VelocityEmptyResponse = try await request(VelocityEmptyResponse.self, path: "/api/crm/tasks", method: "POST", token: token, body: body)
}
func fetchKanban(token: String) async throws -> [VelocityCRMKanbanColumn] {
let envelope = try await request(VelocityCRMKanbanEnvelope.self, path: "/api/crm/kanban", token: token)
return envelope.data
}
func fetchAdminHealth(token: String) async throws -> VelocityAdminHealth {
try await request(VelocityAdminHealth.self, path: "/api/admin-surface/health", token: token)
}
func fetchMarketingVideos(token: String) async throws -> [VelocityMarketingVideo] {
let envelope = try await request(VelocityMarketingVideosEnvelope.self, path: "/api/videos/marketing", token: token)
return envelope.videos
}
}
struct DreamWeaverJob: Decodable, Sendable {
let jobId: String
let status: String
enum CodingKeys: String, CodingKey {
case jobId = "job_id"
case status
}
}
struct DreamWeaverStatus: Decodable, Sendable {
let status: String
let ready: Bool
let error: String?
}
struct DreamWeaverHealth: Decodable, Sendable {
let status: String
}
enum DreamWeaverRuntimeError: LocalizedError {
case invalidImage
case timeout
case api(String)
var errorDescription: String? {
switch self {
case .invalidImage:
return "The Dream Weaver gateway returned unreadable image data."
case .timeout:
return "Dream Weaver is taking longer than expected. Please try again."
case .api(let message):
return message
}
}
}
actor DreamWeaverClient {
static let shared = DreamWeaverClient()
func checkHealth() async -> Bool {
do {
let health: DreamWeaverHealth = try await rawRequest(DreamWeaverHealth.self, path: "/health")
return health.status == "ok" || health.status == "healthy"
} catch {
return false
}
}
func generate(source: UIImage, roomType: String, keywords: String) async throws -> UIImage {
let normalised = source.fixedOrientation()
guard let imageData = normalised.resizedSquare(to: 1024).jpegData(compressionQuality: 0.86) else {
throw DreamWeaverRuntimeError.api("Failed to encode the captured image.")
}
let boundary = "Boundary-\(UUID().uuidString)"
var request = URLRequest(url: try buildURL(path: "/dream-weaver"))
request.httpMethod = "POST"
request.timeoutInterval = 180
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.httpBody = buildMultipart(imageData: imageData, roomType: roomType, keywords: keywords, boundary: boundary)
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
let detail = String(data: data, encoding: .utf8) ?? "Submission failed."
throw DreamWeaverRuntimeError.api(detail)
}
let job = try JSONDecoder().decode(DreamWeaverJob.self, from: data)
let resultURL = try await pollUntilReady(jobId: job.jobId)
let (imageDataResult, _) = try await URLSession.shared.data(from: resultURL)
guard let image = UIImage(data: imageDataResult) else {
throw DreamWeaverRuntimeError.invalidImage
}
return image
}
private func pollUntilReady(jobId: String, attempts: Int = 150) async throws -> URL {
for _ in 0..<attempts {
try await Task.sleep(nanoseconds: 2_000_000_000)
let status: DreamWeaverStatus = try await rawRequest(DreamWeaverStatus.self, path: "/dream-weaver/status/\(jobId)")
if status.ready {
return try buildURL(path: "/dream-weaver/result/\(jobId)")
}
if status.status == "error" {
throw DreamWeaverRuntimeError.api(status.error ?? "Dream Weaver failed.")
}
}
throw DreamWeaverRuntimeError.timeout
}
private func rawRequest<T: Decodable>(_ type: T.Type, path: String) async throws -> T {
var request = URLRequest(url: try buildURL(path: path))
request.timeoutInterval = 45
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw DreamWeaverRuntimeError.api("Dream Weaver gateway is unavailable.")
}
return try JSONDecoder().decode(T.self, from: data)
}
private func buildURL(path: String) throws -> URL {
guard var components = URLComponents(string: EdgeAppConfig.baseURL) else {
throw VelocityAPIError.invalidURL
}
components.path = path
guard let url = components.url else {
throw VelocityAPIError.invalidURL
}
return url
}
private func buildMultipart(imageData: Data, roomType: String, keywords: String, boundary: String) -> Data {
var body = Data()
let crlf = "\r\n"
body.append("--\(boundary)\(crlf)".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"image\"; filename=\"room.jpg\"\(crlf)".data(using: .utf8)!)
body.append("Content-Type: image/jpeg\(crlf)\(crlf)".data(using: .utf8)!)
body.append(imageData)
body.append(crlf.data(using: .utf8)!)
body.append("--\(boundary)\(crlf)".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"room_type\"\(crlf)\(crlf)".data(using: .utf8)!)
body.append(roomType.data(using: .utf8)!)
body.append(crlf.data(using: .utf8)!)
let trimmedKeywords = keywords.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedKeywords.isEmpty == false {
body.append("--\(boundary)\(crlf)".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"keywords\"\(crlf)\(crlf)".data(using: .utf8)!)
body.append(trimmedKeywords.data(using: .utf8)!)
body.append(crlf.data(using: .utf8)!)
}
body.append("--\(boundary)--\(crlf)".data(using: .utf8)!)
return body
}
}
private extension UIImage {
func fixedOrientation() -> UIImage {
guard imageOrientation != .up else { return self }
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { _ in
draw(in: CGRect(origin: .zero, size: size))
}
}
func resizedSquare(to side: CGFloat) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: CGSize(width: side, height: side))
return renderer.image { _ in
let aspect = size.width / size.height
let rect: CGRect
if aspect > 1 {
let width = side * aspect
rect = CGRect(x: (side - width) / 2, y: 0, width: width, height: side)
} else {
let height = side / aspect
rect = CGRect(x: 0, y: (side - height) / 2, width: side, height: height)
}
draw(in: rect)
}
}
}

View File

@@ -0,0 +1,57 @@
import Foundation
import Security
enum VelocitySecureStore {
private static let service = "in.desineuron.velocity.iphone"
private static let tokenAccount = "velocity_access_token"
static func readToken() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: tokenAccount,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess,
let data = item as? Data,
let token = String(data: data, encoding: .utf8),
token.isEmpty == false else {
return nil
}
return token
}
static func writeToken(_ token: String) {
let data = Data(token.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: tokenAccount,
]
let attributes: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if status == errSecItemNotFound {
var insert = query
insert.merge(attributes) { _, new in new }
SecItemAdd(insert as CFDictionary, nil)
}
}
static func deleteToken() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: tokenAccount,
]
SecItemDelete(query as CFDictionary)
}
}

View File

@@ -0,0 +1,109 @@
import Foundation
import Observation
@MainActor
@Observable
final class EdgeAppStore {
static let shared = EdgeAppStore()
private init() {}
var leads: [EdgeLeadDTO] = []
var alerts: EdgeAlertSnapshotDTO?
var events: [EdgeCommunicationEventDTO] = []
var memoryFacts: [EdgeMemoryFactDTO] = []
var transcript: EdgeTranscriptDTO?
var isLoading = false
var errorMessage: String?
var lastSyncAt: Date?
var lastHeartbeatAt: Date?
var noteStatusMessage: String?
var selectedLead: EdgeLeadDTO? {
leads.sorted(by: { $0.score > $1.score }).first
}
var authDescription: String {
EdgeAppConfig.authModeDescription
}
func refresh(screen: String? = nil, silent: Bool = false) async {
if !silent {
isLoading = true
}
do {
async let leadsTask = VelocityEdgeAPIClient.shared.fetchLeads()
async let alertsTask = VelocityEdgeAPIClient.shared.fetchAlerts()
let fetchedLeads = try await leadsTask
let fetchedAlerts = try await alertsTask
leads = fetchedLeads
alerts = fetchedAlerts
if let lead = selectedLead {
events = try await VelocityEdgeAPIClient.shared.fetchEvents(for: lead.id, limit: 6)
memoryFacts = try await VelocityEdgeAPIClient.shared.fetchMemoryFacts(for: lead.id, limit: 10)
if let event = events.first(where: { ($0.recordingRef ?? "").isEmpty == false }) {
transcript = try await VelocityEdgeAPIClient.shared.fetchTranscript(for: event.id)
} else {
transcript = nil
}
} else {
events = []
memoryFacts = []
transcript = nil
}
if EdgeAppConfig.isConfigured {
try await VelocityEdgeAPIClient.shared.registerHeartbeat(screen: screen)
lastHeartbeatAt = Date()
}
errorMessage = nil
lastSyncAt = Date()
isLoading = false
} catch {
if !silent {
events = []
memoryFacts = []
transcript = nil
}
errorMessage = error.localizedDescription
isLoading = false
}
}
func createNote(_ text: String) async {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
noteStatusMessage = "Note text cannot be empty."
return
}
guard let lead = selectedLead else {
noteStatusMessage = "No live lead is available for note capture."
return
}
do {
try await VelocityEdgeAPIClient.shared.createNote(leadId: lead.id, noteText: trimmed)
noteStatusMessage = "Quick note saved to live mobile-edge memory."
memoryFacts = try await VelocityEdgeAPIClient.shared.fetchMemoryFacts(for: lead.id, limit: 10)
alerts = try? await VelocityEdgeAPIClient.shared.fetchAlerts()
lastSyncAt = Date()
} catch {
noteStatusMessage = error.localizedDescription
}
}
}
extension Date {
var edgeRelativeShort: 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"
}
}

View File

@@ -0,0 +1,519 @@
import Foundation
import Observation
import SwiftUI
enum VelocityRootState {
case booting
case signedOut
case signedIn
}
enum VelocityModule: String, CaseIterable, Identifiable {
case home = "Home"
case command = "Command"
case sentinel = "Sentinel"
case inventory = "Inventory"
case catalyst = "Catalyst"
var id: String { rawValue }
// Unselected (outline / lighter weight) Apple tab bar convention
var systemImage: String {
switch self {
case .home: return "house"
case .command: return "bolt.horizontal.circle"
case .sentinel: return "eye"
case .inventory: return "building.2"
case .catalyst: return "megaphone"
}
}
// Selected (filled) Apple tab bar convention
var selectedSystemImage: String {
switch self {
case .home: return "house.fill"
case .command: return "bolt.horizontal.circle.fill"
case .sentinel: return "eye.fill"
case .inventory: return "building.2.fill"
case .catalyst: return "megaphone.fill"
}
}
}
@MainActor
@Observable
final class VelocitySessionStore {
var rootState: VelocityRootState = .booting
var profile: VelocityAuthProfile?
var token: String?
var errorMessage: String?
var selectedModule: VelocityModule = .home
var showingSettings = false
var lastHeartbeatAt: Date?
var lastScreenName = "home"
var displayName: String {
if let fullName = profile?.fullName?.trimmingCharacters(in: .whitespacesAndNewlines), fullName.isEmpty == false {
return fullName
}
return profile?.email ?? "Velocity Operator"
}
var roleLabel: String {
(profile?.role ?? "OPERATOR")
.lowercased()
.split(separator: "_")
.map { $0.capitalized }
.joined(separator: " ")
}
func bootstrap() async {
if let secureToken = VelocitySecureStore.readToken() {
await authenticate(with: secureToken)
return
}
if let token = EdgeAppConfig.apiBearerToken {
await authenticate(with: token)
return
}
rootState = .signedOut
}
func login(email: String, password: String) async {
errorMessage = nil
do {
let response = try await VelocityLiveAPI.shared.login(email: email, password: password)
VelocitySecureStore.writeToken(response.accessToken)
await authenticate(with: response.accessToken)
} catch {
rootState = .signedOut
errorMessage = error.localizedDescription
}
}
func authenticate(with token: String) async {
do {
let profile = try await VelocityLiveAPI.shared.me(token: token)
self.token = token
self.profile = profile
self.rootState = .signedIn
self.errorMessage = nil
VelocitySecureStore.writeToken(token)
} catch {
VelocitySecureStore.deleteToken()
self.token = nil
self.profile = nil
self.rootState = .signedOut
self.errorMessage = error.localizedDescription
}
}
func logout() {
VelocitySecureStore.deleteToken()
token = nil
profile = nil
rootState = .signedOut
selectedModule = .home
showingSettings = false
}
func sendHeartbeat(module: VelocityModule, screen: String) async {
guard let token else { return }
do {
try await VelocityLiveAPI.shared.registerHeartbeat(token: token, module: module.rawValue.lowercased(), screen: screen)
lastHeartbeatAt = Date()
lastScreenName = screen
} catch {
// Heartbeat failures are non-fatal background events; suppress to avoid
// polluting session.errorMessage and surfacing spurious errors in the UI.
}
}
}
@MainActor
@Observable
final class VelocityHomeStore {
var alerts: VelocityAlertSnapshot?
var leads: [VelocityLead] = []
var events: [VelocityCommunicationEvent] = []
var calendar: [VelocityCalendarEvent] = []
var insights: [VelocityInsight] = []
var adminHealth: VelocityAdminHealth?
var isLoading = false
var errorMessage: String?
func refresh(token: String) async {
isLoading = true
defer { isLoading = false }
do {
async let alerts = VelocityLiveAPI.shared.fetchAlerts(token: token)
async let leads = VelocityLiveAPI.shared.fetchLeads(token: token)
async let calendar = VelocityLiveAPI.shared.fetchCalendar(token: token, limit: 6)
let leadRows = try await leads
let primaryLead = leadRows.sorted(by: { $0.score > $1.score }).first
self.alerts = try await alerts
self.leads = leadRows
self.calendar = try await calendar
self.adminHealth = try? await VelocityLiveAPI.shared.fetchAdminHealth(token: token)
if let primaryLead {
async let events = VelocityLiveAPI.shared.fetchEvents(token: token, leadId: primaryLead.id, limit: 4)
async let insights = VelocityLiveAPI.shared.fetchInsights(token: token, leadId: primaryLead.id)
self.events = try await events
self.insights = try await insights
} else {
self.events = []
self.insights = []
}
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
}
@MainActor
@Observable
final class VelocityCommandStore {
enum Section: String, CaseIterable, Identifiable {
case oracle = "Oracle"
case crm = "CRM"
case alerts = "Alerts"
case communications = "Communications"
case notes = "Notes"
case calendar = "Calendar"
case transcriptions = "Transcriptions"
case insights = "Insights"
var id: String { rawValue }
}
var selectedSection: Section = .oracle
var leads: [VelocityLead] = []
var contacts: [VelocityCRMContact] = []
var opportunities: [VelocityCRMOpportunity] = []
var tasks: [VelocityCRMTask] = []
var kanban: [VelocityCRMKanbanColumn] = []
var alerts: VelocityAlertSnapshot?
var events: [VelocityCommunicationEvent] = []
var memoryFacts: [VelocityMemoryFact] = []
var calendar: [VelocityCalendarEvent] = []
var insights: [VelocityInsight] = []
var transcript: VelocityTranscriptEnvelope?
var client360: JSONValue?
var noteDraft = ""
var taskDraft = ""
var calendarTitleDraft = ""
var isLoading = false
var errorMessage: String?
var actionMessage: String?
var primaryLead: VelocityLead? {
leads.sorted(by: { $0.score > $1.score }).first
}
var primaryContact: VelocityCRMContact? {
contacts.first
}
func refresh(token: String) async {
isLoading = true
defer { isLoading = false }
do {
async let leads = VelocityLiveAPI.shared.fetchLeads(token: token)
async let contacts = VelocityLiveAPI.shared.fetchCRMContacts(token: token)
async let opportunities = VelocityLiveAPI.shared.fetchOpportunities(token: token)
async let tasks = VelocityLiveAPI.shared.fetchTasks(token: token)
async let kanban = VelocityLiveAPI.shared.fetchKanban(token: token)
async let alerts = VelocityLiveAPI.shared.fetchAlerts(token: token)
async let calendar = VelocityLiveAPI.shared.fetchCalendar(token: token, limit: 10)
let loadedLeads = try await leads
self.leads = loadedLeads
self.contacts = try await contacts
self.opportunities = try await opportunities
self.tasks = try await tasks
self.kanban = try await kanban
self.alerts = try await alerts
self.calendar = try await calendar
if let lead = loadedLeads.sorted(by: { $0.score > $1.score }).first {
async let events = VelocityLiveAPI.shared.fetchEvents(token: token, leadId: lead.id)
async let memory = VelocityLiveAPI.shared.fetchMemoryFacts(token: token, leadId: lead.id)
async let insights = VelocityLiveAPI.shared.fetchInsights(token: token, leadId: lead.id)
self.events = try await events
self.memoryFacts = try await memory
self.insights = try await insights
if let transcriptEvent = self.events.first(where: { ($0.recordingRef ?? "").isEmpty == false }) {
self.transcript = try? await VelocityLiveAPI.shared.fetchTranscript(token: token, eventId: transcriptEvent.eventId)
} else {
self.transcript = nil
}
} else {
self.events = []
self.memoryFacts = []
self.insights = []
self.transcript = nil
}
if let personId = self.contacts.first?.personId {
self.client360 = try? await VelocityLiveAPI.shared.fetchClient360(token: token, personId: personId)
} else {
self.client360 = nil
}
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
func createNote(token: String) async {
let trimmed = noteDraft.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.isEmpty == false, let lead = primaryLead else { return }
do {
try await VelocityLiveAPI.shared.createNote(token: token, leadId: lead.id, text: trimmed)
noteDraft = ""
actionMessage = "Note saved to live operator memory."
await refresh(token: token)
} catch {
actionMessage = error.localizedDescription
}
}
func createFollowUp(token: String) async {
let trimmed = taskDraft.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.isEmpty == false, let personId = primaryContact?.personId else { return }
do {
try await VelocityLiveAPI.shared.createTask(token: token, personId: personId, title: trimmed, notes: nil)
taskDraft = ""
actionMessage = "Follow-up task created."
await refresh(token: token)
} catch {
actionMessage = error.localizedDescription
}
}
func createCalendarEvent(token: String) async {
let trimmed = calendarTitleDraft.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.isEmpty == false else { return }
do {
try await VelocityLiveAPI.shared.createCalendarEvent(token: token, leadId: primaryLead?.id, title: trimmed, description: "Created from Velocity iPhone", startAt: Date().addingTimeInterval(3600), durationMinutes: 30)
calendarTitleDraft = ""
actionMessage = "Calendar event created."
await refresh(token: token)
} catch {
actionMessage = error.localizedDescription
}
}
func act(on insight: VelocityInsight, action: String, token: String) async {
do {
try await VelocityLiveAPI.shared.actInsight(token: token, recommendationId: insight.recommendationId, action: action)
actionMessage = "Insight marked as \(action)."
await refresh(token: token)
} catch {
actionMessage = error.localizedDescription
}
}
}
@MainActor
@Observable
final class VelocitySentinelStore {
enum Section: String, CaseIterable, Identifiable {
case overview = "Overview"
case live = "Live Session"
var id: String { rawValue }
}
var selectedSection: Section = .overview
var alerts: VelocityAlertSnapshot?
var leads: [VelocityLead] = []
var videos: [VelocityMarketingVideo] = []
var adminHealth: VelocityAdminHealth?
var isLoading = false
var errorMessage: String?
func refresh(token: String) async {
isLoading = true
defer { isLoading = false }
do {
async let alerts = VelocityLiveAPI.shared.fetchAlerts(token: token)
async let leads = VelocityLiveAPI.shared.fetchLeads(token: token)
async let videos = VelocityLiveAPI.shared.fetchMarketingVideos(token: token)
self.alerts = try await alerts
self.leads = try await leads
self.videos = try await videos
self.adminHealth = try? await VelocityLiveAPI.shared.fetchAdminHealth(token: token)
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
}
@MainActor
@Observable
final class VelocityInventoryStore {
enum Section: String, CaseIterable, Identifiable {
case portfolio = "Portfolio"
case units = "Units"
case dreamWeaver = "Dream Weaver"
case sunseeker = "Sunseeker"
var id: String { rawValue }
}
var selectedSection: Section = .portfolio
var properties: [VelocityInventoryProperty] = []
var selectedPropertyID: String?
var media: [VelocityInventoryMedia] = []
var isLoading = false
var errorMessage: String?
var sourceImage: UIImage?
var generatedImage: UIImage?
var roomType = "living_room"
var keywords = ""
var dreamWeaverOnline: Bool?
var dreamWeaverBusy = false
var dreamWeaverMessage: String?
var selectedProperty: VelocityInventoryProperty? {
properties.first(where: { $0.propertyId == selectedPropertyID }) ?? properties.first
}
func refresh(token: String) async {
isLoading = true
defer { isLoading = false }
do {
let properties = try await VelocityLiveAPI.shared.fetchProperties(token: token)
self.properties = properties
if selectedPropertyID == nil {
selectedPropertyID = properties.first?.propertyId
}
if let propertyId = selectedPropertyID {
self.media = try await VelocityLiveAPI.shared.fetchPropertyMedia(token: token, propertyId: propertyId)
} else {
self.media = []
}
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
func refreshDreamWeaverHealth() async {
dreamWeaverOnline = await DreamWeaverClient.shared.checkHealth()
}
func generateDreamWeaver() async {
guard let sourceImage else {
dreamWeaverMessage = "Capture or select a room photo first."
return
}
dreamWeaverBusy = true
dreamWeaverMessage = nil
defer { dreamWeaverBusy = false }
do {
generatedImage = try await DreamWeaverClient.shared.generate(source: sourceImage, roomType: roomType, keywords: keywords)
dreamWeaverMessage = "Dream Weaver returned a live render."
} catch {
dreamWeaverMessage = error.localizedDescription
}
}
}
@MainActor
@Observable
final class VelocityCatalystStore {
enum Section: String, CaseIterable, Identifiable {
case studio = "Studio"
case command = "Campaign Command"
case roi = "Intelligence & ROI"
case warRoom = "War Room"
case marketing = "Marketing"
var id: String { rawValue }
}
var selectedSection: Section = .studio
var campaigns: [VelocityCampaign] = []
var insights: [VelocityCatalystInsight] = []
var isLoading = false
var errorMessage: String?
func refresh(token: String) async {
isLoading = true
defer { isLoading = false }
do {
self.campaigns = try await VelocityLiveAPI.shared.fetchCampaigns(token: token)
self.insights = (try? await VelocityLiveAPI.shared.fetchCatalystInsights(token: token)) ?? []
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
}
@MainActor
@Observable
final class VelocitySettingsStore {
var adminHealth: VelocityAdminHealth?
var alerts: VelocityAlertSnapshot?
var isLoading = false
var errorMessage: String?
func refresh(token: String) async {
isLoading = true
defer { isLoading = false }
do {
async let alerts = VelocityLiveAPI.shared.fetchAlerts(token: token)
self.adminHealth = try? await VelocityLiveAPI.shared.fetchAdminHealth(token: token)
self.alerts = try await alerts
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
}
@MainActor
@Observable
final class VelocityAppModel {
let session = VelocitySessionStore()
let home = VelocityHomeStore()
let command = VelocityCommandStore()
let sentinel = VelocitySentinelStore()
let inventory = VelocityInventoryStore()
let catalyst = VelocityCatalystStore()
let settings = VelocitySettingsStore()
var bootstrapped = false
func bootstrap() async {
guard bootstrapped == false else { return }
bootstrapped = true
await session.bootstrap()
if session.rootState == .signedIn {
await refreshCurrentModule(forceAll: true)
}
}
func refreshCurrentModule(forceAll: Bool = false) async {
guard let token = session.token else { return }
if forceAll || session.selectedModule == .home { await home.refresh(token: token) }
if forceAll || session.selectedModule == .command { await command.refresh(token: token) }
if forceAll || session.selectedModule == .sentinel { await sentinel.refresh(token: token) }
if forceAll || session.selectedModule == .inventory {
await inventory.refresh(token: token)
await inventory.refreshDreamWeaverHealth()
}
if forceAll || session.selectedModule == .catalyst { await catalyst.refresh(token: token) }
if forceAll || session.showingSettings { await settings.refresh(token: token) }
}
}

View File

@@ -0,0 +1,495 @@
import SwiftUI
import Combine
// MARK: Colour & Gradient Tokens
enum EdgeTheme {
// Base surfaces deep navy/indigo dark mode
static let background = Color(red: 0.012, green: 0.016, blue: 0.040)
static let background2 = Color(red: 0.030, green: 0.040, blue: 0.090)
static let surface = Color(red: 0.068, green: 0.082, blue: 0.155)
static let surface2 = Color(red: 0.102, green: 0.122, blue: 0.210)
static let surface3 = Color(red: 0.130, green: 0.155, blue: 0.252)
// Text
static let foreground = Color(red: 0.955, green: 0.968, blue: 0.995)
static let mutedFg = Color(red: 0.620, green: 0.668, blue: 0.800)
static let subtleFg = Color(red: 0.400, green: 0.455, blue: 0.590)
// Accent palette
static let accent = Color(red: 0.278, green: 0.588, blue: 1.000) // electric blue
static let accentSecondary = Color(red: 0.220, green: 0.878, blue: 0.780) // teal-mint
static let accentWarm = Color(red: 1.000, green: 0.545, blue: 0.337) // coral
static let accentPurple = Color(red: 0.682, green: 0.459, blue: 1.000) // violet
static let accentSubtle = accent.opacity(0.18)
// Semantic
static let success = Color(red: 0.318, green: 0.855, blue: 0.580)
static let warning = Color(red: 1.000, green: 0.780, blue: 0.318)
static let danger = Color(red: 1.000, green: 0.400, blue: 0.420)
// Borders
static let borderSubtle = Color.white.opacity(0.08)
static let borderAccent = accent.opacity(0.28)
static let borderGlass = Color.white.opacity(0.12)
// Gradients
static let shellGradient = LinearGradient(
colors: [background2, background],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
static let heroGradient = LinearGradient(
colors: [accent, accentPurple.opacity(0.85)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
static let heroGradientWarm = LinearGradient(
colors: [accentWarm, Color(red: 1.0, green: 0.35, blue: 0.60)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
static let cardGradient = LinearGradient(
colors: [surface2.opacity(0.94), surface.opacity(0.96)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
static let glassGradient = LinearGradient(
colors: [Color.white.opacity(0.10), Color.white.opacity(0.04)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
// MARK: Ambient Background
//
// Apple design: background is quiet it lives behind content, not on top of it.
// Slow-drifting orbs at low opacity establish depth without fighting text.
struct EdgeAmbientBackground: View {
var body: some View {
ZStack {
// Solid base never pure black, always a deep navy/indigo
EdgeTheme.background.ignoresSafeArea()
// Very slow ambient glow imperceptible in isolation, felt at scale
TimelineView(.animation(minimumInterval: 1 / 24)) { tl in
let t = tl.date.timeIntervalSinceReferenceDate
Canvas { context, size in
// Orb 1 blue, top-right
let o1 = CGPoint(
x: size.width * 0.80 + CGFloat(sin(t * 0.14)) * 40,
y: size.height * 0.18 + CGFloat(cos(t * 0.11)) * 30
)
context.drawLayer { ctx in
ctx.addFilter(.blur(radius: 80))
ctx.fill(
Path(ellipseIn: CGRect(x: o1.x - 130, y: o1.y - 130, width: 260, height: 260)),
with: .color(EdgeTheme.accent.opacity(0.20))
)
}
// Orb 2 violet, top-left
let o2 = CGPoint(
x: size.width * 0.14 + CGFloat(cos(t * 0.09)) * 36,
y: size.height * 0.22 + CGFloat(sin(t * 0.12)) * 28
)
context.drawLayer { ctx in
ctx.addFilter(.blur(radius: 70))
ctx.fill(
Path(ellipseIn: CGRect(x: o2.x - 110, y: o2.y - 110, width: 220, height: 220)),
with: .color(EdgeTheme.accentPurple.opacity(0.14))
)
}
// Orb 3 teal, bottom
let o3 = CGPoint(
x: size.width * 0.45 + CGFloat(sin(t * 0.07)) * 50,
y: size.height * 0.82 + CGFloat(cos(t * 0.10)) * 40
)
context.drawLayer { ctx in
ctx.addFilter(.blur(radius: 90))
ctx.fill(
Path(ellipseIn: CGRect(x: o3.x - 140, y: o3.y - 140, width: 280, height: 280)),
with: .color(EdgeTheme.accentSecondary.opacity(0.10))
)
}
}
}
}
}
}
// MARK: Shell Header (legacy VelocityModuleScreen now uses NavigationStack)
struct EdgeShellHeader: View {
let title: String
let subtitle: String
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.system(.largeTitle, design: .rounded, weight: .bold))
.foregroundStyle(EdgeTheme.foreground)
if !subtitle.isEmpty {
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
}
// MARK: EdgeShell (used by old edge views)
struct EdgeShell<Content: View>: View {
let title: String
let subtitle: String
@ViewBuilder let content: Content
var body: some View {
ZStack {
EdgeAmbientBackground()
.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: 20) {
EdgeShellHeader(title: title, subtitle: subtitle)
content
}
.padding(.horizontal, 18)
.padding(.top, 14)
.padding(.bottom, 34)
}
.scrollIndicators(.hidden)
}
}
}
// MARK: Glass Card (primary card style mirrors VelocityGlassCard for legacy callers)
struct EdgeCard<Content: View>: View {
let title: String
let tint: Color
@ViewBuilder let content: Content
init(title: String, tint: Color = EdgeTheme.accent, @ViewBuilder content: () -> Content) {
self.title = title
self.tint = tint
self.content = content()
}
var body: some View {
VelocityGlassCard(title: title, tint: tint) { content }
}
}
// MARK: Hero Card
struct EdgeHeroCard<Content: View>: View {
let title: String
let subtitle: String
let icon: String
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .top, spacing: 14) {
ZStack {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(Color.white.opacity(0.18))
.blur(radius: 0.5)
Image(systemName: icon)
.font(.system(size: 22, weight: .bold))
.foregroundStyle(.white)
}
.frame(width: 56, height: 56)
VStack(alignment: .leading, spacing: 5) {
Text(title)
.font(.system(size: 20, weight: .heavy, design: .rounded))
.foregroundStyle(.white)
Text(subtitle)
.font(.system(size: 13, weight: .medium, design: .rounded))
.foregroundStyle(.white.opacity(0.80))
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
content
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 30, style: .continuous)
.fill(EdgeTheme.heroGradient)
.overlay(
RoundedRectangle(cornerRadius: 30, style: .continuous)
.stroke(Color.white.opacity(0.18), lineWidth: 1)
)
.shadow(color: EdgeTheme.accent.opacity(0.28), radius: 28, y: 14)
)
}
}
// MARK: Metric Card
struct EdgeMetricCard: View {
let label: String
let value: String
let tint: Color
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 6) {
Circle()
.fill(tint)
.frame(width: 8, height: 8)
.shadow(color: tint.opacity(0.7), radius: 3)
Text(label.uppercased())
.font(.system(size: 10, weight: .heavy, design: .rounded))
.tracking(1.3)
.foregroundStyle(EdgeTheme.mutedFg)
}
Text(value)
.font(.system(size: 30, weight: .heavy, design: .rounded))
.foregroundStyle(EdgeTheme.foreground)
.lineLimit(1)
.minimumScaleFactor(0.7)
Capsule(style: .continuous)
.fill(tint.opacity(0.15))
.frame(height: 5)
.overlay(alignment: .leading) {
Capsule(style: .continuous)
.fill(tint)
.frame(width: 44, height: 5)
}
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(Color.white.opacity(0.055))
.overlay(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.stroke(Color.white.opacity(0.09), lineWidth: 1)
)
)
}
}
// MARK: Status Pill
struct EdgeStatusPill: View {
let label: String
let color: Color
var body: some View {
Text(label)
.font(.system(size: 11, weight: .bold, design: .rounded))
.foregroundStyle(color)
.padding(.horizontal, 11)
.padding(.vertical, 7)
.background(
Capsule(style: .continuous)
.fill(color.opacity(0.14))
.overlay(
Capsule(style: .continuous)
.stroke(color.opacity(0.28), lineWidth: 1)
)
)
}
}
// MARK: Key-Value Row
//
// Matches iOS Settings row label in secondary on left, value on right.
struct EdgeKeyValueRow: View {
let label: String
let value: String
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text(label)
.font(.system(.subheadline, design: .rounded, weight: .regular))
.foregroundStyle(.secondary)
Spacer(minLength: 8)
Text(value)
.font(.system(.subheadline, design: .rounded, weight: .medium))
.foregroundStyle(EdgeTheme.foreground)
.multilineTextAlignment(.trailing)
}
.padding(.vertical, 6)
}
}
// MARK: Timeline Row
//
// Used inside VelocityGlassCard rows no timeline connector, Apple Notes style.
struct EdgeTimelineRow: View {
let title: String
let subtitle: String
let trailing: String
let tint: Color
var body: some View {
HStack(alignment: .top, spacing: 12) {
// Tint dot compact visual anchor, no vertical connector
Circle()
.fill(tint)
.frame(width: 8, height: 8)
.shadow(color: tint.opacity(0.55), radius: 3)
.padding(.top, 5)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.system(.subheadline, design: .rounded, weight: .semibold))
.foregroundStyle(EdgeTheme.foreground)
.lineLimit(1)
Text(subtitle)
.font(.system(.caption, design: .rounded, weight: .regular))
.foregroundStyle(.secondary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
Text(trailing)
.font(.system(.caption2, design: .rounded, weight: .medium))
.foregroundStyle(Color(uiColor: .tertiaryLabel))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 5)
}
}
// MARK: Primary Button Style
struct EdgePrimaryButtonStyle: ButtonStyle {
let enabled: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(.body, design: .rounded, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 15)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(
enabled
? AnyShapeStyle(LinearGradient(
colors: [EdgeTheme.accent, EdgeTheme.accentPurple],
startPoint: .leading, endPoint: .trailing
))
: AnyShapeStyle(Color.white.opacity(0.07))
)
.shadow(color: enabled ? EdgeTheme.accent.opacity(0.32) : .clear, radius: 12, y: 5)
)
.foregroundStyle(.white)
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.opacity(configuration.isPressed ? 0.88 : 1)
.animation(.spring(response: 0.22, dampingFraction: 0.72), value: configuration.isPressed)
}
}
// MARK: Utility Cards
struct EdgeLoadingCard: View {
let message: String
var body: some View {
HStack(spacing: 14) {
ProgressView().tint(EdgeTheme.accent)
Text(message)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(EdgeTheme.borderGlass, lineWidth: 0.5)
)
)
}
}
struct EdgeErrorCard: View {
let message: String
var body: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(EdgeTheme.danger)
.font(.system(size: 14, weight: .semibold))
Text(message)
.font(.subheadline)
.foregroundStyle(EdgeTheme.danger.opacity(0.90))
.fixedSize(horizontal: false, vertical: true)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(EdgeTheme.danger.opacity(0.09))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(EdgeTheme.danger.opacity(0.18), lineWidth: 0.5)
)
)
}
}
struct EdgeEmptyCard: View {
let title: String
let message: String
var body: some View {
VStack(spacing: 8) {
Text(title)
.font(.system(.subheadline, design: .rounded, weight: .semibold))
.foregroundStyle(EdgeTheme.foreground)
Text(message)
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
}
}
// MARK: Auto-Refresh Modifier (legacy EdgeAppStore surface)
private struct EdgeLiveRefreshModifier: ViewModifier {
@State private var refreshTimer = Timer.publish(every: 20, on: .main, in: .common).autoconnect()
let store: EdgeAppStore
let screen: String
func body(content: Content) -> some View {
content.onReceive(refreshTimer) { _ in
Task { await store.refresh(screen: screen, silent: true) }
}
}
}
extension View {
func edgeAutoRefresh(store: EdgeAppStore, screen: String) -> some View {
modifier(EdgeLiveRefreshModifier(store: store, screen: screen))
}
}

View File

@@ -0,0 +1,390 @@
import SwiftUI
// MARK: Module Screen Shell
//
// Apple design principle: NavigationStack owns the title. The large title
// collapses to inline as the user scrolls exactly like Settings, Health,
// and App Store. The ambient background sits underneath but never competes
// with content.
struct VelocityModuleScreen<Content: View>: View {
let title: String
let subtitle: String
let content: Content
init(title: String, subtitle: String, @ViewBuilder content: () -> Content) {
self.title = title
self.subtitle = subtitle
self.content = content()
}
var body: some View {
NavigationStack {
ZStack {
EdgeAmbientBackground().ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// Module subtitle sits just below the large nav title
if !subtitle.isEmpty {
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
// No extra padding NavigationStack large-title area handles vertical rhythm
}
content
}
// 16pt is Apple's canonical content margin (readableContentGuide)
.padding(.horizontal, 16)
// navigationBarTitleDisplayMode(.large) adds ~96pt gap already;
// we add 4pt to breathe between the title and the first card.
.padding(.top, 4)
// Tab bar (49pt) + home indicator (~34pt) + 17pt breathing = 100pt
.padding(.bottom, 100)
}
.scrollIndicators(.hidden)
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.large)
// Transparent nav bar so the ambient BG shows through
.toolbarBackground(.clear, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
}
}
}
// MARK: Glass Card
//
// Apple design: cards use grouped inset list semantics a rounded rect
// with a subtle material fill, a small section header above content, and a 1pt
// separator between rows. Shadow is restrained (depth, not decoration).
struct VelocityGlassCard<Content: View>: View {
let title: String
let tint: Color
let content: Content
init(title: String, tint: Color = EdgeTheme.accent, @ViewBuilder content: () -> Content) {
self.title = title
self.tint = tint
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Section label Apple uses all-caps caption above grouped content
HStack(spacing: 5) {
Circle()
.fill(tint)
.frame(width: 5, height: 5)
.shadow(color: tint.opacity(0.6), radius: 2)
Text(title.uppercased())
.font(.system(.caption2, design: .rounded, weight: .bold))
.tracking(1.0)
.foregroundStyle(tint.opacity(0.90))
}
// Apple's section header sits 6pt above the card surface
.padding(.horizontal, 4)
.padding(.bottom, 6)
// Card body 16pt H mirrors Apple's grouped inset cell horizontal inset
VStack(alignment: .leading, spacing: 0) {
content
}
.padding(.horizontal, 16)
// 12pt V tighter than before; matches Apple's compact list row feel
.padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(EdgeTheme.surface.opacity(0.50))
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(EdgeTheme.borderGlass, lineWidth: 0.5)
)
.shadow(color: .black.opacity(0.18), radius: 10, y: 3)
)
}
}
}
// MARK: Hero Card
//
// Full-bleed gradient card used for the primary module CTA or status.
// Apple uses these sparingly one per screen, max.
struct VelocityHeroCard<Content: View>: View {
let title: String
let subtitle: String
let systemImage: String
let content: Content
init(title: String, subtitle: String, systemImage: String, @ViewBuilder content: () -> Content) {
self.title = title
self.subtitle = subtitle
self.systemImage = systemImage
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .top, spacing: 14) {
ZStack {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(.white.opacity(0.20))
Image(systemName: systemImage)
.font(.system(size: 20, weight: .semibold, design: .default))
.foregroundStyle(.white)
.symbolRenderingMode(.hierarchical)
}
.frame(width: 52, height: 52)
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.system(.headline, design: .rounded, weight: .bold))
.foregroundStyle(.white)
Text(subtitle)
.font(.system(.footnote, design: .rounded, weight: .regular))
.foregroundStyle(.white.opacity(0.78))
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
content
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(LinearGradient(
colors: [EdgeTheme.accent, EdgeTheme.accentPurple.opacity(0.90)],
startPoint: .topLeading, endPoint: .bottomTrailing
))
.overlay(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.stroke(.white.opacity(0.16), lineWidth: 0.5)
)
.shadow(color: EdgeTheme.accent.opacity(0.28), radius: 20, y: 8)
)
}
}
// MARK: Metric Tile
//
// Inspired by Apple Health's summary widgets label above, value large,
// tint accent bar at bottom. Uses SF Pro Rounded for numbers.
struct VelocityMetricTile: View {
let label: String
let value: String
let tint: Color
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Label row
HStack(spacing: 5) {
Image(systemName: "circle.fill")
.font(.system(size: 6))
.foregroundStyle(tint)
.shadow(color: tint.opacity(0.6), radius: 3)
Text(label)
.font(.system(.caption2, design: .rounded, weight: .semibold))
.foregroundStyle(.secondary)
.lineLimit(1)
}
// Value
Text(value)
.font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundStyle(tint)
.lineLimit(1)
.minimumScaleFactor(0.60)
// Accent indicator bar
Capsule()
.fill(tint.opacity(0.14))
.frame(height: 3)
.overlay(alignment: .leading) {
Capsule()
.fill(tint)
.frame(width: 40, height: 3)
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(tint.opacity(0.055))
)
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(tint.opacity(0.14), lineWidth: 0.5)
)
)
}
}
// MARK: Section Picker
//
// Apple-style segmented tabs as an inline horizontal scroll of chips.
// Selected chip has a solid tint fill; unselected is ghost.
struct VelocitySectionPicker<Selection: Hashable & CaseIterable & Identifiable>: View
where Selection.AllCases: RandomAccessCollection {
let sections: [Selection]
@Binding var selection: Selection
let title: (Selection) -> String
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(sections, id: \.id) { section in
let selected = selection == section
Button {
let impact = UIImpactFeedbackGenerator(style: .light)
impact.impactOccurred()
withAnimation(.spring(response: 0.26, dampingFraction: 0.76)) {
selection = section
}
} label: {
Text(title(section))
// Use callout slightly smaller than subheadline, avoids chips feeling bulky
.font(.system(.callout, design: .rounded, weight: selected ? .semibold : .regular))
.foregroundStyle(selected ? .white : Color.secondary)
// 14pt H / 7pt V Apple's standard chip sizing (lock screen widgets etc.)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(
Capsule(style: .continuous)
.fill(selected ? EdgeTheme.accent : Color.white.opacity(0.07))
.shadow(color: selected ? EdgeTheme.accent.opacity(0.28) : .clear, radius: 6, y: 2)
)
}
.buttonStyle(.plain)
}
}
// 1pt H padding prevents chip shadows from clipping at scroll edge
.padding(.horizontal, 1)
.padding(.vertical, 1)
}
}
}
// MARK: Shell Button Style
struct VelocityShellButtonStyle: ButtonStyle {
let tint: Color
let prominent: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(.subheadline, design: .rounded, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 13)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(
prominent
? AnyShapeStyle(tint.opacity(configuration.isPressed ? 0.70 : 0.90))
: AnyShapeStyle(Color.white.opacity(configuration.isPressed ? 0.09 : 0.06))
)
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(
prominent ? tint.opacity(0.20) : EdgeTheme.borderSubtle,
lineWidth: 0.5
)
)
.shadow(color: prominent ? tint.opacity(0.22) : .clear, radius: 8, y: 4)
)
.foregroundStyle(prominent ? Color.white : EdgeTheme.foreground)
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.animation(.spring(response: 0.20, dampingFraction: 0.72), value: configuration.isPressed)
}
}
// MARK: Bottom Navigation
//
// Apple tab bar aesthetic: pure SF Symbol icon + small label, displayed
// over an ultra-thin material bar that blurs the content behind it.
// No custom box around selected Apple uses pure color differentiation.
struct VelocityBottomNavigation: View {
@Binding var selection: VelocityModule
let onSettings: () -> Void
var body: some View {
VStack(spacing: 0) {
Spacer()
// Tab bar surface
HStack(alignment: .bottom, spacing: 0) {
ForEach(VelocityModule.allCases) { module in
let selected = selection == module
Button {
let impact = UIImpactFeedbackGenerator(style: .light)
impact.impactOccurred()
withAnimation(.spring(response: 0.28, dampingFraction: 0.76)) {
selection = module
}
} label: {
VStack(spacing: 4) {
Image(systemName: selected ? module.selectedSystemImage : module.systemImage)
.font(.system(size: 22, weight: selected ? .semibold : .regular))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(selected ? EdgeTheme.accent : Color(uiColor: .secondaryLabel))
.scaleEffect(selected ? 1.08 : 1.0)
.animation(.spring(response: 0.26, dampingFraction: 0.70), value: selected)
Text(module.rawValue)
.font(.system(size: 10, weight: selected ? .semibold : .regular, design: .rounded))
.foregroundStyle(selected ? EdgeTheme.accent : Color(uiColor: .secondaryLabel))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
.buttonStyle(.plain)
}
// Settings button distinct from module tabs
Button(action: onSettings) {
VStack(spacing: 4) {
Image(systemName: "person.crop.circle")
.font(.system(size: 22, weight: .regular))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(Color(uiColor: .secondaryLabel))
Text("Profile")
.font(.system(size: 10, weight: .regular, design: .rounded))
.foregroundStyle(Color(uiColor: .secondaryLabel))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 8)
.background(
Rectangle()
.fill(.ultraThinMaterial)
.overlay(
Rectangle()
.fill(Color.black.opacity(0.40))
)
.overlay(alignment: .top) {
Rectangle()
.fill(Color.white.opacity(0.08))
.frame(height: 0.5) // hairline top divider Apple tab bar signature
}
.ignoresSafeArea(edges: .bottom)
)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
import SwiftUI
struct EdgeAlertsView: View {
@State private var store = EdgeAppStore.shared
private let metricColumns = [GridItem(.adaptive(minimum: 150), spacing: 12)]
var body: some View {
EdgeShell(
title: "Alerts",
subtitle: "High-urgency signal flow for the active operator window."
) {
if let error = store.errorMessage {
EdgeErrorCard(message: error)
}
if store.isLoading && store.alerts == nil {
EdgeLoadingCard(message: "Fetching live alert posture and registering the iPhone edge surface.")
} else if let alerts = store.alerts {
EdgeHeroCard(
title: "Active Alert Stack",
subtitle: "A fast mobile view into what needs action first across insight review, transcripts, and calendar drift.",
icon: "bolt.badge.clock"
) {
HStack(spacing: 8) {
EdgeStatusPill(label: "Insights \(alerts.pendingInsights)", color: EdgeTheme.danger)
EdgeStatusPill(label: "Transcripts \(alerts.pendingTranscriptions)", color: EdgeTheme.warning)
EdgeStatusPill(label: "24h \(alerts.upcomingCalendarEvents24h)", color: EdgeTheme.success)
}
}
LazyVGrid(columns: metricColumns, spacing: 12) {
EdgeMetricCard(label: "Insights", value: "\(alerts.pendingInsights)", tint: EdgeTheme.danger)
EdgeMetricCard(label: "Transcripts", value: "\(alerts.pendingTranscriptions)", tint: EdgeTheme.warning)
EdgeMetricCard(label: "24h", value: "\(alerts.upcomingCalendarEvents24h)", tint: EdgeTheme.success)
}
EdgeCard(title: "Operator posture") {
if let lead = store.selectedLead {
EdgeTimelineRow(
title: lead.name,
subtitle: "\(lead.qualification.capitalized) lead · \(lead.unitInterest) · \(lead.budget)",
trailing: "\(lead.score)",
tint: EdgeTheme.accentSecondary
)
Text("This lead currently sits at the top of the live urgency model and should anchor your next conversation.")
.font(.system(size: 13, weight: .medium, design: .rounded))
.foregroundStyle(EdgeTheme.mutedFg)
} else {
Text("No live lead is available yet for urgency ranking.")
.font(.system(size: 13, weight: .medium, design: .rounded))
.foregroundStyle(EdgeTheme.mutedFg)
}
}
} else {
EdgeEmptyCard(title: "Alerts", message: "No live alert payload was returned.")
}
}
.task { await store.refresh(screen: "alerts") }
.refreshable { await store.refresh(screen: "alerts") }
.edgeAutoRefresh(store: store, screen: "alerts")
}
}

View File

@@ -0,0 +1,51 @@
import SwiftUI
struct EdgeCommunicationsView: View {
@State private var store = EdgeAppStore.shared
var body: some View {
EdgeShell(
title: "Communications",
subtitle: "Recent live call and messaging movement around the priority lead."
) {
if let error = store.errorMessage {
EdgeErrorCard(message: error)
}
if store.isLoading && store.events.isEmpty {
EdgeLoadingCard(message: "Fetching live communication events.")
} else if let lead = store.selectedLead {
EdgeHeroCard(
title: "Current Thread",
subtitle: "\(lead.name) is the current highest-priority lead on this device.",
icon: "phone.badge.waveform"
) {
HStack(spacing: 8) {
EdgeStatusPill(label: lead.unitInterest, color: EdgeTheme.accent)
EdgeStatusPill(label: lead.qualification.capitalized, color: EdgeTheme.accentSecondary)
}
}
if store.events.isEmpty {
EdgeEmptyCard(title: "Communications", message: "No live communication events were returned yet.")
} else {
EdgeCard(title: "Recent threads") {
ForEach(store.events) { event in
EdgeTimelineRow(
title: event.channel.replacingOccurrences(of: "_", with: " ").capitalized,
subtitle: event.summary ?? "No summary available.",
trailing: event.timestampDate?.edgeRelativeShort ?? event.timestamp,
tint: event.recordingRef == nil ? EdgeTheme.accent : EdgeTheme.accentWarm
)
}
}
}
} else {
EdgeEmptyCard(title: "Communications", message: "No live lead context is available for this surface.")
}
}
.task { await store.refresh(screen: "communications") }
.refreshable { await store.refresh(screen: "communications") }
.edgeAutoRefresh(store: store, screen: "communications")
}
}

View File

@@ -0,0 +1,54 @@
import SwiftUI
struct EdgeLeadSummaryView: View {
@State private var store = EdgeAppStore.shared
var body: some View {
EdgeShell(
title: "Lead Summary",
subtitle: "A compact executive view of the strongest live buyer signal."
) {
if let error = store.errorMessage {
EdgeErrorCard(message: error)
}
if store.isLoading && store.leads.isEmpty {
EdgeLoadingCard(message: "Fetching live leads.")
} else if let lead = store.selectedLead {
EdgeHeroCard(
title: lead.name,
subtitle: "\(lead.qualification.capitalized) intent with interest in \(lead.unitInterest).",
icon: "person.crop.circle.badge.checkmark"
) {
HStack(spacing: 8) {
EdgeStatusPill(label: "Score \(lead.score)", color: EdgeTheme.accent)
EdgeStatusPill(label: lead.budget, color: EdgeTheme.accentWarm)
}
}
EdgeCard(title: "Top lead") {
EdgeKeyValueRow(label: "Lead", value: lead.name)
EdgeKeyValueRow(label: "Qualification", value: lead.qualification.capitalized)
EdgeKeyValueRow(label: "Unit Interest", value: lead.unitInterest)
EdgeKeyValueRow(label: "Budget", value: lead.budget)
}
EdgeCard(title: "Top lead queue") {
ForEach(store.leads.sorted(by: { $0.score > $1.score }).prefix(5)) { item in
EdgeTimelineRow(
title: "\(item.name) · \(item.score)",
subtitle: "\(item.qualification.capitalized) · \(item.unitInterest) · \(item.budget)",
trailing: "Live",
tint: item.id == lead.id ? EdgeTheme.accent : EdgeTheme.accentWarm
)
}
}
} else {
EdgeEmptyCard(title: "Lead Summary", message: "No live leads are visible to this operator scope yet.")
}
}
.task { await store.refresh(screen: "lead_summary") }
.refreshable { await store.refresh(screen: "lead_summary") }
.edgeAutoRefresh(store: store, screen: "lead_summary")
}
}

View File

@@ -0,0 +1,93 @@
import SwiftUI
struct EdgeNotesView: View {
@State private var store = EdgeAppStore.shared
@State private var noteText = ""
var body: some View {
EdgeShell(
title: "Notes",
subtitle: "Capture fast operator memory with a cleaner live-writing surface."
) {
if let error = store.errorMessage {
EdgeErrorCard(message: error)
}
if store.isLoading && store.selectedLead == nil {
EdgeLoadingCard(message: "Fetching live lead and memory context.")
} else if let lead = store.selectedLead {
EdgeHeroCard(
title: "Quick Capture",
subtitle: "Write production notes directly against the current highest-priority lead.",
icon: "square.and.pencil.circle.fill"
) {
HStack(spacing: 8) {
EdgeStatusPill(label: lead.name, color: EdgeTheme.accent)
EdgeStatusPill(label: lead.unitInterest, color: EdgeTheme.accentWarm)
}
}
EdgeCard(title: "Lead memory") {
Text(lead.name)
.font(.system(size: 19, weight: .bold, design: .rounded))
.foregroundStyle(EdgeTheme.foreground)
if store.memoryFacts.isEmpty {
Text("No persisted memory facts yet.")
.font(.system(size: 13, weight: .medium, design: .rounded))
.foregroundStyle(EdgeTheme.mutedFg)
} else {
ForEach(store.memoryFacts) { fact in
EdgeTimelineRow(
title: fact.factType.replacingOccurrences(of: "_", with: " ").capitalized,
subtitle: fact.factText,
trailing: "Memory",
tint: EdgeTheme.accentSecondary
)
}
}
}
EdgeCard(title: "Create quick note") {
TextField("Operator note", text: $noteText, axis: .vertical)
.textFieldStyle(.plain)
.textInputAutocapitalization(.sentences)
.foregroundStyle(EdgeTheme.foreground)
.padding(14)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(Color.white.opacity(0.05))
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.stroke(EdgeTheme.borderSubtle, lineWidth: 1)
)
)
Button {
Task {
await store.createNote(noteText.trimmingCharacters(in: .whitespacesAndNewlines))
if store.noteStatusMessage?.localizedCaseInsensitiveContains("saved") == true {
noteText = ""
}
}
} label: {
Label("Save note", systemImage: "paperplane.fill")
}
.buttonStyle(EdgePrimaryButtonStyle(enabled: !noteText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && EdgeAppConfig.isConfigured))
.disabled(noteText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !EdgeAppConfig.isConfigured)
if let noteStatusMessage = store.noteStatusMessage {
Text(noteStatusMessage)
.font(.system(size: 12, weight: .semibold, design: .rounded))
.foregroundStyle(noteStatusMessage.localizedCaseInsensitiveContains("saved") ? EdgeTheme.success : EdgeTheme.danger)
}
}
} else {
EdgeEmptyCard(title: "Notes", message: "No live lead is available yet, so note capture cannot be targeted.")
}
}
.task { await store.refresh(screen: "notes") }
.refreshable { await store.refresh(screen: "notes") }
.edgeAutoRefresh(store: store, screen: "notes")
}
}

View File

@@ -0,0 +1,51 @@
import SwiftUI
struct EdgeSettingsView: View {
@State private var store = EdgeAppStore.shared
var body: some View {
EdgeShell(
title: "Settings",
subtitle: "Runtime posture and deployment truth for the production iPhone edge surface."
) {
if let error = store.errorMessage {
EdgeErrorCard(message: error)
}
EdgeHeroCard(
title: "Production Runtime",
subtitle: "This device keeps the iPad Velocity styling language while staying functionally aligned with the Android edge-phone surface.",
icon: "gearshape.2.fill"
) {
HStack(spacing: 8) {
EdgeStatusPill(label: store.authDescription, color: EdgeTheme.accent)
EdgeStatusPill(label: EdgeAppConfig.appVersion, color: EdgeTheme.accentWarm)
}
}
EdgeCard(title: "Connectivity") {
settingsRow(label: "Backend", value: EdgeAppConfig.baseURL)
settingsRow(label: "Auth mode", value: store.authDescription)
settingsRow(label: "App version", value: EdgeAppConfig.appVersion)
settingsRow(label: "Last sync", value: store.lastSyncAt?.edgeRelativeShort ?? "No live fetch yet")
settingsRow(label: "Last heartbeat", value: store.lastHeartbeatAt?.edgeRelativeShort ?? "No heartbeat yet")
}
EdgeCard(title: "Production notes") {
Text("This iPhone app keeps the iPad Velocity styling language, but its feature scope intentionally matches the Android edge-phone surface.")
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundStyle(EdgeTheme.foreground)
Text("It uses live backend routes only and registers `iphone_edge` heartbeats through `/api/mobile-edge/session` when credentials are configured.")
.font(.system(size: 12, weight: .medium, design: .rounded))
.foregroundStyle(EdgeTheme.mutedFg)
}
}
.task { await store.refresh(screen: "settings") }
.refreshable { await store.refresh(screen: "settings") }
.edgeAutoRefresh(store: store, screen: "settings")
}
private func settingsRow(label: String, value: String) -> some View {
EdgeKeyValueRow(label: label, value: value)
}
}

View File

@@ -0,0 +1,42 @@
import SwiftUI
struct EdgeTranscriptionsView: View {
@State private var store = EdgeAppStore.shared
var body: some View {
EdgeShell(
title: "Transcriptions",
subtitle: "Recording-backed transcript readiness for the latest live communication."
) {
if let error = store.errorMessage {
EdgeErrorCard(message: error)
}
if store.isLoading && store.transcript == nil {
EdgeLoadingCard(message: "Fetching transcript status.")
} else if let transcript = store.transcript {
EdgeHeroCard(
title: "Transcript Pipeline",
subtitle: "A narrow phone view into live processing status for the most recent recording-backed event.",
icon: "waveform.path.ecg.rectangle"
) {
HStack(spacing: 8) {
EdgeStatusPill(label: transcript.status.capitalized, color: EdgeTheme.accent)
EdgeStatusPill(label: "\(transcript.segmentCount) segments", color: EdgeTheme.accentSecondary)
}
}
EdgeCard(title: "Transcript pipeline") {
EdgeKeyValueRow(label: "Event", value: transcript.eventId)
EdgeKeyValueRow(label: "Job Status", value: transcript.status)
EdgeKeyValueRow(label: "Segments", value: "\(transcript.segmentCount)")
}
} else {
EdgeEmptyCard(title: "Transcriptions", message: "No live recording-backed event is available yet for transcript review.")
}
}
.task { await store.refresh(screen: "transcriptions") }
.refreshable { await store.refresh(screen: "transcriptions") }
.edgeAutoRefresh(store: store, screen: "transcriptions")
}
}

View File

@@ -0,0 +1,14 @@
import SwiftUI
@main
struct VelocityIPhoneApp: App {
@State private var appModel = VelocityAppModel()
var body: some Scene {
WindowGroup {
EdgeRootView()
.preferredColorScheme(.dark)
.environment(appModel)
}
}
}