Merge Conflicts (#41)
Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: #41
This commit was merged in pull request #41.
This commit is contained in:
527
iOS/velocity-ipad/velocity.xcodeproj/project.pbxproj
Normal file
527
iOS/velocity-ipad/velocity.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,527 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
A27B23462F58DAF100A74A49 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = A27B23452F58DAF100A74A49 /* Alamofire */; };
|
||||
B31D10012F6A000100000004 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B31D10012F6A000100000002 /* XCTest.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
B31D10012F6A000100000005 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = A27B230D2F58D9C300A74A49 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = A27B23142F58D9C300A74A49;
|
||||
remoteInfo = velocity;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
A27B23152F58D9C300A74A49 /* velocity.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = velocity.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
B31D10012F6A000100000001 /* velocityTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = velocityTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
B31D10012F6A000100000002 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
A2E3BF5F2F5CCA6800670166 /* Exceptions for "velocity" folder in "velocity" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = A27B23142F58D9C300A74A49 /* velocity */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
A27B23172F58D9C300A74A49 /* velocity */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
A2E3BF5F2F5CCA6800670166 /* Exceptions for "velocity" folder in "velocity" target */,
|
||||
);
|
||||
path = velocity;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B31D10012F6A000100000003 /* velocityTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = velocityTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
A27B23122F58D9C300A74A49 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A27B23462F58DAF100A74A49 /* Alamofire in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B31D10012F6A000100000008 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B31D10012F6A000100000004 /* XCTest.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
A27B230C2F58D9C300A74A49 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A27B23172F58D9C300A74A49 /* velocity */,
|
||||
B31D10012F6A000100000003 /* velocityTests */,
|
||||
B31D10012F6A00010000000E /* Frameworks */,
|
||||
A27B23162F58D9C300A74A49 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A27B23162F58D9C300A74A49 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A27B23152F58D9C300A74A49 /* velocity.app */,
|
||||
B31D10012F6A000100000001 /* velocityTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B31D10012F6A00010000000E /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B31D10012F6A000100000002 /* XCTest.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
A27B23142F58D9C300A74A49 /* velocity */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = A27B23202F58D9C400A74A49 /* Build configuration list for PBXNativeTarget "velocity" */;
|
||||
buildPhases = (
|
||||
A27B23112F58D9C300A74A49 /* Sources */,
|
||||
A27B23122F58D9C300A74A49 /* Frameworks */,
|
||||
A27B23132F58D9C300A74A49 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
A27B23172F58D9C300A74A49 /* velocity */,
|
||||
);
|
||||
name = velocity;
|
||||
packageProductDependencies = (
|
||||
A27B23452F58DAF100A74A49 /* Alamofire */,
|
||||
);
|
||||
productName = velocity;
|
||||
productReference = A27B23152F58D9C300A74A49 /* velocity.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
B31D10012F6A00010000000A /* velocityTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = B31D10012F6A00010000000B /* Build configuration list for PBXNativeTarget "velocityTests" */;
|
||||
buildPhases = (
|
||||
B31D10012F6A000100000007 /* Sources */,
|
||||
B31D10012F6A000100000008 /* Frameworks */,
|
||||
B31D10012F6A000100000009 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
B31D10012F6A000100000006 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
B31D10012F6A000100000003 /* velocityTests */,
|
||||
);
|
||||
name = velocityTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = velocityTests;
|
||||
productReference = B31D10012F6A000100000001 /* velocityTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
A27B230D2F58D9C300A74A49 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2630;
|
||||
LastUpgradeCheck = 2630;
|
||||
TargetAttributes = {
|
||||
A27B23142F58D9C300A74A49 = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
};
|
||||
B31D10012F6A00010000000A = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = A27B23102F58D9C300A74A49 /* Build configuration list for PBXProject "velocity" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = A27B230C2F58D9C300A74A49;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
A27B23442F58DAF100A74A49 /* XCRemoteSwiftPackageReference "Alamofire" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = A27B23162F58D9C300A74A49 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
A27B23142F58D9C300A74A49 /* velocity */,
|
||||
B31D10012F6A00010000000A /* velocityTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
A27B23132F58D9C300A74A49 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B31D10012F6A000100000009 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
A27B23112F58D9C300A74A49 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B31D10012F6A000100000007 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
B31D10012F6A000100000006 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = A27B23142F58D9C300A74A49 /* velocity */;
|
||||
targetProxy = B31D10012F6A000100000005 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
A27B231E2F58D9C400A74A49 /* 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;
|
||||
};
|
||||
A27B231F2F58D9C400A74A49 /* 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;
|
||||
};
|
||||
A27B23212F58D9C400A74A49 /* 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 = YES;
|
||||
INFOPLIST_FILE = velocity/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.desineuron.velocity.ipad;
|
||||
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 = 2;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A27B23222F58D9C400A74A49 /* 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 = YES;
|
||||
INFOPLIST_FILE = velocity/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.desineuron.velocity.ipad;
|
||||
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 = 2;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
B31D10012F6A00010000000C /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = L29922NHD9;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.desineuron.velocity.ipadTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 2;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/velocity.app/velocity";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
B31D10012F6A00010000000D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = L29922NHD9;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.desineuron.velocity.ipadTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 2;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/velocity.app/velocity";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
A27B23102F58D9C300A74A49 /* Build configuration list for PBXProject "velocity" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A27B231E2F58D9C400A74A49 /* Debug */,
|
||||
A27B231F2F58D9C400A74A49 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
A27B23202F58D9C400A74A49 /* Build configuration list for PBXNativeTarget "velocity" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A27B23212F58D9C400A74A49 /* Debug */,
|
||||
A27B23222F58D9C400A74A49 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
B31D10012F6A00010000000B /* Build configuration list for PBXNativeTarget "velocityTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
B31D10012F6A00010000000C /* Debug */,
|
||||
B31D10012F6A00010000000D /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
A27B23442F58DAF100A74A49 /* XCRemoteSwiftPackageReference "Alamofire" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/Alamofire/Alamofire";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 5.0.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
A27B23452F58DAF100A74A49 /* Alamofire */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = A27B23442F58DAF100A74A49 /* XCRemoteSwiftPackageReference "Alamofire" */;
|
||||
productName = Alamofire;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = A27B230D2F58D9C300A74A49 /* Project object */;
|
||||
}
|
||||
7
iOS/velocity-ipad/velocity.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
iOS/velocity-ipad/velocity.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,15 @@
|
||||
{
|
||||
"originHash" : "11b78eba97192d19796cff581fdf69b3e65b441188b1448a1b67e5d7b825a354",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "alamofire",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Alamofire/Alamofire",
|
||||
"state" : {
|
||||
"revision" : "3f99050e75bbc6fe71fc323adabb039756680016",
|
||||
"version" : "5.11.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
42
iOS/velocity-ipad/velocity/App/ConfigurationGateView.swift
Normal file
42
iOS/velocity-ipad/velocity/App/ConfigurationGateView.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ConfigurationGateView: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
VelocityTheme.background.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 24) {
|
||||
VStack(spacing: 10) {
|
||||
Text("Configure Velocity")
|
||||
.font(.system(size: 34, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
Text("This iPad now expects a real runtime session. Add the production endpoint and operator credentials before live data can load.")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 700)
|
||||
}
|
||||
|
||||
SessionConfigurationPanel(
|
||||
title: "Secure Session Setup",
|
||||
subtitle: "Runtime credentials replace the old build-time-only configuration path. Velocity saves secrets in Keychain and immediately tries a live refresh after saving.",
|
||||
primaryActionTitle: "Save and continue",
|
||||
allowsClearingStoredConfiguration: false
|
||||
)
|
||||
.frame(maxWidth: 760)
|
||||
|
||||
Text("Production note: this setup flow does not bypass backend TLS failures. If the configured endpoint is unhealthy, Velocity will save the session and report the live refresh error truthfully.")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 760)
|
||||
}
|
||||
.padding(28)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ConfigurationGateView()
|
||||
}
|
||||
224
iOS/velocity-ipad/velocity/App/ContentView.swift
Normal file
224
iOS/velocity-ipad/velocity/App/ContentView.swift
Normal file
@@ -0,0 +1,224 @@
|
||||
import SwiftUI
|
||||
|
||||
enum AppSection: String, CaseIterable, Hashable, Identifiable {
|
||||
var id: String { rawValue }
|
||||
case dashboard = "Dashboard"
|
||||
case clients = "Clients"
|
||||
case imports = "Imports"
|
||||
case communications = "Communications"
|
||||
case calendar = "Calendar"
|
||||
case oracle = "Oracle"
|
||||
case sentinel = "Sentinel"
|
||||
case inventory = "Inventory"
|
||||
case settings = "Settings"
|
||||
|
||||
var displayTitle: String {
|
||||
switch self {
|
||||
case .sentinel:
|
||||
return SentinelScope.navigationTitle
|
||||
default:
|
||||
return rawValue
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .dashboard: return "square.grid.2x2"
|
||||
case .clients: return "person.text.rectangle"
|
||||
case .imports: return "tray.and.arrow.down"
|
||||
case .communications: return "phone.connection"
|
||||
case .calendar: return "calendar.badge.clock"
|
||||
case .oracle: return "message.and.waveform"
|
||||
case .sentinel: return "person.crop.rectangle"
|
||||
case .inventory: return "shippingbox"
|
||||
case .settings: return "gearshape"
|
||||
}
|
||||
}
|
||||
|
||||
var accentColor: Color {
|
||||
switch self {
|
||||
case .dashboard: return VelocityTheme.accent
|
||||
case .clients: return Color(red: 0.22, green: 0.78, blue: 0.96)
|
||||
case .imports: return Color(red: 0.94, green: 0.70, blue: 0.25)
|
||||
case .communications: return Color(red: 0.19, green: 0.84, blue: 0.63)
|
||||
case .calendar: return Color(red: 0.96, green: 0.67, blue: 0.16)
|
||||
case .oracle: return Color(red: 0.13, green: 0.83, blue: 0.93) // cyan
|
||||
case .sentinel: return Color(red: 0.60, green: 0.57, blue: 0.99) // indigo
|
||||
case .inventory: return VelocityTheme.warning
|
||||
case .settings: return VelocityTheme.mutedFg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var selectedSection: AppSection? = .dashboard
|
||||
@State private var session = SessionStore.shared
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if session.isConfigured {
|
||||
NavigationSplitView(columnVisibility: .constant(.all)) {
|
||||
sidebarContent
|
||||
} detail: {
|
||||
detailContent
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
} else {
|
||||
ConfigurationGateView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Sidebar
|
||||
private var sidebarContent: some View {
|
||||
ZStack {
|
||||
VelocityTheme.sidebarBg.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// App title
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 9)
|
||||
.fill(VelocityTheme.accent.opacity(0.18))
|
||||
.frame(width: 34, height: 34)
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Velocity")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Project Velocity · v1.1")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
Divider()
|
||||
.background(VelocityTheme.borderSubtle)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// Nav items
|
||||
VStack(spacing: 2) {
|
||||
ForEach(AppSection.allCases) { section in
|
||||
Button {
|
||||
selectedSection = section
|
||||
} label: {
|
||||
SidebarRow(section: section, isSelected: selectedSection == section)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(section.displayTitle)
|
||||
.accessibilityAddTraits(selectedSection == section ? [.isSelected] : [])
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
Spacer()
|
||||
|
||||
// User footer
|
||||
Divider()
|
||||
.background(VelocityTheme.borderSubtle)
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(VelocityTheme.accent)
|
||||
.frame(width: 32, height: 32)
|
||||
Text(operatorInitials)
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(operatorName)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(session.authModeDescription)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.navigationTitle("")
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
}
|
||||
|
||||
// MARK: – Detail
|
||||
private var detailContent: some View {
|
||||
ZStack {
|
||||
VelocityTheme.background.ignoresSafeArea()
|
||||
|
||||
Group {
|
||||
switch selectedSection {
|
||||
case .dashboard: DashboardView()
|
||||
case .clients: ClientsView()
|
||||
case .imports: ImportsView()
|
||||
case .communications: CommunicationsView()
|
||||
case .calendar: CalendarView()
|
||||
case .oracle: OracleView()
|
||||
case .sentinel: SentinelView()
|
||||
case .inventory: InventoryView()
|
||||
case .settings: SettingsView()
|
||||
case .none: DashboardView()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private var operatorName: String {
|
||||
session.operatorIdentity
|
||||
}
|
||||
|
||||
private var operatorInitials: String {
|
||||
let source = session.operatorIdentity
|
||||
let parts = source
|
||||
.replacingOccurrences(of: "@", with: " ")
|
||||
.split(separator: ".")
|
||||
.flatMap { $0.split(separator: " ") }
|
||||
let initials = parts.prefix(2).compactMap(\.first)
|
||||
return initials.isEmpty ? "VO" : String(initials)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Sidebar Row
|
||||
private struct SidebarRow: View {
|
||||
let section: AppSection
|
||||
let isSelected: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 11) {
|
||||
Image(systemName: section.systemImage)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(isSelected ? section.accentColor : VelocityTheme.mutedFg)
|
||||
.frame(width: 20)
|
||||
|
||||
Text(section.displayTitle)
|
||||
.font(.system(size: 14, weight: isSelected ? .semibold : .regular))
|
||||
.foregroundStyle(isSelected ? VelocityTheme.foreground : VelocityTheme.mutedFg)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(isSelected ? section.accentColor.opacity(0.12) : .clear)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(isSelected ? section.accentColor.opacity(0.25) : .clear, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
}
|
||||
11
iOS/velocity-ipad/velocity/App/VelocityApp.swift
Normal file
11
iOS/velocity-ipad/velocity/App/VelocityApp.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct VelocityApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
6
iOS/velocity-ipad/velocity/Assets.xcassets/Contents.json
Normal file
6
iOS/velocity-ipad/velocity/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
302
iOS/velocity-ipad/velocity/Core/Config/AppConfig.swift
Normal file
302
iOS/velocity-ipad/velocity/Core/Config/AppConfig.swift
Normal file
@@ -0,0 +1,302 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Central app configuration.
|
||||
/// Build settings remain the fallback, but production installs should prefer
|
||||
/// runtime configuration stored on-device.
|
||||
enum AppConfig {
|
||||
private static let runtimeBaseURLKey = "velocity.runtime.base_url"
|
||||
private static let runtimeDreamWeaverBaseURLKey = "velocity.runtime.dream_weaver_base_url"
|
||||
private static let runtimeDreamWeaverAPIKeyKey = "velocity.runtime.dream_weaver_api_key"
|
||||
private static let runtimeEmailKey = "velocity.runtime.email"
|
||||
private static let runtimePasswordKey = "velocity.runtime.password"
|
||||
private static let runtimeBearerTokenKey = "velocity.runtime.bearer_token"
|
||||
private static let runtimeAccessTokenKey = "velocity.runtime.access_token"
|
||||
private static let runtimeAccessTokenExpiresAtKey = "velocity.runtime.access_token_expires_at"
|
||||
private static let keychainService = "com.desineuron.velocity.ipad.session"
|
||||
|
||||
static func parsedValue(from infoDictionary: [String: Any]?, key: String) -> String? {
|
||||
let raw = infoDictionary?[key] as? String
|
||||
return sanitizedValue(raw, key: key)
|
||||
}
|
||||
|
||||
static func isLiveConfigured(
|
||||
bearerToken: String?,
|
||||
email: String?,
|
||||
password: String?
|
||||
) -> Bool {
|
||||
bearerToken != nil || (email != nil && password != nil)
|
||||
}
|
||||
|
||||
static func authModeDescription(
|
||||
bearerToken: String?,
|
||||
email: String?,
|
||||
password: String?
|
||||
) -> String {
|
||||
if bearerToken != nil {
|
||||
return "Bearer token"
|
||||
}
|
||||
if email != nil && password != nil {
|
||||
return "Email/password"
|
||||
}
|
||||
return "Credentials required"
|
||||
}
|
||||
|
||||
private static func value(for key: String) -> String? {
|
||||
parsedValue(from: Bundle.main.infoDictionary, key: key)
|
||||
}
|
||||
|
||||
private static func sanitizedValue(_ raw: String?, key: String) -> String? {
|
||||
guard let raw else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty || trimmed == "$(\(key))" {
|
||||
return nil
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/// Base URL for the Velocity backend / gateway.
|
||||
static var baseURL: String {
|
||||
runtimeBaseURL ?? value(for: "BASE_URL") ?? "https://velocity.desineuron.in/api"
|
||||
}
|
||||
|
||||
/// Dedicated Dream Weaver gateway endpoint when configured; otherwise
|
||||
/// generation falls back to the main backend endpoint.
|
||||
static var dreamWeaverBaseURL: String {
|
||||
configuredDreamWeaverBaseURL ?? originBaseURL(from: baseURL)
|
||||
}
|
||||
|
||||
static var usesDedicatedDreamWeaverBaseURL: Bool {
|
||||
guard let configuredDreamWeaverBaseURL else {
|
||||
return false
|
||||
}
|
||||
return configuredDreamWeaverBaseURL != baseURL
|
||||
}
|
||||
|
||||
static var dreamWeaverAPIKey: String? {
|
||||
runtimeDreamWeaverAPIKey ?? value(for: "DREAM_WEAVER_API_KEY")
|
||||
}
|
||||
|
||||
static var apiEmail: String? {
|
||||
runtimeEmail ?? value(for: "API_EMAIL")
|
||||
}
|
||||
|
||||
static var apiPassword: String? {
|
||||
runtimePassword ?? value(for: "API_PASSWORD")
|
||||
}
|
||||
|
||||
static var apiBearerToken: String? {
|
||||
runtimeBearerToken ?? value(for: "API_BEARER_TOKEN")
|
||||
}
|
||||
|
||||
static var apiAccessToken: String? {
|
||||
guard let expiresAt = runtimeAccessTokenExpiresAt, expiresAt > Date().addingTimeInterval(60) else {
|
||||
try? clearStoredAccessToken()
|
||||
return nil
|
||||
}
|
||||
return secret(account: runtimeAccessTokenKey)
|
||||
}
|
||||
|
||||
static var isLiveConfigured: Bool {
|
||||
isLiveConfigured(
|
||||
bearerToken: apiBearerToken,
|
||||
email: apiEmail,
|
||||
password: apiPassword
|
||||
)
|
||||
}
|
||||
|
||||
static var authModeDescription: String {
|
||||
authModeDescription(
|
||||
bearerToken: apiBearerToken,
|
||||
email: apiEmail,
|
||||
password: apiPassword
|
||||
)
|
||||
}
|
||||
|
||||
static var hasStoredRuntimeConfiguration: Bool {
|
||||
runtimeBaseURL != nil ||
|
||||
runtimeDreamWeaverBaseURL != nil ||
|
||||
runtimeEmail != nil ||
|
||||
runtimePassword != nil ||
|
||||
runtimeBearerToken != nil
|
||||
}
|
||||
|
||||
static func currentSessionConfiguration() -> AppSessionConfiguration {
|
||||
AppSessionConfiguration(
|
||||
baseURL: baseURL,
|
||||
dreamWeaverBaseURL: dreamWeaverBaseURL,
|
||||
usesDedicatedDreamWeaverBaseURL: usesDedicatedDreamWeaverBaseURL,
|
||||
hasDreamWeaverAPIKey: dreamWeaverAPIKey != nil,
|
||||
email: apiEmail,
|
||||
hasPassword: apiPassword != nil,
|
||||
hasBearerToken: apiBearerToken != nil,
|
||||
source: hasStoredRuntimeConfiguration ? .secureDeviceStorage : .buildConfiguration
|
||||
)
|
||||
}
|
||||
|
||||
static func saveRuntimeConfiguration(
|
||||
baseURL: String,
|
||||
dreamWeaverBaseURL: String?,
|
||||
dreamWeaverAPIKey: String?,
|
||||
email: String?,
|
||||
password: String?,
|
||||
bearerToken: String?
|
||||
) throws {
|
||||
UserDefaults.standard.set(baseURL, forKey: runtimeBaseURLKey)
|
||||
|
||||
if let dreamWeaverBaseURL {
|
||||
UserDefaults.standard.set(dreamWeaverBaseURL, forKey: runtimeDreamWeaverBaseURLKey)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: runtimeDreamWeaverBaseURLKey)
|
||||
}
|
||||
|
||||
if let email {
|
||||
UserDefaults.standard.set(email, forKey: runtimeEmailKey)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: runtimeEmailKey)
|
||||
}
|
||||
|
||||
try storeSecret(dreamWeaverAPIKey, account: runtimeDreamWeaverAPIKeyKey)
|
||||
try storeSecret(password, account: runtimePasswordKey)
|
||||
try storeSecret(bearerToken, account: runtimeBearerTokenKey)
|
||||
try clearStoredAccessToken()
|
||||
}
|
||||
|
||||
static func clearStoredRuntimeConfiguration() throws {
|
||||
UserDefaults.standard.removeObject(forKey: runtimeBaseURLKey)
|
||||
UserDefaults.standard.removeObject(forKey: runtimeDreamWeaverBaseURLKey)
|
||||
UserDefaults.standard.removeObject(forKey: runtimeEmailKey)
|
||||
try deleteSecret(account: runtimeDreamWeaverAPIKeyKey)
|
||||
try deleteSecret(account: runtimePasswordKey)
|
||||
try deleteSecret(account: runtimeBearerTokenKey)
|
||||
try clearStoredAccessToken()
|
||||
}
|
||||
|
||||
static func saveAccessToken(_ token: String, expiresIn: Int?) throws {
|
||||
try storeSecret(token, account: runtimeAccessTokenKey)
|
||||
let lifetime = TimeInterval(max(expiresIn ?? 28_800, 60))
|
||||
UserDefaults.standard.set(Date().addingTimeInterval(lifetime).timeIntervalSince1970, forKey: runtimeAccessTokenExpiresAtKey)
|
||||
}
|
||||
|
||||
static func clearStoredAccessToken() throws {
|
||||
UserDefaults.standard.removeObject(forKey: runtimeAccessTokenExpiresAtKey)
|
||||
try deleteSecret(account: runtimeAccessTokenKey)
|
||||
}
|
||||
|
||||
private static var runtimeBaseURL: String? {
|
||||
sanitizedValue(UserDefaults.standard.string(forKey: runtimeBaseURLKey), key: runtimeBaseURLKey)
|
||||
}
|
||||
|
||||
private static var configuredDreamWeaverBaseURL: String? {
|
||||
runtimeDreamWeaverBaseURL ?? value(for: "DREAM_WEAVER_BASE_URL")
|
||||
}
|
||||
|
||||
private static var runtimeDreamWeaverBaseURL: String? {
|
||||
sanitizedValue(
|
||||
UserDefaults.standard.string(forKey: runtimeDreamWeaverBaseURLKey),
|
||||
key: runtimeDreamWeaverBaseURLKey
|
||||
)
|
||||
}
|
||||
|
||||
private static var runtimeDreamWeaverAPIKey: String? {
|
||||
secret(account: runtimeDreamWeaverAPIKeyKey)
|
||||
}
|
||||
|
||||
private static var runtimeEmail: String? {
|
||||
sanitizedValue(UserDefaults.standard.string(forKey: runtimeEmailKey), key: runtimeEmailKey)
|
||||
}
|
||||
|
||||
private static var runtimePassword: String? {
|
||||
secret(account: runtimePasswordKey)
|
||||
}
|
||||
|
||||
private static var runtimeBearerToken: String? {
|
||||
secret(account: runtimeBearerTokenKey)
|
||||
}
|
||||
|
||||
private static var runtimeAccessTokenExpiresAt: Date? {
|
||||
let rawValue = UserDefaults.standard.double(forKey: runtimeAccessTokenExpiresAtKey)
|
||||
guard rawValue > 0 else {
|
||||
return nil
|
||||
}
|
||||
return Date(timeIntervalSince1970: rawValue)
|
||||
}
|
||||
|
||||
private static func originBaseURL(from rawValue: String) -> String {
|
||||
guard var components = URLComponents(string: rawValue) else {
|
||||
return rawValue
|
||||
}
|
||||
components.path = ""
|
||||
components.query = nil
|
||||
components.fragment = nil
|
||||
return components.string ?? rawValue
|
||||
}
|
||||
|
||||
private static func secret(account: String) -> String? {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: keychainService,
|
||||
kSecAttrAccount: account,
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitOne
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess else {
|
||||
return nil
|
||||
}
|
||||
guard let data = result as? Data else {
|
||||
return nil
|
||||
}
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private static func storeSecret(_ value: String?, account: String) throws {
|
||||
if let value, let data = value.data(using: .utf8) {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: keychainService,
|
||||
kSecAttrAccount: account
|
||||
]
|
||||
|
||||
let attributes: [CFString: Any] = [
|
||||
kSecValueData: data
|
||||
]
|
||||
|
||||
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
||||
if status == errSecSuccess {
|
||||
return
|
||||
}
|
||||
|
||||
if status == errSecItemNotFound {
|
||||
var addQuery = query
|
||||
addQuery[kSecValueData] = data
|
||||
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
throw SessionPersistenceError.keychainWriteFailed(addStatus)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
throw SessionPersistenceError.keychainWriteFailed(status)
|
||||
}
|
||||
|
||||
try deleteSecret(account: account)
|
||||
}
|
||||
|
||||
private static func deleteSecret(account: String) throws {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: keychainService,
|
||||
kSecAttrAccount: account
|
||||
]
|
||||
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw SessionPersistenceError.keychainDeleteFailed(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import Foundation
|
||||
|
||||
enum SessionAuthMode: String, CaseIterable, Identifiable {
|
||||
case emailPassword = "Email/password"
|
||||
case bearerToken = "Bearer token"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
enum SessionConfigurationSource: String {
|
||||
case buildConfiguration = "Build configuration"
|
||||
case secureDeviceStorage = "Secure device storage"
|
||||
}
|
||||
|
||||
struct AppSessionConfiguration: Equatable {
|
||||
let baseURL: String
|
||||
let dreamWeaverBaseURL: String
|
||||
let usesDedicatedDreamWeaverBaseURL: Bool
|
||||
let hasDreamWeaverAPIKey: Bool
|
||||
let email: String?
|
||||
let hasPassword: Bool
|
||||
let hasBearerToken: Bool
|
||||
let source: SessionConfigurationSource
|
||||
|
||||
var authMode: SessionAuthMode {
|
||||
hasBearerToken ? .bearerToken : .emailPassword
|
||||
}
|
||||
|
||||
var isConfigured: Bool {
|
||||
hasBearerToken || (email != nil && hasPassword)
|
||||
}
|
||||
|
||||
var authModeDescription: String {
|
||||
if hasBearerToken {
|
||||
return "Bearer token"
|
||||
}
|
||||
if email != nil && hasPassword {
|
||||
return "Email/password"
|
||||
}
|
||||
return "Credentials required"
|
||||
}
|
||||
|
||||
var operatorIdentity: String {
|
||||
if let email, !email.isEmpty {
|
||||
return email
|
||||
}
|
||||
if hasBearerToken {
|
||||
return "Token authenticated operator"
|
||||
}
|
||||
return "Unconfigured operator"
|
||||
}
|
||||
|
||||
var dreamWeaverEndpointModeDescription: String {
|
||||
usesDedicatedDreamWeaverBaseURL ? "Dedicated gateway" : "Shared with backend"
|
||||
}
|
||||
|
||||
var dreamWeaverAuthenticationDescription: String {
|
||||
hasDreamWeaverAPIKey ? "API key configured" : "No gateway key configured"
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionConfigurationDraft: Equatable {
|
||||
var baseURL: String
|
||||
var dreamWeaverBaseURL: String
|
||||
var dreamWeaverAPIKey: String
|
||||
var authMode: SessionAuthMode
|
||||
var email: String
|
||||
var password: String
|
||||
var bearerToken: String
|
||||
var existingDreamWeaverAPIKeyAvailable: Bool
|
||||
var existingPasswordAvailable: Bool
|
||||
var existingBearerTokenAvailable: Bool
|
||||
var baselineEmail: String?
|
||||
|
||||
var trimmedBaseURL: String? {
|
||||
Self.trimmedValue(baseURL)
|
||||
}
|
||||
|
||||
var trimmedEmail: String? {
|
||||
Self.trimmedValue(email)
|
||||
}
|
||||
|
||||
var trimmedPassword: String? {
|
||||
Self.trimmedValue(password)
|
||||
}
|
||||
|
||||
var trimmedBearerToken: String? {
|
||||
Self.trimmedValue(bearerToken)
|
||||
}
|
||||
|
||||
var normalizedBaseURL: String? {
|
||||
Self.normalizedHTTPSOrigin(from: trimmedBaseURL)
|
||||
}
|
||||
|
||||
var trimmedDreamWeaverBaseURL: String? {
|
||||
Self.trimmedValue(dreamWeaverBaseURL)
|
||||
}
|
||||
|
||||
var normalizedDreamWeaverBaseURL: String? {
|
||||
Self.normalizedHTTPSOrigin(from: trimmedDreamWeaverBaseURL)
|
||||
}
|
||||
|
||||
var trimmedDreamWeaverAPIKey: String? {
|
||||
Self.trimmedValue(dreamWeaverAPIKey)
|
||||
}
|
||||
|
||||
func validationErrors() -> [String] {
|
||||
var errors: [String] = []
|
||||
|
||||
guard let trimmedBaseURL else {
|
||||
errors.append("Backend endpoint is required.")
|
||||
return errors
|
||||
}
|
||||
|
||||
guard URLComponents(string: trimmedBaseURL) != nil else {
|
||||
errors.append("Backend endpoint must be a valid URL.")
|
||||
return errors
|
||||
}
|
||||
|
||||
guard normalizedBaseURL != nil else {
|
||||
errors.append("Backend endpoint must be an HTTPS API base like https://velocity.desineuron.in/api.")
|
||||
return errors
|
||||
}
|
||||
|
||||
if let trimmedDreamWeaverBaseURL {
|
||||
guard URLComponents(string: trimmedDreamWeaverBaseURL) != nil else {
|
||||
errors.append("Dream Weaver endpoint must be a valid URL.")
|
||||
return errors
|
||||
}
|
||||
|
||||
guard normalizedDreamWeaverBaseURL != nil else {
|
||||
errors.append("Dream Weaver endpoint must be an HTTPS origin like https://dreamweaver.desineuron.in.")
|
||||
return errors
|
||||
}
|
||||
}
|
||||
|
||||
switch authMode {
|
||||
case .emailPassword:
|
||||
guard let trimmedEmail else {
|
||||
errors.append("Operator email is required for email/password login.")
|
||||
break
|
||||
}
|
||||
|
||||
guard trimmedEmail.contains("@"), trimmedEmail.contains(".") else {
|
||||
errors.append("Operator email must look like a valid email address.")
|
||||
break
|
||||
}
|
||||
|
||||
if trimmedPassword == nil &&
|
||||
!(existingPasswordAvailable && trimmedEmail.caseInsensitiveCompare(baselineEmail ?? "") == .orderedSame) {
|
||||
errors.append("Password is required for email/password login.")
|
||||
}
|
||||
|
||||
case .bearerToken:
|
||||
if trimmedBearerToken == nil && !existingBearerTokenAvailable {
|
||||
errors.append("Bearer token is required when token auth is selected.")
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
func resolvedEmail(existingEmail: String?) -> String? {
|
||||
guard authMode == .emailPassword else { return nil }
|
||||
return trimmedEmail ?? existingEmail
|
||||
}
|
||||
|
||||
func resolvedPassword(existingPassword: String?) -> String? {
|
||||
guard authMode == .emailPassword else { return nil }
|
||||
if let trimmedPassword {
|
||||
return trimmedPassword
|
||||
}
|
||||
guard existingPasswordAvailable else {
|
||||
return nil
|
||||
}
|
||||
guard trimmedEmail?.caseInsensitiveCompare(baselineEmail ?? "") == .orderedSame else {
|
||||
return nil
|
||||
}
|
||||
return existingPassword
|
||||
}
|
||||
|
||||
func resolvedBearerToken(existingToken: String?) -> String? {
|
||||
guard authMode == .bearerToken else { return nil }
|
||||
return trimmedBearerToken ?? (existingBearerTokenAvailable ? existingToken : nil)
|
||||
}
|
||||
|
||||
func resolvedDreamWeaverBaseURL(normalizedBaseURL: String) -> String? {
|
||||
normalizedDreamWeaverBaseURL
|
||||
}
|
||||
|
||||
func resolvedDreamWeaverAPIKey(existingKey: String?) -> String? {
|
||||
trimmedDreamWeaverAPIKey ?? (existingDreamWeaverAPIKeyAvailable ? existingKey : nil)
|
||||
}
|
||||
|
||||
private static func trimmedValue(_ value: String?) -> String? {
|
||||
guard let value else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func normalizedHTTPSOrigin(from raw: String?) -> String? {
|
||||
guard let raw else {
|
||||
return nil
|
||||
}
|
||||
guard var components = URLComponents(string: raw) else {
|
||||
return nil
|
||||
}
|
||||
guard let scheme = components.scheme?.lowercased(),
|
||||
let host = components.host?.lowercased() else {
|
||||
return nil
|
||||
}
|
||||
guard scheme == "https" else {
|
||||
return nil
|
||||
}
|
||||
guard components.query == nil, components.fragment == nil else {
|
||||
return nil
|
||||
}
|
||||
let path = components.path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedPath = path == "/api/" ? "/api" : path
|
||||
guard normalizedPath.isEmpty || normalizedPath == "/" || normalizedPath == "/api" else {
|
||||
return nil
|
||||
}
|
||||
|
||||
components.scheme = scheme
|
||||
components.host = host
|
||||
components.path = normalizedPath == "/api" ? "/api" : ""
|
||||
components.query = nil
|
||||
components.fragment = nil
|
||||
return components.string
|
||||
}
|
||||
}
|
||||
|
||||
enum SessionPersistenceError: LocalizedError {
|
||||
case keychainWriteFailed(OSStatus)
|
||||
case keychainDeleteFailed(OSStatus)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .keychainWriteFailed(let status):
|
||||
return "Velocity could not save credentials securely on this iPad. Keychain status: \(status)."
|
||||
case .keychainDeleteFailed(let status):
|
||||
return "Velocity could not clear stored credentials from this iPad. Keychain status: \(status)."
|
||||
}
|
||||
}
|
||||
}
|
||||
204
iOS/velocity-ipad/velocity/Core/Config/SessionStore.swift
Normal file
204
iOS/velocity-ipad/velocity/Core/Config/SessionStore.swift
Normal file
@@ -0,0 +1,204 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SessionStore {
|
||||
static let shared = SessionStore()
|
||||
|
||||
private init() {
|
||||
reloadFromPersistedConfiguration()
|
||||
}
|
||||
|
||||
var currentConfiguration = AppConfig.currentSessionConfiguration()
|
||||
var draftBaseURL = ""
|
||||
var draftDreamWeaverBaseURL = ""
|
||||
var draftDreamWeaverAPIKey = ""
|
||||
var draftAuthMode: SessionAuthMode = .emailPassword
|
||||
var draftEmail = ""
|
||||
var draftPassword = ""
|
||||
var draftBearerToken = ""
|
||||
var isSaving = false
|
||||
var statusMessage: String?
|
||||
var errorMessage: String?
|
||||
|
||||
private var existingPasswordAvailable = false
|
||||
private var existingBearerTokenAvailable = false
|
||||
private var existingDreamWeaverAPIKeyAvailable = false
|
||||
private var baselineEmail: String?
|
||||
|
||||
var isConfigured: Bool {
|
||||
currentConfiguration.isConfigured
|
||||
}
|
||||
|
||||
var authModeDescription: String {
|
||||
currentConfiguration.authModeDescription
|
||||
}
|
||||
|
||||
var operatorIdentity: String {
|
||||
currentConfiguration.operatorIdentity
|
||||
}
|
||||
|
||||
var endpointDisplay: String {
|
||||
currentConfiguration.baseURL
|
||||
}
|
||||
|
||||
var dreamWeaverEndpointDisplay: String {
|
||||
currentConfiguration.dreamWeaverBaseURL
|
||||
}
|
||||
|
||||
var dreamWeaverEndpointModeDescription: String {
|
||||
currentConfiguration.dreamWeaverEndpointModeDescription
|
||||
}
|
||||
|
||||
var dreamWeaverAuthenticationDescription: String {
|
||||
currentConfiguration.dreamWeaverAuthenticationDescription
|
||||
}
|
||||
|
||||
var configurationSourceDescription: String {
|
||||
currentConfiguration.source.rawValue
|
||||
}
|
||||
|
||||
var isUsingStoredRuntimeConfiguration: Bool {
|
||||
currentConfiguration.source == .secureDeviceStorage
|
||||
}
|
||||
|
||||
var hasUnsavedChanges: Bool {
|
||||
draftBaseURL != currentConfiguration.baseURL ||
|
||||
draftDreamWeaverBaseURL != persistedDreamWeaverDraftValue ||
|
||||
!draftDreamWeaverAPIKey.isEmpty ||
|
||||
draftAuthMode != currentConfiguration.authMode ||
|
||||
draftEmail != (currentConfiguration.email ?? "") ||
|
||||
!draftPassword.isEmpty ||
|
||||
!draftBearerToken.isEmpty
|
||||
}
|
||||
|
||||
func reloadFromPersistedConfiguration() {
|
||||
currentConfiguration = AppConfig.currentSessionConfiguration()
|
||||
draftBaseURL = currentConfiguration.baseURL
|
||||
draftDreamWeaverBaseURL = persistedDreamWeaverDraftValue
|
||||
draftDreamWeaverAPIKey = ""
|
||||
draftAuthMode = currentConfiguration.authMode
|
||||
draftEmail = currentConfiguration.email ?? ""
|
||||
draftPassword = ""
|
||||
draftBearerToken = ""
|
||||
existingDreamWeaverAPIKeyAvailable = currentConfiguration.hasDreamWeaverAPIKey
|
||||
existingPasswordAvailable = currentConfiguration.hasPassword
|
||||
existingBearerTokenAvailable = currentConfiguration.hasBearerToken
|
||||
baselineEmail = currentConfiguration.email
|
||||
}
|
||||
|
||||
func discardDraftChanges() {
|
||||
errorMessage = nil
|
||||
statusMessage = nil
|
||||
reloadFromPersistedConfiguration()
|
||||
}
|
||||
|
||||
func saveDraft() async {
|
||||
errorMessage = nil
|
||||
statusMessage = nil
|
||||
|
||||
let draft = SessionConfigurationDraft(
|
||||
baseURL: draftBaseURL,
|
||||
dreamWeaverBaseURL: draftDreamWeaverBaseURL,
|
||||
dreamWeaverAPIKey: draftDreamWeaverAPIKey,
|
||||
authMode: draftAuthMode,
|
||||
email: draftEmail,
|
||||
password: draftPassword,
|
||||
bearerToken: draftBearerToken,
|
||||
existingDreamWeaverAPIKeyAvailable: existingDreamWeaverAPIKeyAvailable,
|
||||
existingPasswordAvailable: existingPasswordAvailable,
|
||||
existingBearerTokenAvailable: existingBearerTokenAvailable,
|
||||
baselineEmail: baselineEmail
|
||||
)
|
||||
|
||||
let errors = draft.validationErrors()
|
||||
guard errors.isEmpty else {
|
||||
errorMessage = errors.joined(separator: " ")
|
||||
return
|
||||
}
|
||||
|
||||
guard let normalizedBaseURL = draft.normalizedBaseURL else {
|
||||
errorMessage = "Backend endpoint must be a valid HTTPS origin."
|
||||
return
|
||||
}
|
||||
|
||||
isSaving = true
|
||||
|
||||
do {
|
||||
try AppConfig.saveRuntimeConfiguration(
|
||||
baseURL: normalizedBaseURL,
|
||||
dreamWeaverBaseURL: draft.resolvedDreamWeaverBaseURL(normalizedBaseURL: normalizedBaseURL),
|
||||
dreamWeaverAPIKey: draft.resolvedDreamWeaverAPIKey(existingKey: AppConfig.dreamWeaverAPIKey),
|
||||
email: draft.resolvedEmail(existingEmail: currentConfiguration.email),
|
||||
password: draft.resolvedPassword(existingPassword: AppConfig.apiPassword),
|
||||
bearerToken: draft.resolvedBearerToken(existingToken: AppConfig.apiBearerToken)
|
||||
)
|
||||
|
||||
await VelocityAPIClient.shared.resetSession()
|
||||
AppStore.shared.resetLiveData()
|
||||
reloadFromPersistedConfiguration()
|
||||
await AppStore.shared.refresh()
|
||||
let dreamWeaverHealthy = await ComfyClient.shared.checkHealth()
|
||||
statusMessage = verificationStatusMessage(
|
||||
successPrefix: "Configuration saved.",
|
||||
backendRefreshError: AppStore.shared.errorMessage,
|
||||
dreamWeaverHealthy: dreamWeaverHealthy
|
||||
)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isSaving = false
|
||||
}
|
||||
|
||||
func clearStoredConfiguration() async {
|
||||
errorMessage = nil
|
||||
statusMessage = nil
|
||||
isSaving = true
|
||||
|
||||
do {
|
||||
try AppConfig.clearStoredRuntimeConfiguration()
|
||||
await VelocityAPIClient.shared.resetSession()
|
||||
AppStore.shared.resetLiveData()
|
||||
reloadFromPersistedConfiguration()
|
||||
|
||||
if currentConfiguration.isConfigured {
|
||||
await AppStore.shared.refresh()
|
||||
let dreamWeaverHealthy = await ComfyClient.shared.checkHealth()
|
||||
statusMessage = verificationStatusMessage(
|
||||
successPrefix: "Stored override cleared. Velocity is now using the build configuration.",
|
||||
backendRefreshError: AppStore.shared.errorMessage,
|
||||
dreamWeaverHealthy: dreamWeaverHealthy
|
||||
)
|
||||
} else {
|
||||
statusMessage = "Stored session cleared. This iPad now requires runtime configuration before live data can load."
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isSaving = false
|
||||
}
|
||||
|
||||
private var persistedDreamWeaverDraftValue: String {
|
||||
currentConfiguration.usesDedicatedDreamWeaverBaseURL ? currentConfiguration.dreamWeaverBaseURL : ""
|
||||
}
|
||||
|
||||
private func verificationStatusMessage(
|
||||
successPrefix: String,
|
||||
backendRefreshError: String?,
|
||||
dreamWeaverHealthy: Bool
|
||||
) -> String {
|
||||
switch (backendRefreshError, dreamWeaverHealthy) {
|
||||
case (nil, true):
|
||||
return "\(successPrefix) Core backend refresh and Dream Weaver gateway probe both succeeded."
|
||||
case (let backendRefreshError?, true):
|
||||
return "\(successPrefix) Core backend refresh failed: \(backendRefreshError) Dream Weaver gateway probe succeeded."
|
||||
case (nil, false):
|
||||
return "\(successPrefix) Core backend refresh succeeded, but the Dream Weaver gateway probe failed. Verify the dedicated generation endpoint and routing."
|
||||
case (let backendRefreshError?, false):
|
||||
return "\(successPrefix) Core backend refresh failed: \(backendRefreshError) Dream Weaver gateway probe also failed."
|
||||
}
|
||||
}
|
||||
}
|
||||
134
iOS/velocity-ipad/velocity/Core/Math/SunMath.swift
Normal file
134
iOS/velocity-ipad/velocity/Core/Math/SunMath.swift
Normal file
@@ -0,0 +1,134 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
struct SunPosition {
|
||||
let azimuth: Double // 0...360, degrees clockwise from true north
|
||||
let elevation: Double // -90...90 degrees above horizon
|
||||
}
|
||||
|
||||
enum SunMath {
|
||||
|
||||
// MARK: - Single Position
|
||||
|
||||
static func calculateSunPosition(date: Date, coordinate: CLLocationCoordinate2D) -> SunPosition {
|
||||
let timezone = TimeZone.current
|
||||
let localOffsetHours = Double(timezone.secondsFromGMT(for: date)) / 3600.0
|
||||
let julianDay = date.julianDay
|
||||
|
||||
let n = julianDay - 2_451_545.0
|
||||
let meanLongitude = normalizeDegrees(280.46 + 0.985_647_4 * n)
|
||||
let meanAnomaly = normalizeDegrees(357.528 + 0.985_600_3 * n)
|
||||
|
||||
let lambda = meanLongitude
|
||||
+ 1.915 * sin(meanAnomaly.radians)
|
||||
+ 0.020 * sin((2.0 * meanAnomaly).radians)
|
||||
let obliquity = 23.439 - 0.000_000_4 * n
|
||||
|
||||
let rightAscension = atan2(
|
||||
cos(obliquity.radians) * sin(lambda.radians),
|
||||
cos(lambda.radians)
|
||||
).degrees
|
||||
let declination = asin(sin(obliquity.radians) * sin(lambda.radians)).degrees
|
||||
|
||||
let utcHours = date.utcHours
|
||||
let lst = normalizeDegrees(100.46 + 0.985_647 * n + coordinate.longitude + 15.0 * utcHours + localOffsetHours)
|
||||
let hourAngle = normalizeDegrees(lst - rightAscension)
|
||||
let signedHourAngle = hourAngle > 180.0 ? hourAngle - 360.0 : hourAngle
|
||||
|
||||
let latitude = coordinate.latitude.radians
|
||||
let declinationRad = declination.radians
|
||||
let hourAngleRad = signedHourAngle.radians
|
||||
|
||||
let elevation = asin(
|
||||
sin(latitude) * sin(declinationRad)
|
||||
+ cos(latitude) * cos(declinationRad) * cos(hourAngleRad)
|
||||
).degrees
|
||||
|
||||
let azimuth = normalizeDegrees(
|
||||
atan2(
|
||||
-sin(hourAngleRad),
|
||||
tan(declinationRad) * cos(latitude) - sin(latitude) * cos(hourAngleRad)
|
||||
).degrees
|
||||
)
|
||||
|
||||
return SunPosition(azimuth: azimuth, elevation: elevation)
|
||||
}
|
||||
|
||||
// MARK: - Hourly Arc (used by legacy code & DashedSunLine)
|
||||
|
||||
/// 5-sample dictionary kept for backward compat with the Dollhouse slider.
|
||||
static func sunPathSamples(for date: Date, coordinate: CLLocationCoordinate2D) -> [Date: SunPosition] {
|
||||
let calendar = Calendar.current
|
||||
let sampleHours = [8, 10, 12, 14, 16]
|
||||
var output: [Date: SunPosition] = [:]
|
||||
for hour in sampleHours {
|
||||
if let sampleDate = calendar.date(bySettingHour: hour, minute: 0, second: 0, of: date) {
|
||||
output[sampleDate] = calculateSunPosition(date: sampleDate, coordinate: coordinate)
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
/// Dense arc for the AR overlay — one sample per hour from 4 AM to 8 PM.
|
||||
/// Filters out below-horizon positions (elevation < -5°).
|
||||
static func sunPathArc(for date: Date, coordinate: CLLocationCoordinate2D) -> [(date: Date, position: SunPosition)] {
|
||||
let calendar = Calendar.current
|
||||
var result: [(Date, SunPosition)] = []
|
||||
for hour in 4...20 {
|
||||
guard let sampleDate = calendar.date(bySettingHour: hour, minute: 0, second: 0, of: date) else { continue }
|
||||
let pos = calculateSunPosition(date: sampleDate, coordinate: coordinate)
|
||||
// include a small below-horizon buffer so arc starts/ends smoothly
|
||||
if pos.elevation > -5 {
|
||||
result.append((sampleDate, pos))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Approximate sunrise and sunset by scanning for elevation sign changes.
|
||||
static func sunRiseSet(for date: Date, coordinate: CLLocationCoordinate2D) -> (rise: Date?, set: Date?) {
|
||||
let calendar = Calendar.current
|
||||
var rise: Date? = nil
|
||||
var set: Date? = nil
|
||||
var prevElevation: Double? = nil
|
||||
var prevDate: Date? = nil
|
||||
|
||||
for minuteOffset in stride(from: 0, through: 24 * 60, by: 10) {
|
||||
guard let sampleDate = calendar.date(byAdding: .minute, value: minuteOffset, to: calendar.startOfDay(for: date)) else { continue }
|
||||
let pos = calculateSunPosition(date: sampleDate, coordinate: coordinate)
|
||||
if let prev = prevElevation, let prevD = prevDate {
|
||||
if prev < 0 && pos.elevation >= 0 { rise = prevD }
|
||||
if prev >= 0 && pos.elevation < 0 { set = prevD }
|
||||
}
|
||||
prevElevation = pos.elevation
|
||||
prevDate = sampleDate
|
||||
}
|
||||
return (rise, set)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
static func normalizeDegrees(_ value: Double) -> Double {
|
||||
let reduced = value.truncatingRemainder(dividingBy: 360.0)
|
||||
return reduced >= 0 ? reduced : reduced + 360.0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Date helpers
|
||||
|
||||
private extension Date {
|
||||
var utcHours: Double {
|
||||
let calendar = Calendar(identifier: .gregorian)
|
||||
let comps = calendar.dateComponents(in: TimeZone(secondsFromGMT: 0)!, from: self)
|
||||
return Double(comps.hour ?? 0) + Double(comps.minute ?? 0) / 60.0 + Double(comps.second ?? 0) / 3600.0
|
||||
}
|
||||
|
||||
var julianDay: Double {
|
||||
timeIntervalSince1970 / 86_400.0 + 2_440_587.5
|
||||
}
|
||||
}
|
||||
|
||||
private extension Double {
|
||||
var radians: Double { self * .pi / 180.0 }
|
||||
var degrees: Double { self * 180.0 / .pi }
|
||||
}
|
||||
351
iOS/velocity-ipad/velocity/Core/Networking/ComfyClient.swift
Normal file
351
iOS/velocity-ipad/velocity/Core/Networking/ComfyClient.swift
Normal file
@@ -0,0 +1,351 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
// MARK: - ComfyClient
|
||||
|
||||
/// Handles all Dream Weaver API communication.
|
||||
/// The iPad app talks only to the configured Dream Weaver gateway, never directly to ComfyUI.
|
||||
/// Flow: POST /dream-weaver → poll /status → GET /result
|
||||
final class ComfyClient {
|
||||
static let shared = ComfyClient()
|
||||
private let urlSession: URLSession
|
||||
private var baseURL: String { AppConfig.dreamWeaverBaseURL }
|
||||
private var apiKey: String? { AppConfig.dreamWeaverAPIKey }
|
||||
|
||||
init(urlSession: URLSession = .shared) {
|
||||
self.urlSession = urlSession
|
||||
}
|
||||
|
||||
// MARK: - Health Check
|
||||
|
||||
/// Call on app launch to confirm the Dream Weaver gateway is reachable
|
||||
/// and the Dream Weaver routes are actually mounted behind it.
|
||||
func checkHealth() async -> Bool {
|
||||
do {
|
||||
var request = authorizedRequest(url: try resolvedURL(candidate: nil, fallbackPath: "/health"))
|
||||
request.timeoutInterval = 30.0
|
||||
|
||||
let (data, response) = try await urlSession.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
|
||||
return false
|
||||
}
|
||||
|
||||
let json = try JSONDecoder().decode(HealthResponse.self, from: data)
|
||||
guard ["ok", "healthy"].contains(json.status.lowercased()) else {
|
||||
return false
|
||||
}
|
||||
|
||||
return try await probeDreamWeaverRoute()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Main Generation Pipeline
|
||||
|
||||
/// Full pipeline: upload → queue → poll → download.
|
||||
/// - Parameters:
|
||||
/// - source: Room photo from camera or library.
|
||||
/// - roomType: The type of room being designed (e.g. "bedroom", "living_room").
|
||||
/// - keywords: Comma-separated user keywords appended to the style prompt (can be empty).
|
||||
func generateImage(source: UIImage, roomType: String, keywords: String) async throws -> UIImage {
|
||||
let normalised = source.fixedOrientation()
|
||||
let resized = normalised.resizedSquare(to: 1024)
|
||||
guard let imageData = resized.jpegData(compressionQuality: 0.85) else {
|
||||
throw DreamWeaverError.encodingFailed
|
||||
}
|
||||
|
||||
// 1. Submit job → get job_id
|
||||
let job = try await submitJob(imageData: imageData, roomType: roomType, keywords: keywords)
|
||||
|
||||
// 2. Poll status every 2s until ready (max 5 min per integration guide §3.3)
|
||||
let resultURL = try await pollUntilReady(job: job)
|
||||
|
||||
// 3. Download result PNG
|
||||
return try await downloadResult(from: resultURL)
|
||||
}
|
||||
|
||||
// MARK: - Step 1: POST /dream-weaver
|
||||
|
||||
private func submitJob(imageData: Data, roomType: String, keywords: String) async throws -> GenerationJob {
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
var request = authorizedRequest(url: try resolvedURL(candidate: nil, fallbackPath: "/dream-weaver"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
request.timeoutInterval = 180.0
|
||||
request.httpBody = buildMultipart(
|
||||
imageData: imageData,
|
||||
roomType: roomType,
|
||||
keywords: keywords,
|
||||
boundary: boundary
|
||||
)
|
||||
|
||||
let (data, response) = try await urlSession.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
|
||||
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||
let detail = String(data: data, encoding: .utf8) ?? ""
|
||||
throw DreamWeaverError.generationFailed("Submission failed (HTTP \(code))\(detail.isEmpty ? "" : ": \(detail)")")
|
||||
}
|
||||
|
||||
return try JSONDecoder().decode(GenerationJob.self, from: data)
|
||||
}
|
||||
|
||||
// MARK: - Step 2: GET /dream-weaver/status/{job_id}
|
||||
|
||||
/// Polls every 2s, max 150 attempts (5 minutes). Returns full result URL when ready.
|
||||
private func pollUntilReady(job: GenerationJob, maxAttempts: Int = 150) async throws -> URL {
|
||||
let statusURL = try job.resolvedPollURL(baseURL: baseURL)
|
||||
|
||||
for _ in 0..<maxAttempts {
|
||||
try await Task.sleep(nanoseconds: 2_000_000_000) // 2s
|
||||
let (data, response) = try await urlSession.data(for: authorizedRequest(url: statusURL))
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw DreamWeaverError.generationFailed("Dream Weaver status check returned no HTTP response.")
|
||||
}
|
||||
guard 200..<300 ~= http.statusCode else {
|
||||
let detail = String(data: data, encoding: .utf8) ?? ""
|
||||
throw DreamWeaverError.generationFailed(
|
||||
"Dream Weaver status check failed (HTTP \(http.statusCode))\(detail.isEmpty ? "" : ": \(detail)")"
|
||||
)
|
||||
}
|
||||
|
||||
let status = try JSONDecoder().decode(JobStatus.self, from: data)
|
||||
|
||||
if status.ready {
|
||||
return try status.resolvedResultURL(baseURL: baseURL, jobId: job.jobId)
|
||||
}
|
||||
if status.status.lowercased() == "error" {
|
||||
throw DreamWeaverError.generationFailed(status.error ?? "Unknown server error")
|
||||
}
|
||||
}
|
||||
throw DreamWeaverError.timeout
|
||||
}
|
||||
|
||||
// MARK: - Step 3: GET /dream-weaver/result/{job_id}
|
||||
|
||||
private func downloadResult(from url: URL) async throws -> UIImage {
|
||||
let (data, response) = try await urlSession.data(for: authorizedRequest(url: url, accept: "image/png"))
|
||||
guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
|
||||
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||
let detail = String(data: data, encoding: .utf8) ?? ""
|
||||
throw DreamWeaverError.generationFailed(
|
||||
"Dream Weaver result download failed (HTTP \(code))\(detail.isEmpty ? "" : ": \(detail)")"
|
||||
)
|
||||
}
|
||||
guard let image = UIImage(data: data) else {
|
||||
throw DreamWeaverError.invalidImageData
|
||||
}
|
||||
return image
|
||||
}
|
||||
|
||||
// MARK: - Multipart Builder
|
||||
|
||||
private func buildMultipart(imageData: Data, roomType: String, keywords: String, boundary: String) -> Data {
|
||||
var body = Data()
|
||||
let crlf = "\r\n"
|
||||
|
||||
// image field
|
||||
body += "--\(boundary)\(crlf)"
|
||||
body += "Content-Disposition: form-data; name=\"image\"; filename=\"room.jpg\"\(crlf)"
|
||||
body += "Content-Type: image/jpeg\(crlf)\(crlf)"
|
||||
body += imageData
|
||||
body += crlf
|
||||
|
||||
// roomType field
|
||||
body += "--\(boundary)\(crlf)"
|
||||
body += "Content-Disposition: form-data; name=\"room_type\"\(crlf)\(crlf)"
|
||||
body += roomType
|
||||
body += crlf
|
||||
|
||||
// keywords field — user's optional comma-separated additions
|
||||
if !keywords.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
body += "--\(boundary)\(crlf)"
|
||||
body += "Content-Disposition: form-data; name=\"keywords\"\(crlf)\(crlf)"
|
||||
body += keywords.trimmingCharacters(in: .whitespaces)
|
||||
body += crlf
|
||||
}
|
||||
|
||||
body += "--\(boundary)--\(crlf)"
|
||||
return body
|
||||
}
|
||||
|
||||
private func probeDreamWeaverRoute() async throws -> Bool {
|
||||
let probeURL = try resolvedURL(
|
||||
candidate: nil,
|
||||
fallbackPath: "/dream-weaver/status/velocity-route-probe"
|
||||
)
|
||||
var request = authorizedRequest(url: probeURL)
|
||||
request.timeoutInterval = 30.0
|
||||
|
||||
let (data, response) = try await urlSession.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
return false
|
||||
}
|
||||
|
||||
switch http.statusCode {
|
||||
case 200..<300:
|
||||
return (try? JSONDecoder().decode(JobStatus.self, from: data)) != nil
|
||||
case 404:
|
||||
guard let errorResponse = try? JSONDecoder().decode(DreamWeaverErrorResponse.self, from: data) else {
|
||||
return false
|
||||
}
|
||||
return errorResponse.detail.localizedCaseInsensitiveContains("job not found")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvedURL(candidate: String?, fallbackPath: String) throws -> URL {
|
||||
let gatewayBaseURL = baseURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let base = URL(string: gatewayBaseURL) else {
|
||||
throw DreamWeaverError.generationFailed("Invalid Dream Weaver gateway URL: \(gatewayBaseURL)")
|
||||
}
|
||||
|
||||
let value = candidate?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let value, !value.isEmpty {
|
||||
if let absolute = URL(string: value), absolute.scheme != nil {
|
||||
return absolute
|
||||
}
|
||||
if let relative = URL(string: value, relativeTo: base)?.absoluteURL {
|
||||
return relative
|
||||
}
|
||||
}
|
||||
|
||||
guard let fallback = URL(string: fallbackPath, relativeTo: base)?.absoluteURL else {
|
||||
throw DreamWeaverError.generationFailed("Invalid Dream Weaver route: \(fallbackPath)")
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
private func authorizedRequest(url: URL, accept: String = "application/json") -> URLRequest {
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue(accept, forHTTPHeaderField: "Accept")
|
||||
if let apiKey, !apiKey.isEmpty {
|
||||
request.setValue(apiKey, forHTTPHeaderField: "X-Dream-Weaver-API-Key")
|
||||
}
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data Models (§5 of integration guide)
|
||||
|
||||
struct GenerationJob: Codable {
|
||||
let jobId: String
|
||||
let status: String
|
||||
let pollUrl: String?
|
||||
let resultUrl: String?
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case jobId = "job_id"
|
||||
case status
|
||||
case pollUrl = "poll_url"
|
||||
case resultUrl = "result_url"
|
||||
}
|
||||
|
||||
func resolvedPollURL(baseURL: String) throws -> URL {
|
||||
try resolvedURL(candidate: pollUrl, baseURL: baseURL, fallbackPath: "/dream-weaver/status/\(jobId)")
|
||||
}
|
||||
|
||||
func resolvedResultURL(baseURL: String) throws -> URL {
|
||||
try resolvedURL(candidate: resultUrl, baseURL: baseURL, fallbackPath: "/dream-weaver/result/\(jobId)")
|
||||
}
|
||||
}
|
||||
|
||||
struct JobStatus: Codable {
|
||||
let status: String
|
||||
let ready: Bool
|
||||
let resultUrl: String?
|
||||
let error: String?
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case status, ready
|
||||
case resultUrl = "result_url"
|
||||
case error
|
||||
}
|
||||
|
||||
func resolvedResultURL(baseURL: String, jobId: String) throws -> URL {
|
||||
try resolvedURL(candidate: resultUrl, baseURL: baseURL, fallbackPath: "/dream-weaver/result/\(jobId)")
|
||||
}
|
||||
}
|
||||
|
||||
struct HealthResponse: Codable {
|
||||
let status: String
|
||||
let comfyui: Bool?
|
||||
}
|
||||
|
||||
struct DreamWeaverErrorResponse: Codable {
|
||||
let detail: String
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum DreamWeaverError: LocalizedError {
|
||||
case encodingFailed
|
||||
case invalidImageData
|
||||
case generationFailed(String)
|
||||
case timeout
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .encodingFailed: return "Failed to encode the captured image."
|
||||
case .invalidImageData: return "The server returned an unreadable image."
|
||||
case .generationFailed(let msg): return msg
|
||||
case .timeout: return "The server is taking longer than expected. Please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvedURL(candidate: String?, baseURL: String, fallbackPath: String) throws -> URL {
|
||||
let gatewayBaseURL = baseURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let base = URL(string: gatewayBaseURL) else {
|
||||
throw DreamWeaverError.generationFailed("Invalid Dream Weaver gateway URL: \(gatewayBaseURL)")
|
||||
}
|
||||
|
||||
let value = candidate?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let value, !value.isEmpty {
|
||||
if let absolute = URL(string: value), absolute.scheme != nil {
|
||||
return absolute
|
||||
}
|
||||
if let relative = URL(string: value, relativeTo: base)?.absoluteURL {
|
||||
return relative
|
||||
}
|
||||
}
|
||||
|
||||
guard let fallback = URL(string: fallbackPath, relativeTo: base)?.absoluteURL else {
|
||||
throw DreamWeaverError.generationFailed("Invalid Dream Weaver route: \(fallbackPath)")
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// MARK: - UIImage Helpers
|
||||
|
||||
extension UIImage {
|
||||
func fixedOrientation() -> UIImage {
|
||||
guard imageOrientation != .up else { return self }
|
||||
let fmt = UIGraphicsImageRendererFormat.default()
|
||||
fmt.scale = scale
|
||||
return UIGraphicsImageRenderer(size: size, format: fmt).image { _ in
|
||||
draw(in: CGRect(origin: .zero, size: size))
|
||||
}
|
||||
}
|
||||
|
||||
func resizedSquare(to side: CGFloat) -> UIImage {
|
||||
let fmt = UIGraphicsImageRendererFormat.default()
|
||||
fmt.scale = 1
|
||||
return UIGraphicsImageRenderer(size: CGSize(width: side, height: side), format: fmt).image { _ in
|
||||
let aspect = size.width / size.height
|
||||
let rect: CGRect
|
||||
if aspect > 1 {
|
||||
let w = side * aspect
|
||||
rect = CGRect(x: (side - w) / 2, y: 0, width: w, height: side)
|
||||
} else {
|
||||
let h = side / aspect
|
||||
rect = CGRect(x: 0, y: (side - h) / 2, width: side, height: h)
|
||||
}
|
||||
draw(in: rect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data Helpers
|
||||
|
||||
private func += (lhs: inout Data, rhs: String) { if let d = rhs.data(using: .utf8) { lhs.append(d) } }
|
||||
private func += (lhs: inout Data, rhs: Data) { lhs.append(rhs) }
|
||||
2165
iOS/velocity-ipad/velocity/Core/Networking/VelocityAPIClient.swift
Normal file
2165
iOS/velocity-ipad/velocity/Core/Networking/VelocityAPIClient.swift
Normal file
File diff suppressed because it is too large
Load Diff
815
iOS/velocity-ipad/velocity/Core/State/AppStore.swift
Normal file
815
iOS/velocity-ipad/velocity/Core/State/AppStore.swift
Normal file
@@ -0,0 +1,815 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
struct DashboardMetrics {
|
||||
let leadCount: Int
|
||||
let whaleLeadCount: Int
|
||||
let propertyCount: Int
|
||||
let todayCalendarCount: Int
|
||||
let pendingTaskCount: Int
|
||||
let urgentTaskCount: Int
|
||||
let pendingInsights: Int
|
||||
let pendingTranscriptions: Int
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppStore {
|
||||
static let shared = AppStore()
|
||||
|
||||
private static let locallyCreatedCalendarEventsKey = "velocity.calendar.locally_created_events"
|
||||
private static let locallyMutatedTasksKey = "velocity.calendar.locally_mutated_tasks"
|
||||
private static let locallyHiddenTaskIDsKey = "velocity.calendar.locally_hidden_task_ids"
|
||||
|
||||
private init() {
|
||||
localTaskOverrides = Self.loadLocallyMutatedTasks()
|
||||
locallyResolvedTaskIDs = Self.loadLocallyHiddenTaskIDs()
|
||||
tasks = VelocityTaskDTO.sortedForOperatorReview(Array(localTaskOverrides.values))
|
||||
locallyCreatedCalendarEvents = Self.loadLocallyCreatedCalendarEvents()
|
||||
calendarEvents = locallyCreatedCalendarEvents
|
||||
}
|
||||
|
||||
private struct RefreshSnapshot {
|
||||
let contacts: [VelocityCanonicalContactListItemDTO]
|
||||
let leads: [VelocityLeadDTO]
|
||||
let tasks: [VelocityTaskDTO]
|
||||
let pendingTaskCount: Int
|
||||
let pendingTaskIDs: Set<String>
|
||||
let kanbanColumns: [VelocityKanbanColumnDTO]
|
||||
let opportunities: [VelocityOpportunityDTO]
|
||||
let properties: [VelocityPropertyDTO]
|
||||
let calendarEvents: [VelocityCalendarEventDTO]
|
||||
let alertSnapshot: VelocityAlertSnapshotDTO
|
||||
let leadEvents: [String: [VelocityCommunicationEventDTO]]
|
||||
}
|
||||
|
||||
private struct CalendarTaskRefresh {
|
||||
let tasks: [VelocityTaskDTO]
|
||||
let pendingTaskCount: Int
|
||||
let pendingTaskIDs: Set<String>
|
||||
}
|
||||
|
||||
private struct PersistedCalendarEvent: Codable {
|
||||
let calendarEventId: String
|
||||
let leadId: String?
|
||||
let title: String
|
||||
let description: String?
|
||||
let startAt: String
|
||||
let endAt: String
|
||||
let allDay: Bool
|
||||
let status: String
|
||||
let reminderMinutes: [Int]
|
||||
let createdBy: String
|
||||
let location: String?
|
||||
let createdAt: String
|
||||
|
||||
init(event: VelocityCalendarEventDTO) {
|
||||
calendarEventId = event.calendarEventId
|
||||
leadId = event.leadId
|
||||
title = event.title
|
||||
description = event.description
|
||||
startAt = event.startAt
|
||||
endAt = event.endAt
|
||||
allDay = event.allDay
|
||||
status = event.status
|
||||
reminderMinutes = event.reminderMinutes
|
||||
createdBy = event.createdBy
|
||||
location = event.location
|
||||
createdAt = event.createdAt
|
||||
}
|
||||
|
||||
var event: VelocityCalendarEventDTO {
|
||||
VelocityCalendarEventDTO(
|
||||
calendarEventId: calendarEventId,
|
||||
leadId: leadId,
|
||||
title: title,
|
||||
description: description,
|
||||
startAt: startAt,
|
||||
endAt: endAt,
|
||||
allDay: allDay,
|
||||
status: status,
|
||||
reminderMinutes: reminderMinutes,
|
||||
createdBy: createdBy,
|
||||
location: location,
|
||||
createdAt: createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PersistedTask: Codable {
|
||||
let reminderId: String
|
||||
let reminderType: String
|
||||
let title: String
|
||||
let notes: String?
|
||||
let dueAt: String?
|
||||
let status: String
|
||||
let priority: String
|
||||
let personId: String?
|
||||
let clientName: String?
|
||||
let clientPhone: String?
|
||||
|
||||
init(task: VelocityTaskDTO) {
|
||||
reminderId = task.reminderId
|
||||
reminderType = task.reminderType
|
||||
title = task.title
|
||||
notes = task.notes
|
||||
dueAt = task.dueAt
|
||||
status = task.status
|
||||
priority = task.priority
|
||||
personId = task.personId
|
||||
clientName = task.clientName
|
||||
clientPhone = task.clientPhone
|
||||
}
|
||||
|
||||
var task: VelocityTaskDTO {
|
||||
VelocityTaskDTO(
|
||||
reminderId: reminderId,
|
||||
reminderType: reminderType,
|
||||
title: title,
|
||||
notes: notes,
|
||||
dueAt: dueAt,
|
||||
status: status,
|
||||
priority: priority,
|
||||
personId: personId,
|
||||
clientName: clientName,
|
||||
clientPhone: clientPhone
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var contacts: [VelocityCanonicalContactListItemDTO] = []
|
||||
var leads: [VelocityLeadDTO] = []
|
||||
var tasks: [VelocityTaskDTO] = []
|
||||
var kanbanColumns: [VelocityKanbanColumnDTO] = []
|
||||
var opportunities: [VelocityOpportunityDTO] = []
|
||||
var properties: [VelocityPropertyDTO] = []
|
||||
var calendarEvents: [VelocityCalendarEventDTO] = []
|
||||
var leadEvents: [String: [VelocityCommunicationEventDTO]] = [:]
|
||||
var alertSnapshot: VelocityAlertSnapshotDTO?
|
||||
var pendingTaskMetricCount = 0
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
var lastRefreshAt: Date?
|
||||
private var activeRefreshTask: Task<RefreshSnapshot, Error>?
|
||||
private var canonicalPendingTaskCount = 0
|
||||
private var canonicalPendingTaskIDs: Set<String> = []
|
||||
private var locallyResolvedTaskIDs: Set<String> = []
|
||||
private var localTaskOverrides: [String: VelocityTaskDTO] = [:]
|
||||
private var locallyCreatedCalendarEvents: [VelocityCalendarEventDTO] = []
|
||||
|
||||
var operatorIdentity: String {
|
||||
if let email = AppConfig.apiEmail, !email.isEmpty {
|
||||
return email
|
||||
}
|
||||
if let token = AppConfig.apiBearerToken, !token.isEmpty {
|
||||
return "Token authenticated operator"
|
||||
}
|
||||
return "Unconfigured operator"
|
||||
}
|
||||
|
||||
var authDescription: String {
|
||||
if let _ = AppConfig.apiBearerToken {
|
||||
return "Bearer token"
|
||||
}
|
||||
if AppConfig.apiEmail != nil, AppConfig.apiPassword != nil {
|
||||
return "Email/password login"
|
||||
}
|
||||
return "Credentials required"
|
||||
}
|
||||
|
||||
var isConfigured: Bool {
|
||||
AppConfig.isLiveConfigured
|
||||
}
|
||||
|
||||
var metrics: DashboardMetrics {
|
||||
DashboardMetrics(
|
||||
leadCount: leads.count,
|
||||
whaleLeadCount: leads.filter { $0.score >= 90 || $0.qualification.lowercased() == "whale" }.count,
|
||||
propertyCount: properties.count,
|
||||
todayCalendarCount: calendarEvents.filter { $0.startsToday }.count,
|
||||
pendingTaskCount: pendingTaskMetricCount,
|
||||
urgentTaskCount: tasks.filter {
|
||||
$0.status.lowercased() == "pending" && ["urgent", "high"].contains($0.priority.lowercased())
|
||||
}.count,
|
||||
pendingInsights: alertSnapshot?.pendingInsights ?? 0,
|
||||
pendingTranscriptions: alertSnapshot?.pendingTranscriptions ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
var highlightedLeads: [VelocityLeadDTO] {
|
||||
Array(leads.sorted(by: { $0.score > $1.score }).prefix(5))
|
||||
}
|
||||
|
||||
var highlightedContacts: [VelocityCanonicalContactListItemDTO] {
|
||||
Array(contacts.prefix(12))
|
||||
}
|
||||
|
||||
var timelineEvents: [TimelineEvent] {
|
||||
leadEvents
|
||||
.flatMap { leadId, events in
|
||||
events.map { TimelineEvent(leadId: leadId, event: $0, leadName: leadName(for: leadId)) }
|
||||
}
|
||||
.sorted(by: { $0.date > $1.date })
|
||||
}
|
||||
|
||||
var prioritizedTasks: [VelocityTaskDTO] {
|
||||
VelocityTaskDTO.sortedForOperatorReview(tasks)
|
||||
}
|
||||
|
||||
func resetLiveData() {
|
||||
contacts = []
|
||||
leads = []
|
||||
tasks = []
|
||||
kanbanColumns = []
|
||||
opportunities = []
|
||||
properties = []
|
||||
calendarEvents = []
|
||||
leadEvents = [:]
|
||||
alertSnapshot = nil
|
||||
pendingTaskMetricCount = 0
|
||||
canonicalPendingTaskCount = 0
|
||||
canonicalPendingTaskIDs = []
|
||||
isLoading = false
|
||||
errorMessage = nil
|
||||
lastRefreshAt = nil
|
||||
canonicalPendingTaskCount = 0
|
||||
canonicalPendingTaskIDs = []
|
||||
locallyResolvedTaskIDs = []
|
||||
localTaskOverrides = [:]
|
||||
locallyCreatedCalendarEvents = []
|
||||
Self.saveLocallyHiddenTaskIDs([])
|
||||
Self.saveLocallyMutatedTasks([])
|
||||
Self.saveLocallyCreatedCalendarEvents([])
|
||||
}
|
||||
|
||||
func refresh(silent: Bool = false) async {
|
||||
if !silent {
|
||||
isLoading = true
|
||||
}
|
||||
|
||||
do {
|
||||
let task = activeRefreshTask ?? makeRefreshTask()
|
||||
activeRefreshTask = task
|
||||
|
||||
let snapshot = try await task.value
|
||||
activeRefreshTask = nil
|
||||
|
||||
contacts = snapshot.contacts
|
||||
leads = snapshot.leads
|
||||
tasks = mergedTasks(with: snapshot.tasks)
|
||||
canonicalPendingTaskCount = snapshot.pendingTaskCount
|
||||
canonicalPendingTaskIDs = snapshot.pendingTaskIDs
|
||||
kanbanColumns = snapshot.kanbanColumns
|
||||
opportunities = snapshot.opportunities
|
||||
properties = snapshot.properties
|
||||
calendarEvents = mergedCalendarEvents(with: snapshot.calendarEvents)
|
||||
refreshPendingTaskMetricCount()
|
||||
alertSnapshot = snapshot.alertSnapshot
|
||||
leadEvents = snapshot.leadEvents
|
||||
lastRefreshAt = Date()
|
||||
errorMessage = nil
|
||||
isLoading = false
|
||||
} catch {
|
||||
activeRefreshTask = nil
|
||||
if !silent || lastRefreshAt == nil {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
if !silent {
|
||||
contacts = []
|
||||
leads = []
|
||||
tasks = []
|
||||
kanbanColumns = []
|
||||
opportunities = []
|
||||
properties = []
|
||||
calendarEvents = []
|
||||
alertSnapshot = nil
|
||||
pendingTaskMetricCount = 0
|
||||
canonicalPendingTaskCount = 0
|
||||
canonicalPendingTaskIDs = []
|
||||
leadEvents = [:]
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
func leadName(for leadId: String) -> String {
|
||||
leads.first(where: { $0.id == leadId })?.name ?? "Unknown lead"
|
||||
}
|
||||
|
||||
func updateTaskStatus(
|
||||
reminderId: String,
|
||||
status: String,
|
||||
dueAt: String? = nil,
|
||||
notes: String? = nil
|
||||
) async throws -> VelocityTaskDTO {
|
||||
let serverTask: VelocityTaskDTO
|
||||
do {
|
||||
serverTask = try await VelocityAPIClient.shared.updateTask(
|
||||
reminderId: reminderId,
|
||||
status: status,
|
||||
dueAt: dueAt,
|
||||
notes: notes
|
||||
)
|
||||
} catch let error as VelocityAPIError where error.statusCode == 404 {
|
||||
serverTask = locallyResolveMissingTask(
|
||||
reminderId: reminderId,
|
||||
status: status,
|
||||
dueAt: dueAt
|
||||
)
|
||||
}
|
||||
let updatedTask = locallyMutatedTask(from: serverTask, status: status, dueAt: dueAt)
|
||||
if updatedTask.status.lowercased() == "cancelled" {
|
||||
localTaskOverrides.removeValue(forKey: reminderId)
|
||||
locallyResolvedTaskIDs.insert(reminderId)
|
||||
Self.saveLocallyMutatedTasks(Array(localTaskOverrides.values))
|
||||
Self.saveLocallyHiddenTaskIDs(Array(locallyResolvedTaskIDs))
|
||||
tasks.removeAll { $0.reminderId == reminderId }
|
||||
} else {
|
||||
locallyResolvedTaskIDs.remove(reminderId)
|
||||
upsertLocalTaskOverride(updatedTask)
|
||||
if let index = tasks.firstIndex(where: { $0.reminderId == reminderId }) {
|
||||
tasks[index] = updatedTask
|
||||
} else {
|
||||
tasks.append(updatedTask)
|
||||
}
|
||||
tasks = VelocityTaskDTO.sortedForOperatorReview(tasks)
|
||||
}
|
||||
refreshPendingTaskMetricCount()
|
||||
errorMessage = nil
|
||||
await refresh(silent: true)
|
||||
return updatedTask
|
||||
}
|
||||
|
||||
func createCalendarEvent(
|
||||
leadId: String?,
|
||||
title: String,
|
||||
description: String?,
|
||||
startAt: String,
|
||||
endAt: String,
|
||||
allDay: Bool,
|
||||
status: String,
|
||||
reminderMinutes: [Int],
|
||||
location: String?,
|
||||
metadata: [String: String] = [:]
|
||||
) async throws -> VelocityCalendarEventCreateResultDTO {
|
||||
let createdEvent: VelocityCalendarEventCreateResultDTO
|
||||
var shouldPersistLocalFallback = false
|
||||
do {
|
||||
createdEvent = try await VelocityAPIClient.shared.createCalendarEvent(
|
||||
leadId: leadId,
|
||||
title: title,
|
||||
description: description,
|
||||
startAt: startAt,
|
||||
endAt: endAt,
|
||||
allDay: allDay,
|
||||
status: status,
|
||||
reminderMinutes: reminderMinutes,
|
||||
location: location,
|
||||
metadata: metadata
|
||||
)
|
||||
} catch let error as VelocityAPIError where error.isRecoverableCalendarCreateFailure {
|
||||
createdEvent = VelocityCalendarEventCreateResultDTO(
|
||||
calendarEventId: "local-\(UUID().uuidString)",
|
||||
createdAt: ISO8601DateFormatter().string(from: Date())
|
||||
)
|
||||
shouldPersistLocalFallback = true
|
||||
}
|
||||
let optimisticEvent = VelocityCalendarEventDTO(
|
||||
calendarEventId: createdEvent.calendarEventId,
|
||||
leadId: leadId,
|
||||
title: title,
|
||||
description: description,
|
||||
startAt: startAt,
|
||||
endAt: endAt,
|
||||
allDay: allDay,
|
||||
status: status,
|
||||
reminderMinutes: reminderMinutes,
|
||||
createdBy: "user",
|
||||
location: location,
|
||||
createdAt: createdEvent.createdAt
|
||||
)
|
||||
upsertLocalCalendarEvent(optimisticEvent, persist: shouldPersistLocalFallback)
|
||||
calendarEvents = mergedCalendarEvents(with: calendarEvents)
|
||||
refreshPendingTaskMetricCount()
|
||||
errorMessage = nil
|
||||
await refresh(silent: true)
|
||||
return createdEvent
|
||||
}
|
||||
|
||||
func updateCalendarEvent(
|
||||
_ event: VelocityCalendarEventDTO,
|
||||
status: String? = nil,
|
||||
startAt: String? = nil,
|
||||
endAt: String? = nil
|
||||
) async throws -> VelocityCalendarEventDTO {
|
||||
let shouldPersistFallback: Bool
|
||||
do {
|
||||
try await VelocityAPIClient.shared.updateCalendarEvent(
|
||||
calendarEventId: event.calendarEventId,
|
||||
startAt: startAt,
|
||||
endAt: endAt,
|
||||
status: status
|
||||
)
|
||||
shouldPersistFallback = event.calendarEventId.hasPrefix("local-")
|
||||
} catch let error as VelocityAPIError where error.isRecoverableCalendarCreateFailure {
|
||||
shouldPersistFallback = true
|
||||
}
|
||||
|
||||
let updatedEvent = VelocityCalendarEventDTO(
|
||||
calendarEventId: event.calendarEventId,
|
||||
leadId: event.leadId,
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
startAt: startAt ?? event.startAt,
|
||||
endAt: endAt ?? event.endAt,
|
||||
allDay: event.allDay,
|
||||
status: status ?? event.status,
|
||||
reminderMinutes: event.reminderMinutes,
|
||||
createdBy: event.createdBy,
|
||||
location: event.location,
|
||||
createdAt: event.createdAt
|
||||
)
|
||||
|
||||
if updatedEvent.status == "cancelled" {
|
||||
calendarEvents.removeAll { $0.calendarEventId == event.calendarEventId }
|
||||
locallyCreatedCalendarEvents.removeAll { $0.calendarEventId == event.calendarEventId }
|
||||
Self.saveLocallyCreatedCalendarEvents(locallyCreatedCalendarEvents.filter { $0.calendarEventId.hasPrefix("local-") })
|
||||
} else {
|
||||
upsertLocalCalendarEvent(updatedEvent, persist: shouldPersistFallback)
|
||||
calendarEvents = mergedCalendarEvents(with: calendarEvents)
|
||||
}
|
||||
refreshPendingTaskMetricCount()
|
||||
errorMessage = nil
|
||||
await refresh(silent: true)
|
||||
return updatedEvent
|
||||
}
|
||||
|
||||
func cancelCalendarEvent(_ event: VelocityCalendarEventDTO) async throws {
|
||||
var shouldPersistFallback = event.calendarEventId.hasPrefix("local-")
|
||||
do {
|
||||
try await VelocityAPIClient.shared.cancelCalendarEvent(calendarEventId: event.calendarEventId)
|
||||
} catch let error as VelocityAPIError where error.isRecoverableCalendarCreateFailure {
|
||||
shouldPersistFallback = true
|
||||
}
|
||||
let cancelledEvent = VelocityCalendarEventDTO(
|
||||
calendarEventId: event.calendarEventId,
|
||||
leadId: event.leadId,
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
startAt: event.startAt,
|
||||
endAt: event.endAt,
|
||||
allDay: event.allDay,
|
||||
status: "cancelled",
|
||||
reminderMinutes: event.reminderMinutes,
|
||||
createdBy: event.createdBy,
|
||||
location: event.location,
|
||||
createdAt: event.createdAt
|
||||
)
|
||||
upsertLocalCalendarEvent(cancelledEvent, persist: shouldPersistFallback)
|
||||
calendarEvents.removeAll { $0.calendarEventId == event.calendarEventId }
|
||||
refreshPendingTaskMetricCount()
|
||||
errorMessage = nil
|
||||
await refresh(silent: true)
|
||||
}
|
||||
|
||||
func updateLeadStage(
|
||||
leadId: String,
|
||||
status: String,
|
||||
notes: String? = nil
|
||||
) async throws -> VelocityLeadStageUpdateDTO {
|
||||
let updatedLead = try await VelocityAPIClient.shared.updateLeadStage(
|
||||
leadId: leadId,
|
||||
status: status,
|
||||
notes: notes
|
||||
)
|
||||
await refresh(silent: true)
|
||||
return updatedLead
|
||||
}
|
||||
|
||||
func updateOpportunity(
|
||||
opportunityId: String,
|
||||
stage: String? = nil,
|
||||
probability: Int? = nil,
|
||||
nextAction: String? = nil,
|
||||
notes: String? = nil
|
||||
) async throws -> VelocityOpportunityDTO {
|
||||
let updatedOpportunity = try await VelocityAPIClient.shared.updateOpportunity(
|
||||
opportunityId: opportunityId,
|
||||
stage: stage,
|
||||
probability: probability,
|
||||
nextAction: nextAction,
|
||||
notes: notes
|
||||
)
|
||||
await refresh(silent: true)
|
||||
return updatedOpportunity
|
||||
}
|
||||
|
||||
private func locallyResolveMissingTask(
|
||||
reminderId: String,
|
||||
status: String,
|
||||
dueAt: String?
|
||||
) -> VelocityTaskDTO {
|
||||
if status.lowercased() == "cancelled" {
|
||||
locallyResolvedTaskIDs.insert(reminderId)
|
||||
}
|
||||
let existing = tasks.first { $0.reminderId == reminderId }
|
||||
return VelocityTaskDTO(
|
||||
reminderId: reminderId,
|
||||
reminderType: existing?.reminderType ?? "follow_up",
|
||||
title: existing?.title ?? "Calendar task",
|
||||
notes: existing?.notes,
|
||||
dueAt: dueAt ?? existing?.dueAt,
|
||||
status: status,
|
||||
priority: existing?.priority ?? "normal",
|
||||
personId: existing?.personId,
|
||||
clientName: existing?.clientName,
|
||||
clientPhone: existing?.clientPhone
|
||||
)
|
||||
}
|
||||
|
||||
private func locallyMutatedTask(
|
||||
from task: VelocityTaskDTO,
|
||||
status: String,
|
||||
dueAt: String?
|
||||
) -> VelocityTaskDTO {
|
||||
VelocityTaskDTO(
|
||||
reminderId: task.reminderId,
|
||||
reminderType: task.reminderType,
|
||||
title: task.title,
|
||||
notes: task.notes,
|
||||
dueAt: dueAt ?? task.dueAt,
|
||||
status: status,
|
||||
priority: task.priority,
|
||||
personId: task.personId,
|
||||
clientName: task.clientName,
|
||||
clientPhone: task.clientPhone
|
||||
)
|
||||
}
|
||||
|
||||
private func upsertLocalTaskOverride(_ task: VelocityTaskDTO) {
|
||||
localTaskOverrides[task.reminderId] = task
|
||||
Self.saveLocallyMutatedTasks(Array(localTaskOverrides.values))
|
||||
Self.saveLocallyHiddenTaskIDs(Array(locallyResolvedTaskIDs))
|
||||
}
|
||||
|
||||
private func mergedTasks(with fetchedTasks: [VelocityTaskDTO]) -> [VelocityTaskDTO] {
|
||||
var taskByID = Dictionary(uniqueKeysWithValues: fetchedTasks.map { ($0.reminderId, $0) })
|
||||
for task in localTaskOverrides.values {
|
||||
taskByID[task.reminderId] = task
|
||||
}
|
||||
let visibleTasks = taskByID.values.filter { !locallyResolvedTaskIDs.contains($0.reminderId) }
|
||||
return VelocityTaskDTO.sortedForOperatorReview(Array(visibleTasks))
|
||||
}
|
||||
|
||||
private func refreshPendingTaskMetricCount() {
|
||||
var localDelta = 0
|
||||
for task in localTaskOverrides.values {
|
||||
let isCanonicalPending = canonicalPendingTaskIDs.contains(task.reminderId)
|
||||
let isLocallyPending = task.status.lowercased() == "pending"
|
||||
if isCanonicalPending && !isLocallyPending {
|
||||
localDelta -= 1
|
||||
} else if !isCanonicalPending && isLocallyPending {
|
||||
localDelta += 1
|
||||
}
|
||||
}
|
||||
|
||||
let locallyHiddenPendingCount = locallyResolvedTaskIDs
|
||||
.filter { canonicalPendingTaskIDs.contains($0) }
|
||||
.count
|
||||
let normalCalendarTaskCount = calendarEvents.filter { event in
|
||||
event.status.lowercased() == "tentative"
|
||||
}.count
|
||||
|
||||
pendingTaskMetricCount = max(
|
||||
0,
|
||||
canonicalPendingTaskCount + localDelta - locallyHiddenPendingCount + normalCalendarTaskCount
|
||||
)
|
||||
}
|
||||
|
||||
private static func loadLocallyMutatedTasks() -> [String: VelocityTaskDTO] {
|
||||
guard let data = UserDefaults.standard.data(forKey: locallyMutatedTasksKey),
|
||||
let persistedTasks = try? JSONDecoder().decode([PersistedTask].self, from: data)
|
||||
else {
|
||||
return [:]
|
||||
}
|
||||
return Dictionary(uniqueKeysWithValues: persistedTasks.map { ($0.reminderId, $0.task) })
|
||||
}
|
||||
|
||||
private static func saveLocallyMutatedTasks(_ tasks: [VelocityTaskDTO]) {
|
||||
let persistedTasks = tasks.map(PersistedTask.init(task:))
|
||||
if let data = try? JSONEncoder().encode(persistedTasks) {
|
||||
UserDefaults.standard.set(data, forKey: locallyMutatedTasksKey)
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadLocallyHiddenTaskIDs() -> Set<String> {
|
||||
let ids = UserDefaults.standard.stringArray(forKey: locallyHiddenTaskIDsKey) ?? []
|
||||
return Set(ids)
|
||||
}
|
||||
|
||||
private static func saveLocallyHiddenTaskIDs(_ taskIDs: [String]) {
|
||||
UserDefaults.standard.set(taskIDs, forKey: locallyHiddenTaskIDsKey)
|
||||
}
|
||||
|
||||
private func upsertLocalCalendarEvent(_ event: VelocityCalendarEventDTO, persist: Bool) {
|
||||
locallyCreatedCalendarEvents.removeAll { $0.calendarEventId == event.calendarEventId }
|
||||
locallyCreatedCalendarEvents.append(event)
|
||||
if persist {
|
||||
Self.saveLocallyCreatedCalendarEvents(locallyCreatedCalendarEvents)
|
||||
}
|
||||
}
|
||||
|
||||
private func mergedCalendarEvents(with fetchedEvents: [VelocityCalendarEventDTO]) -> [VelocityCalendarEventDTO] {
|
||||
var eventByID = Dictionary(uniqueKeysWithValues: fetchedEvents.map { ($0.calendarEventId, $0) })
|
||||
for event in locallyCreatedCalendarEvents {
|
||||
eventByID[event.calendarEventId] = event
|
||||
}
|
||||
return eventByID.values.filter { $0.status != "cancelled" }.sorted {
|
||||
($0.startDate ?? .distantFuture) < ($1.startDate ?? .distantFuture)
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadLocallyCreatedCalendarEvents() -> [VelocityCalendarEventDTO] {
|
||||
guard let data = UserDefaults.standard.data(forKey: locallyCreatedCalendarEventsKey),
|
||||
let persistedEvents = try? JSONDecoder().decode([PersistedCalendarEvent].self, from: data)
|
||||
else {
|
||||
return []
|
||||
}
|
||||
return persistedEvents.map(\.event)
|
||||
}
|
||||
|
||||
private static func saveLocallyCreatedCalendarEvents(_ events: [VelocityCalendarEventDTO]) {
|
||||
let persistedEvents = events.map(PersistedCalendarEvent.init(event:))
|
||||
if let data = try? JSONEncoder().encode(persistedEvents) {
|
||||
UserDefaults.standard.set(data, forKey: locallyCreatedCalendarEventsKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeRefreshTask() -> Task<RefreshSnapshot, Error> {
|
||||
let cachedContacts = contacts
|
||||
return Task {
|
||||
async let tasksTask = fetchCalendarTasks()
|
||||
async let kanbanTask: [VelocityKanbanColumnDTO]? = try? await VelocityAPIClient.shared.fetchKanbanBoard()
|
||||
async let opportunitiesTask: [VelocityOpportunityDTO]? = try? await VelocityAPIClient.shared.fetchOpportunities()
|
||||
async let propertiesTask: [VelocityPropertyDTO]? = try? await VelocityAPIClient.shared.fetchProperties(
|
||||
limit: AppStoreRefreshPolicy.inventoryPropertyLimit
|
||||
)
|
||||
async let calendarTask: [VelocityCalendarEventDTO]? = try? await VelocityAPIClient.shared.fetchCalendarEvents()
|
||||
async let alertsTask: VelocityAlertSnapshotDTO? = try? await VelocityAPIClient.shared.fetchAlerts()
|
||||
|
||||
let fetchedContacts: [VelocityCanonicalContactListItemDTO]
|
||||
do {
|
||||
fetchedContacts = try await VelocityAPIClient.shared.fetchContacts()
|
||||
} catch let error as VelocityAPIError where error.statusCode == 404 {
|
||||
fetchedContacts = cachedContacts
|
||||
}
|
||||
let fetchedLeads = VelocityLeadDTO.activeLeadSummaries(from: fetchedContacts)
|
||||
let taskRefresh = await tasksTask
|
||||
let fetchedTasks = taskRefresh.tasks.filter { !locallyResolvedTaskIDs.contains($0.reminderId) }
|
||||
let fetchedKanban = await kanbanTask ?? []
|
||||
let fetchedOpportunities = await opportunitiesTask ?? []
|
||||
let fetchedProperties = await propertiesTask ?? []
|
||||
let fetchedCalendar = await calendarTask ?? []
|
||||
let fetchedAlerts = await alertsTask ?? VelocityAlertSnapshotDTO.empty
|
||||
let leadEvents = await fetchLeadEvents(for: fetchedLeads)
|
||||
|
||||
return RefreshSnapshot(
|
||||
contacts: fetchedContacts,
|
||||
leads: fetchedLeads,
|
||||
tasks: fetchedTasks,
|
||||
pendingTaskCount: taskRefresh.pendingTaskCount,
|
||||
pendingTaskIDs: taskRefresh.pendingTaskIDs,
|
||||
kanbanColumns: fetchedKanban,
|
||||
opportunities: fetchedOpportunities,
|
||||
properties: fetchedProperties,
|
||||
calendarEvents: fetchedCalendar,
|
||||
alertSnapshot: fetchedAlerts,
|
||||
leadEvents: leadEvents
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchCalendarTasks() async -> CalendarTaskRefresh {
|
||||
async let allTasks: [VelocityTaskDTO]? = try? await VelocityAPIClient.shared.fetchTasks(status: "all")
|
||||
async let pendingTasks: [VelocityTaskDTO]? = try? await VelocityAPIClient.shared.fetchTasks(status: "pending")
|
||||
async let confirmedTasks: [VelocityTaskDTO]? = try? await VelocityAPIClient.shared.fetchTasks(status: "confirmed")
|
||||
async let doneTasks: [VelocityTaskDTO]? = try? await VelocityAPIClient.shared.fetchTasks(status: "done")
|
||||
async let snoozedTasks: [VelocityTaskDTO]? = try? await VelocityAPIClient.shared.fetchTasks(status: "snoozed")
|
||||
|
||||
let fetchedAllTasks = await allTasks ?? []
|
||||
let pendingTaskResponse = await pendingTasks
|
||||
let fetchedPendingTasks = pendingTaskResponse ?? []
|
||||
let fetchedConfirmedTasks = await confirmedTasks ?? []
|
||||
let fetchedDoneTasks = await doneTasks ?? []
|
||||
let fetchedSnoozedTasks = await snoozedTasks ?? []
|
||||
|
||||
var taskByID: [String: VelocityTaskDTO] = [:]
|
||||
for task in fetchedAllTasks {
|
||||
taskByID[task.reminderId] = task
|
||||
}
|
||||
for task in fetchedPendingTasks {
|
||||
taskByID[task.reminderId] = task
|
||||
}
|
||||
for task in fetchedConfirmedTasks {
|
||||
taskByID[task.reminderId] = task
|
||||
}
|
||||
for task in fetchedDoneTasks {
|
||||
taskByID[task.reminderId] = task
|
||||
}
|
||||
for task in fetchedSnoozedTasks {
|
||||
taskByID[task.reminderId] = task
|
||||
}
|
||||
let pendingTaskCount = pendingTaskResponse?.count ?? fetchedAllTasks.filter { $0.status.lowercased() == "pending" }.count
|
||||
let pendingTaskIDs = Set(fetchedPendingTasks.map(\.reminderId))
|
||||
return CalendarTaskRefresh(
|
||||
tasks: VelocityTaskDTO.sortedForOperatorReview(Array(taskByID.values)),
|
||||
pendingTaskCount: pendingTaskCount,
|
||||
pendingTaskIDs: pendingTaskIDs
|
||||
)
|
||||
}
|
||||
|
||||
private func fetchLeadEvents(
|
||||
for leads: [VelocityLeadDTO]
|
||||
) async -> [String: [VelocityCommunicationEventDTO]] {
|
||||
let prioritizedLeadIDs = AppStoreRefreshPolicy.prioritizedLeadIDs(from: leads)
|
||||
|
||||
return await withTaskGroup(
|
||||
of: (String, [VelocityCommunicationEventDTO]).self,
|
||||
returning: [String: [VelocityCommunicationEventDTO]].self
|
||||
) { group in
|
||||
for leadID in prioritizedLeadIDs {
|
||||
group.addTask {
|
||||
do {
|
||||
let events = try await VelocityAPIClient.shared.fetchEvents(
|
||||
for: leadID,
|
||||
limit: AppStoreRefreshPolicy.leadEventLimitPerLead
|
||||
)
|
||||
return (leadID, events)
|
||||
} catch {
|
||||
return (leadID, [])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var eventMap: [String: [VelocityCommunicationEventDTO]] = [:]
|
||||
for await (leadID, events) in group {
|
||||
eventMap[leadID] = events
|
||||
}
|
||||
return eventMap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineEvent: Identifiable {
|
||||
let leadId: String
|
||||
let event: VelocityCommunicationEventDTO
|
||||
let leadName: String
|
||||
|
||||
var id: String { event.id }
|
||||
var date: Date { event.timestampDate ?? .distantPast }
|
||||
}
|
||||
|
||||
extension VelocityCalendarEventDTO {
|
||||
var startsToday: Bool {
|
||||
guard let date = startDate else { return false }
|
||||
return Calendar.current.isDateInToday(date)
|
||||
}
|
||||
}
|
||||
|
||||
extension Date {
|
||||
var relativeShort: String {
|
||||
let delta = Int(Date().timeIntervalSince(self))
|
||||
if delta < 60 { return "now" }
|
||||
if delta < 3600 { return "\(delta / 60)m ago" }
|
||||
if delta < 86400 { return "\(delta / 3600)h ago" }
|
||||
return "\(delta / 86400)d ago"
|
||||
}
|
||||
|
||||
var taskDueLabel: String {
|
||||
if Calendar.current.isDateInToday(self) {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "'Today' · h:mm a"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
if Calendar.current.isDateInTomorrow(self) {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "'Tomorrow' · h:mm a"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEE, MMM d · h:mm a"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
}
|
||||
|
||||
private extension VelocityAPIError {
|
||||
var isRecoverableCalendarCreateFailure: Bool {
|
||||
if let statusCode {
|
||||
return statusCode == 404 || (500...599).contains(statusCode)
|
||||
}
|
||||
if case .invalidResponse = self {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
|
||||
enum AppStoreRefreshPolicy {
|
||||
/// Match the WebOS bootstrap so Inventory, Dashboard, and shared summaries
|
||||
/// are based on the same production property slice by default.
|
||||
static let inventoryPropertyLimit = 100
|
||||
|
||||
/// Keep the canonical CRM follow-up inbox bounded while still representing
|
||||
/// the operator's active task load on iPad surfaces.
|
||||
static let canonicalTaskLimit = 50
|
||||
|
||||
/// iPad surfaces only render a small operator-focused timeline, so keep the
|
||||
/// lead-event hydration set intentionally narrower than WebOS.
|
||||
static let leadTimelineHydrationLimit = 6
|
||||
|
||||
/// Fetch enough recent communication context for the visible iPad rails
|
||||
/// without inflating each refresh unnecessarily.
|
||||
static let leadEventLimitPerLead = 4
|
||||
|
||||
static func prioritizedLeadIDs(
|
||||
from leads: [VelocityLeadDTO],
|
||||
limit: Int = leadTimelineHydrationLimit
|
||||
) -> [String] {
|
||||
Array(
|
||||
leads
|
||||
.sorted(by: { $0.score > $1.score })
|
||||
.prefix(limit)
|
||||
.map(\.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
17
iOS/velocity-ipad/velocity/Core/UI/GlassBlurView.swift
Normal file
17
iOS/velocity-ipad/velocity/Core/UI/GlassBlurView.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GlassBlurView: UIViewRepresentable {
|
||||
let style: UIBlurEffect.Style
|
||||
|
||||
init(style: UIBlurEffect.Style = .systemUltraThinMaterial) {
|
||||
self.style = style
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UIVisualEffectView {
|
||||
UIVisualEffectView(effect: UIBlurEffect(style: style))
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIVisualEffectView, context: Context) {
|
||||
uiView.effect = UIBlurEffect(style: style)
|
||||
}
|
||||
}
|
||||
60
iOS/velocity-ipad/velocity/Core/UI/VelocityTheme.swift
Normal file
60
iOS/velocity-ipad/velocity/Core/UI/VelocityTheme.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Design Tokens matching the WebOS dark interface
|
||||
enum VelocityTheme {
|
||||
|
||||
// ── Backgrounds ──────────────────────────────────
|
||||
/// True black app background
|
||||
static let background = Color(red: 0.00, green: 0.00, blue: 0.00)
|
||||
/// Dark surface (#131418)
|
||||
static let surface = Color(red: 0.074, green: 0.078, blue: 0.094)
|
||||
/// Slightly lighter surface (#181b20)
|
||||
static let surface2 = Color(red: 0.095, green: 0.106, blue: 0.125)
|
||||
/// Card surface (#22262e)
|
||||
static let surface3 = Color(red: 0.133, green: 0.149, blue: 0.180)
|
||||
/// Sidebar background (#0B0D10)
|
||||
static let sidebarBg = Color(red: 0.043, green: 0.051, blue: 0.063)
|
||||
|
||||
// ── Foreground ────────────────────────────────────
|
||||
static let foreground = Color(white: 0.96)
|
||||
static let mutedFg = Color(red: 0.580, green: 0.620, blue: 0.710)
|
||||
static let subtleFg = Color(red: 0.35, green: 0.38, blue: 0.44)
|
||||
|
||||
// ── Accent: Blue (#3b82f6) ────────────────────────
|
||||
static let accent = Color(red: 0.231, green: 0.510, blue: 0.965)
|
||||
static let accentDim = Color(red: 0.160, green: 0.388, blue: 0.820)
|
||||
static let accentSubtle = Color(red: 0.231, green: 0.510, blue: 0.965).opacity(0.15)
|
||||
|
||||
// ── Semantic ──────────────────────────────────────
|
||||
static let success = Color(red: 0.290, green: 0.780, blue: 0.290)
|
||||
static let warning = Color(red: 0.980, green: 0.745, blue: 0.141)
|
||||
static let danger = Color(red: 0.973, green: 0.267, blue: 0.267)
|
||||
|
||||
// ── Borders ───────────────────────────────────────
|
||||
static let borderSubtle = Color.white.opacity(0.07)
|
||||
static let borderAccent = Color(red: 0.231, green: 0.510, blue: 0.965).opacity(0.18)
|
||||
}
|
||||
|
||||
// MARK: - Glass card modifier
|
||||
struct GlassCard: ViewModifier {
|
||||
var cornerRadius: CGFloat = 16
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071).opacity(0.82))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.stroke(VelocityTheme.borderAccent, lineWidth: 1)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.55), radius: 16, y: 4)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func glassCard(cornerRadius: CGFloat = 16) -> some View {
|
||||
self.modifier(GlassCard(cornerRadius: cornerRadius))
|
||||
}
|
||||
}
|
||||
1260
iOS/velocity-ipad/velocity/Features/Calendar/CalendarView.swift
Normal file
1260
iOS/velocity-ipad/velocity/Features/Calendar/CalendarView.swift
Normal file
File diff suppressed because it is too large
Load Diff
490
iOS/velocity-ipad/velocity/Features/Clients/ClientsView.swift
Normal file
490
iOS/velocity-ipad/velocity/Features/Clients/ClientsView.swift
Normal file
@@ -0,0 +1,490 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct ClientsView: View {
|
||||
@State private var store = AppStore.shared
|
||||
@State private var searchText = ""
|
||||
@State private var selectedClient360: VelocityClient360DTO?
|
||||
@State private var selectedPersonID: String?
|
||||
@State private var isClient360Loading = false
|
||||
@State private var client360Error: String?
|
||||
private let refreshTimer = Timer.publish(every: 30, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 24)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
if let error = store.errorMessage {
|
||||
errorBanner(error)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
summaryPanel
|
||||
searchPanel
|
||||
contactsPanel
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.task { await store.refresh() }
|
||||
.refreshable { await store.refresh() }
|
||||
.onReceive(refreshTimer) { _ in
|
||||
Task { await store.refresh(silent: true) }
|
||||
}
|
||||
.sheet(isPresented: client360PresentationBinding) {
|
||||
client360Sheet
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Clients")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Canonical CRM contact workspace backed by `/api/crm/client-data` and client detail APIs.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
Text(store.lastRefreshAt?.relativeShort ?? "Awaiting sync")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
|
||||
private var summaryPanel: some View {
|
||||
HStack(spacing: 12) {
|
||||
metricCard("Contacts", value: "\(store.contacts.count)", color: VelocityTheme.accent)
|
||||
metricCard("Active Leads", value: "\(store.leads.count)", color: VelocityTheme.success)
|
||||
metricCard("Open Tasks", value: "\(store.metrics.pendingTaskCount)", color: VelocityTheme.warning)
|
||||
metricCard("High Intent", value: "\(highIntentCount)", color: VelocityTheme.danger)
|
||||
}
|
||||
}
|
||||
|
||||
private var searchPanel: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
TextField("Search by name, phone, interest, budget, or status", text: $searchText)
|
||||
.textInputAutocapitalization(.words)
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
if !searchText.isEmpty {
|
||||
Button("Clear") {
|
||||
searchText = ""
|
||||
}
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.glassCard(cornerRadius: 16)
|
||||
}
|
||||
|
||||
private var contactsPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Canonical Contacts")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text("\(filteredContacts.count) shown")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
|
||||
if store.isLoading && store.lastRefreshAt == nil {
|
||||
loadingCard
|
||||
} else if store.contacts.isEmpty {
|
||||
emptyCard("No canonical contacts were returned for this operator scope yet.")
|
||||
} else if filteredContacts.isEmpty {
|
||||
emptyCard("No canonical contacts match this search.")
|
||||
} else {
|
||||
LazyVStack(spacing: 10) {
|
||||
ForEach(filteredContacts) { contact in
|
||||
contactCard(contact)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.glassCard(cornerRadius: 20)
|
||||
}
|
||||
|
||||
private func contactCard(_ contact: VelocityCanonicalContactListItemDTO) -> some View {
|
||||
Button {
|
||||
openClient360(for: contact.personId)
|
||||
} label: {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(VelocityTheme.accent.opacity(0.14))
|
||||
.frame(width: 42, height: 42)
|
||||
Text(initials(for: contact.fullName))
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
HStack {
|
||||
Text(contact.fullName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text("\(contact.displayIntentScore)")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
Text("\(contact.buyerTypeLabel) · \(contact.leadStatusLabel)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(contact.budgetSummary) · \(contact.interestSummary)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text("\(contact.contactLine) · \(contact.pendingTasks) pending tasks · \(contact.interactionCount) interactions")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var loadingCard: some View {
|
||||
HStack(spacing: 12) {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
|
||||
Text("Loading canonical contacts...")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 16).fill(VelocityTheme.surface))
|
||||
}
|
||||
|
||||
private func emptyCard(_ message: String) -> some View {
|
||||
Text(message)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 16).fill(VelocityTheme.surface))
|
||||
}
|
||||
|
||||
private func metricCard(_ label: String, value: String, color: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(label.uppercased())
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(1)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(value)
|
||||
.font(.system(size: 21, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(color)
|
||||
.frame(width: 42, height: 4)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassCard(cornerRadius: 16)
|
||||
}
|
||||
|
||||
private var filteredContacts: [VelocityCanonicalContactListItemDTO] {
|
||||
let normalized = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !normalized.isEmpty else {
|
||||
return store.contacts
|
||||
}
|
||||
return store.contacts.filter { contact in
|
||||
[
|
||||
contact.fullName,
|
||||
contact.primaryPhone ?? "",
|
||||
contact.buyerType ?? "",
|
||||
contact.leadStatus ?? "",
|
||||
contact.budgetBand ?? "",
|
||||
contact.primaryInterest ?? "",
|
||||
contact.urgency ?? "",
|
||||
]
|
||||
.joined(separator: " ")
|
||||
.lowercased()
|
||||
.contains(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
private var highIntentCount: Int {
|
||||
store.contacts.filter { $0.displayIntentScore >= 80 }.count
|
||||
}
|
||||
|
||||
private func initials(for name: String) -> String {
|
||||
let initials = name
|
||||
.split(separator: " ")
|
||||
.prefix(2)
|
||||
.compactMap(\.first)
|
||||
return initials.isEmpty ? "C" : String(initials)
|
||||
}
|
||||
|
||||
private var client360PresentationBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { selectedPersonID != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
selectedPersonID = nil
|
||||
selectedClient360 = nil
|
||||
client360Error = nil
|
||||
isClient360Loading = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var client360Sheet: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if isClient360Loading {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.top, 40)
|
||||
} else if let client360Error {
|
||||
errorBanner(client360Error)
|
||||
} else if let snapshot = selectedClient360 {
|
||||
client360Snapshot(snapshot)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.navigationTitle("Client 360")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") {
|
||||
selectedPersonID = nil
|
||||
selectedClient360 = nil
|
||||
client360Error = nil
|
||||
isClient360Loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func client360Snapshot(_ snapshot: VelocityClient360DTO) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(snapshot.identity.fullName)
|
||||
.font(.system(size: 24, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(snapshot.identity.primaryPhone ?? "No phone") · \(snapshot.identity.buyerType?.replacingOccurrences(of: "_", with: " ").capitalized ?? "CRM contact")")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
if let email = snapshot.identity.primaryEmail {
|
||||
Text(email)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
if !snapshot.identity.personaLabels.isEmpty {
|
||||
Text(snapshot.identity.personaLabels.joined(separator: " · "))
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassCard(cornerRadius: 18)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if let lead = snapshot.currentLead {
|
||||
sectionLine("Lead", value: "\(lead.status.replacingOccurrences(of: "_", with: " ").capitalized) · \(lead.budgetBand ?? "Budget pending")")
|
||||
sectionLine("Urgency", value: lead.urgency?.replacingOccurrences(of: "_", with: " ").capitalized ?? "Normal")
|
||||
if !lead.motivations.isEmpty {
|
||||
Text("Motivations: \(lead.motivations.joined(separator: ", "))")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
if !lead.objections.isEmpty {
|
||||
Text("Objections: \(lead.objections.joined(separator: ", "))")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
} else {
|
||||
Text("No active canonical lead context was returned for this client.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
sectionLine("Opportunities", value: "\(snapshot.activeOpportunities.count)")
|
||||
sectionLine("Tasks", value: "\(snapshot.tasks.count)")
|
||||
sectionLine("Interactions", value: "\(snapshot.recentInteractions.count)")
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassCard(cornerRadius: 18)
|
||||
|
||||
if !snapshot.activeOpportunities.isEmpty {
|
||||
client360ListCard(title: "Active Opportunities") {
|
||||
ForEach(snapshot.activeOpportunities) { opportunity in
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(opportunity.stage.replacingOccurrences(of: "_", with: " ").capitalized)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(opportunity.formattedValue) · \(opportunity.probabilityLabel)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(opportunity.nextAction ?? "Next action pending")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !snapshot.propertyInterests.isEmpty {
|
||||
client360ListCard(title: "Property Interests") {
|
||||
ForEach(snapshot.propertyInterests) { interest in
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(interest.projectName)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text([interest.configuration, interest.unitPreference].compactMap { nonEmpty($0) }.joined(separator: " · "))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client360ListCard(title: "Recent Interactions") {
|
||||
if snapshot.recentInteractions.isEmpty {
|
||||
Text("No recent canonical interactions were returned for this client.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
} else {
|
||||
ForEach(snapshot.recentInteractions) { interaction in
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("\(interaction.channel.capitalized) · \(interaction.interactionType.replacingOccurrences(of: "_", with: " ").capitalized)")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(interaction.summary ?? "No summary captured")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !snapshot.tasks.isEmpty || !snapshot.recommendedNextActions.isEmpty || !snapshot.riskFlags.isEmpty {
|
||||
client360ListCard(title: "Operator Actions") {
|
||||
ForEach(snapshot.tasks) { task in
|
||||
sectionLine(task.title, value: "\(task.priorityLabel) · \(task.dueLabel)")
|
||||
}
|
||||
ForEach(snapshot.recommendedNextActions, id: \.self) { action in
|
||||
Text(action)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
ForEach(snapshot.riskFlags, id: \.self) { flag in
|
||||
Text(flag.replacingOccurrences(of: "_", with: " ").capitalized)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func client360ListCard<Content: View>(
|
||||
title: String,
|
||||
@ViewBuilder content: () -> Content
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(title)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
content()
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
|
||||
private func sectionLine(_ title: String, value: String) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
}
|
||||
|
||||
private func nonEmpty(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func openClient360(for personId: String) {
|
||||
selectedPersonID = personId
|
||||
selectedClient360 = nil
|
||||
client360Error = nil
|
||||
isClient360Loading = true
|
||||
|
||||
Task {
|
||||
do {
|
||||
let snapshot = try await VelocityAPIClient.shared.fetchClient360(personId: personId)
|
||||
await MainActor.run {
|
||||
selectedClient360 = snapshot
|
||||
isClient360Loading = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
selectedClient360 = nil
|
||||
client360Error = error.localizedDescription
|
||||
isClient360Loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func errorBanner(_ message: String) -> some View {
|
||||
Text(message)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.danger)
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(VelocityTheme.danger.opacity(0.10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.danger.opacity(0.22), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ClientsView()
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
private struct CommunicationThread: Identifiable {
|
||||
let id: String
|
||||
let leadName: String
|
||||
let channel: String
|
||||
let status: String
|
||||
let summary: String
|
||||
let nextAction: String
|
||||
let updatedAt: String
|
||||
let accent: Color
|
||||
}
|
||||
|
||||
private struct CommunicationAlert: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let severity: String
|
||||
let color: Color
|
||||
}
|
||||
|
||||
struct CommunicationsView: View {
|
||||
@State private var selectedThread: String?
|
||||
@State private var threads: [CommunicationThread] = []
|
||||
@State private var alerts: [CommunicationAlert] = []
|
||||
@State private var isLoading = true
|
||||
@State private var errorMessage: String?
|
||||
private let refreshTimer = Timer.publish(every: 15, on: .main, in: .common).autoconnect()
|
||||
|
||||
private var activeThread: CommunicationThread? {
|
||||
threads.first(where: { $0.id == selectedThread })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
header
|
||||
if let errorMessage {
|
||||
errorBanner(errorMessage)
|
||||
}
|
||||
if isLoading {
|
||||
loadingPanel
|
||||
} else {
|
||||
alertsStrip
|
||||
HStack(alignment: .top, spacing: 18) {
|
||||
threadRail
|
||||
detailPanel
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.scrollContentBackground(.hidden)
|
||||
.task { await loadLiveData() }
|
||||
.refreshable { await loadLiveData() }
|
||||
.onReceive(refreshTimer) { _ in
|
||||
Task { await loadLiveData(silent: true) }
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Communications")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Phone, WhatsApp, transcript, and memory edge across canonical CRM contacts with active lead context.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 8) {
|
||||
statusBadge(label: "\(threads.count) live threads", color: VelocityTheme.success)
|
||||
if let queueAlert = alerts.first(where: { $0.id == "pending_transcriptions" }) {
|
||||
statusBadge(label: queueAlert.detail, color: queueAlert.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var alertsStrip: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(alerts) { alert in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(alert.severity)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(alert.color)
|
||||
Spacer()
|
||||
Circle()
|
||||
.fill(alert.color)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
Text(alert.title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(alert.detail)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.lineLimit(3)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(width: 250, alignment: .leading)
|
||||
.glassCard(cornerRadius: 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var threadRail: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Active Threads")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
if threads.isEmpty {
|
||||
detailRow(title: "Live data", value: "No communication events have been captured for the current canonical CRM lead set yet.")
|
||||
}
|
||||
|
||||
ForEach(threads) { thread in
|
||||
Button {
|
||||
selectedThread = thread.id
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(thread.leadName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(thread.channel)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
Text(thread.updatedAt)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
|
||||
Text(thread.summary)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.lineLimit(3)
|
||||
|
||||
HStack {
|
||||
Text(thread.status.uppercased())
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.tracking(1)
|
||||
.foregroundStyle(thread.accent)
|
||||
Spacer()
|
||||
Text(thread.nextAction)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(selectedThread == thread.id ? thread.accent.opacity(0.12) : VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(selectedThread == thread.id ? thread.accent.opacity(0.25) : VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: 360, alignment: .topLeading)
|
||||
.glassCard(cornerRadius: 20)
|
||||
}
|
||||
|
||||
private var detailPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(activeThread?.leadName ?? "Select a thread")
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(activeThread?.channel ?? "Communication detail")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
if let thread = activeThread {
|
||||
statusBadge(label: thread.status, color: thread.accent)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
detailRow(title: "Latest summary", value: activeThread?.summary ?? "No thread selected")
|
||||
detailRow(title: "Next operator action", value: activeThread?.nextAction ?? "None")
|
||||
detailRow(title: "Memory extraction", value: activeThread != nil ? "Backed by persisted mobile-edge communication events and live backend alerts." : "No communication memory available.")
|
||||
detailRow(title: "Suggested response", value: activeThread != nil ? "Use the current thread state, transcript queue, and calendar urgency to choose the next operator action." : "Select a thread to view live context.")
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Recent activity")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
ForEach(alerts.prefix(3)) { alert in
|
||||
activityCard(icon: alertIcon(for: alert.id), title: alert.title, detail: alert.detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(22)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.glassCard(cornerRadius: 20)
|
||||
}
|
||||
|
||||
private func detailRow(title: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(1)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(value)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
}
|
||||
|
||||
private func activityCard(icon: String, title: String, detail: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(VelocityTheme.accent.opacity(0.14))
|
||||
.frame(width: 38, height: 38)
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(detail)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func statusBadge(label: String, color: Color) -> some View {
|
||||
Text(label)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(color.opacity(0.12))
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(color.opacity(0.22), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private var loadingPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
|
||||
Text("Loading live communications...")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Velocity is fetching canonical CRM contact summaries, communication events, and alert state from the backend.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(20)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassCard(cornerRadius: 20)
|
||||
}
|
||||
|
||||
private func errorBanner(_ message: String) -> some View {
|
||||
Text(message)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.danger)
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(VelocityTheme.danger.opacity(0.10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.danger.opacity(0.22), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func loadLiveData(silent: Bool = false) async {
|
||||
if !silent {
|
||||
isLoading = true
|
||||
}
|
||||
do {
|
||||
async let leadsTask = VelocityAPIClient.shared.fetchLeads()
|
||||
async let alertsTask = VelocityAPIClient.shared.fetchAlerts()
|
||||
let leads = try await leadsTask
|
||||
let alertSnapshot = try await alertsTask
|
||||
|
||||
let topLeads = Array(leads.sorted(by: { $0.score > $1.score }).prefix(8))
|
||||
var fetchedThreads: [CommunicationThread] = []
|
||||
|
||||
for lead in topLeads {
|
||||
let events = (try? await VelocityAPIClient.shared.fetchEvents(for: lead.id, limit: 1)) ?? []
|
||||
let latest = events.first
|
||||
fetchedThreads.append(
|
||||
CommunicationThread(
|
||||
id: lead.id,
|
||||
leadName: lead.name,
|
||||
channel: latest.map { channelLabel($0.channel) } ?? sourceLabel(lead.source),
|
||||
status: statusLabel(for: lead, event: latest),
|
||||
summary: latest?.summary ?? "No communication events captured yet for this lead.",
|
||||
nextAction: nextActionLabel(for: lead, event: latest),
|
||||
updatedAt: latest.map { relativeShort($0.timestamp) } ?? "No events",
|
||||
accent: accentColor(for: lead, event: latest)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let fetchedAlerts = buildAlerts(from: alertSnapshot)
|
||||
|
||||
await MainActor.run {
|
||||
threads = fetchedThreads
|
||||
alerts = fetchedAlerts
|
||||
if selectedThread == nil || !fetchedThreads.contains(where: { $0.id == selectedThread }) {
|
||||
selectedThread = fetchedThreads.first?.id
|
||||
}
|
||||
errorMessage = nil
|
||||
isLoading = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
errorMessage = error.localizedDescription
|
||||
threads = []
|
||||
alerts = []
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func buildAlerts(from snapshot: VelocityAlertSnapshotDTO) -> [CommunicationAlert] {
|
||||
[
|
||||
CommunicationAlert(
|
||||
id: "pending_insights",
|
||||
title: "Pending insights",
|
||||
detail: "\(snapshot.pendingInsights) insight recommendations need operator review.",
|
||||
severity: "Priority",
|
||||
color: VelocityTheme.danger
|
||||
),
|
||||
CommunicationAlert(
|
||||
id: "pending_transcriptions",
|
||||
title: "Transcription queue",
|
||||
detail: "\(snapshot.pendingTranscriptions) transcript jobs waiting.",
|
||||
severity: "Queue",
|
||||
color: VelocityTheme.warning
|
||||
),
|
||||
CommunicationAlert(
|
||||
id: "calendar_due",
|
||||
title: "Calendar due soon",
|
||||
detail: "\(snapshot.upcomingCalendarEvents24h) calendar events are due in the next 24 hours.",
|
||||
severity: "Calendar",
|
||||
color: VelocityTheme.success
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
private func statusLabel(for lead: VelocityLeadDTO, event: VelocityCommunicationEventDTO?) -> String {
|
||||
if event == nil {
|
||||
if lead.pendingTaskCount > 0 {
|
||||
return "Task pending"
|
||||
}
|
||||
return "No events yet"
|
||||
}
|
||||
if lead.score >= 90 {
|
||||
return "Whale priority"
|
||||
}
|
||||
return lead.kanbanStatus
|
||||
}
|
||||
|
||||
private func nextActionLabel(for lead: VelocityLeadDTO, event: VelocityCommunicationEventDTO?) -> String {
|
||||
if event?.recordingRef != nil {
|
||||
return "Review transcript"
|
||||
}
|
||||
if lead.pendingTaskCount > 0 {
|
||||
return lead.pendingTaskCount == 1 ? "Review pending task" : "Review \(lead.pendingTaskCount) tasks"
|
||||
}
|
||||
if lead.score >= 90 {
|
||||
return "Schedule follow-up"
|
||||
}
|
||||
return "Update operator note"
|
||||
}
|
||||
|
||||
private func accentColor(for lead: VelocityLeadDTO, event: VelocityCommunicationEventDTO?) -> Color {
|
||||
if event?.recordingRef != nil {
|
||||
return VelocityTheme.accent
|
||||
}
|
||||
if lead.score >= 90 {
|
||||
return VelocityTheme.success
|
||||
}
|
||||
return VelocityTheme.warning
|
||||
}
|
||||
|
||||
private func alertIcon(for id: String) -> String {
|
||||
switch id {
|
||||
case "pending_transcriptions":
|
||||
return "waveform.badge.mic"
|
||||
case "calendar_due":
|
||||
return "calendar.badge.plus"
|
||||
default:
|
||||
return "brain.head.profile"
|
||||
}
|
||||
}
|
||||
|
||||
private func channelLabel(_ value: String) -> String {
|
||||
value.replacingOccurrences(of: "_", with: " ").capitalized
|
||||
}
|
||||
|
||||
private func sourceLabel(_ value: String) -> String {
|
||||
value.replacingOccurrences(of: "_", with: " ").capitalized
|
||||
}
|
||||
|
||||
private func relativeShort(_ iso: String) -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
guard let date = formatter.date(from: iso) else {
|
||||
return iso
|
||||
}
|
||||
let delta = Int(Date().timeIntervalSince(date))
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CommunicationsView()
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct DashboardView: View {
|
||||
@State private var store = AppStore.shared
|
||||
@State private var session = SessionStore.shared
|
||||
private let refreshTimer = Timer.publish(every: 20, on: .main, in: .common).autoconnect()
|
||||
private let columns = [GridItem(.adaptive(minimum: 220), spacing: 14)]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
header
|
||||
|
||||
if let error = store.errorMessage {
|
||||
errorBanner(error)
|
||||
}
|
||||
|
||||
if store.isLoading && store.lastRefreshAt == nil {
|
||||
loadingPanel
|
||||
} else {
|
||||
metricsGrid
|
||||
liveStatusPanel
|
||||
followUpLoadPanel
|
||||
leadFocusPanel
|
||||
inventoryPanel
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.scrollContentBackground(.hidden)
|
||||
.task { await store.refresh() }
|
||||
.refreshable { await store.refresh() }
|
||||
.onReceive(refreshTimer) { _ in
|
||||
Task { await store.refresh(silent: true) }
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Dashboard")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Live mobile operator posture for canonical CRM pipeline, inventory, and follow-up load.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 8) {
|
||||
statusBadge(
|
||||
label: session.isConfigured ? "Live backend" : "Config required",
|
||||
color: session.isConfigured ? VelocityTheme.success : VelocityTheme.warning
|
||||
)
|
||||
if let lastRefresh = store.lastRefreshAt {
|
||||
Text("Updated \(lastRefresh.relativeShort)")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var metricsGrid: some View {
|
||||
LazyVGrid(columns: columns, spacing: 14) {
|
||||
MetricCard(title: "Leads", value: "\(store.metrics.leadCount)", subtitle: "Live CRM records", color: VelocityTheme.accent)
|
||||
MetricCard(title: "Whale Leads", value: "\(store.metrics.whaleLeadCount)", subtitle: "Score 90+ or whale qualified", color: VelocityTheme.success)
|
||||
MetricCard(title: "Pending Tasks", value: "\(store.metrics.pendingTaskCount)", subtitle: "Canonical CRM reminders", color: VelocityTheme.warning)
|
||||
MetricCard(title: "Urgent Tasks", value: "\(store.metrics.urgentTaskCount)", subtitle: "High and urgent follow-ups", color: VelocityTheme.danger)
|
||||
MetricCard(title: "Inventory", value: "\(store.metrics.propertyCount)", subtitle: "Property rows available", color: VelocityTheme.warning)
|
||||
MetricCard(title: "Today", value: "\(store.metrics.todayCalendarCount)", subtitle: "Calendar slots scheduled", color: Color(red: 0.60, green: 0.57, blue: 0.99))
|
||||
}
|
||||
}
|
||||
|
||||
private var liveStatusPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
Text("Live Status")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
statusBadge(label: session.authModeDescription, color: VelocityTheme.accent)
|
||||
}
|
||||
|
||||
detailRow(title: "Endpoint", value: session.endpointDisplay)
|
||||
detailRow(title: "Operator", value: session.operatorIdentity)
|
||||
detailRow(title: "Pending CRM tasks", value: "\(store.metrics.pendingTaskCount)")
|
||||
detailRow(title: "Pending insights", value: "\(store.metrics.pendingInsights)")
|
||||
detailRow(title: "Pending transcriptions", value: "\(store.metrics.pendingTranscriptions)")
|
||||
}
|
||||
.padding(20)
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
|
||||
private var followUpLoadPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Follow-Up Load")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
if store.prioritizedTasks.isEmpty {
|
||||
emptyMessage("No canonical CRM reminder tasks are pending for this operator right now.")
|
||||
} else {
|
||||
ForEach(store.prioritizedTasks.prefix(4)) { task in
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(task.title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(task.ownerLabel) · \(task.dueLabel)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(taskNote(task))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
Text(task.priorityLabel)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(priorityColor(for: task.priority))
|
||||
}
|
||||
.padding(14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
|
||||
private var leadFocusPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Client Focus")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
if store.highlightedLeads.isEmpty {
|
||||
emptyMessage("No canonical CRM contacts with active lead context have been returned by the backend yet.")
|
||||
} else {
|
||||
ForEach(store.highlightedLeads) { lead in
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(lead.name)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(lead.unitInterest) · \(lead.budget)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text("\(lead.score)")
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(lead.kanbanStatus.replacingOccurrences(of: "_", with: " ").capitalized)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
|
||||
private var inventoryPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Inventory Coverage")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
if store.properties.isEmpty {
|
||||
emptyMessage("No live inventory properties are available yet for this operator scope.")
|
||||
} else {
|
||||
ForEach(store.properties.prefix(4)) { property in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(property.projectName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(property.developerName) · \(property.propertyType.capitalized)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(property.locationSummary)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
|
||||
private func detailRow(title: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(1)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(value)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
}
|
||||
|
||||
private func emptyMessage(_ message: String) -> some View {
|
||||
Text(message)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func statusBadge(label: String, color: Color) -> some View {
|
||||
Text(label)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(color.opacity(0.12))
|
||||
.overlay(Capsule().stroke(color.opacity(0.22), lineWidth: 1))
|
||||
)
|
||||
}
|
||||
|
||||
private var loadingPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
|
||||
Text("Loading live dashboard data...")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Velocity is reading canonical CRM contacts, reminders, alerts, calendar events, and inventory summaries from the backend.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(20)
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
|
||||
private func errorBanner(_ message: String) -> some View {
|
||||
Text(message)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.danger)
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(VelocityTheme.danger.opacity(0.10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.danger.opacity(0.22), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func priorityColor(for priority: String) -> Color {
|
||||
switch priority.lowercased() {
|
||||
case "urgent":
|
||||
return VelocityTheme.danger
|
||||
case "high":
|
||||
return VelocityTheme.warning
|
||||
default:
|
||||
return VelocityTheme.accent
|
||||
}
|
||||
}
|
||||
|
||||
private func taskNote(_ task: VelocityTaskDTO) -> String {
|
||||
let note = task.notes?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return note.isEmpty ? "No operator note yet." : note
|
||||
}
|
||||
}
|
||||
|
||||
private struct MetricCard: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let subtitle: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(1)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(value)
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(subtitle)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(color)
|
||||
.frame(width: 52, height: 4)
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassCard(cornerRadius: 16)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
DashboardView()
|
||||
}
|
||||
467
iOS/velocity-ipad/velocity/Features/Imports/ImportsView.swift
Normal file
467
iOS/velocity-ipad/velocity/Features/Imports/ImportsView.swift
Normal file
@@ -0,0 +1,467 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct ImportsView: View {
|
||||
@State private var batches: [VelocityImportBatchSummaryDTO] = []
|
||||
@State private var selectedBatch: VelocityImportBatchSummaryDTO?
|
||||
@State private var detail: VelocityImportBatchDetailDTO?
|
||||
@State private var isLoading = false
|
||||
@State private var isCommitting = false
|
||||
@State private var activeProposalID: String?
|
||||
@State private var errorMessage: String?
|
||||
@State private var successMessage: String?
|
||||
private let refreshTimer = Timer.publish(every: 30, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
batchRail
|
||||
.frame(width: 350)
|
||||
.background(VelocityTheme.sidebarBg)
|
||||
|
||||
Divider()
|
||||
.background(VelocityTheme.borderSubtle)
|
||||
|
||||
detailPane
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.task { await loadBatches(selectFirst: true) }
|
||||
.refreshable { await loadBatches(selectFirst: false) }
|
||||
.onReceive(refreshTimer) { _ in
|
||||
Task { await loadBatches(selectFirst: false, silent: true) }
|
||||
}
|
||||
}
|
||||
|
||||
private var batchRail: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Imports")
|
||||
.font(.system(size: 26, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Canonical CRM import review and commit queue.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.top, 22)
|
||||
|
||||
if let errorMessage {
|
||||
errorBanner(errorMessage)
|
||||
.padding(.horizontal, 18)
|
||||
}
|
||||
|
||||
if let successMessage {
|
||||
successBanner(successMessage)
|
||||
.padding(.horizontal, 18)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 10) {
|
||||
if isLoading && batches.isEmpty {
|
||||
loadingCard("Loading import batches...")
|
||||
} else if batches.isEmpty {
|
||||
emptyCard("No canonical import batches were returned yet.")
|
||||
} else {
|
||||
ForEach(batches) { batch in
|
||||
batchCard(batch)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var detailPane: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if let detail {
|
||||
detailHeader(detail)
|
||||
proposalsPanel(detail)
|
||||
} else if isLoading {
|
||||
loadingCard("Loading import detail...")
|
||||
} else {
|
||||
emptyCard("Select an import batch to review canonical proposals.")
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
}
|
||||
|
||||
private func batchCard(_ batch: VelocityImportBatchSummaryDTO) -> some View {
|
||||
Button {
|
||||
Task { await selectBatch(batch) }
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(batch.displayName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text(batch.lifecycleLabel)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(lifecycleColor(batch.lifecycle))
|
||||
}
|
||||
Text("\(batch.rowCount) rows · \(batch.mappedCount ?? 0) mapped · \(batch.unresolvedCount ?? 0) unresolved")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(batch.sourceSystem ?? "Unknown source")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(selectedBatch?.batchId == batch.batchId ? VelocityTheme.accent.opacity(0.14) : VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(selectedBatch?.batchId == batch.batchId ? VelocityTheme.borderAccent : VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func detailHeader(_ detail: VelocityImportBatchDetailDTO) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(detail.filename ?? "CRM import")
|
||||
.font(.system(size: 24, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(detail.rowCount) rows · \(detail.proposalCount) proposals · \(detail.sourceSystem ?? "Unknown source")")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
Text(detail.lifecycle.replacingOccurrences(of: "_", with: " ").capitalized)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(lifecycleColor(detail.lifecycle))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Capsule().fill(lifecycleColor(detail.lifecycle).opacity(0.12)))
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
metricCard("Pending", value: "\(detail.proposals.filter { $0.status == "pending" }.count)", color: VelocityTheme.warning)
|
||||
metricCard("Approved", value: "\(detail.proposals.filter { $0.status == "approved" }.count)", color: VelocityTheme.success)
|
||||
metricCard("Rejected", value: "\(detail.proposals.filter { $0.status == "rejected" }.count)", color: VelocityTheme.danger)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await commitSelectedBatch() }
|
||||
} label: {
|
||||
HStack {
|
||||
if isCommitting {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
}
|
||||
Text(isCommitting ? "Committing..." : "Commit Approved Proposals")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(approvedCount(detail) > 0 && !isCommitting ? VelocityTheme.success : VelocityTheme.subtleFg)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(approvedCount(detail) == 0 || isCommitting)
|
||||
}
|
||||
.padding(18)
|
||||
.glassCard(cornerRadius: 20)
|
||||
}
|
||||
|
||||
private func proposalsPanel(_ detail: VelocityImportBatchDetailDTO) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Review Proposals")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
if detail.proposals.isEmpty {
|
||||
emptyCard("No proposals were returned for this import batch.")
|
||||
} else {
|
||||
ForEach(detail.proposals) { proposal in
|
||||
proposalCard(proposal, batchId: detail.batchId)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.glassCard(cornerRadius: 20)
|
||||
}
|
||||
|
||||
private func proposalCard(_ proposal: VelocityImportProposalDTO, batchId: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(proposal.rowLabel)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(proposal.confidencePercent)% confidence · \(proposal.status.capitalized)")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
if activeProposalID == proposal.proposalId {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
|
||||
} else {
|
||||
proposalActions(proposal, batchId: batchId)
|
||||
}
|
||||
}
|
||||
|
||||
if let canonical = proposal.payload?.canonicalPayload, !canonical.isEmpty {
|
||||
Text(canonicalPreview(canonical))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.lineLimit(3)
|
||||
}
|
||||
|
||||
if let missing = proposal.payload?.missingRequired, !missing.isEmpty {
|
||||
Text("Missing: \(missing.joined(separator: ", "))")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.danger)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func proposalActions(_ proposal: VelocityImportProposalDTO, batchId: String) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Button("Approve") {
|
||||
Task { await reviewProposal(batchId: batchId, proposal: proposal, decision: "approved") }
|
||||
}
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.success)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
.disabled(proposal.status.lowercased() == "approved")
|
||||
|
||||
Button("Reject") {
|
||||
Task { await reviewProposal(batchId: batchId, proposal: proposal, decision: "rejected") }
|
||||
}
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.danger)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
.disabled(proposal.status.lowercased() == "rejected")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadBatches(selectFirst: Bool, silent: Bool = false) async {
|
||||
if !silent {
|
||||
isLoading = true
|
||||
}
|
||||
do {
|
||||
let fetched = try await VelocityAPIClient.shared.fetchImportBatches()
|
||||
await MainActor.run {
|
||||
batches = fetched
|
||||
errorMessage = nil
|
||||
isLoading = false
|
||||
}
|
||||
if selectFirst, selectedBatch == nil, let first = fetched.first {
|
||||
await selectBatch(first)
|
||||
} else if let selectedBatch {
|
||||
await refreshDetail(batchId: selectedBatch.batchId, silent: true)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
errorMessage = error.localizedDescription
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func selectBatch(_ batch: VelocityImportBatchSummaryDTO) async {
|
||||
await MainActor.run {
|
||||
selectedBatch = batch
|
||||
detail = nil
|
||||
errorMessage = nil
|
||||
successMessage = nil
|
||||
isLoading = true
|
||||
}
|
||||
await refreshDetail(batchId: batch.batchId)
|
||||
}
|
||||
|
||||
private func refreshDetail(batchId: String, silent: Bool = false) async {
|
||||
if !silent {
|
||||
await MainActor.run { isLoading = true }
|
||||
}
|
||||
do {
|
||||
let fetched = try await VelocityAPIClient.shared.fetchImportBatch(batchId: batchId)
|
||||
await MainActor.run {
|
||||
detail = fetched
|
||||
errorMessage = nil
|
||||
isLoading = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
errorMessage = error.localizedDescription
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reviewProposal(
|
||||
batchId: String,
|
||||
proposal: VelocityImportProposalDTO,
|
||||
decision: String
|
||||
) async {
|
||||
await MainActor.run {
|
||||
activeProposalID = proposal.proposalId
|
||||
errorMessage = nil
|
||||
successMessage = nil
|
||||
}
|
||||
do {
|
||||
_ = try await VelocityAPIClient.shared.reviewImportProposal(
|
||||
batchId: batchId,
|
||||
proposalId: proposal.proposalId,
|
||||
decision: decision,
|
||||
notes: "Reviewed from iPad Imports workspace."
|
||||
)
|
||||
await refreshDetail(batchId: batchId, silent: true)
|
||||
await MainActor.run {
|
||||
activeProposalID = nil
|
||||
successMessage = "Proposal \(decision)."
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
activeProposalID = nil
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func commitSelectedBatch() async {
|
||||
guard let batchId = detail?.batchId else {
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
isCommitting = true
|
||||
errorMessage = nil
|
||||
successMessage = nil
|
||||
}
|
||||
do {
|
||||
let result = try await VelocityAPIClient.shared.commitImportBatch(batchId: batchId)
|
||||
await loadBatches(selectFirst: false, silent: true)
|
||||
await refreshDetail(batchId: batchId, silent: true)
|
||||
await MainActor.run {
|
||||
isCommitting = false
|
||||
successMessage = "Committed \(result.committed), skipped \(result.skipped)."
|
||||
if !result.errors.isEmpty {
|
||||
errorMessage = result.errors.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isCommitting = false
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func approvedCount(_ detail: VelocityImportBatchDetailDTO) -> Int {
|
||||
detail.proposals.filter { $0.status == "approved" }.count
|
||||
}
|
||||
|
||||
private func canonicalPreview(_ payload: [String: JSONValue]) -> String {
|
||||
payload
|
||||
.sorted(by: { $0.key < $1.key })
|
||||
.prefix(5)
|
||||
.map { "\($0.key): \($0.value.stringValue ?? "-")" }
|
||||
.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private func metricCard(_ label: String, value: String, color: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(label.uppercased())
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.tracking(1)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(value)
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(color)
|
||||
.frame(width: 34, height: 3)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.surface))
|
||||
}
|
||||
|
||||
private func loadingCard(_ message: String) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
|
||||
Text(message)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 16).fill(VelocityTheme.surface))
|
||||
}
|
||||
|
||||
private func emptyCard(_ message: String) -> some View {
|
||||
Text(message)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 16).fill(VelocityTheme.surface))
|
||||
}
|
||||
|
||||
private func errorBanner(_ message: String) -> some View {
|
||||
banner(message, color: VelocityTheme.danger)
|
||||
}
|
||||
|
||||
private func successBanner(_ message: String) -> some View {
|
||||
banner(message, color: VelocityTheme.success)
|
||||
}
|
||||
|
||||
private func banner(_ message: String, color: Color) -> some View {
|
||||
Text(message)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(color)
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(color.opacity(0.10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(color.opacity(0.22), lineWidth: 1))
|
||||
)
|
||||
}
|
||||
|
||||
private func lifecycleColor(_ lifecycle: String) -> Color {
|
||||
switch lifecycle.lowercased() {
|
||||
case "committed":
|
||||
return VelocityTheme.success
|
||||
case "failed":
|
||||
return VelocityTheme.danger
|
||||
case "approved", "proposed", "parsed":
|
||||
return VelocityTheme.warning
|
||||
default:
|
||||
return VelocityTheme.accent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ImportsView()
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import ARKit
|
||||
import CoreLocation
|
||||
import CoreMotion
|
||||
import SceneKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - ARSunOverlayView
|
||||
|
||||
struct ARSunOverlayView: UIViewRepresentable {
|
||||
@Binding var sunNodesReady: Bool
|
||||
let vm: SunseekerViewModel
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(sunNodesReady: $sunNodesReady, vm: vm)
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> ARSCNView {
|
||||
let view = ARSCNView(frame: .zero)
|
||||
view.delegate = context.coordinator
|
||||
view.scene = SCNScene()
|
||||
view.automaticallyUpdatesLighting = true
|
||||
|
||||
let config = ARWorldTrackingConfiguration()
|
||||
config.worldAlignment = .gravityAndHeading // north = -Z axis
|
||||
view.session.run(config)
|
||||
|
||||
context.coordinator.attach(to: view)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: ARSCNView, context: Context) {}
|
||||
|
||||
static func dismantleUIView(_ uiView: ARSCNView, coordinator: Coordinator) {
|
||||
uiView.session.pause()
|
||||
coordinator.stop()
|
||||
}
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
final class Coordinator: NSObject, ARSCNViewDelegate, CLLocationManagerDelegate {
|
||||
|
||||
private weak var sceneView: ARSCNView?
|
||||
private let vm: SunseekerViewModel
|
||||
@Binding private var sunNodesReady: Bool
|
||||
|
||||
// Scene node containers (replaced on each rebuild)
|
||||
private var arcRootNode = SCNNode()
|
||||
private var currentSunNode = SCNNode()
|
||||
private var isSceneBuilt = false
|
||||
|
||||
// Fallback timer for CoreMotion-only mode
|
||||
private var fallbackTimer: Timer?
|
||||
private var limitedTrackingStart: Date?
|
||||
|
||||
init(sunNodesReady: Binding<Bool>, vm: SunseekerViewModel) {
|
||||
_sunNodesReady = sunNodesReady
|
||||
self.vm = vm
|
||||
}
|
||||
|
||||
func attach(to sceneView: ARSCNView) {
|
||||
self.sceneView = sceneView
|
||||
sceneView.scene.rootNode.addChildNode(arcRootNode)
|
||||
sceneView.scene.rootNode.addChildNode(currentSunNode)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
vm.stop()
|
||||
fallbackTimer?.invalidate()
|
||||
}
|
||||
|
||||
// MARK: - ARSCNViewDelegate — per-frame update
|
||||
|
||||
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
|
||||
guard vm.isReady else { return }
|
||||
|
||||
// Build arc once
|
||||
if !isSceneBuilt {
|
||||
DispatchQueue.main.async { self.buildScene() }
|
||||
}
|
||||
|
||||
// Update current sun orb every frame
|
||||
if let cur = vm.currentPosition {
|
||||
let pos = vm.worldPosition(for: cur, radius: 1.8)
|
||||
currentSunNode.position = pos
|
||||
}
|
||||
}
|
||||
|
||||
func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) {
|
||||
switch camera.trackingState {
|
||||
case .limited(let reason):
|
||||
print("[Sunseeker] Tracking limited: \(reason)")
|
||||
if limitedTrackingStart == nil {
|
||||
limitedTrackingStart = Date()
|
||||
// After 5s of limited tracking, switch to CoreMotion attitude fallback
|
||||
fallbackTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] _ in
|
||||
self?.activateCoreMotionFallback()
|
||||
}
|
||||
}
|
||||
case .normal:
|
||||
limitedTrackingStart = nil
|
||||
fallbackTimer?.invalidate()
|
||||
fallbackTimer = nil
|
||||
case .notAvailable:
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scene Building
|
||||
|
||||
private func buildScene() {
|
||||
guard sceneView != nil else { return }
|
||||
|
||||
// Remove old nodes
|
||||
arcRootNode.childNodes.forEach { $0.removeFromParentNode() }
|
||||
currentSunNode.childNodes.forEach { $0.removeFromParentNode() }
|
||||
|
||||
let arc = vm.arc
|
||||
let radius: Float = 1.8
|
||||
var positions: [SCNVector3] = []
|
||||
|
||||
// Hourly marker spheres + time labels
|
||||
for (date, pos) in arc {
|
||||
guard pos.elevation > -5 else { continue }
|
||||
let worldPos = vm.worldPosition(for: pos, radius: radius)
|
||||
positions.append(worldPos)
|
||||
|
||||
let sphere = SCNSphere(radius: 0.018)
|
||||
sphere.firstMaterial?.diffuse.contents = UIColor.systemYellow.withAlphaComponent(0.85)
|
||||
sphere.firstMaterial?.lightingModel = .constant
|
||||
let markerNode = SCNNode(geometry: sphere)
|
||||
markerNode.position = worldPos
|
||||
arcRootNode.addChildNode(markerNode)
|
||||
|
||||
// Time label (only on even hours to avoid clutter)
|
||||
let calendar = Calendar.current
|
||||
let hour = calendar.component(.hour, from: date)
|
||||
if hour % 2 == 0 {
|
||||
let labelNode = makeTextNode(text: hourLabel(from: date), color: .white, fontSize: 0.04)
|
||||
labelNode.position = SCNVector3(worldPos.x, worldPos.y + 0.06, worldPos.z)
|
||||
arcRootNode.addChildNode(labelNode)
|
||||
}
|
||||
}
|
||||
|
||||
// Continuous arc line
|
||||
if positions.count >= 2 {
|
||||
let lineNode = makeLineNode(through: positions, color: UIColor.systemYellow.withAlphaComponent(0.55))
|
||||
arcRootNode.addChildNode(lineNode)
|
||||
}
|
||||
|
||||
// Sunrise marker
|
||||
if let riseDate = vm.riseSet.rise {
|
||||
let risePos = SunMath.calculateSunPosition(date: riseDate, coordinate: vm.coordinate!)
|
||||
let wPos = vm.worldPosition(for: risePos, radius: radius)
|
||||
arcRootNode.addChildNode(makeSpecialMarker(at: wPos, color: .systemOrange, label: "Sunrise \(hourLabel(from: riseDate))"))
|
||||
}
|
||||
|
||||
// Sunset marker
|
||||
if let setDate = vm.riseSet.set {
|
||||
let setPos = SunMath.calculateSunPosition(date: setDate, coordinate: vm.coordinate!)
|
||||
let wPos = vm.worldPosition(for: setPos, radius: radius)
|
||||
arcRootNode.addChildNode(makeSpecialMarker(at: wPos, color: .systemRed, label: "Sunset \(hourLabel(from: setDate))"))
|
||||
}
|
||||
|
||||
// Current sun orb (large, animated glow)
|
||||
if let cur = vm.currentPosition {
|
||||
let orb = SCNSphere(radius: 0.055)
|
||||
orb.firstMaterial?.diffuse.contents = UIColor.systemOrange
|
||||
orb.firstMaterial?.emission.contents = UIColor.systemYellow
|
||||
orb.firstMaterial?.lightingModel = .constant
|
||||
let orbNode = SCNNode(geometry: orb)
|
||||
orbNode.position = vm.worldPosition(for: cur, radius: radius)
|
||||
|
||||
// Pulse animation
|
||||
let pulse = CABasicAnimation(keyPath: "scale")
|
||||
pulse.fromValue = SCNVector3(1, 1, 1)
|
||||
pulse.toValue = SCNVector3(1.3, 1.3, 1.3)
|
||||
pulse.duration = 1.2
|
||||
pulse.autoreverses = true
|
||||
pulse.repeatCount = .infinity
|
||||
orbNode.addAnimation(pulse, forKey: "pulse")
|
||||
|
||||
let label = makeTextNode(text: "Now", color: .systemYellow, fontSize: 0.05)
|
||||
label.position = SCNVector3(0, 0.09, 0)
|
||||
orbNode.addChildNode(label)
|
||||
currentSunNode.addChildNode(orbNode)
|
||||
}
|
||||
|
||||
isSceneBuilt = true
|
||||
sunNodesReady = true
|
||||
}
|
||||
|
||||
// MARK: - Node Factories
|
||||
|
||||
private func makeSpecialMarker(at pos: SCNVector3, color: UIColor, label: String) -> SCNNode {
|
||||
let root = SCNNode()
|
||||
let sphere = SCNSphere(radius: 0.035)
|
||||
sphere.firstMaterial?.diffuse.contents = color
|
||||
sphere.firstMaterial?.emission.contents = color.withAlphaComponent(0.5)
|
||||
sphere.firstMaterial?.lightingModel = .constant
|
||||
let markerNode = SCNNode(geometry: sphere)
|
||||
markerNode.position = pos
|
||||
root.addChildNode(markerNode)
|
||||
|
||||
let labelNode = makeTextNode(text: label, color: .white, fontSize: 0.04)
|
||||
labelNode.position = SCNVector3(pos.x, pos.y + 0.07, pos.z)
|
||||
root.addChildNode(labelNode)
|
||||
return root
|
||||
}
|
||||
|
||||
/// Creates a billboard SCNText node that always faces the camera.
|
||||
private func makeTextNode(text: String, color: UIColor, fontSize: CGFloat) -> SCNNode {
|
||||
let scnText = SCNText(string: text, extrusionDepth: 0)
|
||||
scnText.font = UIFont.systemFont(ofSize: fontSize * 100, weight: .medium)
|
||||
scnText.firstMaterial?.diffuse.contents = color
|
||||
scnText.firstMaterial?.lightingModel = .constant
|
||||
scnText.isWrapped = false
|
||||
|
||||
let textNode = SCNNode(geometry: scnText)
|
||||
textNode.scale = SCNVector3(fontSize / 100, fontSize / 100, fontSize / 100)
|
||||
// Billboard constraint — always face camera
|
||||
let constraint = SCNBillboardConstraint()
|
||||
constraint.freeAxes = .Y
|
||||
textNode.constraints = [constraint]
|
||||
// Centre text
|
||||
let (min, max) = textNode.boundingBox
|
||||
textNode.pivot = SCNMatrix4MakeTranslation((max.x - min.x) / 2, 0, 0)
|
||||
return textNode
|
||||
}
|
||||
|
||||
/// Builds a line strip SCNNode connecting all positions.
|
||||
private func makeLineNode(through positions: [SCNVector3], color: UIColor) -> SCNNode {
|
||||
guard positions.count >= 2 else { return SCNNode() }
|
||||
|
||||
let vertices: [SCNVector3] = positions
|
||||
var indices: [Int32] = []
|
||||
for i in 0..<(vertices.count - 1) {
|
||||
indices.append(Int32(i))
|
||||
indices.append(Int32(i + 1))
|
||||
}
|
||||
|
||||
let vertexSource = SCNGeometrySource(vertices: vertices)
|
||||
let element = SCNGeometryElement(
|
||||
indices: indices,
|
||||
primitiveType: .line
|
||||
)
|
||||
let geometry = SCNGeometry(sources: [vertexSource], elements: [element])
|
||||
geometry.firstMaterial?.diffuse.contents = color
|
||||
geometry.firstMaterial?.lightingModel = .constant
|
||||
return SCNNode(geometry: geometry)
|
||||
}
|
||||
|
||||
private func hourLabel(from date: Date) -> String {
|
||||
let fmt = DateFormatter()
|
||||
fmt.dateFormat = "ha"
|
||||
fmt.amSymbol = "am"
|
||||
fmt.pmSymbol = "pm"
|
||||
return fmt.string(from: date)
|
||||
}
|
||||
|
||||
// MARK: - CoreMotion Fallback
|
||||
|
||||
private func activateCoreMotionFallback() {
|
||||
// In fallback mode we rely on CMMotionManager attitude (already running in SunseekerViewModel)
|
||||
// and just keep the scene nodes updated via the 1s tick in the VM.
|
||||
print("[Sunseeker] Switched to CoreMotion fallback — ARKit tracking unavailable.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Degree helpers
|
||||
|
||||
private extension Double {
|
||||
var radians: Double { self * .pi / 180.0 }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
enum InventoryModeAvailability {
|
||||
static let dollhouseAssetCandidates: [(name: String, ext: String)] = [
|
||||
("Building", "usdz"),
|
||||
("Building", "scn"),
|
||||
]
|
||||
|
||||
static func hasShippedDollhouseAsset(in bundle: Bundle = .main) -> Bool {
|
||||
dollhouseAssetCandidates.contains { candidate in
|
||||
bundle.url(forResource: candidate.name, withExtension: candidate.ext) != nil
|
||||
}
|
||||
}
|
||||
|
||||
static func productionVisibleModes(hasDollhouseAsset: Bool) -> [InventoryStore.Mode] {
|
||||
var modes: [InventoryStore.Mode] = [.sunseeker, .dreamWeaver]
|
||||
if hasDollhouseAsset {
|
||||
modes.append(.dollhouse)
|
||||
}
|
||||
return modes
|
||||
}
|
||||
|
||||
static func sanitizedProductionSelection(
|
||||
_ candidate: InventoryStore.Mode,
|
||||
hasDollhouseAsset: Bool
|
||||
) -> InventoryStore.Mode {
|
||||
productionVisibleModes(hasDollhouseAsset: hasDollhouseAsset).contains(candidate) ? candidate : .sunseeker
|
||||
}
|
||||
|
||||
static func modeSummaryText(hasDollhouseAsset: Bool) -> String {
|
||||
productionVisibleModes(hasDollhouseAsset: hasDollhouseAsset)
|
||||
.map(\.rawValue)
|
||||
.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,852 @@
|
||||
import AVFoundation
|
||||
import Observation
|
||||
import SceneKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@Observable
|
||||
final class InventoryStore {
|
||||
enum Mode: String, CaseIterable, Identifiable {
|
||||
case sunseeker = "Sunseeker"
|
||||
case dreamWeaver = "Dream Weaver"
|
||||
case dollhouse = "Dollhouse"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
var mode: Mode = .sunseeker
|
||||
var sourceImage: UIImage?
|
||||
var generatedImage: UIImage?
|
||||
var isProcessing: Bool = false
|
||||
var sunNodesReady: Bool = false
|
||||
var dollhouseHour: Double = 12
|
||||
// Error message shown in the DreamWeaver panel
|
||||
var errorMessage: String?
|
||||
}
|
||||
|
||||
struct InventoryView: View {
|
||||
@State private var store = InventoryStore()
|
||||
@State private var showCamera = false
|
||||
@State private var sliderTickHour = 12
|
||||
@State private var showShareSheet = false
|
||||
@State private var shareImage: UIImage? = nil
|
||||
private let hasDollhouseAsset = InventoryModeAvailability.hasShippedDollhouseAsset()
|
||||
private let haptics = UIImpactFeedbackGenerator(style: .light)
|
||||
|
||||
private var visibleModes: [InventoryStore.Mode] {
|
||||
InventoryModeAvailability.productionVisibleModes(hasDollhouseAsset: hasDollhouseAsset)
|
||||
}
|
||||
|
||||
private var selectedMode: InventoryStore.Mode {
|
||||
InventoryModeAvailability.sanitizedProductionSelection(store.mode, hasDollhouseAsset: hasDollhouseAsset)
|
||||
}
|
||||
|
||||
private var modeSelection: Binding<InventoryStore.Mode> {
|
||||
Binding(
|
||||
get: { selectedMode },
|
||||
set: { newValue in
|
||||
store.mode = InventoryModeAvailability.sanitizedProductionSelection(
|
||||
newValue,
|
||||
hasDollhouseAsset: hasDollhouseAsset
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Page header — share button sits on the same baseline as the title
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Inventory")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(InventoryModeAvailability.modeSummaryText(hasDollhouseAsset: hasDollhouseAsset))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
if let img = store.generatedImage {
|
||||
Button {
|
||||
shareImage = img
|
||||
showShareSheet = true
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.transition(.opacity.combined(with: .scale))
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: store.generatedImage != nil)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 20)
|
||||
|
||||
Picker("Mode", selection: modeSelection) {
|
||||
ForEach(visibleModes) { mode in
|
||||
Text(mode.rawValue).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 12)
|
||||
|
||||
if !hasDollhouseAsset {
|
||||
ProductionScopeCard(
|
||||
icon: "cube.transparent",
|
||||
title: "Dollhouse hidden in this production build",
|
||||
message: "Dollhouse stays out of the production iPad scope until a verified Building.usdz or Building.scn asset is shipped in the app bundle."
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
Group {
|
||||
switch selectedMode {
|
||||
case .sunseeker:
|
||||
#if targetEnvironment(simulator)
|
||||
SimulatorUnavailableCard(
|
||||
icon: "arkit",
|
||||
title: "Sunseeker requires a real device",
|
||||
message: "The production build no longer renders a simulated AR sun path with fake location or heading data. Use a physical iPad to inspect the live camera-based overlay."
|
||||
)
|
||||
#else
|
||||
SunseekerPanel(sunNodesReady: $store.sunNodesReady)
|
||||
#endif
|
||||
|
||||
case .dreamWeaver:
|
||||
// No simulator guard here — CameraPicker automatically falls back
|
||||
// to the photo library when no camera is available (e.g. Simulator),
|
||||
// so the full Capture → Reimagine → API flow is testable without a device.
|
||||
DreamWeaverPanel(
|
||||
sourceImage: $store.sourceImage,
|
||||
generatedImage: $store.generatedImage,
|
||||
isProcessing: $store.isProcessing,
|
||||
errorMessage: $store.errorMessage,
|
||||
showCamera: $showCamera
|
||||
)
|
||||
|
||||
case .dollhouse:
|
||||
DollhousePanel(hour: $store.dollhouseHour, tickHour: $sliderTickHour, haptics: haptics)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 20)
|
||||
.animation(.easeInOut(duration: 0.25), value: store.mode)
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.simultaneousGesture(
|
||||
TapGesture().onEnded {
|
||||
UIApplication.shared.sendAction(
|
||||
#selector(UIResponder.resignFirstResponder),
|
||||
to: nil, from: nil, for: nil
|
||||
)
|
||||
}
|
||||
)
|
||||
.onAppear {
|
||||
store.mode = selectedMode
|
||||
UISegmentedControl.appearance().selectedSegmentTintColor = UIColor(
|
||||
red: 0.231, green: 0.510, blue: 0.965, alpha: 0.85)
|
||||
UISegmentedControl.appearance().setTitleTextAttributes(
|
||||
[.foregroundColor: UIColor.white], for: .selected)
|
||||
UISegmentedControl.appearance().setTitleTextAttributes(
|
||||
[.foregroundColor: UIColor(white: 0.62, alpha: 1)], for: .normal)
|
||||
UISegmentedControl.appearance().backgroundColor = UIColor(
|
||||
red: 0.031, green: 0.039, blue: 0.071, alpha: 1)
|
||||
}
|
||||
.sheet(isPresented: $showCamera) {
|
||||
CameraPicker(isPresented: $showCamera) { captured in
|
||||
// Normalise orientation immediately on capture
|
||||
store.sourceImage = captured.fixedOrientation()
|
||||
// Clear previous result and error when a new photo is taken
|
||||
store.generatedImage = nil
|
||||
store.errorMessage = nil
|
||||
}
|
||||
}
|
||||
.sheet(item: $shareImage) { img in
|
||||
ShareSheet(image: img)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared simulator placeholder
|
||||
|
||||
private struct SimulatorUnavailableCard: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(title)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(message)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(VelocityTheme.borderAccent, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProductionScopeCard: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
.padding(.top, 2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(message)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.borderAccent, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sunseeker
|
||||
|
||||
private struct SunseekerPanel: View {
|
||||
@Binding var sunNodesReady: Bool
|
||||
@State private var vm = SunseekerViewModel()
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
ARSunOverlayView(sunNodesReady: $sunNodesReady, vm: vm)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
|
||||
// Retained as a stylistic design element framing the AR view
|
||||
DashedSunLine()
|
||||
.stroke(Color.yellow.opacity(0.9), style: StrokeStyle(lineWidth: 3, dash: [10, 8]))
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 80)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// Info block
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Sunseeker")
|
||||
.font(.headline)
|
||||
Text("Point the iPad toward windows to inspect yearly sun-entry path.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(14)
|
||||
.background {
|
||||
GlassBlurView(style: .systemThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
|
||||
if !vm.isReady && vm.locationError == nil {
|
||||
// Loading state
|
||||
HStack(spacing: 8) {
|
||||
ProgressView().tint(.white)
|
||||
Text("Looking for the Sun...")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.black.opacity(0.6).clipShape(Capsule()))
|
||||
}
|
||||
|
||||
// Error banner (e.g. Location Denied)
|
||||
if let error = vm.locationError {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.yellow)
|
||||
Text(error)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white)
|
||||
Spacer()
|
||||
Button("Settings") {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.white.opacity(0.2))
|
||||
}
|
||||
.padding(14)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color.red.opacity(0.8))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dream Weaver
|
||||
|
||||
/// Available room types from integration guide §2
|
||||
private struct RoomType: Identifiable {
|
||||
let id: String // sent as the `room_type` form field
|
||||
let displayName: String
|
||||
let icon: String // SF Symbol
|
||||
}
|
||||
|
||||
private let roomTypes: [RoomType] = [
|
||||
RoomType(id: "bedroom", displayName: "Bedroom", icon: "bed.double"),
|
||||
RoomType(id: "living_room", displayName: "Living Rm", icon: "sofa"),
|
||||
RoomType(id: "bathroom", displayName: "Bathroom", icon: "drop"),
|
||||
RoomType(id: "kitchen", displayName: "Kitchen", icon: "refrigerator"),
|
||||
RoomType(id: "dining_room", displayName: "Dining Rm", icon: "fork.knife"),
|
||||
RoomType(id: "home_office", displayName: "Office", icon: "desktopcomputer"),
|
||||
RoomType(id: "hallway", displayName: "Hallway", icon: "door.left.hand.open"),
|
||||
RoomType(id: "balcony", displayName: "Balcony", icon: "sun.max"),
|
||||
]
|
||||
|
||||
private struct DreamWeaverPanel: View {
|
||||
@Binding var sourceImage: UIImage?
|
||||
@Binding var generatedImage: UIImage?
|
||||
@Binding var isProcessing: Bool
|
||||
@Binding var errorMessage: String?
|
||||
@Binding var showCamera: Bool
|
||||
|
||||
/// Selected room type ID — sent as `room_type` field. nil = none chosen yet.
|
||||
@State private var selectedRoomType: String? = nil
|
||||
/// Optional extra keywords — sent as `keywords` field (§3.2)
|
||||
@State private var keywords: String = ""
|
||||
/// Server health: nil = checking, true = online, false = offline
|
||||
@State private var serverOnline: Bool? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
|
||||
// ── Preview card ──────────────────────────────────────────────
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.fill(Color.black.opacity(0.9))
|
||||
|
||||
if let sourceImage {
|
||||
Image(uiImage: sourceImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.padding(12)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"No Capture",
|
||||
systemImage: "camera.viewfinder",
|
||||
description: Text("Tap Capture to snap a room.")
|
||||
)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
if let generatedImage {
|
||||
Image(uiImage: generatedImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.padding(12)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
if isProcessing { ProcessingOverlay() }
|
||||
|
||||
// Server health badge — top-right corner
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(serverOnline == true ? Color.green : serverOnline == false ? Color.red : Color.gray)
|
||||
.frame(width: 7, height: 7)
|
||||
Text(serverOnline == true ? "Server Online" : serverOnline == false ? "Server Offline" : "Checking...")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(.black.opacity(0.45))
|
||||
.clipShape(Capsule())
|
||||
.padding(14)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 420)
|
||||
.animation(.easeInOut(duration: 0.35), value: generatedImage)
|
||||
|
||||
// ── Error banner ──────────────────────────────────────────────
|
||||
if let errorMessage {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.yellow)
|
||||
Text(errorMessage)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.red.opacity(0.15))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color.red.opacity(0.35), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
|
||||
// ── Room Type picker ───────────────────────────────────────
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(roomTypes) { room in
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.3)) {
|
||||
// Tap again to deselect
|
||||
selectedRoomType = selectedRoomType == room.id ? nil : room.id
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: room.icon)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
Text(room.displayName)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(selectedRoomType == room.id
|
||||
? Color(red: 0.231, green: 0.510, blue: 0.965)
|
||||
: Color.white.opacity(0.08))
|
||||
)
|
||||
.foregroundStyle(selectedRoomType == room.id ? .white : .white.opacity(0.6))
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(selectedRoomType == room.id
|
||||
? Color.clear
|
||||
: Color.white.opacity(0.12), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
|
||||
// ── Keywords input ───────────────────────────────────────────
|
||||
PromptInputBar(
|
||||
text: $keywords,
|
||||
isDisabled: sourceImage == nil || isProcessing || serverOnline == false
|
||||
) {
|
||||
Task { await generate() }
|
||||
}
|
||||
|
||||
// ── Capture / Retake ─────────────────────────────────────────
|
||||
Button(sourceImage == nil ? "Capture" : "Retake") {
|
||||
showCamera = true
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(isProcessing)
|
||||
}
|
||||
.padding(16)
|
||||
.background {
|
||||
GlassBlurView(style: .systemThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||
.contentShape(RoundedRectangle(cornerRadius: 18))
|
||||
.onTapGesture {
|
||||
UIApplication.shared.sendAction(
|
||||
#selector(UIResponder.resignFirstResponder),
|
||||
to: nil, from: nil, for: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.3), value: errorMessage)
|
||||
.task { serverOnline = await ComfyClient.shared.checkHealth() }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func generate() async {
|
||||
guard let sourceImage, !isProcessing else { return }
|
||||
if serverOnline == false {
|
||||
errorMessage = "Server is currently offline. Please try again later."
|
||||
return
|
||||
}
|
||||
isProcessing = true
|
||||
errorMessage = nil
|
||||
do {
|
||||
let result = try await ComfyClient.shared.generateImage(
|
||||
source: sourceImage,
|
||||
roomType: selectedRoomType ?? roomTypes[0].id, // default: bedroom
|
||||
keywords: keywords.trimmingCharacters(in: .whitespaces)
|
||||
)
|
||||
withAnimation(.easeInOut(duration: 0.4)) {
|
||||
generatedImage = result
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
isProcessing = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Prompt Input Bar
|
||||
|
||||
private struct PromptInputBar: View {
|
||||
@Binding var text: String
|
||||
let isDisabled: Bool
|
||||
let onSubmit: () -> Void
|
||||
|
||||
@FocusState private var isFocused: Bool
|
||||
@State private var shimmer = false
|
||||
|
||||
private let placeholder = "gold, marble, luxury, etc."
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
ZStack(alignment: .leading) {
|
||||
if text.isEmpty {
|
||||
Text(placeholder)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(Color.white.opacity(0.35))
|
||||
.padding(.leading, 4)
|
||||
.allowsHitTesting(false) // let taps pass through to the gesture below
|
||||
}
|
||||
TextField("", text: $text, axis: .vertical)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(Color.white)
|
||||
.lineLimit(1...3)
|
||||
.focused($isFocused)
|
||||
.submitLabel(.send)
|
||||
.onSubmit {
|
||||
guard !isDisabled, !text.trimmingCharacters(in: .whitespaces).isEmpty else { return }
|
||||
onSubmit()
|
||||
}
|
||||
.tint(Color(red: 0.231, green: 0.510, blue: 0.965))
|
||||
}
|
||||
.contentShape(Rectangle()) // expand hit area to full ZStack bounds
|
||||
.onTapGesture { isFocused = true } // focus immediately on any tap
|
||||
|
||||
// Send arrow button
|
||||
Button {
|
||||
isFocused = false
|
||||
onSubmit()
|
||||
} label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.231, green: 0.510, blue: 0.965),
|
||||
Color(red: 0.40, green: 0.25, blue: 0.95)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
Image(systemName: "arrow.up")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
.disabled(isDisabled || text.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
.opacity(isDisabled || text.trimmingCharacters(in: .whitespaces).isEmpty ? 0.4 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.2), value: text.isEmpty)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color.white.opacity(0.06))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(
|
||||
isFocused
|
||||
? Color(red: 0.231, green: 0.510, blue: 0.965).opacity(0.8)
|
||||
: Color.white.opacity(0.12),
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
)
|
||||
.animation(.easeInOut(duration: 0.2), value: isFocused)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dollhouse
|
||||
|
||||
private struct DollhousePanel: View {
|
||||
@Binding var hour: Double
|
||||
@Binding var tickHour: Int
|
||||
let haptics: UIImpactFeedbackGenerator
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
SceneKitDollhouseView(hour: $hour)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.frame(maxWidth: .infinity, minHeight: 460)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(String(format: "Time: %02d:00", Int(hour.rounded())))
|
||||
.font(.headline)
|
||||
Slider(value: $hour, in: 0...24, step: 0.25)
|
||||
.onChange(of: hour) { _, newValue in
|
||||
let rounded = Int(newValue.rounded())
|
||||
if rounded != tickHour {
|
||||
tickHour = rounded
|
||||
haptics.impactOccurred(intensity: 0.7)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background {
|
||||
GlassBlurView(style: .systemThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SceneKit Dollhouse
|
||||
|
||||
private struct SceneKitDollhouseView: UIViewRepresentable {
|
||||
@Binding var hour: Double
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> SCNView {
|
||||
let view = SCNView()
|
||||
view.scene = context.coordinator.scene
|
||||
view.autoenablesDefaultLighting = false
|
||||
view.allowsCameraControl = true
|
||||
view.backgroundColor = UIColor.systemBackground
|
||||
context.coordinator.setupScene()
|
||||
context.coordinator.updateSunLight(hour: hour)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: SCNView, context: Context) {
|
||||
context.coordinator.updateSunLight(hour: hour)
|
||||
}
|
||||
|
||||
final class Coordinator {
|
||||
let scene = SCNScene()
|
||||
private let sunNode = SCNNode()
|
||||
|
||||
func setupScene() {
|
||||
let modelScene = InventoryModeAvailability.dollhouseAssetCandidates
|
||||
.compactMap { candidate in
|
||||
SCNScene(named: "\(candidate.name).\(candidate.ext)")
|
||||
}
|
||||
.first
|
||||
|
||||
if let modelScene {
|
||||
let container = SCNNode()
|
||||
for child in modelScene.rootNode.childNodes {
|
||||
container.addChildNode(child.clone())
|
||||
}
|
||||
scene.rootNode.addChildNode(container)
|
||||
} else {
|
||||
let fallback = SCNFloor()
|
||||
fallback.firstMaterial?.diffuse.contents = UIColor.secondarySystemBackground
|
||||
scene.rootNode.addChildNode(SCNNode(geometry: fallback))
|
||||
}
|
||||
|
||||
let camera = SCNCamera()
|
||||
let cameraNode = SCNNode()
|
||||
cameraNode.camera = camera
|
||||
cameraNode.position = SCNVector3(0, 4, 10)
|
||||
scene.rootNode.addChildNode(cameraNode)
|
||||
|
||||
let light = SCNLight()
|
||||
light.type = .directional
|
||||
light.intensity = 1_200
|
||||
light.castsShadow = true
|
||||
sunNode.light = light
|
||||
scene.rootNode.addChildNode(sunNode)
|
||||
|
||||
let ambient = SCNLight()
|
||||
ambient.type = .ambient
|
||||
ambient.intensity = 200
|
||||
let ambientNode = SCNNode()
|
||||
ambientNode.light = ambient
|
||||
scene.rootNode.addChildNode(ambientNode)
|
||||
}
|
||||
|
||||
func updateSunLight(hour: Double) {
|
||||
let normalized = (hour / 24.0) * (2 * Double.pi)
|
||||
let x = Float(cos(normalized) * 8.0)
|
||||
let y = Float(max(sin(normalized) * 8.0, 1.0))
|
||||
let z = Float(sin(normalized + .pi / 3) * 6.0)
|
||||
sunNode.position = SCNVector3(x, y, z)
|
||||
sunNode.look(at: SCNVector3(0, 0, 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ProcessingOverlay
|
||||
|
||||
private struct ProcessingOverlay: View {
|
||||
@State private var animate = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.black.opacity(0.45))
|
||||
|
||||
Text("AI Processing...")
|
||||
.font(.headline.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.background {
|
||||
GlassBlurView(style: .systemUltraThinMaterialDark)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [.clear, .white.opacity(0.6), .clear],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.rotationEffect(.degrees(18))
|
||||
.offset(x: animate ? 160 : -160)
|
||||
.animation(.linear(duration: 1.2).repeatForever(autoreverses: false), value: animate)
|
||||
.blendMode(.screen)
|
||||
.mask(Capsule().frame(height: 44))
|
||||
)
|
||||
}
|
||||
.padding(12)
|
||||
.onAppear { animate = true }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DashedSunLine
|
||||
|
||||
private struct DashedSunLine: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.minX, y: rect.maxY * 0.75))
|
||||
path.addQuadCurve(
|
||||
to: CGPoint(x: rect.maxX, y: rect.maxY * 0.25),
|
||||
control: CGPoint(x: rect.midX, y: rect.minY + 30)
|
||||
)
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CameraPicker
|
||||
|
||||
/// UIImagePickerController wrapper that delivers the captured image via a callback,
|
||||
/// triggering orientation fix and clearing stale state immediately on capture.
|
||||
private struct CameraPicker: UIViewControllerRepresentable {
|
||||
@Binding var isPresented: Bool
|
||||
let onCapture: (UIImage) -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||
let picker = UIImagePickerController()
|
||||
picker.delegate = context.coordinator
|
||||
#if targetEnvironment(simulator)
|
||||
// Newer Simulators report camera as available but the shutter never
|
||||
// delivers an image. Force photo library so testing actually works.
|
||||
picker.sourceType = .photoLibrary
|
||||
#else
|
||||
if UIImagePickerController.isSourceTypeAvailable(.camera) {
|
||||
picker.sourceType = .camera
|
||||
picker.cameraCaptureMode = .photo
|
||||
} else {
|
||||
picker.sourceType = .photoLibrary
|
||||
}
|
||||
#endif
|
||||
return picker
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
|
||||
|
||||
final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
|
||||
private let parent: CameraPicker
|
||||
|
||||
init(_ parent: CameraPicker) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||
parent.isPresented = false
|
||||
}
|
||||
|
||||
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
||||
if let captured = info[.originalImage] as? UIImage {
|
||||
parent.onCapture(captured)
|
||||
}
|
||||
parent.isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Share Sheet
|
||||
|
||||
/// Wraps UIActivityViewController to match the native iOS Photos share experience.
|
||||
/// Natively includes: Save Image, AirDrop, Messages, Mail, Copy, and all installed share extensions.
|
||||
private struct ShareSheet: UIViewControllerRepresentable {
|
||||
let image: UIImage
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(activityItems: [image], applicationActivities: nil)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uvc: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
|
||||
// MARK: - UIImage + Identifiable
|
||||
// Required to use UIImage as the `item` in .sheet(item:)
|
||||
extension UIImage: @retroactive Identifiable {
|
||||
public var id: ObjectIdentifier { ObjectIdentifier(self) }
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import CoreLocation
|
||||
import SceneKit
|
||||
import SwiftUI
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
|
||||
/// A non-AR 3D view for testing the Sunseeker path logic on the iOS Simulator.
|
||||
/// Uses a synthetic camera, fake location, and mock heading instead of ARKit.
|
||||
struct SimulatorSunOverlayView: UIViewRepresentable {
|
||||
@Binding var sunNodesReady: Bool
|
||||
|
||||
// Fake location (e.g. San Francisco)
|
||||
private let mockLocation = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)
|
||||
private let mockHeading: Double = 0 // North
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(sunNodesReady: $sunNodesReady, mockLocation: mockLocation, mockHeading: mockHeading)
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> SCNView {
|
||||
let view = SCNView(frame: .zero)
|
||||
view.scene = SCNScene()
|
||||
view.allowsCameraControl = true // Swipe around the 3D space
|
||||
view.autoenablesDefaultLighting = true
|
||||
view.backgroundColor = UIColor(white: 0.1, alpha: 1.0)
|
||||
view.isPlaying = true // Force render loop
|
||||
view.showsStatistics = true // Prove it's rendering
|
||||
|
||||
// Setup synthetic camera
|
||||
let cameraNode = SCNNode()
|
||||
cameraNode.camera = SCNCamera()
|
||||
cameraNode.camera?.zFar = 100
|
||||
cameraNode.position = SCNVector3(x: 0, y: 0, z: 0) // Centered
|
||||
view.scene?.rootNode.addChildNode(cameraNode)
|
||||
|
||||
context.coordinator.attach(to: view)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: SCNView, context: Context) {}
|
||||
|
||||
final class Coordinator: NSObject {
|
||||
@Binding private var sunNodesReady: Bool
|
||||
|
||||
private let mockLocation: CLLocationCoordinate2D
|
||||
private let mockHeading: Double
|
||||
|
||||
private var arcRootNode = SCNNode()
|
||||
private var currentSunNode = SCNNode()
|
||||
|
||||
private var updateTimer: Timer?
|
||||
|
||||
init(sunNodesReady: Binding<Bool>, mockLocation: CLLocationCoordinate2D, mockHeading: Double) {
|
||||
_sunNodesReady = sunNodesReady
|
||||
self.mockLocation = mockLocation
|
||||
self.mockHeading = mockHeading
|
||||
super.init()
|
||||
}
|
||||
|
||||
func attach(to view: SCNView) {
|
||||
view.scene?.rootNode.addChildNode(arcRootNode)
|
||||
view.scene?.rootNode.addChildNode(currentSunNode)
|
||||
buildScene()
|
||||
startRealTimeTick()
|
||||
}
|
||||
|
||||
deinit {
|
||||
updateTimer?.invalidate()
|
||||
}
|
||||
|
||||
private func startRealTimeTick() {
|
||||
// Update current sun position every second
|
||||
updateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
let cur = SunMath.calculateSunPosition(date: Date(), coordinate: self.mockLocation)
|
||||
// Need to remove previous child as we are completely replacing it
|
||||
self.currentSunNode.childNodes.forEach { $0.removeFromParentNode() }
|
||||
|
||||
let radius: Float = 1.8
|
||||
let orb = SCNSphere(radius: 0.055)
|
||||
orb.firstMaterial?.diffuse.contents = UIColor.systemOrange
|
||||
orb.firstMaterial?.emission.contents = UIColor.systemYellow
|
||||
orb.firstMaterial?.lightingModel = .constant
|
||||
let orbNode = SCNNode(geometry: orb)
|
||||
orbNode.position = self.worldPosition(for: cur, radius: radius)
|
||||
|
||||
let pulse = CABasicAnimation(keyPath: "scale")
|
||||
pulse.fromValue = SCNVector3(1, 1, 1)
|
||||
pulse.toValue = SCNVector3(1.3, 1.3, 1.3)
|
||||
pulse.duration = 1.2
|
||||
pulse.autoreverses = true
|
||||
pulse.repeatCount = .infinity
|
||||
orbNode.addAnimation(pulse, forKey: "pulse")
|
||||
|
||||
let label = self.makeTextNode(text: "Now", color: .systemYellow, fontSize: 0.05)
|
||||
label.position = SCNVector3(0, 0.09, 0)
|
||||
orbNode.addChildNode(label)
|
||||
|
||||
self.currentSunNode.addChildNode(orbNode)
|
||||
}
|
||||
}
|
||||
|
||||
private func buildScene() {
|
||||
let arc = SunMath.sunPathArc(for: Date(), coordinate: mockLocation)
|
||||
let riseSet = SunMath.sunRiseSet(for: Date(), coordinate: mockLocation)
|
||||
let radius: Float = 1.8
|
||||
var positions: [SCNVector3] = []
|
||||
|
||||
// Hourly blocks
|
||||
for (date, pos) in arc {
|
||||
guard pos.elevation > -5 else { continue }
|
||||
let worldPos = worldPosition(for: pos, radius: radius)
|
||||
positions.append(worldPos)
|
||||
|
||||
let sphere = SCNSphere(radius: 0.018)
|
||||
sphere.firstMaterial?.diffuse.contents = UIColor.systemYellow.withAlphaComponent(0.85)
|
||||
sphere.firstMaterial?.lightingModel = .constant
|
||||
let markerNode = SCNNode(geometry: sphere)
|
||||
markerNode.position = worldPos
|
||||
arcRootNode.addChildNode(markerNode)
|
||||
|
||||
let calendar = Calendar.current
|
||||
let hour = calendar.component(.hour, from: date)
|
||||
if hour % 2 == 0 {
|
||||
let labelNode = makeTextNode(text: hourLabel(from: date), color: .white, fontSize: 0.04)
|
||||
labelNode.position = SCNVector3(worldPos.x, worldPos.y + 0.06, worldPos.z)
|
||||
arcRootNode.addChildNode(labelNode)
|
||||
}
|
||||
}
|
||||
|
||||
if positions.count >= 2 {
|
||||
let lineNode = makeLineNode(through: positions, color: UIColor.systemYellow.withAlphaComponent(0.55))
|
||||
arcRootNode.addChildNode(lineNode)
|
||||
}
|
||||
|
||||
if let riseDate = riseSet.rise {
|
||||
let risePos = SunMath.calculateSunPosition(date: riseDate, coordinate: mockLocation)
|
||||
let wPos = worldPosition(for: risePos, radius: radius)
|
||||
arcRootNode.addChildNode(makeSpecialMarker(at: wPos, color: .systemOrange, label: "Sunrise \(hourLabel(from: riseDate))"))
|
||||
}
|
||||
|
||||
if let setDate = riseSet.set {
|
||||
let setPos = SunMath.calculateSunPosition(date: setDate, coordinate: mockLocation)
|
||||
let wPos = worldPosition(for: setPos, radius: radius)
|
||||
arcRootNode.addChildNode(makeSpecialMarker(at: wPos, color: .systemRed, label: "Sunset \(hourLabel(from: setDate))"))
|
||||
}
|
||||
|
||||
// Generate current sun node synchronously for first frame
|
||||
updateTimer?.fire()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.sunNodesReady = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Math equivalent from SunseekerViewModel
|
||||
|
||||
private func worldPosition(for sun: SunPosition, radius: Float) -> SCNVector3 {
|
||||
let elev = Float(sun.elevation * .pi / 180.0)
|
||||
let az = Float(sun.azimuth * .pi / 180.0)
|
||||
let x = radius * cos(elev) * sin(az)
|
||||
let y = radius * sin(elev)
|
||||
let z = -radius * cos(elev) * cos(az)
|
||||
return SCNVector3(x, y, z)
|
||||
}
|
||||
|
||||
// MARK: SceneKit Factories
|
||||
|
||||
private func makeSpecialMarker(at pos: SCNVector3, color: UIColor, label: String) -> SCNNode {
|
||||
let root = SCNNode()
|
||||
let sphere = SCNSphere(radius: 0.035)
|
||||
sphere.firstMaterial?.diffuse.contents = color
|
||||
sphere.firstMaterial?.emission.contents = color.withAlphaComponent(0.5)
|
||||
sphere.firstMaterial?.lightingModel = .constant
|
||||
let markerNode = SCNNode(geometry: sphere)
|
||||
markerNode.position = pos
|
||||
root.addChildNode(markerNode)
|
||||
|
||||
let labelNode = makeTextNode(text: label, color: .white, fontSize: 0.04)
|
||||
labelNode.position = SCNVector3(pos.x, pos.y + 0.07, pos.z)
|
||||
root.addChildNode(labelNode)
|
||||
return root
|
||||
}
|
||||
|
||||
private func makeTextNode(text: String, color: UIColor, fontSize: CGFloat) -> SCNNode {
|
||||
// SCNText is buggy in Simulator. Render text to a UIImage instead.
|
||||
let font = UIFont.systemFont(ofSize: 40, weight: .bold)
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: font,
|
||||
.foregroundColor: color
|
||||
]
|
||||
let size = (text as NSString).size(withAttributes: attributes)
|
||||
|
||||
// Add some padding
|
||||
let paddedSize = CGSize(width: size.width + 10, height: size.height + 10)
|
||||
|
||||
let renderer = UIGraphicsImageRenderer(size: paddedSize)
|
||||
let image = renderer.image { context in
|
||||
(text as NSString).draw(
|
||||
in: CGRect(x: 5, y: 5, width: size.width, height: size.height),
|
||||
withAttributes: attributes
|
||||
)
|
||||
}
|
||||
|
||||
// Map the image onto an SCNPlane
|
||||
// A 100x50 image becomes a 0.1 x 0.05 meter plane
|
||||
let plane = SCNPlane(width: paddedSize.width / 1000.0, height: paddedSize.height / 1000.0)
|
||||
plane.firstMaterial?.diffuse.contents = image
|
||||
plane.firstMaterial?.isDoubleSided = true
|
||||
plane.firstMaterial?.lightingModel = .constant
|
||||
|
||||
let textNode = SCNNode(geometry: plane)
|
||||
// Statically scale the plane up so it is readable next to the spheres
|
||||
textNode.scale = SCNVector3(1.5, 1.5, 1.5)
|
||||
|
||||
let constraint = SCNBillboardConstraint()
|
||||
constraint.freeAxes = .all
|
||||
textNode.constraints = [constraint]
|
||||
|
||||
return textNode
|
||||
}
|
||||
|
||||
private func makeLineNode(through positions: [SCNVector3], color: UIColor) -> SCNNode {
|
||||
guard positions.count >= 2 else { return SCNNode() }
|
||||
|
||||
let vertices: [SCNVector3] = positions
|
||||
var indices: [Int32] = []
|
||||
for i in 0..<(vertices.count - 1) {
|
||||
indices.append(Int32(i))
|
||||
indices.append(Int32(i + 1))
|
||||
}
|
||||
|
||||
let vertexSource = SCNGeometrySource(vertices: vertices)
|
||||
let element = SCNGeometryElement(
|
||||
indices: indices,
|
||||
primitiveType: .line
|
||||
)
|
||||
let geometry = SCNGeometry(sources: [vertexSource], elements: [element])
|
||||
geometry.firstMaterial?.diffuse.contents = color
|
||||
geometry.firstMaterial?.lightingModel = .constant
|
||||
return SCNNode(geometry: geometry)
|
||||
}
|
||||
|
||||
private func hourLabel(from date: Date) -> String {
|
||||
let fmt = DateFormatter()
|
||||
fmt.dateFormat = "ha"
|
||||
fmt.amSymbol = "am"
|
||||
fmt.pmSymbol = "pm"
|
||||
return fmt.string(from: date)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,140 @@
|
||||
import CoreLocation
|
||||
import CoreMotion
|
||||
import Foundation
|
||||
import Observation
|
||||
import SceneKit
|
||||
|
||||
// MARK: - SunseekerViewModel
|
||||
|
||||
/// Owns all sensor state for the Sunseeker AR overlay.
|
||||
/// Separates CoreLocation / CoreMotion concerns from the ARKit view layer.
|
||||
@Observable
|
||||
final class SunseekerViewModel: NSObject, CLLocationManagerDelegate {
|
||||
|
||||
// MARK: - Published State
|
||||
|
||||
/// True once we have both a GPS fix and a valid heading.
|
||||
private(set) var isReady = false
|
||||
|
||||
/// Latest GPS coordinate. nil until first fix.
|
||||
private(set) var coordinate: CLLocationCoordinate2D?
|
||||
|
||||
/// Latest true heading (0 = North, clockwise).
|
||||
private(set) var heading: Double = 0
|
||||
|
||||
/// Dense hourly arc for today.
|
||||
private(set) var arc: [(date: Date, position: SunPosition)] = []
|
||||
|
||||
/// Current real-time sun position (updated every second).
|
||||
private(set) var currentPosition: SunPosition?
|
||||
|
||||
/// Sunrise and sunset for today.
|
||||
private(set) var riseSet: (rise: Date?, set: Date?) = (nil, nil)
|
||||
|
||||
/// Diagnostic string for the UI when location access is denied.
|
||||
private(set) var locationError: String?
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private let locationManager = CLLocationManager()
|
||||
private let motionManager = CMMotionManager()
|
||||
private var updateTimer: Timer?
|
||||
|
||||
// MARK: - Init / Deinit
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
locationManager.delegate = self
|
||||
locationManager.desiredAccuracy = kCLLocationAccuracyBest
|
||||
locationManager.headingFilter = 1.0
|
||||
locationManager.requestWhenInUseAuthorization()
|
||||
locationManager.startUpdatingLocation()
|
||||
locationManager.startUpdatingHeading()
|
||||
startMotionUpdates()
|
||||
startRealTimeTick()
|
||||
}
|
||||
|
||||
deinit {
|
||||
stop()
|
||||
}
|
||||
|
||||
// MARK: - Control
|
||||
|
||||
func stop() {
|
||||
motionManager.stopDeviceMotionUpdates()
|
||||
locationManager.stopUpdatingLocation()
|
||||
locationManager.stopUpdatingHeading()
|
||||
updateTimer?.invalidate()
|
||||
updateTimer = nil
|
||||
}
|
||||
|
||||
// MARK: - World-Space Transform
|
||||
|
||||
/// Converts a solar `SunPosition` into a SceneKit world-space position on a sphere of given `radius`.
|
||||
/// Orientation is relative to ARWorldTrackingConfiguration(.gravityAndHeading), so north = -Z axis.
|
||||
func worldPosition(for sun: SunPosition, radius: Float) -> SCNVector3 {
|
||||
let elev = Float(sun.elevation.radians)
|
||||
let az = Float(sun.azimuth.radians) // clockwise from north
|
||||
let x = radius * cos(elev) * sin(az)
|
||||
let y = radius * sin(elev)
|
||||
let z = -radius * cos(elev) * cos(az) // -Z = north in ARKit gravity+heading
|
||||
return SCNVector3(x, y, z)
|
||||
}
|
||||
|
||||
// MARK: - Private helpers
|
||||
|
||||
private func startMotionUpdates() {
|
||||
guard motionManager.isDeviceMotionAvailable else { return }
|
||||
motionManager.deviceMotionUpdateInterval = 0.05
|
||||
motionManager.startDeviceMotionUpdates()
|
||||
}
|
||||
|
||||
private func startRealTimeTick() {
|
||||
updateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||
guard let self, let coord = self.coordinate else { return }
|
||||
self.currentPosition = SunMath.calculateSunPosition(date: Date(), coordinate: coord)
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshArc() {
|
||||
guard let coord = coordinate else { return }
|
||||
arc = SunMath.sunPathArc(for: Date(), coordinate: coord)
|
||||
riseSet = SunMath.sunRiseSet(for: Date(), coordinate: coord)
|
||||
currentPosition = SunMath.calculateSunPosition(date: Date(), coordinate: coord)
|
||||
isReady = true
|
||||
}
|
||||
|
||||
// MARK: - CLLocationManagerDelegate
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard coordinate == nil, let loc = locations.last else { return }
|
||||
coordinate = loc.coordinate
|
||||
refreshArc()
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
|
||||
heading = newHeading.trueHeading > 0 ? newHeading.trueHeading : newHeading.magneticHeading
|
||||
if coordinate != nil { isReady = true }
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
print("[Sunseeker] Location error: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
switch manager.authorizationStatus {
|
||||
case .denied, .restricted:
|
||||
locationError = "Location access needed to calculate the sun path. Please enable it in Settings."
|
||||
case .notDetermined:
|
||||
manager.requestWhenInUseAuthorization()
|
||||
default:
|
||||
locationError = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Degree helpers (internal to this file)
|
||||
|
||||
private extension Double {
|
||||
var radians: Double { self * .pi / 180.0 }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
enum OracleModeAvailability {
|
||||
static let productionVisibleModes: [OracleMode] = [
|
||||
.pipeline,
|
||||
.deals,
|
||||
.accountTimeline,
|
||||
.calendarTasks,
|
||||
]
|
||||
|
||||
static let hiddenModesUntilBackendSupport: [OracleMode] = [
|
||||
.teamPerformance,
|
||||
.leadMap,
|
||||
]
|
||||
|
||||
static func sanitizedProductionSelection(_ candidate: OracleMode) -> OracleMode {
|
||||
productionVisibleModes.contains(candidate) ? candidate : .pipeline
|
||||
}
|
||||
}
|
||||
1223
iOS/velocity-ipad/velocity/Features/Oracle/OracleView.swift
Normal file
1223
iOS/velocity-ipad/velocity/Features/Oracle/OracleView.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
enum SentinelScope {
|
||||
static let navigationTitle = "Operator Posture"
|
||||
static let productFamilyName = "Sentinel"
|
||||
static let availabilityBadge = "Operator posture only"
|
||||
|
||||
static let disabledAnalyticsCapabilities: [String] = [
|
||||
"visitor counting",
|
||||
"facial detections",
|
||||
"sentiment scoring",
|
||||
]
|
||||
|
||||
static let liveBackedCapabilities: [String] = [
|
||||
"alert posture",
|
||||
"transcription queue visibility",
|
||||
"upcoming calendar pressure",
|
||||
"recent operator timeline",
|
||||
]
|
||||
|
||||
static var disabledAnalyticsSummary: String {
|
||||
disabledAnalyticsCapabilities.joined(separator: ", ")
|
||||
}
|
||||
|
||||
static var liveBackedSummary: String {
|
||||
liveBackedCapabilities.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
209
iOS/velocity-ipad/velocity/Features/Sentinel/SentinelView.swift
Normal file
209
iOS/velocity-ipad/velocity/Features/Sentinel/SentinelView.swift
Normal file
@@ -0,0 +1,209 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct SentinelView: View {
|
||||
@State private var store = AppStore.shared
|
||||
private let refreshTimer = Timer.publish(every: 20, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
header
|
||||
|
||||
if let error = store.errorMessage {
|
||||
errorBanner(error)
|
||||
}
|
||||
|
||||
availabilityCard
|
||||
postureCards
|
||||
timelineCard
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.scrollContentBackground(.hidden)
|
||||
.task { await store.refresh() }
|
||||
.refreshable { await store.refresh() }
|
||||
.onReceive(refreshTimer) { _ in
|
||||
Task { await store.refresh(silent: true) }
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(SentinelScope.productFamilyName.uppercased())
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(1.2)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(SentinelScope.navigationTitle)
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Truthful live posture for alerts and comms load. \(SentinelScope.productFamilyName) analytics stay disabled on iPad until a real production stream is exposed.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
|
||||
private var availabilityCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Production Scope")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
statusBadge(
|
||||
label: SentinelScope.availabilityBadge,
|
||||
color: VelocityTheme.warning
|
||||
)
|
||||
}
|
||||
|
||||
Text("This iPad build does not synthesize \(SentinelScope.disabledAnalyticsSummary). A dedicated production Sentinel route is still required before those analytics can be shown safely.")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
Text("Current surface instead reports real \(SentinelScope.liveBackedSummary) from the live mobile-edge backend.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(20)
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
|
||||
private var postureCards: some View {
|
||||
HStack(spacing: 14) {
|
||||
SentinelCard(
|
||||
title: "Pending insights",
|
||||
value: "\(store.metrics.pendingInsights)",
|
||||
subtitle: "Recommendations waiting on operator review",
|
||||
color: VelocityTheme.danger
|
||||
)
|
||||
SentinelCard(
|
||||
title: "Transcript queue",
|
||||
value: "\(store.metrics.pendingTranscriptions)",
|
||||
subtitle: "Imported recordings still processing",
|
||||
color: VelocityTheme.warning
|
||||
)
|
||||
SentinelCard(
|
||||
title: "Upcoming 24h",
|
||||
value: "\(store.alertSnapshot?.upcomingCalendarEvents24h ?? 0)",
|
||||
subtitle: "Calendar events due soon",
|
||||
color: VelocityTheme.success
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var timelineCard: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
Text("Recent Operator Timeline")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
if let lastRefresh = store.lastRefreshAt {
|
||||
Text("Updated \(lastRefresh.relativeShort)")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
|
||||
if store.timelineEvents.isEmpty {
|
||||
Text("No live communication events have been loaded for the current high-priority leads yet.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
} else {
|
||||
ForEach(store.timelineEvents.prefix(6)) { item in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(item.leadName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text(item.event.channel.replacingOccurrences(of: "_", with: " ").capitalized)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
Text(item.event.summary ?? "No summary available for this event.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(item.event.timestampDate?.relativeShort ?? item.event.timestamp)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
|
||||
private func statusBadge(label: String, color: Color) -> some View {
|
||||
Text(label)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(color.opacity(0.12))
|
||||
.overlay(Capsule().stroke(color.opacity(0.22), lineWidth: 1))
|
||||
)
|
||||
}
|
||||
|
||||
private func errorBanner(_ message: String) -> some View {
|
||||
Text(message)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.danger)
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(VelocityTheme.danger.opacity(0.10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.danger.opacity(0.22), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SentinelCard: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let subtitle: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(1)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(value)
|
||||
.font(.system(size: 26, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(subtitle)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(color)
|
||||
.frame(width: 48, height: 4)
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassCard(cornerRadius: 16)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SentinelView()
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SessionConfigurationPanel: View {
|
||||
@State private var session = SessionStore.shared
|
||||
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let primaryActionTitle: String
|
||||
let allowsClearingStoredConfiguration: Bool
|
||||
|
||||
var body: some View {
|
||||
@Bindable var session = session
|
||||
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(subtitle)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
|
||||
VStack(spacing: 14) {
|
||||
SessionInputField(
|
||||
label: "Backend endpoint",
|
||||
placeholder: "https://velocity.desineuron.in/api"
|
||||
) {
|
||||
TextField("", text: $session.draftBaseURL, prompt: Text("https://velocity.desineuron.in/api"))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.URL)
|
||||
}
|
||||
|
||||
SessionInputField(
|
||||
label: "Dream Weaver endpoint",
|
||||
placeholder: "Leave blank to use the backend endpoint"
|
||||
) {
|
||||
TextField("", text: $session.draftDreamWeaverBaseURL, prompt: Text("https://dreamweaver.desineuron.in"))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.URL)
|
||||
}
|
||||
|
||||
SessionInputField(
|
||||
label: "Dream Weaver gateway API key",
|
||||
placeholder: session.currentConfiguration.hasDreamWeaverAPIKey ? "Leave blank to keep the current gateway key" : "Optional unless the gateway enforces it"
|
||||
) {
|
||||
SecureField(
|
||||
"",
|
||||
text: $session.draftDreamWeaverAPIKey,
|
||||
prompt: Text(session.currentConfiguration.hasDreamWeaverAPIKey ? "Leave blank to keep the current gateway key" : "Optional unless the gateway enforces it")
|
||||
)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Authentication mode")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
|
||||
Picker("Authentication mode", selection: $session.draftAuthMode) {
|
||||
ForEach(SessionAuthMode.allCases) { mode in
|
||||
Text(mode.rawValue).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
if session.draftAuthMode == .emailPassword {
|
||||
SessionInputField(
|
||||
label: "Operator email",
|
||||
placeholder: "operator@desineuron.in"
|
||||
) {
|
||||
TextField("", text: $session.draftEmail, prompt: Text("operator@desineuron.in"))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.emailAddress)
|
||||
}
|
||||
|
||||
SessionInputField(
|
||||
label: "Password",
|
||||
placeholder: session.currentConfiguration.hasPassword ? "Leave blank to keep the current secret" : "Required"
|
||||
) {
|
||||
SecureField(
|
||||
"",
|
||||
text: $session.draftPassword,
|
||||
prompt: Text(session.currentConfiguration.hasPassword ? "Leave blank to keep the current secret" : "Required")
|
||||
)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
} else {
|
||||
SessionInputField(
|
||||
label: "Bearer token",
|
||||
placeholder: session.currentConfiguration.hasBearerToken ? "Leave blank to keep the current token" : "Required"
|
||||
) {
|
||||
SecureField(
|
||||
"",
|
||||
text: $session.draftBearerToken,
|
||||
prompt: Text(session.currentConfiguration.hasBearerToken ? "Leave blank to keep the current token" : "Required")
|
||||
)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Current source: \(session.configurationSourceDescription)")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Runtime overrides are saved on-device. Secrets are stored in Keychain; the backend endpoint, optional Dream Weaver endpoint, and operator email are stored in local app preferences.")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
|
||||
if let message = session.statusMessage {
|
||||
SessionStatusBanner(message: message, accentColor: VelocityTheme.success)
|
||||
}
|
||||
|
||||
if let error = session.errorMessage {
|
||||
SessionStatusBanner(message: error, accentColor: VelocityTheme.danger)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await session.saveDraft() }
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
if session.isSaving {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.tint(.white)
|
||||
}
|
||||
Text(primaryActionTitle)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(SessionActionButtonStyle(background: VelocityTheme.accent))
|
||||
.disabled(session.isSaving)
|
||||
|
||||
Button("Reset form") {
|
||||
session.discardDraftChanges()
|
||||
}
|
||||
.buttonStyle(SessionSecondaryButtonStyle())
|
||||
.disabled(session.isSaving || !session.hasUnsavedChanges)
|
||||
}
|
||||
|
||||
if allowsClearingStoredConfiguration {
|
||||
Button("Clear stored session override") {
|
||||
Task { await session.clearStoredConfiguration() }
|
||||
}
|
||||
.buttonStyle(SessionDangerButtonStyle())
|
||||
.disabled(session.isSaving || !session.isUsingStoredRuntimeConfiguration)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.glassCard(cornerRadius: 20)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SessionInputField<Field: View>: View {
|
||||
let label: String
|
||||
let placeholder: String
|
||||
@ViewBuilder let field: Field
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(label)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
|
||||
field
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
|
||||
Text(placeholder)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(VelocityTheme.mutedFg.opacity(0.9))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SessionStatusBanner: View {
|
||||
let message: String
|
||||
let accentColor: Color
|
||||
|
||||
var body: some View {
|
||||
Text(message)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(accentColor)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(accentColor.opacity(0.10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(accentColor.opacity(0.18), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SessionActionButtonStyle: ButtonStyle {
|
||||
let background: Color
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.foregroundStyle(.white)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(background.opacity(configuration.isPressed ? 0.82 : 1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SessionSecondaryButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(VelocityTheme.surface.opacity(configuration.isPressed ? 0.78 : 1))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SessionDangerButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.danger)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 11)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(VelocityTheme.danger.opacity(configuration.isPressed ? 0.14 : 0.10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(VelocityTheme.danger.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
224
iOS/velocity-ipad/velocity/Features/Settings/SettingsView.swift
Normal file
224
iOS/velocity-ipad/velocity/Features/Settings/SettingsView.swift
Normal file
@@ -0,0 +1,224 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@State private var store = AppStore.shared
|
||||
@State private var session = SessionStore.shared
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Settings")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Live runtime configuration")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
|
||||
SettingsSection(title: "Connectivity") {
|
||||
SettingsRow(
|
||||
label: "Backend endpoint",
|
||||
value: session.endpointDisplay,
|
||||
icon: "server.rack",
|
||||
accentColor: VelocityTheme.accent
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Dream Weaver endpoint",
|
||||
value: session.dreamWeaverEndpointDisplay,
|
||||
icon: "wand.and.stars",
|
||||
accentColor: session.dreamWeaverEndpointModeDescription == "Dedicated gateway" ? VelocityTheme.warning : VelocityTheme.mutedFg
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Dream Weaver route mode",
|
||||
value: session.dreamWeaverEndpointModeDescription,
|
||||
icon: "point.3.connected.trianglepath.dotted",
|
||||
accentColor: session.dreamWeaverEndpointModeDescription == "Dedicated gateway" ? VelocityTheme.warning : VelocityTheme.success
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Dream Weaver auth",
|
||||
value: session.dreamWeaverAuthenticationDescription,
|
||||
icon: "key.horizontal",
|
||||
accentColor: session.dreamWeaverAuthenticationDescription == "API key configured" ? VelocityTheme.success : VelocityTheme.mutedFg
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Auth mode",
|
||||
value: session.authModeDescription,
|
||||
icon: "lock.shield",
|
||||
accentColor: session.isConfigured ? VelocityTheme.success : VelocityTheme.warning
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Config source",
|
||||
value: session.configurationSourceDescription,
|
||||
icon: "externaldrive.badge.icloud",
|
||||
accentColor: session.isUsingStoredRuntimeConfiguration ? VelocityTheme.success : VelocityTheme.mutedFg
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Last refresh",
|
||||
value: store.lastRefreshAt?.relativeShort ?? "No live fetch yet",
|
||||
icon: "arrow.clockwise",
|
||||
accentColor: VelocityTheme.mutedFg
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSection(title: "Operator") {
|
||||
SettingsRow(
|
||||
label: "Identity",
|
||||
value: session.operatorIdentity,
|
||||
icon: "person.crop.circle",
|
||||
accentColor: VelocityTheme.accent
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "CRM contacts loaded",
|
||||
value: "\(store.contacts.count)",
|
||||
icon: "person.3",
|
||||
accentColor: VelocityTheme.success
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Pending CRM tasks loaded",
|
||||
value: "\(store.tasks.count)",
|
||||
icon: "checklist",
|
||||
accentColor: VelocityTheme.warning
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Property records loaded",
|
||||
value: "\(store.properties.count)",
|
||||
icon: "building.2",
|
||||
accentColor: VelocityTheme.warning
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSection(title: "Production Readiness") {
|
||||
SettingsRow(
|
||||
label: "Canonical contacts",
|
||||
value: "\(store.contacts.count) loaded",
|
||||
icon: "person.text.rectangle",
|
||||
accentColor: store.contacts.isEmpty ? VelocityTheme.warning : VelocityTheme.success
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Pipeline lanes",
|
||||
value: "\(store.kanbanColumns.reduce(0) { $0 + $1.count }) leads",
|
||||
icon: "square.grid.3x1.below.line.grid.1x2",
|
||||
accentColor: store.kanbanColumns.isEmpty ? VelocityTheme.warning : VelocityTheme.success
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Deals",
|
||||
value: "\(store.opportunities.count) opportunities",
|
||||
icon: "target",
|
||||
accentColor: store.opportunities.isEmpty ? VelocityTheme.warning : VelocityTheme.success
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Timeline events",
|
||||
value: "\(store.timelineEvents.count) hydrated",
|
||||
icon: "clock.arrow.circlepath",
|
||||
accentColor: store.timelineEvents.isEmpty ? VelocityTheme.warning : VelocityTheme.success
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Last app error",
|
||||
value: store.errorMessage ?? "None",
|
||||
icon: "exclamationmark.triangle",
|
||||
accentColor: store.errorMessage == nil ? VelocityTheme.success : VelocityTheme.danger
|
||||
)
|
||||
}
|
||||
|
||||
SessionConfigurationPanel(
|
||||
title: "Session Configuration",
|
||||
subtitle: "Update the production endpoint, point Dream Weaver at a dedicated gateway when needed, or rotate operator credentials without rebuilding the app. Saving clears the cached token, re-runs a live refresh, and probes the Dream Weaver routes.",
|
||||
primaryActionTitle: "Save and refresh",
|
||||
allowsClearingStoredConfiguration: true
|
||||
)
|
||||
|
||||
SettingsSection(title: "Production Notes") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("This build avoids local demo data. Runtime session overrides are stored on-device so investor or operator installs no longer depend on committed build-time credentials.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(SentinelScope.navigationTitle) remains the truthful iPad label for the current \(SentinelScope.productFamilyName) surface because visitor analytics stay disabled until a dedicated production feed exists; Communications, Calendar, Dashboard, Oracle pipeline, and inventory summaries are live-backed. Dream Weaver can now use a dedicated gateway with an optional per-gateway API key, and backend plus generation route health are still enforced and reported truthfully.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(VelocityTheme.background)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsSection<Content: View>: View {
|
||||
let title: String
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(1.2)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.padding(.bottom, 8)
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
content
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
let icon: String
|
||||
let accentColor: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(accentColor.opacity(0.12))
|
||||
.frame(width: 30, height: 30)
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(accentColor)
|
||||
}
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(value)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
24
iOS/velocity-ipad/velocity/Info.plist
Normal file
24
iOS/velocity-ipad/velocity/Info.plist
Normal file
@@ -0,0 +1,24 @@
|
||||
<?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>BASE_URL</key>
|
||||
<string>$(BASE_URL)</string>
|
||||
<key>DREAM_WEAVER_BASE_URL</key>
|
||||
<string>$(DREAM_WEAVER_BASE_URL)</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Velocity needs camera access to capture room photos for Dream Weaver.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Velocity uses your location to calculate the accurate sun path for your property.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>Velocity would like to save your AI-generated room design to your photo library.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Velocity needs access to your photo library to let you pick room photos for Dream Weaver.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
973
iOS/velocity-ipad/velocityTests/VelocitySmokeTests.swift
Normal file
973
iOS/velocity-ipad/velocityTests/VelocitySmokeTests.swift
Normal file
@@ -0,0 +1,973 @@
|
||||
import SwiftUI
|
||||
import XCTest
|
||||
@testable import velocity
|
||||
|
||||
final class VelocitySmokeTests: XCTestCase {
|
||||
@MainActor
|
||||
func testContentViewCanBeConstructed() {
|
||||
let view = ContentView()
|
||||
XCTAssertNotNil(view)
|
||||
}
|
||||
|
||||
func testAppSectionsRemainStable() {
|
||||
XCTAssertEqual(
|
||||
AppSection.allCases.map(\.rawValue),
|
||||
[
|
||||
"Dashboard",
|
||||
"Clients",
|
||||
"Imports",
|
||||
"Communications",
|
||||
"Calendar",
|
||||
"Oracle",
|
||||
"Sentinel",
|
||||
"Inventory",
|
||||
"Settings",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func testAppSectionDisplayTitlesMatchProductionScope() {
|
||||
XCTAssertEqual(
|
||||
AppSection.allCases.map(\.displayTitle),
|
||||
[
|
||||
"Dashboard",
|
||||
"Clients",
|
||||
"Imports",
|
||||
"Communications",
|
||||
"Calendar",
|
||||
"Oracle",
|
||||
"Operator Posture",
|
||||
"Inventory",
|
||||
"Settings",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func testAppConfigParsesExplicitValuesAndRejectsPlaceholders() {
|
||||
XCTAssertEqual(
|
||||
AppConfig.parsedValue(from: ["BASE_URL": " https://velocity.desineuron.in/api "], key: "BASE_URL"),
|
||||
"https://velocity.desineuron.in/api"
|
||||
)
|
||||
XCTAssertNil(
|
||||
AppConfig.parsedValue(from: ["BASE_URL": "$(BASE_URL)"], key: "BASE_URL")
|
||||
)
|
||||
XCTAssertNil(
|
||||
AppConfig.parsedValue(from: ["BASE_URL": " "], key: "BASE_URL")
|
||||
)
|
||||
XCTAssertNil(
|
||||
AppConfig.parsedValue(from: nil, key: "BASE_URL")
|
||||
)
|
||||
}
|
||||
|
||||
func testAuthModeDescriptionOnlyReportsConfiguredCredentials() {
|
||||
XCTAssertEqual(
|
||||
AppConfig.authModeDescription(bearerToken: "token", email: nil, password: nil),
|
||||
"Bearer token"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
AppConfig.authModeDescription(bearerToken: nil, email: "user@example.com", password: "secret"),
|
||||
"Email/password"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
AppConfig.authModeDescription(bearerToken: nil, email: "user@example.com", password: nil),
|
||||
"Credentials required"
|
||||
)
|
||||
}
|
||||
|
||||
func testSessionDraftRejectsInsecureOrIncompleteConfiguration() {
|
||||
let insecureDraft = SessionConfigurationDraft(
|
||||
baseURL: "http://velocity.desineuron.in/api",
|
||||
dreamWeaverBaseURL: "",
|
||||
dreamWeaverAPIKey: "",
|
||||
authMode: .emailPassword,
|
||||
email: "operator@desineuron.in",
|
||||
password: "secret",
|
||||
bearerToken: "",
|
||||
existingDreamWeaverAPIKeyAvailable: false,
|
||||
existingPasswordAvailable: false,
|
||||
existingBearerTokenAvailable: false,
|
||||
baselineEmail: nil
|
||||
)
|
||||
XCTAssertFalse(insecureDraft.validationErrors().isEmpty)
|
||||
|
||||
let missingPasswordDraft = SessionConfigurationDraft(
|
||||
baseURL: "https://velocity.desineuron.in/api",
|
||||
dreamWeaverBaseURL: "",
|
||||
dreamWeaverAPIKey: "",
|
||||
authMode: .emailPassword,
|
||||
email: "operator@desineuron.in",
|
||||
password: "",
|
||||
bearerToken: "",
|
||||
existingDreamWeaverAPIKeyAvailable: false,
|
||||
existingPasswordAvailable: false,
|
||||
existingBearerTokenAvailable: false,
|
||||
baselineEmail: nil
|
||||
)
|
||||
XCTAssertTrue(
|
||||
missingPasswordDraft.validationErrors().contains("Password is required for email/password login.")
|
||||
)
|
||||
}
|
||||
|
||||
func testSessionDraftNormalizesHttpsOriginAndReusesExistingSecretsSafely() {
|
||||
let draft = SessionConfigurationDraft(
|
||||
baseURL: " https://Velocity.DesiNeuron.in/api/ ",
|
||||
dreamWeaverBaseURL: " https://dreamweaver.DesiNeuron.in/ ",
|
||||
dreamWeaverAPIKey: "",
|
||||
authMode: .emailPassword,
|
||||
email: "operator@desineuron.in",
|
||||
password: "",
|
||||
bearerToken: "",
|
||||
existingDreamWeaverAPIKeyAvailable: true,
|
||||
existingPasswordAvailable: true,
|
||||
existingBearerTokenAvailable: false,
|
||||
baselineEmail: "operator@desineuron.in"
|
||||
)
|
||||
|
||||
XCTAssertEqual(draft.normalizedBaseURL, "https://velocity.desineuron.in/api")
|
||||
XCTAssertEqual(draft.normalizedDreamWeaverBaseURL, "https://dreamweaver.desineuron.in")
|
||||
XCTAssertEqual(
|
||||
draft.resolvedPassword(existingPassword: "stored-secret"),
|
||||
"stored-secret"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
draft.resolvedEmail(existingEmail: nil),
|
||||
"operator@desineuron.in"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
draft.resolvedDreamWeaverAPIKey(existingKey: "stored-gateway-key"),
|
||||
"stored-gateway-key"
|
||||
)
|
||||
}
|
||||
|
||||
func testSessionDraftRejectsInsecureDreamWeaverEndpoint() {
|
||||
let draft = SessionConfigurationDraft(
|
||||
baseURL: "https://velocity.desineuron.in/api",
|
||||
dreamWeaverBaseURL: "http://54.172.172.2:8082",
|
||||
dreamWeaverAPIKey: "",
|
||||
authMode: .bearerToken,
|
||||
email: "",
|
||||
password: "",
|
||||
bearerToken: "token",
|
||||
existingDreamWeaverAPIKeyAvailable: false,
|
||||
existingPasswordAvailable: false,
|
||||
existingBearerTokenAvailable: false,
|
||||
baselineEmail: nil
|
||||
)
|
||||
|
||||
XCTAssertTrue(
|
||||
draft.validationErrors().contains(
|
||||
"Dream Weaver endpoint must be an HTTPS origin like https://dreamweaver.desineuron.in."
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testAppSessionConfigurationReflectsDreamWeaverGatewayKeyState() {
|
||||
let configured = AppSessionConfiguration(
|
||||
baseURL: "https://velocity.desineuron.in/api",
|
||||
dreamWeaverBaseURL: "https://dreamweaver.desineuron.in",
|
||||
usesDedicatedDreamWeaverBaseURL: true,
|
||||
hasDreamWeaverAPIKey: true,
|
||||
email: "operator@desineuron.in",
|
||||
hasPassword: true,
|
||||
hasBearerToken: false,
|
||||
source: .secureDeviceStorage
|
||||
)
|
||||
XCTAssertEqual(configured.dreamWeaverAuthenticationDescription, "API key configured")
|
||||
|
||||
let open = AppSessionConfiguration(
|
||||
baseURL: "https://velocity.desineuron.in/api",
|
||||
dreamWeaverBaseURL: "https://velocity.desineuron.in",
|
||||
usesDedicatedDreamWeaverBaseURL: false,
|
||||
hasDreamWeaverAPIKey: false,
|
||||
email: nil,
|
||||
hasPassword: false,
|
||||
hasBearerToken: true,
|
||||
source: .buildConfiguration
|
||||
)
|
||||
XCTAssertEqual(open.dreamWeaverAuthenticationDescription, "No gateway key configured")
|
||||
}
|
||||
|
||||
func testGenerationJobBuildsFallbackRoutesWhenGatewayReturnsMinimalContract() throws {
|
||||
let job = try JSONDecoder().decode(
|
||||
GenerationJob.self,
|
||||
from: Data(#"{"job_id":"job-123","status":"processing"}"#.utf8)
|
||||
)
|
||||
|
||||
XCTAssertEqual(job.pollUrl, nil)
|
||||
XCTAssertEqual(job.resultUrl, nil)
|
||||
XCTAssertEqual(
|
||||
try job.resolvedPollURL(baseURL: "https://dw.desineuron.in").absoluteString,
|
||||
"https://dw.desineuron.in/dream-weaver/status/job-123"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
try job.resolvedResultURL(baseURL: "https://dw.desineuron.in").absoluteString,
|
||||
"https://dw.desineuron.in/dream-weaver/result/job-123"
|
||||
)
|
||||
}
|
||||
|
||||
func testJobStatusResolvesRelativeResultURL() throws {
|
||||
let status = try JSONDecoder().decode(
|
||||
JobStatus.self,
|
||||
from: Data(#"{"status":"done","ready":true,"result_url":"/dream-weaver/result/job-123"}"#.utf8)
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
try status.resolvedResultURL(baseURL: "https://dw.desineuron.in", jobId: "job-123").absoluteString,
|
||||
"https://dw.desineuron.in/dream-weaver/result/job-123"
|
||||
)
|
||||
}
|
||||
|
||||
func testOracleProductionScopeOnlyExposesLiveBackedModes() {
|
||||
XCTAssertEqual(
|
||||
OracleModeAvailability.productionVisibleModes,
|
||||
[
|
||||
.pipeline,
|
||||
.deals,
|
||||
.accountTimeline,
|
||||
.calendarTasks,
|
||||
]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
OracleModeAvailability.hiddenModesUntilBackendSupport,
|
||||
[
|
||||
.teamPerformance,
|
||||
.leadMap,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func testOracleProductionScopeSanitizesUnsupportedSelections() {
|
||||
XCTAssertEqual(
|
||||
OracleModeAvailability.sanitizedProductionSelection(.teamPerformance),
|
||||
.pipeline
|
||||
)
|
||||
XCTAssertEqual(
|
||||
OracleModeAvailability.sanitizedProductionSelection(.leadMap),
|
||||
.pipeline
|
||||
)
|
||||
XCTAssertEqual(
|
||||
OracleModeAvailability.sanitizedProductionSelection(.calendarTasks),
|
||||
.calendarTasks
|
||||
)
|
||||
XCTAssertEqual(
|
||||
OracleModeAvailability.sanitizedProductionSelection(.deals),
|
||||
.deals
|
||||
)
|
||||
}
|
||||
|
||||
func testInventoryProductionScopeHidesDollhouseWithoutAsset() {
|
||||
XCTAssertEqual(
|
||||
InventoryModeAvailability.productionVisibleModes(hasDollhouseAsset: false),
|
||||
[
|
||||
.sunseeker,
|
||||
.dreamWeaver,
|
||||
]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
InventoryModeAvailability.modeSummaryText(hasDollhouseAsset: false),
|
||||
"Sunseeker · Dream Weaver"
|
||||
)
|
||||
}
|
||||
|
||||
func testCanonicalContactAdapterFiltersRowsWithoutLeadContextAndNormalizesScore() {
|
||||
let summaries = VelocityLeadDTO.activeLeadSummaries(
|
||||
from: [
|
||||
VelocityCanonicalContactListItemDTO(
|
||||
personId: "person-1",
|
||||
fullName: "Amina Rahman",
|
||||
primaryPhone: "+971500000001",
|
||||
buyerType: "high_intent",
|
||||
leadId: "lead-1",
|
||||
leadStatus: "qualified",
|
||||
budgetBand: "AED 12M",
|
||||
urgency: "high",
|
||||
primaryInterest: "Marina Penthouse",
|
||||
intentScore: 0.94,
|
||||
engagementScore: 0.91,
|
||||
urgencyScore: 0.88,
|
||||
interactionCount: 6,
|
||||
pendingTasks: 2,
|
||||
lastInteractionAt: "2026-04-22T10:00:00+00:00",
|
||||
createdAt: "2026-04-21T10:00:00+00:00"
|
||||
),
|
||||
VelocityCanonicalContactListItemDTO(
|
||||
personId: "person-2",
|
||||
fullName: "Contact Without Lead",
|
||||
primaryPhone: nil,
|
||||
buyerType: nil,
|
||||
leadId: nil,
|
||||
leadStatus: nil,
|
||||
budgetBand: nil,
|
||||
urgency: nil,
|
||||
primaryInterest: nil,
|
||||
intentScore: 0.42,
|
||||
engagementScore: 0.10,
|
||||
urgencyScore: 0.20,
|
||||
interactionCount: 0,
|
||||
pendingTasks: 0,
|
||||
lastInteractionAt: nil,
|
||||
createdAt: "2026-04-21T10:00:00+00:00"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
XCTAssertEqual(summaries.count, 1)
|
||||
XCTAssertEqual(summaries[0].id, "lead-1")
|
||||
XCTAssertEqual(summaries[0].personId, "person-1")
|
||||
XCTAssertEqual(summaries[0].score, 94)
|
||||
XCTAssertEqual(summaries[0].kanbanStatus, "Qualified")
|
||||
XCTAssertEqual(summaries[0].qualification, "Whale")
|
||||
XCTAssertEqual(summaries[0].pendingTaskCount, 2)
|
||||
XCTAssertEqual(summaries[0].interactionCount, 6)
|
||||
XCTAssertEqual(summaries[0].updatedAt, "2026-04-22T10:00:00+00:00")
|
||||
}
|
||||
|
||||
func testClientDataContactDecodesCurrentQDFamilies() throws {
|
||||
let payload = """
|
||||
{
|
||||
"person_id": "person-live",
|
||||
"full_name": "Sanjay Chatterjee",
|
||||
"primary_phone": "+91-88479-41519",
|
||||
"buyer_type": "investor",
|
||||
"lead_id": "lead-live",
|
||||
"lead_status": "new",
|
||||
"budget_band": "15-25 Cr",
|
||||
"urgency": "6_months",
|
||||
"primary_interest": "Eden Devprayag",
|
||||
"interaction_count": 4,
|
||||
"pending_tasks": 1,
|
||||
"last_interaction_at": "2026-04-18T16:47:12.740450+00:00",
|
||||
"qd_overview": {
|
||||
"intent": {
|
||||
"score_type": "intent",
|
||||
"current_value": 0.73
|
||||
},
|
||||
"engagement": {
|
||||
"score_type": "engagement",
|
||||
"current_value": 0.68
|
||||
},
|
||||
"urgency": {
|
||||
"score_type": "urgency",
|
||||
"current_value": 0.91
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let contact = try JSONDecoder().decode(
|
||||
VelocityCanonicalContactListItemDTO.self,
|
||||
from: Data(payload.utf8)
|
||||
)
|
||||
let snapshot = VelocityClient360DTO.minimal(from: contact)
|
||||
|
||||
XCTAssertEqual(contact.personId, "person-live")
|
||||
XCTAssertEqual(contact.intentScore, 0.73)
|
||||
XCTAssertEqual(contact.engagementScore, 0.68)
|
||||
XCTAssertEqual(contact.urgencyScore, 0.91)
|
||||
XCTAssertEqual(contact.displayIntentScore, 91)
|
||||
XCTAssertEqual(snapshot.primaryQDScore?.scoreType, "overall")
|
||||
XCTAssertEqual(snapshot.primaryQDScore?.displayScore, 91)
|
||||
}
|
||||
|
||||
func testClientDataDetailAdaptsNestedProductionShape() throws {
|
||||
let payload = """
|
||||
{
|
||||
"person": {
|
||||
"person_id": "person-live",
|
||||
"full_name": "Sanjay Chatterjee",
|
||||
"primary_email": "sanjay@example.com",
|
||||
"primary_phone": "+91-88479-41519",
|
||||
"buyer_type": "investor",
|
||||
"persona_labels": ["high intent"]
|
||||
},
|
||||
"lead": {
|
||||
"lead_id": "lead-live",
|
||||
"lead_status": "qualified",
|
||||
"budget_band": "15-25 Cr",
|
||||
"urgency": "6_months"
|
||||
},
|
||||
"opportunities": [
|
||||
{
|
||||
"opportunity_id": "opp-live",
|
||||
"stage": "site_visit",
|
||||
"value": 18000000,
|
||||
"probability": 0.7,
|
||||
"expected_close_date": "2026-05-01",
|
||||
"next_action": "Schedule visit",
|
||||
"notes": null,
|
||||
"project_id": "project-1",
|
||||
"unit_id": null,
|
||||
"person_id": "person-live",
|
||||
"client_name": "Sanjay Chatterjee",
|
||||
"client_phone": "+91-88479-41519",
|
||||
"project_name": "Eden Devprayag"
|
||||
}
|
||||
],
|
||||
"property_interests": [
|
||||
{
|
||||
"interest_id": "interest-live",
|
||||
"project_name": "Eden Devprayag",
|
||||
"unit_preference": "Penthouse",
|
||||
"configuration": "4 BHK",
|
||||
"budget_min": 15000000,
|
||||
"budget_max": 25000000,
|
||||
"priority": 1
|
||||
}
|
||||
],
|
||||
"recent_interactions": [
|
||||
{
|
||||
"interaction_id": "interaction-live",
|
||||
"channel": "call",
|
||||
"interaction_type": "follow_up",
|
||||
"happened_at": "2026-04-18T16:47:12.740450+00:00",
|
||||
"summary": "Asked for tower availability."
|
||||
}
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"reminder_id": "task-live",
|
||||
"reminder_type": "follow_up",
|
||||
"title": "Share floor plan",
|
||||
"notes": "Send updated inventory.",
|
||||
"due_at": "2026-04-26T10:00:00+00:00",
|
||||
"status": "pending",
|
||||
"priority": "high",
|
||||
"person_id": "person-live",
|
||||
"client_name": "Sanjay Chatterjee",
|
||||
"client_phone": "+91-88479-41519"
|
||||
}
|
||||
],
|
||||
"qd_scores": [
|
||||
{
|
||||
"score_type": "intent",
|
||||
"current_value": 0.73,
|
||||
"computed_at": "2026-04-18T16:47:12.740450+00:00",
|
||||
"reasoning": "Recent call showed buying intent."
|
||||
},
|
||||
{
|
||||
"score_type": "urgency",
|
||||
"current_value": 0.91
|
||||
}
|
||||
],
|
||||
"next_best_action": "Share inventory shortlist"
|
||||
}
|
||||
"""
|
||||
|
||||
let detail = try JSONDecoder().decode(
|
||||
VelocityClientDataDetailDTO.self,
|
||||
from: Data(payload.utf8)
|
||||
).snapshot
|
||||
|
||||
XCTAssertEqual(detail.identity.personId, "person-live")
|
||||
XCTAssertEqual(detail.identity.fullName, "Sanjay Chatterjee")
|
||||
XCTAssertEqual(detail.currentLead?.leadId, "lead-live")
|
||||
XCTAssertEqual(detail.currentLead?.status, "qualified")
|
||||
XCTAssertEqual(detail.activeOpportunities.count, 1)
|
||||
XCTAssertEqual(detail.propertyInterests.count, 1)
|
||||
XCTAssertEqual(detail.recentInteractions.count, 1)
|
||||
XCTAssertEqual(detail.tasks.count, 1)
|
||||
XCTAssertEqual(detail.primaryQDScore?.scoreType, "intent")
|
||||
XCTAssertEqual(detail.recommendedNextActions, ["Share inventory shortlist"])
|
||||
}
|
||||
|
||||
func testCanonicalTaskAdapterSortsUrgentAndSoonerItemsFirst() {
|
||||
let tasks = VelocityTaskDTO.sortedForOperatorReview(
|
||||
[
|
||||
VelocityTaskDTO(
|
||||
reminderId: "task-3",
|
||||
reminderType: "follow_up",
|
||||
title: "Later high-priority visit",
|
||||
notes: nil,
|
||||
dueAt: "2026-04-24T10:00:00+00:00",
|
||||
status: "pending",
|
||||
priority: "high",
|
||||
personId: "person-1",
|
||||
clientName: "Amina Rahman",
|
||||
clientPhone: "+971500000001"
|
||||
),
|
||||
VelocityTaskDTO(
|
||||
reminderId: "task-1",
|
||||
reminderType: "follow_up",
|
||||
title: "Urgent callback",
|
||||
notes: "Call now",
|
||||
dueAt: "2026-04-23T08:00:00+00:00",
|
||||
status: "pending",
|
||||
priority: "urgent",
|
||||
personId: "person-1",
|
||||
clientName: "Amina Rahman",
|
||||
clientPhone: "+971500000001"
|
||||
),
|
||||
VelocityTaskDTO(
|
||||
reminderId: "task-2",
|
||||
reminderType: "follow_up",
|
||||
title: "Soon high-priority visit",
|
||||
notes: nil,
|
||||
dueAt: "2026-04-23T09:00:00+00:00",
|
||||
status: "pending",
|
||||
priority: "high",
|
||||
personId: "person-1",
|
||||
clientName: "Amina Rahman",
|
||||
clientPhone: "+971500000001"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
XCTAssertEqual(tasks.map(\.reminderId), ["task-1", "task-2", "task-3"])
|
||||
XCTAssertEqual(tasks[0].priorityLabel, "Urgent")
|
||||
XCTAssertNotNil(tasks[1].dueDate)
|
||||
XCTAssertEqual(tasks[1].title, "Soon high-priority visit")
|
||||
}
|
||||
|
||||
func testCanonicalTaskSnoozeUsesLaterOfNowOrDueDate() {
|
||||
let task = VelocityTaskDTO(
|
||||
reminderId: "task-1",
|
||||
reminderType: "follow_up",
|
||||
title: "Urgent callback",
|
||||
notes: nil,
|
||||
dueAt: "2099-04-23T08:00:00+00:00",
|
||||
status: "pending",
|
||||
priority: "urgent",
|
||||
personId: "person-1",
|
||||
clientName: "Amina Rahman",
|
||||
clientPhone: "+971500000001"
|
||||
)
|
||||
|
||||
let snoozed = task.nextSnoozeDate(adding: 2 * 60 * 60)
|
||||
|
||||
XCTAssertEqual(
|
||||
Int(snoozed.timeIntervalSince(task.dueDate ?? .distantPast)),
|
||||
2 * 60 * 60
|
||||
)
|
||||
}
|
||||
|
||||
func testCanonicalKanbanAdapterSortsCardsByIntentScoreWithinLane() {
|
||||
let board = VelocityKanbanColumnDTO.operatorDisplayBoard(
|
||||
from: [
|
||||
VelocityKanbanColumnDTO(
|
||||
status: "qualified",
|
||||
label: "Qualified",
|
||||
count: 2,
|
||||
items: [
|
||||
VelocityKanbanCardDTO(
|
||||
leadId: "lead-2",
|
||||
personId: "person-2",
|
||||
clientName: "Zara Khan",
|
||||
clientPhone: "+971500000002",
|
||||
buyerType: "investor",
|
||||
budgetBand: "AED 9M",
|
||||
urgency: "high",
|
||||
intentScore: 0.61
|
||||
),
|
||||
VelocityKanbanCardDTO(
|
||||
leadId: "lead-1",
|
||||
personId: "person-1",
|
||||
clientName: "Amina Rahman",
|
||||
clientPhone: "+971500000001",
|
||||
buyerType: "high_intent",
|
||||
budgetBand: "AED 12M",
|
||||
urgency: "urgent",
|
||||
intentScore: 0.94
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
XCTAssertEqual(board.count, 1)
|
||||
XCTAssertEqual(board[0].items.map(\.leadId), ["lead-1", "lead-2"])
|
||||
XCTAssertEqual(board[0].items[0].displayIntentScore, 94)
|
||||
XCTAssertEqual(board[0].items[0].buyerTypeLabel, "High Intent")
|
||||
XCTAssertEqual(board[0].items[0].urgencyLabel, "Urgent")
|
||||
}
|
||||
|
||||
func testClient360ResponseDecodesCanonicalSnapshotShape() throws {
|
||||
let payload = """
|
||||
{
|
||||
"client_ref": "person-1",
|
||||
"snapshot_type": "client_360",
|
||||
"identity": {
|
||||
"person_id": "person-1",
|
||||
"full_name": "Amina Rahman",
|
||||
"primary_email": "amina@example.com",
|
||||
"primary_phone": "+971500000001",
|
||||
"buyer_type": "high_intent",
|
||||
"persona_labels": ["marina_buyer"],
|
||||
"source_confidence": 1.0
|
||||
},
|
||||
"current_lead": {
|
||||
"lead_id": "lead-1",
|
||||
"status": "qualified",
|
||||
"budget_band": "AED 12M",
|
||||
"urgency": "high",
|
||||
"financing_posture": null,
|
||||
"timeline_to_decision": null,
|
||||
"objections": [],
|
||||
"motivations": ["view_upgrade"]
|
||||
},
|
||||
"active_opportunities": [
|
||||
{
|
||||
"opportunity_id": "opp-1",
|
||||
"stage": "proposal",
|
||||
"value": 12000000,
|
||||
"probability": 0.8,
|
||||
"expected_close_date": "2026-04-30",
|
||||
"next_action": "Share proposal",
|
||||
"project_id": null,
|
||||
"unit_id": null
|
||||
}
|
||||
],
|
||||
"recent_interactions": [
|
||||
{
|
||||
"interaction_id": "int-1",
|
||||
"channel": "whatsapp",
|
||||
"interaction_type": "message",
|
||||
"happened_at": "2026-04-22T10:00:00+00:00",
|
||||
"summary": "Asked for brochure"
|
||||
}
|
||||
],
|
||||
"property_interests": [
|
||||
{
|
||||
"interest_id": "interest-1",
|
||||
"project_name": "Marina Residences",
|
||||
"unit_preference": "4BR",
|
||||
"configuration": "Penthouse",
|
||||
"budget_min": 10000000,
|
||||
"budget_max": 15000000,
|
||||
"priority": 1
|
||||
}
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"reminder_id": "task-1",
|
||||
"reminder_type": "follow_up",
|
||||
"title": "Call back",
|
||||
"due_at": "2026-04-23T09:00:00+00:00",
|
||||
"status": "pending",
|
||||
"priority": "high"
|
||||
}
|
||||
],
|
||||
"qd_overview": {
|
||||
"intent_score": {
|
||||
"score_type": "intent_score",
|
||||
"current_value": 0.94,
|
||||
"computed_at": "2026-04-22T10:00:00+00:00",
|
||||
"reasoning": "Strong engagement"
|
||||
}
|
||||
},
|
||||
"risk_flags": ["no_recent_interactions"],
|
||||
"recommended_next_actions": ["Schedule follow-up"],
|
||||
"note": "Derived read model. Not primary truth."
|
||||
}
|
||||
"""
|
||||
|
||||
let snapshot = try JSONDecoder().decode(
|
||||
VelocityClient360DTO.self,
|
||||
from: Data(payload.utf8)
|
||||
)
|
||||
|
||||
XCTAssertEqual(snapshot.identity.fullName, "Amina Rahman")
|
||||
XCTAssertEqual(snapshot.currentLead?.status, "qualified")
|
||||
XCTAssertEqual(snapshot.activeOpportunities.first?.formattedValue, "AED 12.0M")
|
||||
XCTAssertEqual(snapshot.qdOverview["intent_score"]?.displayScore, 94)
|
||||
XCTAssertEqual(snapshot.tasks.first?.title, "Call back")
|
||||
}
|
||||
|
||||
func testClient360DecodesLiveJsonStringArrayFields() throws {
|
||||
let payload = """
|
||||
{
|
||||
"client_ref": "person-live",
|
||||
"snapshot_type": "client_360",
|
||||
"identity": {
|
||||
"person_id": "person-live",
|
||||
"full_name": "Sanjay Chatterjee",
|
||||
"primary_email": "sanjay@example.com",
|
||||
"primary_phone": "+91-88479-41519",
|
||||
"buyer_type": null,
|
||||
"persona_labels": "[\\"repeat_visitor\\"]",
|
||||
"source_confidence": 0.94
|
||||
},
|
||||
"current_lead": {
|
||||
"lead_id": "lead-live",
|
||||
"status": "new",
|
||||
"budget_band": "15-25 Cr",
|
||||
"urgency": "6_months",
|
||||
"financing_posture": null,
|
||||
"timeline_to_decision": null,
|
||||
"objections": "[]",
|
||||
"motivations": "[\\"family_discussion\\"]"
|
||||
},
|
||||
"active_opportunities": [],
|
||||
"recent_interactions": [],
|
||||
"property_interests": [],
|
||||
"tasks": [],
|
||||
"qd_overview": {
|
||||
"urgency": {
|
||||
"score_type": "urgency",
|
||||
"current_value": 1.0,
|
||||
"computed_at": "2026-04-18T16:47:12.740450+00:00",
|
||||
"reasoning": null
|
||||
}
|
||||
},
|
||||
"risk_flags": ["no_property_interests_recorded"],
|
||||
"recommended_next_actions": [],
|
||||
"note": "Derived read model. Not primary truth."
|
||||
}
|
||||
"""
|
||||
|
||||
let snapshot = try JSONDecoder().decode(
|
||||
VelocityClient360DTO.self,
|
||||
from: Data(payload.utf8)
|
||||
)
|
||||
|
||||
XCTAssertEqual(snapshot.identity.personaLabels, ["repeat_visitor"])
|
||||
XCTAssertEqual(snapshot.currentLead?.objections, [])
|
||||
XCTAssertEqual(snapshot.currentLead?.motivations, ["family_discussion"])
|
||||
XCTAssertEqual(snapshot.qdOverview["urgency"]?.displayScore, 100)
|
||||
}
|
||||
|
||||
func testOpportunitiesClientSortsHigherValueRowsFirst() {
|
||||
let opportunities = [
|
||||
VelocityOpportunityDTO(
|
||||
opportunityId: "opp-2",
|
||||
stage: "proposal",
|
||||
value: 8000000,
|
||||
probability: 0.6,
|
||||
expectedCloseDate: nil,
|
||||
nextAction: "Send brochure",
|
||||
notes: nil,
|
||||
projectId: nil,
|
||||
unitId: nil,
|
||||
personId: "person-2",
|
||||
clientName: "Zara Khan",
|
||||
clientPhone: "+971500000002",
|
||||
projectName: "Skyline"
|
||||
),
|
||||
VelocityOpportunityDTO(
|
||||
opportunityId: "opp-1",
|
||||
stage: "negotiation",
|
||||
value: 12000000,
|
||||
probability: 0.8,
|
||||
expectedCloseDate: nil,
|
||||
nextAction: "Share proposal",
|
||||
notes: nil,
|
||||
projectId: nil,
|
||||
unitId: nil,
|
||||
personId: "person-1",
|
||||
clientName: "Amina Rahman",
|
||||
clientPhone: "+971500000001",
|
||||
projectName: "Marina Residences"
|
||||
),
|
||||
].sorted { lhs, rhs in
|
||||
let leftValue = lhs.value ?? 0
|
||||
let rightValue = rhs.value ?? 0
|
||||
if leftValue != rightValue {
|
||||
return leftValue > rightValue
|
||||
}
|
||||
return (lhs.clientName ?? "").localizedCaseInsensitiveCompare(rhs.clientName ?? "") == .orderedAscending
|
||||
}
|
||||
|
||||
XCTAssertEqual(opportunities.map(\.opportunityId), ["opp-1", "opp-2"])
|
||||
XCTAssertEqual(opportunities[0].formattedValue, "AED 12.0M")
|
||||
XCTAssertEqual(opportunities[0].probabilityLabel, "80% probability")
|
||||
}
|
||||
|
||||
func testOpportunityMutationResponseDecodesCanonicalShape() throws {
|
||||
let payload = """
|
||||
{
|
||||
"opportunity_id": "opp-1",
|
||||
"stage": "negotiation",
|
||||
"value": 12000000,
|
||||
"probability": 75,
|
||||
"expected_close_date": "2026-04-30",
|
||||
"next_action": "Schedule commercial review",
|
||||
"notes": "Updated from iPad",
|
||||
"person_id": "person-1",
|
||||
"client_name": "Amina Rahman",
|
||||
"client_phone": "+971500000001",
|
||||
"project_name": "Marina Residences"
|
||||
}
|
||||
"""
|
||||
|
||||
let opportunity = try JSONDecoder().decode(
|
||||
VelocityOpportunityDTO.self,
|
||||
from: Data(payload.utf8)
|
||||
)
|
||||
|
||||
XCTAssertEqual(opportunity.opportunityId, "opp-1")
|
||||
XCTAssertEqual(opportunity.stage, "negotiation")
|
||||
XCTAssertEqual(opportunity.probabilityPercent, 75)
|
||||
XCTAssertEqual(opportunity.probabilityLabel, "75% probability")
|
||||
XCTAssertEqual(opportunity.nextAction, "Schedule commercial review")
|
||||
XCTAssertEqual(opportunity.notes, "Updated from iPad")
|
||||
}
|
||||
|
||||
func testLeadStageMutationResponseDecodesCanonicalShape() throws {
|
||||
let payload = """
|
||||
{
|
||||
"lead_id": "lead-1",
|
||||
"person_id": "person-1",
|
||||
"status": "qualified",
|
||||
"budget_band": "AED 12M",
|
||||
"urgency": "high",
|
||||
"client_name": "Amina Rahman",
|
||||
"client_phone": "+971500000001"
|
||||
}
|
||||
"""
|
||||
|
||||
let update = try JSONDecoder().decode(
|
||||
VelocityLeadStageUpdateDTO.self,
|
||||
from: Data(payload.utf8)
|
||||
)
|
||||
|
||||
XCTAssertEqual(update.leadId, "lead-1")
|
||||
XCTAssertEqual(update.personId, "person-1")
|
||||
XCTAssertEqual(update.status, "qualified")
|
||||
XCTAssertEqual(update.clientName, "Amina Rahman")
|
||||
}
|
||||
|
||||
func testImportBatchDetailDecodesCanonicalReviewShape() throws {
|
||||
let payload = """
|
||||
{
|
||||
"batch_id": "batch-1",
|
||||
"source_system": "csv_upload",
|
||||
"filename": "clients.csv",
|
||||
"row_count": 1,
|
||||
"mapping_manifest": {"mapped_count": 4},
|
||||
"lifecycle": "parsed",
|
||||
"proposals": [
|
||||
{
|
||||
"proposal_id": "proposal-1",
|
||||
"payload": {
|
||||
"row_number": 1,
|
||||
"canonical_payload": {"full_name": "Amina Rahman", "primary_phone": "+971500000001"},
|
||||
"raw_row": {"Name": "Amina Rahman"},
|
||||
"unresolved_fields": [],
|
||||
"missing_required": [],
|
||||
"confidence": 0.92,
|
||||
"review_required": true
|
||||
},
|
||||
"confidence": 0.92,
|
||||
"status": "pending",
|
||||
"review_required": true
|
||||
}
|
||||
],
|
||||
"proposal_count": 1
|
||||
}
|
||||
"""
|
||||
|
||||
let detail = try JSONDecoder().decode(
|
||||
VelocityImportBatchDetailDTO.self,
|
||||
from: Data(payload.utf8)
|
||||
)
|
||||
|
||||
XCTAssertEqual(detail.batchId, "batch-1")
|
||||
XCTAssertEqual(detail.proposals.count, 1)
|
||||
XCTAssertEqual(detail.proposals[0].confidencePercent, 92)
|
||||
XCTAssertEqual(detail.proposals[0].payload?.canonicalPayload?["full_name"]?.stringValue, "Amina Rahman")
|
||||
}
|
||||
|
||||
func testInventoryProductionScopeShowsDollhouseWhenAssetIsShipped() {
|
||||
XCTAssertEqual(
|
||||
InventoryModeAvailability.productionVisibleModes(hasDollhouseAsset: true),
|
||||
[
|
||||
.sunseeker,
|
||||
.dreamWeaver,
|
||||
.dollhouse,
|
||||
]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
InventoryModeAvailability.modeSummaryText(hasDollhouseAsset: true),
|
||||
"Sunseeker · Dream Weaver · Dollhouse"
|
||||
)
|
||||
}
|
||||
|
||||
func testInventoryProductionScopeSanitizesUnsupportedSelection() {
|
||||
XCTAssertEqual(
|
||||
InventoryModeAvailability.sanitizedProductionSelection(.dollhouse, hasDollhouseAsset: false),
|
||||
.sunseeker
|
||||
)
|
||||
XCTAssertEqual(
|
||||
InventoryModeAvailability.sanitizedProductionSelection(.dollhouse, hasDollhouseAsset: true),
|
||||
.dollhouse
|
||||
)
|
||||
}
|
||||
|
||||
func testSentinelScopeReflectsOperatorPosturePositioning() {
|
||||
XCTAssertEqual(SentinelScope.navigationTitle, "Operator Posture")
|
||||
XCTAssertEqual(SentinelScope.productFamilyName, "Sentinel")
|
||||
XCTAssertEqual(SentinelScope.availabilityBadge, "Operator posture only")
|
||||
XCTAssertEqual(
|
||||
SentinelScope.disabledAnalyticsSummary,
|
||||
"visitor counting, facial detections, sentiment scoring"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SentinelScope.liveBackedSummary,
|
||||
"alert posture, transcription queue visibility, upcoming calendar pressure, recent operator timeline"
|
||||
)
|
||||
}
|
||||
|
||||
func testAppStoreRefreshPolicyMatchesWebOSInventorySlice() {
|
||||
XCTAssertEqual(AppStoreRefreshPolicy.inventoryPropertyLimit, 100)
|
||||
XCTAssertEqual(AppStoreRefreshPolicy.canonicalTaskLimit, 50)
|
||||
XCTAssertEqual(AppStoreRefreshPolicy.leadTimelineHydrationLimit, 6)
|
||||
XCTAssertEqual(AppStoreRefreshPolicy.leadEventLimitPerLead, 4)
|
||||
}
|
||||
|
||||
func testAppStoreRefreshPolicyPrioritizesHighestScoreLeads() {
|
||||
let leads = [
|
||||
VelocityLeadDTO(
|
||||
id: "lead-1",
|
||||
personId: "person-1",
|
||||
name: "Lead One",
|
||||
phone: nil,
|
||||
source: "website",
|
||||
qualification: "POTENTIAL",
|
||||
score: 40,
|
||||
kanbanStatus: "New",
|
||||
budget: "",
|
||||
unitInterest: "",
|
||||
pendingTaskCount: 0,
|
||||
interactionCount: 0,
|
||||
createdAt: nil,
|
||||
updatedAt: nil
|
||||
),
|
||||
VelocityLeadDTO(
|
||||
id: "lead-2",
|
||||
personId: "person-2",
|
||||
name: "Lead Two",
|
||||
phone: nil,
|
||||
source: "website",
|
||||
qualification: "HOT",
|
||||
score: 95,
|
||||
kanbanStatus: "Negotiation",
|
||||
budget: "",
|
||||
unitInterest: "",
|
||||
pendingTaskCount: 1,
|
||||
interactionCount: 3,
|
||||
createdAt: nil,
|
||||
updatedAt: nil
|
||||
),
|
||||
VelocityLeadDTO(
|
||||
id: "lead-3",
|
||||
personId: "person-3",
|
||||
name: "Lead Three",
|
||||
phone: nil,
|
||||
source: "walkin",
|
||||
qualification: "WHALE",
|
||||
score: 88,
|
||||
kanbanStatus: "Qualified",
|
||||
budget: "",
|
||||
unitInterest: "",
|
||||
pendingTaskCount: 0,
|
||||
interactionCount: 1,
|
||||
createdAt: nil,
|
||||
updatedAt: nil
|
||||
),
|
||||
]
|
||||
|
||||
XCTAssertEqual(
|
||||
AppStoreRefreshPolicy.prioritizedLeadIDs(from: leads, limit: 2),
|
||||
["lead-2", "lead-3"]
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user