feat/#28 (#29)
Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: #29
This commit was merged in pull request #29.
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user