forked from sagnik/Project_Velocity
feat/#28 (#29)
Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: sagnik/Project_Velocity#29
This commit is contained in:
@@ -1,40 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
enum EdgeSection: String, CaseIterable, Identifiable {
|
||||
case alerts = "Alerts"
|
||||
case leadSummary = "Lead Summary"
|
||||
case communications = "Communications"
|
||||
case notes = "Notes"
|
||||
case transcriptions = "Transcriptions"
|
||||
case settings = "Settings"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
struct EdgeRootView: View {
|
||||
@State private var selectedSection: EdgeSection = .alerts
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedSection) {
|
||||
EdgeAlertsView()
|
||||
.tabItem { Label("Alerts", systemImage: "bell.badge") }
|
||||
.tag(EdgeSection.alerts)
|
||||
EdgeLeadSummaryView()
|
||||
.tabItem { Label("Lead", systemImage: "person.text.rectangle") }
|
||||
.tag(EdgeSection.leadSummary)
|
||||
EdgeCommunicationsView()
|
||||
.tabItem { Label("Comms", systemImage: "phone.connection") }
|
||||
.tag(EdgeSection.communications)
|
||||
EdgeNotesView()
|
||||
.tabItem { Label("Notes", systemImage: "square.and.pencil") }
|
||||
.tag(EdgeSection.notes)
|
||||
EdgeTranscriptionsView()
|
||||
.tabItem { Label("Transcripts", systemImage: "waveform.badge.magnifyingglass") }
|
||||
.tag(EdgeSection.transcriptions)
|
||||
EdgeSettingsView()
|
||||
.tabItem { Label("Settings", systemImage: "gearshape") }
|
||||
.tag(EdgeSection.settings)
|
||||
}
|
||||
.tint(Color(red: 0.22, green: 0.60, blue: 0.98))
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EdgeScaffold: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let actionLabel: String
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.03, green: 0.05, blue: 0.08),
|
||||
Color.black,
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text(title)
|
||||
.font(.system(size: 30, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
Text(subtitle)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(Color.white.opacity(0.7))
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("EDGE ACTION")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(1.4)
|
||||
.foregroundStyle(Color(red: 0.22, green: 0.60, blue: 0.98))
|
||||
Text(actionLabel)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text("This narrow surface is ready for `/api/mobile-edge` hookup once auth, installs, and heartbeat registration are connected.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Color.white.opacity(0.72))
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 22)
|
||||
.fill(Color.white.opacity(0.06))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 22)
|
||||
.stroke(Color.white.opacity(0.08), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EdgeAlertsView: View {
|
||||
var body: some View {
|
||||
EdgeScaffold(
|
||||
title: "Alerts",
|
||||
subtitle: "Unread lead responses, callback urgency, and showroom event nudges for field operators.",
|
||||
actionLabel: "Respond to unread whale-lead thread"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EdgeCommunicationsView: View {
|
||||
var body: some View {
|
||||
EdgeScaffold(
|
||||
title: "Communications",
|
||||
subtitle: "Calls, WhatsApp touchpoints, and imported operator activity in one surface.",
|
||||
actionLabel: "Log a manual communication note"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EdgeLeadSummaryView: View {
|
||||
var body: some View {
|
||||
EdgeScaffold(
|
||||
title: "Lead Summary",
|
||||
subtitle: "Compact account memory, qualification signals, and next-best action.",
|
||||
actionLabel: "Review Mohammed Al-Rashid context"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EdgeNotesView: View {
|
||||
var body: some View {
|
||||
EdgeScaffold(
|
||||
title: "Notes",
|
||||
subtitle: "Quick capture for memory facts, objections, and promised follow-ups.",
|
||||
actionLabel: "Save a note with memory extraction hints"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EdgeSettingsView: View {
|
||||
var body: some View {
|
||||
EdgeScaffold(
|
||||
title: "Settings",
|
||||
subtitle: "Install registration, operator identity, and backend connection state.",
|
||||
actionLabel: "Verify surface heartbeat and app version"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EdgeTranscriptionsView: View {
|
||||
var body: some View {
|
||||
EdgeScaffold(
|
||||
title: "Transcriptions",
|
||||
subtitle: "Imported voice artifacts and transcript summaries for field follow-up.",
|
||||
actionLabel: "Review pending recording import"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# Velocity Edge Phone
|
||||
|
||||
SwiftUI scaffold for the narrow phone companion surface. This folder is intentionally source-first so it can be dropped into a new Xcode target without carrying repo-wide project changes during MVP.
|
||||
@@ -1,10 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct VelocityEdgePhoneApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
EdgeRootView()
|
||||
}
|
||||
}
|
||||
}
|
||||
5
iOS/velocity-iphone/Config.xcconfig.example
Normal file
5
iOS/velocity-iphone/Config.xcconfig.example
Normal file
@@ -0,0 +1,5 @@
|
||||
BASE_URL = https://api.desineuron.in
|
||||
API_EMAIL =
|
||||
API_PASSWORD =
|
||||
API_BEARER_TOKEN =
|
||||
APP_VERSION = 1.0.0
|
||||
32
iOS/velocity-iphone/Info.plist
Normal file
32
iOS/velocity-iphone/Info.plist
Normal 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>
|
||||
42
iOS/velocity-iphone/README.md
Normal file
42
iOS/velocity-iphone/README.md
Normal 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.
|
||||
339
iOS/velocity-iphone/velocity-iphone.xcodeproj/project.pbxproj
Normal file
339
iOS/velocity-iphone/velocity-iphone.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
7
iOS/velocity-iphone/velocity-iphone.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
iOS/velocity-iphone/velocity-iphone.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
495
iOS/velocity-iphone/velocity-iphone/Core/UI/EdgeTheme.swift
Normal file
495
iOS/velocity-iphone/velocity-iphone/Core/UI/EdgeTheme.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
1924
iOS/velocity-iphone/velocity-iphone/EdgeRootView.swift
Normal file
1924
iOS/velocity-iphone/velocity-iphone/EdgeRootView.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
14
iOS/velocity-iphone/velocity-iphone/VelocityIPhoneApp.swift
Normal file
14
iOS/velocity-iphone/velocity-iphone/VelocityIPhoneApp.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,15 +101,15 @@ struct ContentView: View {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(VelocityTheme.accent)
|
||||
.frame(width: 32, height: 32)
|
||||
Text("AF")
|
||||
Text(operatorInitials)
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Ahmed Al-Farsi")
|
||||
Text(operatorName)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Sales Director")
|
||||
Text(AppConfig.authModeDescription)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
@@ -142,6 +142,20 @@ struct ContentView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private var operatorName: String {
|
||||
AppConfig.apiEmail ?? "Velocity Operator"
|
||||
}
|
||||
|
||||
private var operatorInitials: String {
|
||||
let source = AppConfig.apiEmail ?? "VO"
|
||||
let parts = source
|
||||
.replacingOccurrences(of: "@", with: " ")
|
||||
.split(separator: ".")
|
||||
.flatMap { $0.split(separator: " ") }
|
||||
let initials = parts.prefix(2).compactMap(\.first)
|
||||
return initials.isEmpty ? "VO" : String(initials)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Sidebar Row
|
||||
|
||||
@@ -16,8 +16,22 @@ enum AppConfig {
|
||||
}
|
||||
|
||||
/// Base URL for the Velocity backend / gateway.
|
||||
static let baseURL: String = value(for: "BASE_URL") ?? "http://54.91.19.60:8082"
|
||||
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 var isLiveConfigured: Bool {
|
||||
apiBearerToken != nil || (apiEmail != nil && apiPassword != nil)
|
||||
}
|
||||
|
||||
static var authModeDescription: String {
|
||||
if apiBearerToken != nil {
|
||||
return "Bearer token"
|
||||
}
|
||||
if apiEmail != nil && apiPassword != nil {
|
||||
return "Email/password"
|
||||
}
|
||||
return "Credentials required"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
enum JSONValue: Decodable {
|
||||
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(String.self) {
|
||||
self = .string(value)
|
||||
} else if let value = try? container.decode(Double.self) {
|
||||
self = .number(value)
|
||||
} else if let value = try? container.decode(Bool.self) {
|
||||
self = .bool(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 String(value)
|
||||
case .bool(let value):
|
||||
return value ? "true" : "false"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VelocityLeadDTO: Decodable, Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
@@ -92,6 +133,43 @@ struct VelocityCalendarEventDTO: Decodable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
struct VelocityPropertyDTO: Decodable, Identifiable {
|
||||
let propertyId: String
|
||||
let projectName: String
|
||||
let developerName: String
|
||||
let propertyType: String
|
||||
let location: [String: JSONValue]?
|
||||
let priceBands: [[String: JSONValue]]
|
||||
let unitMix: [[String: JSONValue]]
|
||||
let status: String
|
||||
let ingestedAt: String?
|
||||
let createdAt: String?
|
||||
|
||||
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 location
|
||||
case priceBands = "price_bands"
|
||||
case unitMix = "unit_mix"
|
||||
case status
|
||||
case ingestedAt = "ingested_at"
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
|
||||
var locationSummary: String {
|
||||
let city = location?["city"]?.stringValue
|
||||
let district = location?["district"]?.stringValue
|
||||
if let city, let district {
|
||||
return "\(district), \(city)"
|
||||
}
|
||||
return city ?? district ?? "Location pending"
|
||||
}
|
||||
}
|
||||
|
||||
struct VelocityAlertSnapshotDTO: Decodable {
|
||||
let pendingInsights: Int
|
||||
let upcomingCalendarEvents24h: Int
|
||||
@@ -151,6 +229,10 @@ actor VelocityAPIClient {
|
||||
let events: [VelocityCalendarEventDTO]
|
||||
}
|
||||
|
||||
private struct PropertiesEnvelope: Decodable {
|
||||
let properties: [VelocityPropertyDTO]
|
||||
}
|
||||
|
||||
private let decoder = JSONDecoder()
|
||||
private var cachedToken: String?
|
||||
|
||||
@@ -177,6 +259,15 @@ actor VelocityAPIClient {
|
||||
return response.events
|
||||
}
|
||||
|
||||
func fetchProperties(limit: Int = 25) async throws -> [VelocityPropertyDTO] {
|
||||
let request = try await authorizedRequest(
|
||||
path: "/api/inventory/properties",
|
||||
queryItems: [URLQueryItem(name: "limit", value: String(limit))]
|
||||
)
|
||||
let response: PropertiesEnvelope = try await perform(request)
|
||||
return response.properties
|
||||
}
|
||||
|
||||
func fetchAlerts() async throws -> VelocityAlertSnapshotDTO {
|
||||
let request = try await authorizedRequest(path: "/api/mobile-edge/alerts")
|
||||
return try await perform(request)
|
||||
@@ -256,3 +347,17 @@ actor VelocityAPIClient {
|
||||
private struct APIErrorPayload: Decodable {
|
||||
let detail: String?
|
||||
}
|
||||
|
||||
private let velocityDateFormatter = ISO8601DateFormatter()
|
||||
|
||||
extension VelocityCommunicationEventDTO {
|
||||
var timestampDate: Date? {
|
||||
velocityDateFormatter.date(from: timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
extension VelocityCalendarEventDTO {
|
||||
var startDate: Date? {
|
||||
velocityDateFormatter.date(from: startAt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,256 +1,149 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
// MARK: – Data Models
|
||||
|
||||
enum SentimentType: String, CaseIterable {
|
||||
case excited, interested, neutral, confused, disinterested
|
||||
var score: Int {
|
||||
switch self {
|
||||
case .excited: return 100
|
||||
case .interested: return 80
|
||||
case .neutral: return 50
|
||||
case .confused: return 30
|
||||
case .disinterested: return 10
|
||||
}
|
||||
}
|
||||
var emoji: String {
|
||||
switch self {
|
||||
case .excited: return "😃"
|
||||
case .interested: return "🤔"
|
||||
case .neutral: return "😐"
|
||||
case .confused: return "😕"
|
||||
case .disinterested: return "😴"
|
||||
}
|
||||
}
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .excited: return VelocityTheme.success
|
||||
case .interested: return VelocityTheme.accent
|
||||
case .neutral: return VelocityTheme.mutedFg
|
||||
case .confused: return VelocityTheme.warning
|
||||
case .disinterested: return VelocityTheme.danger
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Visitor: Identifiable {
|
||||
let id: String
|
||||
let faceId: String
|
||||
var sentiment: SentimentType
|
||||
var confidence: Double
|
||||
var dwellTime: Int // seconds
|
||||
var zone: String
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
enum LeadSource: String {
|
||||
case whatsapp = "WhatsApp"
|
||||
case walkin = "Walk-in"
|
||||
case website = "Website"
|
||||
}
|
||||
|
||||
enum LeadStatus: String {
|
||||
case hot = "Hot"
|
||||
case engaged = "Engaged"
|
||||
case new = "New"
|
||||
case qualified = "Qualified"
|
||||
case closed = "Closed"
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .hot: return VelocityTheme.danger
|
||||
case .engaged: return VelocityTheme.accent
|
||||
case .new: return VelocityTheme.mutedFg
|
||||
case .qualified: return VelocityTheme.success
|
||||
case .closed: return Color(red: 0.60, green: 0.57, blue: 0.99)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Lead: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let phone: String
|
||||
let source: LeadSource
|
||||
var status: LeadStatus
|
||||
var lastMessage: String
|
||||
var lastActive: Date
|
||||
var unreadCount: Int
|
||||
let qualification: String
|
||||
let budget: String
|
||||
let interest: String
|
||||
var initials: String { String(name.split(separator: " ").prefix(2).compactMap(\.first)) }
|
||||
}
|
||||
|
||||
struct ChatMessage: Identifiable {
|
||||
let id: String
|
||||
let sender: String // "user" | "oracle" | "ai"
|
||||
let content: String
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
struct SystemHealth {
|
||||
var cpu: Double // 0–1
|
||||
var gpu: Double
|
||||
var memory: Double
|
||||
}
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
struct DashboardMetrics {
|
||||
var activeVisitors: Int
|
||||
var revenue: String
|
||||
var aiJobs: Int
|
||||
var dailyVisitors: Int
|
||||
var sentimentScore: Double // 0–100
|
||||
var systemHealth: SystemHealth
|
||||
let leadCount: Int
|
||||
let whaleLeadCount: Int
|
||||
let propertyCount: Int
|
||||
let todayCalendarCount: Int
|
||||
let pendingInsights: Int
|
||||
let pendingTranscriptions: Int
|
||||
}
|
||||
|
||||
// MARK: – Shared Store
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppStore {
|
||||
|
||||
static let shared = AppStore()
|
||||
private init() { startTimer() }
|
||||
|
||||
// ── Dashboard ─────────────────────────────────────────────────
|
||||
var metrics = DashboardMetrics(
|
||||
activeVisitors: 17,
|
||||
revenue: "$3.2M",
|
||||
aiJobs: 24,
|
||||
dailyVisitors: 128,
|
||||
sentimentScore: 78,
|
||||
systemHealth: SystemHealth(cpu: 0.42, gpu: 0.61, memory: 0.55)
|
||||
)
|
||||
private init() {}
|
||||
|
||||
var dashboardMessages: [ChatMessage] = [
|
||||
ChatMessage(id: "d0", sender: "ai",
|
||||
content: "Hello, Ahmed. I've analysed the Q3 pipeline. Would you like a refined strategy for the Apex Innovations deal?",
|
||||
timestamp: Date().addingTimeInterval(-300))
|
||||
]
|
||||
var isDashboardThinking = false
|
||||
var leads: [VelocityLeadDTO] = []
|
||||
var properties: [VelocityPropertyDTO] = []
|
||||
var calendarEvents: [VelocityCalendarEventDTO] = []
|
||||
var leadEvents: [String: [VelocityCommunicationEventDTO]] = [:]
|
||||
var alertSnapshot: VelocityAlertSnapshotDTO?
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
var lastRefreshAt: Date?
|
||||
|
||||
// ── Visitors ──────────────────────────────────────────────────
|
||||
var visitors: [Visitor] = [
|
||||
Visitor(id: "v1", faceId: "face_001", sentiment: .excited, confidence: 0.92, dwellTime: 450, zone: "Penthouse Show", timestamp: Date()),
|
||||
Visitor(id: "v2", faceId: "face_002", sentiment: .interested, confidence: 0.87, dwellTime: 320, zone: "Amenity Deck VR", timestamp: Date()),
|
||||
Visitor(id: "v3", faceId: "face_003", sentiment: .neutral, confidence: 0.78, dwellTime: 180, zone: "Reception", timestamp: Date()),
|
||||
Visitor(id: "v4", faceId: "face_004", sentiment: .confused, confidence: 0.74, dwellTime: 95, zone: "Penthouse Show", timestamp: Date()),
|
||||
Visitor(id: "v5", faceId: "face_005", sentiment: .disinterested, confidence: 0.65, dwellTime: 60, zone: "Gallery", timestamp: Date()),
|
||||
]
|
||||
|
||||
// ── Alerts ────────────────────────────────────────────────────
|
||||
var isAlertActive = false
|
||||
var alertMessage = ""
|
||||
|
||||
func triggerAlert(_ msg: String) {
|
||||
isAlertActive = true
|
||||
alertMessage = msg
|
||||
}
|
||||
func clearAlert() {
|
||||
isAlertActive = false
|
||||
alertMessage = ""
|
||||
}
|
||||
|
||||
// ── Leads (Oracle) ────────────────────────────────────────────
|
||||
var leads: [Lead] = [
|
||||
Lead(id: "1", name: "Mohammed Al-Rashid", phone: "+971 55 123 4567", source: .whatsapp,
|
||||
status: .hot, lastMessage: "Can we schedule a viewing for the penthouse tomorrow?",
|
||||
lastActive: Date().addingTimeInterval(-300), unreadCount: 2,
|
||||
qualification: "whale", budget: "AED 15M+", interest: "Penthouse Suite"),
|
||||
Lead(id: "2", name: "Sarah Chen", phone: "+971 50 987 6543", source: .walkin,
|
||||
status: .engaged, lastMessage: "Thank you for the brochure. I will review with my partner.",
|
||||
lastActive: Date().addingTimeInterval(-1800), unreadCount: 0,
|
||||
qualification: "potential", budget: "AED 5–8M", interest: "2BR Sea View"),
|
||||
Lead(id: "3", name: "James Wilson", phone: "+971 52 456 7890", source: .website,
|
||||
status: .new, lastMessage: "Interested in investment opportunities.",
|
||||
lastActive: Date().addingTimeInterval(-7200), unreadCount: 1,
|
||||
qualification: "potential", budget: "AED 3–5M", interest: "1BR Investment"),
|
||||
Lead(id: "4", name: "Fatima Hassan", phone: "+971 54 321 0987", source: .whatsapp,
|
||||
status: .qualified,lastMessage: "What are the payment plan options?",
|
||||
lastActive: Date().addingTimeInterval(-14400), unreadCount: 0,
|
||||
qualification: "whale", budget: "AED 12M+", interest: "3BR + Maid"),
|
||||
Lead(id: "5", name: "David Kumar", phone: "+971 56 789 0123", source: .walkin,
|
||||
status: .closed, lastMessage: "Contract signed. Thank you!",
|
||||
lastActive: Date().addingTimeInterval(-86400), unreadCount: 0,
|
||||
qualification: "whale", budget: "AED 20M", interest: "Full Floor"),
|
||||
]
|
||||
|
||||
var messages: [String: [ChatMessage]] = [
|
||||
"1": [
|
||||
ChatMessage(id: "m1", sender: "user", content: "Hi, I am interested in the penthouse units.",
|
||||
timestamp: Date().addingTimeInterval(-7200)),
|
||||
ChatMessage(id: "m2", sender: "oracle",
|
||||
content: "Welcome! Our penthouse collection features 4 exclusive units with panoramic sea views. Prices start at AED 15M.",
|
||||
timestamp: Date().addingTimeInterval(-7200 + 30)),
|
||||
ChatMessage(id: "m3", sender: "user", content: "Can we schedule a viewing tomorrow?",
|
||||
timestamp: Date().addingTimeInterval(-300)),
|
||||
],
|
||||
"2": [
|
||||
ChatMessage(id: "m4", sender: "oracle",
|
||||
content: "Hello Sarah! Here is the digital brochure for the 2-bedroom units we discussed.",
|
||||
timestamp: Date().addingTimeInterval(-14400)),
|
||||
ChatMessage(id: "m5", sender: "user", content: "Thank you. I will review with my partner.",
|
||||
timestamp: Date().addingTimeInterval(-1800)),
|
||||
],
|
||||
]
|
||||
|
||||
var activeLeadId: String? = "1"
|
||||
var isOracleThinking = false
|
||||
|
||||
func addDashboardMessage(sender: String, content: String) {
|
||||
let msg = ChatMessage(id: UUID().uuidString, sender: sender, content: content, timestamp: Date())
|
||||
dashboardMessages.append(msg)
|
||||
}
|
||||
|
||||
func addOracleMessage(leadId: String, sender: String, content: String) {
|
||||
let msg = ChatMessage(id: UUID().uuidString, sender: sender, content: content, timestamp: Date())
|
||||
if messages[leadId] == nil { messages[leadId] = [] }
|
||||
messages[leadId]!.append(msg)
|
||||
}
|
||||
|
||||
// ── Live ticker ───────────────────────────────────────────────
|
||||
private var timerTask: AnyCancellable?
|
||||
private var alertTask: DispatchWorkItem?
|
||||
|
||||
private func startTimer() {
|
||||
timerTask = Timer.publish(every: 5, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
.sink { [weak self] _ in self?.tick() }
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
// jitter visitor count ±1
|
||||
let delta = Int.random(in: -1...1)
|
||||
metrics.activeVisitors = max(10, metrics.activeVisitors + delta)
|
||||
|
||||
// jitter sentiment ±2
|
||||
let sDelta = Double.random(in: -2...2)
|
||||
metrics.sentimentScore = min(100, max(40, metrics.sentimentScore + sDelta))
|
||||
|
||||
// jitter system health
|
||||
metrics.systemHealth.cpu = Double.random(in: 0.30...0.65)
|
||||
metrics.systemHealth.gpu = Double.random(in: 0.45...0.75)
|
||||
metrics.systemHealth.memory = Double.random(in: 0.40...0.70)
|
||||
|
||||
// Random alert (same 10% chance as WebOS every tick)
|
||||
if !isAlertActive && Double.random(in: 0...1) > 0.85 {
|
||||
triggerAlert("Confusion detected in Zone B – Penthouse Gallery")
|
||||
let work = DispatchWorkItem { [weak self] in self?.clearAlert() }
|
||||
alertTask = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: work)
|
||||
var operatorIdentity: String {
|
||||
if let email = AppConfig.apiEmail, !email.isEmpty {
|
||||
return email
|
||||
}
|
||||
if let token = AppConfig.apiBearerToken, !token.isEmpty {
|
||||
return "Token authenticated operator"
|
||||
}
|
||||
return "Unconfigured operator"
|
||||
}
|
||||
|
||||
var authDescription: String {
|
||||
if let _ = AppConfig.apiBearerToken {
|
||||
return "Bearer token"
|
||||
}
|
||||
if AppConfig.apiEmail != nil, AppConfig.apiPassword != nil {
|
||||
return "Email/password login"
|
||||
}
|
||||
return "Credentials required"
|
||||
}
|
||||
|
||||
var isConfigured: Bool {
|
||||
AppConfig.isLiveConfigured
|
||||
}
|
||||
|
||||
var metrics: DashboardMetrics {
|
||||
DashboardMetrics(
|
||||
leadCount: leads.count,
|
||||
whaleLeadCount: leads.filter { $0.score >= 90 || $0.qualification.lowercased() == "whale" }.count,
|
||||
propertyCount: properties.count,
|
||||
todayCalendarCount: calendarEvents.filter { $0.startsToday }.count,
|
||||
pendingInsights: alertSnapshot?.pendingInsights ?? 0,
|
||||
pendingTranscriptions: alertSnapshot?.pendingTranscriptions ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
var highlightedLeads: [VelocityLeadDTO] {
|
||||
Array(leads.sorted(by: { $0.score > $1.score }).prefix(5))
|
||||
}
|
||||
|
||||
var timelineEvents: [TimelineEvent] {
|
||||
leadEvents
|
||||
.flatMap { leadId, events in
|
||||
events.map { TimelineEvent(leadId: leadId, event: $0, leadName: leadName(for: leadId)) }
|
||||
}
|
||||
.sorted(by: { $0.date > $1.date })
|
||||
}
|
||||
|
||||
func refresh(silent: Bool = false) async {
|
||||
if !silent {
|
||||
isLoading = true
|
||||
}
|
||||
|
||||
do {
|
||||
async let leadsTask = VelocityAPIClient.shared.fetchLeads()
|
||||
async let propertiesTask = VelocityAPIClient.shared.fetchProperties()
|
||||
async let calendarTask = VelocityAPIClient.shared.fetchCalendarEvents()
|
||||
async let alertsTask = VelocityAPIClient.shared.fetchAlerts()
|
||||
|
||||
let fetchedLeads = try await leadsTask
|
||||
let fetchedProperties = try await propertiesTask
|
||||
let fetchedCalendar = try await calendarTask
|
||||
let fetchedAlerts = try await alertsTask
|
||||
|
||||
let leadFocus = Array(fetchedLeads.sorted(by: { $0.score > $1.score }).prefix(6))
|
||||
var eventMap: [String: [VelocityCommunicationEventDTO]] = [:]
|
||||
for lead in leadFocus {
|
||||
let events = try await VelocityAPIClient.shared.fetchEvents(for: lead.id, limit: 4)
|
||||
eventMap[lead.id] = events
|
||||
}
|
||||
|
||||
leads = fetchedLeads
|
||||
properties = fetchedProperties
|
||||
calendarEvents = fetchedCalendar
|
||||
alertSnapshot = fetchedAlerts
|
||||
leadEvents = eventMap
|
||||
lastRefreshAt = Date()
|
||||
errorMessage = nil
|
||||
isLoading = false
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
if !silent {
|
||||
leads = []
|
||||
properties = []
|
||||
calendarEvents = []
|
||||
alertSnapshot = nil
|
||||
leadEvents = [:]
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
func leadName(for leadId: String) -> String {
|
||||
leads.first(where: { $0.id == leadId })?.name ?? "Unknown lead"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Helpers
|
||||
struct TimelineEvent: Identifiable {
|
||||
let leadId: String
|
||||
let event: VelocityCommunicationEventDTO
|
||||
let leadName: String
|
||||
|
||||
var id: String { event.id }
|
||||
var date: Date { event.timestampDate ?? .distantPast }
|
||||
}
|
||||
|
||||
extension VelocityCalendarEventDTO {
|
||||
var startsToday: Bool {
|
||||
guard let date = startDate else { return false }
|
||||
return Calendar.current.isDateInToday(date)
|
||||
}
|
||||
}
|
||||
|
||||
extension Date {
|
||||
var relativeShort: String {
|
||||
let diff = Int(Date().timeIntervalSince(self))
|
||||
if diff < 60 { return "now" }
|
||||
if diff < 3600 { return "\(diff / 60)m ago" }
|
||||
if diff < 86400 { return "\(diff / 3600)h ago" }
|
||||
return "\(diff / 86400)d ago"
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,442 +1,267 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DashboardView: View {
|
||||
private var store: AppStore { AppStore.shared }
|
||||
@State private var chatInput = ""
|
||||
@State private var store = AppStore.shared
|
||||
private let refreshTimer = Timer.publish(every: 20, on: .main, in: .common).autoconnect()
|
||||
private let columns = [GridItem(.adaptive(minimum: 220), spacing: 14)]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
pageHeader
|
||||
header
|
||||
|
||||
// KPI Grid — live from store
|
||||
LazyVGrid(columns: columns, spacing: 14) {
|
||||
LiveKPICard(
|
||||
title: "Visitors",
|
||||
value: "\(store.metrics.activeVisitors)",
|
||||
subtitle: "Active now",
|
||||
icon: "person.2",
|
||||
accentColor: VelocityTheme.accent,
|
||||
glowColor: VelocityTheme.accent.opacity(0.22),
|
||||
badge: "LIVE"
|
||||
)
|
||||
LiveKPICard(
|
||||
title: "Revenue",
|
||||
value: store.metrics.revenue,
|
||||
subtitle: "30-day forecast",
|
||||
icon: "chart.line.uptrend.xyaxis",
|
||||
accentColor: Color(red: 0.13, green: 0.83, blue: 0.93),
|
||||
glowColor: Color(red: 0.13, green: 0.83, blue: 0.93).opacity(0.18)
|
||||
)
|
||||
LiveKPICard(
|
||||
title: "AI Jobs",
|
||||
value: "\(store.metrics.aiJobs)",
|
||||
subtitle: "Queue depth",
|
||||
icon: "cpu",
|
||||
accentColor: Color(red: 0.60, green: 0.57, blue: 0.99),
|
||||
glowColor: Color(red: 0.60, green: 0.57, blue: 0.99).opacity(0.20)
|
||||
)
|
||||
LiveKPICard(
|
||||
title: "Listings",
|
||||
value: "\(store.metrics.dailyVisitors)",
|
||||
subtitle: "Active units",
|
||||
icon: "building.2",
|
||||
accentColor: VelocityTheme.success,
|
||||
glowColor: VelocityTheme.success.opacity(0.18)
|
||||
)
|
||||
if let error = store.errorMessage {
|
||||
errorBanner(error)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.4), value: store.metrics.activeVisitors)
|
||||
|
||||
// Sentiment Gauge
|
||||
sentimentGauge
|
||||
|
||||
// System Health
|
||||
systemHealthPanel
|
||||
|
||||
// AI Chat Widget
|
||||
aiChatWidget
|
||||
if store.isLoading && store.lastRefreshAt == nil {
|
||||
loadingPanel
|
||||
} else {
|
||||
metricsGrid
|
||||
liveStatusPanel
|
||||
leadFocusPanel
|
||||
inventoryPanel
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.scrollContentBackground(.hidden)
|
||||
.task { await store.refresh() }
|
||||
.refreshable { await store.refresh() }
|
||||
.onReceive(refreshTimer) { _ in
|
||||
Task { await store.refresh(silent: true) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Page Header
|
||||
private var pageHeader: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
private var header: some View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Dashboard")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Project Velocity · v.1.1")
|
||||
Text("Live mobile operator posture for leads, inventory, and follow-up load.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(VelocityTheme.success)
|
||||
.frame(width: 7, height: 7)
|
||||
Text("Live")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.success)
|
||||
VStack(alignment: .trailing, spacing: 8) {
|
||||
statusBadge(
|
||||
label: store.isConfigured ? "Live backend" : "Config required",
|
||||
color: store.isConfigured ? VelocityTheme.success : VelocityTheme.warning
|
||||
)
|
||||
if let lastRefresh = store.lastRefreshAt {
|
||||
Text("Updated \(lastRefresh.relativeShort)")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Sentiment Gauge
|
||||
private var sentimentGauge: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
private var metricsGrid: some View {
|
||||
LazyVGrid(columns: columns, spacing: 14) {
|
||||
MetricCard(title: "Leads", value: "\(store.metrics.leadCount)", subtitle: "Live CRM records", color: VelocityTheme.accent)
|
||||
MetricCard(title: "Whale Leads", value: "\(store.metrics.whaleLeadCount)", subtitle: "Score 90+ or whale qualified", color: VelocityTheme.success)
|
||||
MetricCard(title: "Inventory", value: "\(store.metrics.propertyCount)", subtitle: "Property rows available", color: VelocityTheme.warning)
|
||||
MetricCard(title: "Today", value: "\(store.metrics.todayCalendarCount)", subtitle: "Calendar slots scheduled", color: Color(red: 0.60, green: 0.57, blue: 0.99))
|
||||
}
|
||||
}
|
||||
|
||||
private var liveStatusPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
Image(systemName: "waveform.path.ecg")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
Text("Sentiment Thermometer")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
Text("Live Status")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text("Showroom Vibe")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
let label = store.metrics.sentimentScore >= 70 ? "Excellent" :
|
||||
store.metrics.sentimentScore >= 50 ? "Good" : "Needs Attention"
|
||||
let labelColor: Color = store.metrics.sentimentScore >= 70 ? VelocityTheme.success :
|
||||
store.metrics.sentimentScore >= 50 ? VelocityTheme.warning : VelocityTheme.danger
|
||||
Text(label)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(labelColor)
|
||||
statusBadge(label: AppConfig.authModeDescription, color: VelocityTheme.accent)
|
||||
}
|
||||
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(Color.white.opacity(0.05))
|
||||
.frame(height: 26)
|
||||
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color(red: 0.11, green: 0.30, blue: 0.86),
|
||||
VelocityTheme.accent,
|
||||
Color(red: 0.38, green: 0.65, blue: 0.98)],
|
||||
startPoint: .leading, endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.frame(width: geo.size.width * (store.metrics.sentimentScore / 100), height: 26)
|
||||
.shadow(color: VelocityTheme.accent.opacity(0.6), radius: 6)
|
||||
.animation(.easeInOut(duration: 0.8), value: store.metrics.sentimentScore)
|
||||
|
||||
Text("\(Int(store.metrics.sentimentScore))%")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.frame(height: 26)
|
||||
detailRow(title: "Endpoint", value: AppConfig.baseURL)
|
||||
detailRow(title: "Operator", value: store.operatorIdentity)
|
||||
detailRow(title: "Pending insights", value: "\(store.metrics.pendingInsights)")
|
||||
detailRow(title: "Pending transcriptions", value: "\(store.metrics.pendingTranscriptions)")
|
||||
}
|
||||
.padding(20)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||||
)
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
|
||||
// MARK: – System Health
|
||||
private var systemHealthPanel: some View {
|
||||
let gauges: [(label: String, value: Double, color: Color)] = [
|
||||
("CPU", store.metrics.systemHealth.cpu, VelocityTheme.accent),
|
||||
("GPU", store.metrics.systemHealth.gpu, Color(red: 0.50, green: 0.56, blue: 0.97)),
|
||||
("Memory", store.metrics.systemHealth.memory, Color(red: 0.13, green: 0.83, blue: 0.93)),
|
||||
]
|
||||
private var leadFocusPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Lead Focus")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
return VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
Image(systemName: "cpu").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
|
||||
Text("System Health")
|
||||
.font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(VelocityTheme.success).frame(width: 5, height: 5)
|
||||
Text("Optimal").font(.system(size: 11, weight: .medium)).foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 16) {
|
||||
ForEach(gauges, id: \.label) { g in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(g.label).font(.system(size: 10, weight: .medium)).tracking(0.8)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Spacer()
|
||||
Text("\(Int(g.value * 100))%").font(.system(size: 10, weight: .semibold))
|
||||
if store.highlightedLeads.isEmpty {
|
||||
emptyMessage("No live leads have been returned by the backend yet.")
|
||||
} else {
|
||||
ForEach(store.highlightedLeads) { lead in
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(lead.name)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(lead.unitInterest) · \(lead.budget)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.05)).frame(height: 5)
|
||||
RoundedRectangle(cornerRadius: 3).fill(g.color)
|
||||
.frame(width: geo.size.width * g.value, height: 5)
|
||||
.shadow(color: g.color.opacity(0.6), radius: 4)
|
||||
.animation(.easeInOut(duration: 0.6), value: g.value)
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text("\(lead.score)")
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(lead.kanbanStatus.replacingOccurrences(of: "_", with: " ").capitalized)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
.frame(height: 5)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||||
)
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
|
||||
// MARK: – AI Chat Widget
|
||||
private var aiChatWidget: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle().fill(VelocityTheme.accent.opacity(0.18)).frame(width: 34, height: 34)
|
||||
Image(systemName: "sparkles").font(.system(size: 14)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 5) {
|
||||
Text("AI Onboard").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Image(systemName: "staroflife.fill").font(.system(size: 7)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(VelocityTheme.success).frame(width: 5, height: 5)
|
||||
Text("Online").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
private var inventoryPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Inventory Coverage")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
|
||||
// Messages
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(store.dashboardMessages) { msg in
|
||||
ChatBubble(message: msg)
|
||||
.id(msg.id)
|
||||
}
|
||||
if store.isDashboardThinking {
|
||||
TypingIndicator()
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.frame(height: 240)
|
||||
.onChange(of: store.dashboardMessages.count) {
|
||||
if let last = store.dashboardMessages.last {
|
||||
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
||||
}
|
||||
}
|
||||
.onChange(of: store.isDashboardThinking) {
|
||||
if store.isDashboardThinking {
|
||||
withAnimation { proxy.scrollTo("typing", anchor: .bottom) }
|
||||
if store.properties.isEmpty {
|
||||
emptyMessage("No live inventory properties are available yet for this operator scope.")
|
||||
} else {
|
||||
ForEach(store.properties.prefix(4)) { property in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(property.projectName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(property.developerName) · \(property.propertyType.capitalized)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(property.locationSummary)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
|
||||
// Input
|
||||
HStack(spacing: 10) {
|
||||
TextField("Ask AI assistant...", text: $chatInput)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.tint(VelocityTheme.accent)
|
||||
.onSubmit { sendDashboardMessage() }
|
||||
|
||||
Button(action: sendDashboardMessage) {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(chatInput.isEmpty ? VelocityTheme.mutedFg : VelocityTheme.accent)
|
||||
}
|
||||
.disabled(chatInput.isEmpty || store.isDashboardThinking)
|
||||
}
|
||||
.padding(14)
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||||
)
|
||||
.padding(20)
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
|
||||
private func sendDashboardMessage() {
|
||||
let text = chatInput.trimmingCharacters(in: .whitespaces)
|
||||
guard !text.isEmpty else { return }
|
||||
chatInput = ""
|
||||
store.addDashboardMessage(sender: "user", content: text)
|
||||
store.isDashboardThinking = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||
store.isDashboardThinking = false
|
||||
store.addDashboardMessage(
|
||||
sender: "ai",
|
||||
content: dashboardAIResponse(for: text)
|
||||
private func detailRow(title: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(1)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(value)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
}
|
||||
|
||||
private func emptyMessage(_ message: String) -> some View {
|
||||
Text(message)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func statusBadge(label: String, color: Color) -> some View {
|
||||
Text(label)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(color.opacity(0.12))
|
||||
.overlay(Capsule().stroke(color.opacity(0.22), lineWidth: 1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func dashboardAIResponse(for prompt: String) -> String {
|
||||
let p = prompt.lowercased()
|
||||
if p.contains("penthouse") || p.contains("apex") {
|
||||
return "Apex Innovations probability score is now at 85%. I recommend scheduling a closing meeting next Tuesday — sentiment analysis shows high engagement."
|
||||
} else if p.contains("visitor") || p.contains("traffic") {
|
||||
return "Currently \(store.metrics.activeVisitors) active visitors. Zone B (Penthouse Gallery) is generating the highest dwell time today — average 8 minutes."
|
||||
} else if p.contains("revenue") || p.contains("deal") {
|
||||
return "Pipeline value stands at \(store.metrics.revenue) for the 30-day forecast. 3 deals are in final negotiation — I recommend prioritising Mohammed Al-Rashid."
|
||||
} else if p.contains("sentiment") {
|
||||
return "Showroom sentiment is at \(Int(store.metrics.sentimentScore))% — above the excellent threshold. Visitors in Zone D (Reception) show the highest satisfaction scores."
|
||||
private var loadingPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
|
||||
Text("Loading live dashboard data...")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Velocity is reading leads, alerts, calendar events, and inventory summaries from the backend.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
return "I've analysed the current data. Revenue is tracking at \(store.metrics.revenue) with \(store.metrics.activeVisitors) active visitors. Shall I prepare a detailed pipeline report?"
|
||||
.padding(20)
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
|
||||
private func errorBanner(_ message: String) -> some View {
|
||||
Text(message)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.danger)
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(VelocityTheme.danger.opacity(0.10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.danger.opacity(0.22), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – KPI Card (live-bound)
|
||||
private struct LiveKPICard: View {
|
||||
private struct MetricCard: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let subtitle: String
|
||||
let icon: String
|
||||
let accentColor: Color
|
||||
let glowColor: Color
|
||||
var badge: String? = nil
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).fill(accentColor.opacity(0.12)).frame(width: 32, height: 32)
|
||||
Image(systemName: icon).font(.system(size: 14, weight: .medium)).foregroundStyle(accentColor)
|
||||
}
|
||||
Spacer()
|
||||
if let badge {
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(VelocityTheme.success).frame(width: 5, height: 5)
|
||||
Text(badge).font(.system(size: 9, weight: .semibold)).foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 20)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 10, weight: .medium)).tracking(1.2)
|
||||
.foregroundStyle(VelocityTheme.mutedFg).padding(.bottom, 4)
|
||||
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(1)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(value)
|
||||
.font(.system(size: 34, weight: .semibold))
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.contentTransition(.numericText())
|
||||
.minimumScaleFactor(0.7).lineLimit(1).padding(.bottom, 4)
|
||||
|
||||
Text(subtitle).font(.system(size: 11)).foregroundStyle(VelocityTheme.subtleFg)
|
||||
Text(subtitle)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(color)
|
||||
.frame(width: 52, height: 4)
|
||||
}
|
||||
.padding(20)
|
||||
.frame(maxWidth: .infinity, minHeight: 148, alignment: .leading)
|
||||
.background(
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
RoundedRectangle(cornerRadius: 16).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
Ellipse().fill(glowColor).frame(width: 120, height: 90).blur(radius: 28).offset(x: 20, y: 20)
|
||||
VStack {
|
||||
Rectangle()
|
||||
.fill(LinearGradient(colors: [.clear, .white.opacity(0.10), .clear], startPoint: .leading, endPoint: .trailing))
|
||||
.frame(height: 1)
|
||||
Spacer()
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.55), radius: 16, y: 4)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassCard(cornerRadius: 16)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Chat Bubble
|
||||
private struct ChatBubble: View {
|
||||
let message: ChatMessage
|
||||
private var isUser: Bool { message.sender == "user" }
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
if isUser { Spacer(minLength: 40) }
|
||||
|
||||
if !isUser {
|
||||
ZStack {
|
||||
Circle().fill(VelocityTheme.accent.opacity(0.18)).frame(width: 26, height: 26)
|
||||
Image(systemName: "sparkles").font(.system(size: 10)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
|
||||
Text(message.content)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(isUser ? .white : VelocityTheme.foreground)
|
||||
.padding(.horizontal, 12).padding(.vertical, 9)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: isUser ? 14 : 14)
|
||||
.fill(isUser
|
||||
? VelocityTheme.accent.opacity(0.85)
|
||||
: Color.white.opacity(0.06))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(isUser ? .clear : Color.white.opacity(0.10), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
|
||||
if isUser {
|
||||
ZStack {
|
||||
Circle().fill(Color(red: 0.08, green: 0.10, blue: 0.18)).frame(width: 26, height: 26)
|
||||
Text("A").font(.system(size: 10, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
}
|
||||
if !isUser { Spacer(minLength: 40) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Typing Indicator
|
||||
private struct TypingIndicator: View {
|
||||
@State private var phase = 0
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
ZStack {
|
||||
Circle().fill(VelocityTheme.accent.opacity(0.18)).frame(width: 26, height: 26)
|
||||
Image(systemName: "sparkles").font(.system(size: 10)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
ForEach(0..<3, id: \.self) { i in
|
||||
Circle()
|
||||
.fill(VelocityTheme.mutedFg)
|
||||
.frame(width: 6, height: 6)
|
||||
.scaleEffect(phase == i ? 1.4 : 0.8)
|
||||
.animation(.easeInOut(duration: 0.4).repeatForever().delay(Double(i) * 0.15), value: phase)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.06))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.white.opacity(0.10), lineWidth: 1)))
|
||||
Spacer(minLength: 40)
|
||||
}
|
||||
.id("typing")
|
||||
.onAppear {
|
||||
withAnimation { phase = 1 }
|
||||
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
|
||||
phase = (phase + 1) % 3
|
||||
}
|
||||
}
|
||||
}
|
||||
#Preview {
|
||||
DashboardView()
|
||||
}
|
||||
|
||||
@@ -75,8 +75,11 @@ struct InventoryView: View {
|
||||
switch store.mode {
|
||||
case .sunseeker:
|
||||
#if targetEnvironment(simulator)
|
||||
SimulatorSunOverlayView(sunNodesReady: $store.sunNodesReady)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
SimulatorUnavailableCard(
|
||||
icon: "arkit",
|
||||
title: "Sunseeker requires a real device",
|
||||
message: "The production build no longer renders a simulated AR sun path with fake location or heading data. Use a physical iPad to inspect the live camera-based overlay."
|
||||
)
|
||||
#else
|
||||
SunseekerPanel(sunNodesReady: $store.sunNodesReady)
|
||||
#endif
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,413 +1,204 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SentinelView: View {
|
||||
private var store: AppStore { AppStore.shared }
|
||||
private let indigo = Color(red: 0.60, green: 0.57, blue: 0.99)
|
||||
@State private var store = AppStore.shared
|
||||
private let refreshTimer = Timer.publish(every: 20, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
pageHeader
|
||||
kpiGrid
|
||||
analyticsRow
|
||||
bottomRow
|
||||
header
|
||||
|
||||
if let error = store.errorMessage {
|
||||
errorBanner(error)
|
||||
}
|
||||
|
||||
availabilityCard
|
||||
postureCards
|
||||
timelineCard
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.scrollContentBackground(.hidden)
|
||||
.task { await store.refresh() }
|
||||
.refreshable { await store.refresh() }
|
||||
.onReceive(refreshTimer) { _ in
|
||||
Task { await store.refresh(silent: true) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Sub-views extracted so the type-checker can cope
|
||||
private var pageHeader: some View {
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Sentinel")
|
||||
.font(.system(size: 28, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("FaceID · visitor analytics · real-time alerts")
|
||||
.font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Truthful live posture for alerts and comms load; visitor analytics stay disabled until a real Sentinel stream is exposed.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
|
||||
private var kpiGrid: some View {
|
||||
let cols = [GridItem(.adaptive(minimum: 180), spacing: 12)]
|
||||
return LazyVGrid(columns: cols, spacing: 12) {
|
||||
SentinelKPI(icon: "person.2.fill", iconColor: VelocityTheme.accent,
|
||||
label: "Active Visitors", value: "\(store.visitors.count)",
|
||||
sub: "Currently tracked", badge: "LIVE")
|
||||
SentinelKPI(icon: "waveform.path.ecg", iconColor: VelocityTheme.success,
|
||||
label: "Avg Sentiment", value: "\(avgSentiment)%",
|
||||
sub: "Overall mood")
|
||||
SentinelKPI(icon: "eye.fill", iconColor: indigo,
|
||||
label: "Detection Accuracy", value: "\(avgConfidence)%",
|
||||
sub: "Avg confidence")
|
||||
SentinelKPI(icon: "faceid", iconColor: VelocityTheme.warning,
|
||||
label: "Tracked Today", value: "47",
|
||||
sub: "Unique faces")
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.4), value: store.visitors.count)
|
||||
}
|
||||
|
||||
private var analyticsRow: some View {
|
||||
let cols = [GridItem(.flexible(), spacing: 14), GridItem(.flexible(minimum: 260), spacing: 14)]
|
||||
return LazyVGrid(columns: cols, alignment: .leading, spacing: 14) {
|
||||
ZoneAnalyticsPanel()
|
||||
ClientInsightsPanel()
|
||||
}
|
||||
}
|
||||
|
||||
private var bottomRow: some View {
|
||||
let cols = [GridItem(.flexible(), spacing: 14),
|
||||
GridItem(.flexible(), spacing: 14),
|
||||
GridItem(.flexible(), spacing: 14)]
|
||||
return LazyVGrid(columns: cols, alignment: .leading, spacing: 14) {
|
||||
SentimentDistributionPanel(visitors: store.visitors)
|
||||
DwellTimePanel()
|
||||
AlertPanel(isActive: store.isAlertActive, message: store.alertMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private var avgSentiment: Int {
|
||||
guard !store.visitors.isEmpty else { return 0 }
|
||||
let total = store.visitors.reduce(0) { $0 + $1.sentiment.score }
|
||||
return total / store.visitors.count
|
||||
}
|
||||
|
||||
private var avgConfidence: Int {
|
||||
guard !store.visitors.isEmpty else { return 0 }
|
||||
let total = store.visitors.reduce(0.0) { $0 + $1.confidence }
|
||||
return Int((total / Double(store.visitors.count)) * 100)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – KPI Card
|
||||
private struct SentinelKPI: View {
|
||||
let icon: String; let iconColor: Color
|
||||
let label: String; let value: String; let sub: String
|
||||
var badge: String? = nil
|
||||
|
||||
var body: some View {
|
||||
private var availabilityCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).fill(iconColor.opacity(0.14)).frame(width: 34, height: 34)
|
||||
Image(systemName: icon).font(.system(size: 14)).foregroundStyle(iconColor)
|
||||
}
|
||||
Text("Feed Availability")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
if let badge {
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(VelocityTheme.success).frame(width: 5, height: 5)
|
||||
Text(badge).font(.system(size: 9, weight: .semibold)).foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(label.uppercased()).font(.system(size: 10, weight: .medium)).tracking(1.0)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(value).font(.system(size: 30, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
.contentTransition(.numericText())
|
||||
Text(sub).font(.system(size: 11)).foregroundStyle(VelocityTheme.subtleFg)
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, minHeight: 140, alignment: .topLeading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(iconColor.opacity(0.18), lineWidth: 1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Zone Analytics
|
||||
private struct ZoneAnalyticsPanel: View {
|
||||
private let zones: [(id: String, name: String, count: Int, sentiment: Int)] = [
|
||||
("A", "Main Showroom", 5, 72),
|
||||
("B", "Penthouse Gallery",3, 85),
|
||||
("C", "Amenity Deck VR", 2, 68),
|
||||
("D", "Reception", 2, 90),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "mappin.and.ellipse").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
|
||||
Text("Zone Analytics").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
ForEach(zones, id: \.id) { zone in
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 6).fill(VelocityTheme.accent.opacity(0.14)).frame(width: 28, height: 28)
|
||||
Text(zone.id).font(.system(size: 11, weight: .bold)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(zone.name).font(.system(size: 12, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(zone.count) visitors").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 4) {
|
||||
let c: Color = zone.sentiment >= 80 ? VelocityTheme.success :
|
||||
zone.sentiment >= 60 ? VelocityTheme.accent : VelocityTheme.warning
|
||||
Circle().fill(c).frame(width: 7, height: 7)
|
||||
Text("\(zone.sentiment)%").font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.06), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Client Insights
|
||||
private struct ClientInsightsPanel: View {
|
||||
private struct Insight {
|
||||
let name: String; let stage: String; let sentiment: String
|
||||
let score: Int; let insight: String; let color: Color
|
||||
|
||||
var icon: String {
|
||||
score >= 80 ? "arrow.up.right" : score >= 50 ? "minus" : "exclamationmark.triangle"
|
||||
}
|
||||
var scoreColor: Color {
|
||||
score >= 80 ? VelocityTheme.success : score >= 50 ? VelocityTheme.warning : VelocityTheme.danger
|
||||
}
|
||||
}
|
||||
|
||||
private let insights: [Insight] = [
|
||||
.init(name: "Quantum Dynamics", stage: "Proposal Review", sentiment: "Positive", score: 92,
|
||||
insight: "Key decision maker showed high engagement. Emphasise custom penthouse integration.",
|
||||
color: VelocityTheme.success),
|
||||
.init(name: "Nebula Ventures", stage: "Discovery", sentiment: "Neutral", score: 45,
|
||||
insight: "Initial interest detected but hesitation around pricing model tier.",
|
||||
color: VelocityTheme.warning),
|
||||
.init(name: "Apex Industries", stage: "Negotiation", sentiment: "Mixed", score: 68,
|
||||
insight: "Confusion markers during contract terms review. Legal team is the blocker.",
|
||||
color: VelocityTheme.danger),
|
||||
.init(name: "Starlight Systems", stage: "Initial Contact", sentiment: "Positive", score: 78,
|
||||
insight: "Strong ESG engagement. Align pitch with sustainability goals to accelerate close.",
|
||||
color: VelocityTheme.accent),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
insightHeader
|
||||
insightGrid
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
|
||||
private var insightHeader: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "sparkles").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
|
||||
Text("AI Strategic Insights")
|
||||
.font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text("LIVE ANALYSIS").font(.system(size: 8, weight: .bold)).tracking(1)
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
.padding(.horizontal, 6).padding(.vertical, 3)
|
||||
.background(RoundedRectangle(cornerRadius: 4).fill(VelocityTheme.accent.opacity(0.12))
|
||||
.overlay(RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(VelocityTheme.accent.opacity(0.2), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
private var insightGrid: some View {
|
||||
let cols = [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)]
|
||||
return LazyVGrid(columns: cols, alignment: .leading, spacing: 10) {
|
||||
ForEach(insights, id: \.name) { item in
|
||||
InsightCard(
|
||||
name: item.name, stage: item.stage, sentiment: item.sentiment,
|
||||
score: item.score, insight: item.insight, color: item.color,
|
||||
icon: item.icon, scoreColor: item.scoreColor
|
||||
statusBadge(
|
||||
label: "No mock feed",
|
||||
color: VelocityTheme.warning
|
||||
)
|
||||
}
|
||||
|
||||
Text("This iPad build does not synthesize visitor counts, facial detections, or sentiment scores. A dedicated production Sentinel route is still required before those analytics can be shown safely.")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
Text("Current surface instead reports real operator urgency from the live mobile-edge backend.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(20)
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
|
||||
private var postureCards: some View {
|
||||
HStack(spacing: 14) {
|
||||
SentinelCard(
|
||||
title: "Pending insights",
|
||||
value: "\(store.metrics.pendingInsights)",
|
||||
subtitle: "Recommendations waiting on operator review",
|
||||
color: VelocityTheme.danger
|
||||
)
|
||||
SentinelCard(
|
||||
title: "Transcript queue",
|
||||
value: "\(store.metrics.pendingTranscriptions)",
|
||||
subtitle: "Imported recordings still processing",
|
||||
color: VelocityTheme.warning
|
||||
)
|
||||
SentinelCard(
|
||||
title: "Upcoming 24h",
|
||||
value: "\(store.alertSnapshot?.upcomingCalendarEvents24h ?? 0)",
|
||||
subtitle: "Calendar events due soon",
|
||||
color: VelocityTheme.success
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct InsightCard: View {
|
||||
struct Item {
|
||||
let name: String; let stage: String; let sentiment: String
|
||||
let score: Int; let insight: String; let color: Color
|
||||
var icon: String { score >= 80 ? "arrow.up.right" : score >= 50 ? "minus" : "exclamationmark.triangle" }
|
||||
var scoreColor: Color { score >= 80 ? VelocityTheme.success : score >= 50 ? VelocityTheme.warning : VelocityTheme.danger }
|
||||
}
|
||||
|
||||
// Accept ClientInsightsPanel.Insight via protocol duck-typing workaround:
|
||||
let name: String; let stage: String; let sentiment: String
|
||||
let score: Int; let insight: String; let color: Color
|
||||
let icon: String; let scoreColor: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
private var timelineCard: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 6).fill(color.opacity(0.18)).frame(width: 28, height: 28)
|
||||
Image(systemName: icon).font(.system(size: 11)).foregroundStyle(color)
|
||||
}
|
||||
Text("Recent Operator Timeline")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text("\(score)").font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(scoreColor)
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(RoundedRectangle(cornerRadius: 4).fill(scoreColor.opacity(0.15)))
|
||||
}
|
||||
Text(name).font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground).lineLimit(1)
|
||||
Text(insight).font(.system(size: 10))
|
||||
.foregroundStyle(VelocityTheme.mutedFg).lineLimit(3)
|
||||
HStack {
|
||||
Text(stage).font(.system(size: 9)).foregroundStyle(VelocityTheme.subtleFg)
|
||||
Spacer()
|
||||
Text(sentiment).font(.system(size: 9, weight: .semibold)).foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(color.opacity(0.15), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: – Sentiment Distribution
|
||||
private struct SentimentDistributionPanel: View {
|
||||
let visitors: [Visitor]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "chart.bar").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
|
||||
Text("Sentiment").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
ForEach(SentimentType.allCases, id: \.self) { type in
|
||||
let count = visitors.filter { $0.sentiment == type }.count
|
||||
let fraction = visitors.isEmpty ? 0.0 : Double(count) / Double(visitors.count)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(type.emoji).font(.system(size: 14))
|
||||
Text(type.rawValue.capitalized).font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
Spacer()
|
||||
Text("\(count)").font(.system(size: 11, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.06)).frame(height: 5)
|
||||
RoundedRectangle(cornerRadius: 3).fill(type.color)
|
||||
.frame(width: geo.size.width * fraction, height: 5)
|
||||
.animation(.easeOut(duration: 0.6), value: fraction)
|
||||
}
|
||||
}
|
||||
.frame(height: 5)
|
||||
if let lastRefresh = store.lastRefreshAt {
|
||||
Text("Updated \(lastRefresh.relativeShort)")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Dwell Time Panel
|
||||
private struct DwellTimePanel: View {
|
||||
private let data: [(range: String, count: Int, trend: String)] = [
|
||||
("< 5 min", 3, "down"),
|
||||
("5–15 min", 5, "up"),
|
||||
("15–30 min", 8, "up"),
|
||||
("> 30 min", 4, "stable"),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "timer").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
|
||||
Text("Dwell Time").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
let cols = [GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8)]
|
||||
LazyVGrid(columns: cols, spacing: 8) {
|
||||
ForEach(data, id: \.range) { item in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(item.range).font(.system(size: 9)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
Spacer()
|
||||
Image(systemName: item.trend == "up" ? "arrow.up.right" :
|
||||
item.trend == "down" ? "arrow.down.right" : "minus")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(item.trend == "up" ? VelocityTheme.success :
|
||||
item.trend == "down" ? VelocityTheme.danger : VelocityTheme.mutedFg)
|
||||
}
|
||||
Text("\(item.count)").font(.system(size: 22, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("visitors").font(.system(size: 9)).foregroundStyle(VelocityTheme.subtleFg)
|
||||
}
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.06), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Alert Panel
|
||||
private struct AlertPanel: View {
|
||||
let isActive: Bool
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "bell.badge").font(.system(size: 12)).foregroundStyle(VelocityTheme.warning)
|
||||
Text("Alerts").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text(isActive ? "Active" : "Clear")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(isActive ? VelocityTheme.warning : VelocityTheme.success)
|
||||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||
.background(Capsule().fill((isActive ? VelocityTheme.warning : VelocityTheme.success).opacity(0.15))
|
||||
.overlay(Capsule().stroke((isActive ? VelocityTheme.warning : VelocityTheme.success).opacity(0.25), lineWidth: 1)))
|
||||
}
|
||||
|
||||
if isActive {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 14)).foregroundStyle(VelocityTheme.warning)
|
||||
Text("Sentiment Alert").font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.warning)
|
||||
}
|
||||
Text(message).font(.system(size: 12)).foregroundStyle(VelocityTheme.foreground).lineLimit(3)
|
||||
Text("Just now").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(VelocityTheme.warning.opacity(0.08))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(VelocityTheme.warning.opacity(0.3), lineWidth: 1)))
|
||||
.transition(.asymmetric(insertion: .scale(scale: 0.95).combined(with: .opacity),
|
||||
removal: .scale(scale: 0.95).combined(with: .opacity)))
|
||||
if store.timelineEvents.isEmpty {
|
||||
Text("No live communication events have been loaded for the current high-priority leads yet.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "checkmark.shield.fill")
|
||||
.font(.system(size: 14)).foregroundStyle(VelocityTheme.success)
|
||||
Text("All Clear").font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.success)
|
||||
ForEach(store.timelineEvents.prefix(6)) { item in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(item.leadName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text(item.event.channel.replacingOccurrences(of: "_", with: " ").capitalized)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
Text(item.event.summary ?? "No summary available for this event.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(item.event.timestampDate?.relativeShort ?? item.event.timestamp)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Text("No alerts at this time").font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text("System nominal").font(.system(size: 10)).foregroundStyle(VelocityTheme.subtleFg)
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(VelocityTheme.success.opacity(0.08))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(VelocityTheme.success.opacity(0.3), lineWidth: 1)))
|
||||
.transition(.asymmetric(insertion: .scale(scale: 0.95).combined(with: .opacity),
|
||||
removal: .scale(scale: 0.95).combined(with: .opacity)))
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.animation(.easeInOut(duration: 0.3), value: isActive)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
.padding(20)
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
|
||||
private func statusBadge(label: String, color: Color) -> some View {
|
||||
Text(label)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(color.opacity(0.12))
|
||||
.overlay(Capsule().stroke(color.opacity(0.22), lineWidth: 1))
|
||||
)
|
||||
}
|
||||
|
||||
private func errorBanner(_ message: String) -> some View {
|
||||
Text(message)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.danger)
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(VelocityTheme.danger.opacity(0.10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.danger.opacity(0.22), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SentinelCard: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let subtitle: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(1)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(value)
|
||||
.font(.system(size: 26, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(subtitle)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(color)
|
||||
.frame(width: 48, height: 4)
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassCard(cornerRadius: 16)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SentinelView()
|
||||
}
|
||||
|
||||
@@ -1,74 +1,76 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@State private var store = AppStore.shared
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
// Page header
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Settings")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Configuration")
|
||||
Text("Live runtime configuration")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
|
||||
// System (live) section
|
||||
SettingsSection(title: "System") {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(VelocityTheme.success.opacity(0.12)).frame(width: 30, height: 30)
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.system(size: 13, weight: .medium)).foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
Text("Connection Status").font(.system(size: 14)).foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
HStack(spacing: 5) {
|
||||
Circle().fill(VelocityTheme.success).frame(width: 6, height: 6)
|
||||
Text("Online").font(.system(size: 12, weight: .medium)).foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
SettingsSection(title: "Connectivity") {
|
||||
SettingsRow(
|
||||
label: "Backend endpoint",
|
||||
value: AppConfig.baseURL,
|
||||
icon: "server.rack",
|
||||
accentColor: VelocityTheme.accent
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Auth mode",
|
||||
value: AppConfig.authModeDescription,
|
||||
icon: "lock.shield",
|
||||
accentColor: store.isConfigured ? VelocityTheme.success : VelocityTheme.warning
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Last refresh",
|
||||
value: store.lastRefreshAt?.relativeShort ?? "No live fetch yet",
|
||||
icon: "arrow.clockwise",
|
||||
accentColor: VelocityTheme.mutedFg
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSection(title: "Operator") {
|
||||
SettingsRow(
|
||||
label: "Identity",
|
||||
value: store.operatorIdentity,
|
||||
icon: "person.crop.circle",
|
||||
accentColor: VelocityTheme.accent
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Lead records loaded",
|
||||
value: "\(store.leads.count)",
|
||||
icon: "person.3",
|
||||
accentColor: VelocityTheme.success
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Property records loaded",
|
||||
value: "\(store.properties.count)",
|
||||
icon: "building.2",
|
||||
accentColor: VelocityTheme.warning
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSection(title: "Production Notes") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("This build avoids local demo data. If credentials are missing or a route is unavailable, the surface reports that state instead of fabricating operator metrics.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Sentinel visitor analytics remain disabled on iPad until a dedicated production feed exists; Communications, Calendar, Dashboard, Oracle pipeline, and inventory summaries are live-backed.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(.horizontal, 16).padding(.vertical, 12)
|
||||
}
|
||||
|
||||
// Backend section
|
||||
SettingsSection(title: "Backend") {
|
||||
SettingsRow(label: "ComfyUI Endpoint",
|
||||
value: "http://192.168.x.x:8000",
|
||||
icon: "server.rack",
|
||||
accentColor: VelocityTheme.accent)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(label: "Dream Weaver Path",
|
||||
value: "/dream-weaver",
|
||||
icon: "arrow.triangle.branch",
|
||||
accentColor: VelocityTheme.accent)
|
||||
}
|
||||
|
||||
// Display section
|
||||
SettingsSection(title: "Display") {
|
||||
SettingsRow(label: "Orientation",
|
||||
value: "Landscape Only",
|
||||
icon: "rectangle.landscape.rotate",
|
||||
accentColor: VelocityTheme.mutedFg)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(label: "Theme",
|
||||
value: "Dark",
|
||||
icon: "moon.fill",
|
||||
accentColor: Color(red: 0.60, green: 0.57, blue: 0.99))
|
||||
}
|
||||
|
||||
// App info section
|
||||
SettingsSection(title: "About") {
|
||||
SettingsRow(label: "Version",
|
||||
value: "1.1.0",
|
||||
icon: "info.circle",
|
||||
accentColor: VelocityTheme.mutedFg)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(label: "Build",
|
||||
value: "SwiftUI · iOS 17+",
|
||||
icon: "hammer",
|
||||
accentColor: VelocityTheme.mutedFg)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -134,6 +136,7 @@ private struct SettingsRow: View {
|
||||
Text(value)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
Reference in New Issue
Block a user