Fix 25 Xcode build errors and 2 warnings
- Add `import Combine` to PortfolioViewModel, RecommendationsViewModel, PositionsViewModel, AlertsViewModel, and NotificationHandler — required for @Published and ObservableObject to resolve correctly in Swift - Fix @MainActor isolation error in PositionDetailView: replace broken default-parameter init (PositionsViewModel() in sync context) with @StateObject private var localVM and an optional parentVM parameter - Update OpenPositionsView call site to use new parentVM: label - Fix var→let warning in PortfolioViewModel.add() - Remove unused `old` variable in AppDelegate.registerDevice() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -42,9 +42,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
apns_token: token,
|
apns_token: token,
|
||||||
device_name: UIDevice.current.name
|
device_name: UIDevice.current.name
|
||||||
)
|
)
|
||||||
// Temporarily set the token so APIClient has it, but use requestNoAuth
|
// Ensure token is stored before the request so APIClient can use it
|
||||||
// since this is the registration call itself
|
|
||||||
let old = LocalStore.shared.deviceToken
|
|
||||||
LocalStore.shared.deviceToken = token
|
LocalStore.shared.deviceToken = token
|
||||||
let response: DeviceResponse = try await APIClient.shared.requestNoAuth(
|
let response: DeviceResponse = try await APIClient.shared.requestNoAuth(
|
||||||
.registerDevice,
|
.registerDevice,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Combine
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
/// Handles incoming push notifications — both foreground and background tap.
|
/// Handles incoming push notifications — both foreground and background tap.
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 77;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
683501E12F882DB900EECA1B /* OptionsSidekick.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OptionsSidekick.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
683502382F88384800EECA1B /* Exceptions for "OptionsSidekick" folder in "OptionsSidekick" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Resources/Info.plist,
|
||||||
|
);
|
||||||
|
target = 683501E02F882DB900EECA1B /* OptionsSidekick */;
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
683501E32F882DB900EECA1B /* OptionsSidekick */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
683502382F88384800EECA1B /* Exceptions for "OptionsSidekick" folder in "OptionsSidekick" target */,
|
||||||
|
);
|
||||||
|
path = OptionsSidekick;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
683501DE2F882DB900EECA1B /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
683501D82F882DB900EECA1B = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
683501E32F882DB900EECA1B /* OptionsSidekick */,
|
||||||
|
683501E22F882DB900EECA1B /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
683501E22F882DB900EECA1B /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
683501E12F882DB900EECA1B /* OptionsSidekick.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
683501E02F882DB900EECA1B /* OptionsSidekick */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 683501EC2F882DBA00EECA1B /* Build configuration list for PBXNativeTarget "OptionsSidekick" */;
|
||||||
|
buildPhases = (
|
||||||
|
683501DD2F882DB900EECA1B /* Sources */,
|
||||||
|
683501DE2F882DB900EECA1B /* Frameworks */,
|
||||||
|
683501DF2F882DB900EECA1B /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
683501E32F882DB900EECA1B /* OptionsSidekick */,
|
||||||
|
);
|
||||||
|
name = OptionsSidekick;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = OptionsSidekick;
|
||||||
|
productReference = 683501E12F882DB900EECA1B /* OptionsSidekick.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
683501D92F882DB900EECA1B /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 2620;
|
||||||
|
LastUpgradeCheck = 2620;
|
||||||
|
TargetAttributes = {
|
||||||
|
683501E02F882DB900EECA1B = {
|
||||||
|
CreatedOnToolsVersion = 26.2;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 683501DC2F882DB900EECA1B /* Build configuration list for PBXProject "OptionsSidekick" */;
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = 683501D82F882DB900EECA1B;
|
||||||
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
preferredProjectObjectVersion = 77;
|
||||||
|
productRefGroup = 683501E22F882DB900EECA1B /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
683501E02F882DB900EECA1B /* OptionsSidekick */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
683501DF2F882DB900EECA1B /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
683501DD2F882DB900EECA1B /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
683501EA2F882DBA00EECA1B /* 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 = 26.2;
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
683501EB2F882DBA00EECA1B /* 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 = 26.2;
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
683501ED2F882DBA00EECA1B /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = OptionsSidekick/Resources/Info.plist;
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = olson2cm.OptionsSidekick;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
683501EE2F882DBA00EECA1B /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = OptionsSidekick/Resources/Info.plist;
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = olson2cm.OptionsSidekick;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
683501DC2F882DB900EECA1B /* Build configuration list for PBXProject "OptionsSidekick" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
683501EA2F882DBA00EECA1B /* Debug */,
|
||||||
|
683501EB2F882DBA00EECA1B /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
683501EC2F882DBA00EECA1B /* Build configuration list for PBXNativeTarget "OptionsSidekick" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
683501ED2F882DBA00EECA1B /* Debug */,
|
||||||
|
683501EE2F882DBA00EECA1B /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = 683501D92F882DB900EECA1B /* Project object */;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
Binary file not shown.
@@ -0,0 +1,14 @@
|
|||||||
|
<?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>SchemeUserState</key>
|
||||||
|
<dict>
|
||||||
|
<key>OptionsSidekick.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import UIKit
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
|
|
||||||
|
func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||||
|
) -> Bool {
|
||||||
|
NotificationHandler.shared.setup()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called after user grants permission and iOS assigns a device token
|
||||||
|
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||||
|
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
|
||||||
|
LocalStore.shared.deviceToken = token
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await registerDevice(token: token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
||||||
|
print("APNs registration failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Private ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private func registerDevice(token: String) async {
|
||||||
|
struct DeviceRegisterBody: Encodable {
|
||||||
|
let apns_token: String
|
||||||
|
let device_name: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DeviceResponse: Decodable {
|
||||||
|
let id: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let body = DeviceRegisterBody(
|
||||||
|
apns_token: token,
|
||||||
|
device_name: UIDevice.current.name
|
||||||
|
)
|
||||||
|
// Temporarily set the token so APIClient has it, but use requestNoAuth
|
||||||
|
// since this is the registration call itself
|
||||||
|
let old = LocalStore.shared.deviceToken
|
||||||
|
LocalStore.shared.deviceToken = token
|
||||||
|
let response: DeviceResponse = try await APIClient.shared.requestNoAuth(
|
||||||
|
.registerDevice,
|
||||||
|
body: body
|
||||||
|
)
|
||||||
|
LocalStore.shared.deviceId = response.id
|
||||||
|
print("Device registered with id: \(response.id)")
|
||||||
|
} catch {
|
||||||
|
print("Device registration failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum Constants {
|
||||||
|
// ─── API ──────────────────────────────────────────────────────────────────
|
||||||
|
/// Change this to your deployed Railway/Render URL before shipping.
|
||||||
|
/// For local dev, use your Mac's LAN IP so the simulator can reach it.
|
||||||
|
static let baseURL = "http://localhost:8000"
|
||||||
|
static let apiPrefix = "/api/v1"
|
||||||
|
|
||||||
|
static var apiBaseURL: String { baseURL + apiPrefix }
|
||||||
|
|
||||||
|
// ─── Colors ───────────────────────────────────────────────────────────────
|
||||||
|
enum Color {
|
||||||
|
static let strong = SwiftUI.Color.green
|
||||||
|
static let moderate = SwiftUI.Color.yellow
|
||||||
|
static let weak = SwiftUI.Color(red: 0.9, green: 0.4, blue: 0.2)
|
||||||
|
static let accent = SwiftUI.Color.blue
|
||||||
|
static let destructive = SwiftUI.Color.red
|
||||||
|
static let warning = SwiftUI.Color.orange
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── App ──────────────────────────────────────────────────────────────────
|
||||||
|
static let appName = "Options Sidekick"
|
||||||
|
static let notificationCategory = "POSITION_ALERT"
|
||||||
|
static let notificationActionView = "VIEW_POSITION"
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct AppAlert: Codable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let ticker: String
|
||||||
|
let optionPositionId: Int?
|
||||||
|
let alertType: String // "close_early" | "roll_out" | "roll_up_down" | "earnings_warning"
|
||||||
|
let message: String
|
||||||
|
let sentAt: Date
|
||||||
|
var acknowledged: Bool
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, ticker, message, acknowledged
|
||||||
|
case optionPositionId = "option_position_id"
|
||||||
|
case alertType = "alert_type"
|
||||||
|
case sentAt = "sent_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
var typeLabel: String {
|
||||||
|
switch alertType {
|
||||||
|
case "close_early": return "Close Early"
|
||||||
|
case "roll_out": return "Roll Out"
|
||||||
|
case "roll_up_down": return "Roll Strike"
|
||||||
|
case "earnings_warning": return "Earnings Warning"
|
||||||
|
case "new_rec": return "New Recommendation"
|
||||||
|
default: return alertType.replacingOccurrences(of: "_", with: " ").capitalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isUrgent: Bool {
|
||||||
|
alertType == "close_early" || alertType == "earnings_warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct OptionPosition: Codable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let ticker: String
|
||||||
|
let strategy: String // "covered_call" | "cash_secured_put"
|
||||||
|
let strike: Double
|
||||||
|
let expiration: String // ISO date string "YYYY-MM-DD"
|
||||||
|
let premiumReceived: Double
|
||||||
|
let contracts: Int
|
||||||
|
let status: String // "open" | "closed" | "rolled"
|
||||||
|
let closeReason: String?
|
||||||
|
let openedAt: Date
|
||||||
|
let closedAt: Date?
|
||||||
|
let lastSignalHash: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, ticker, strategy, strike, expiration, contracts, status
|
||||||
|
case premiumReceived = "premium_received"
|
||||||
|
case closeReason = "close_reason"
|
||||||
|
case openedAt = "opened_at"
|
||||||
|
case closedAt = "closed_at"
|
||||||
|
case lastSignalHash = "last_signal_hash"
|
||||||
|
}
|
||||||
|
|
||||||
|
var strategyLabel: String {
|
||||||
|
strategy == "covered_call" ? "Covered Call" : "Cash-Secured Put"
|
||||||
|
}
|
||||||
|
|
||||||
|
var expirationDate: Date? {
|
||||||
|
let fmt = DateFormatter()
|
||||||
|
fmt.dateFormat = "yyyy-MM-dd"
|
||||||
|
return fmt.date(from: expiration)
|
||||||
|
}
|
||||||
|
|
||||||
|
var daysToExpiry: Int? {
|
||||||
|
guard let exp = expirationDate else { return nil }
|
||||||
|
let days = Calendar.current.dateComponents([.day], from: Date(), to: exp).day
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCredit: Double {
|
||||||
|
premiumReceived * Double(contracts) * 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OptionPositionCreate: Codable {
|
||||||
|
let ticker: String
|
||||||
|
let strategy: String
|
||||||
|
let strike: Double
|
||||||
|
let expiration: String
|
||||||
|
let premiumReceived: Double
|
||||||
|
let contracts: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case ticker, strategy, strike, expiration, contracts
|
||||||
|
case premiumReceived = "premium_received"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OptionPositionClose: Codable {
|
||||||
|
let status: String
|
||||||
|
let closeReason: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case status
|
||||||
|
case closeReason = "close_reason"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct PortfolioPosition: Codable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let ticker: String
|
||||||
|
let shares: Int
|
||||||
|
let costBasis: Double?
|
||||||
|
let createdAt: Date
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, ticker, shares
|
||||||
|
case costBasis = "cost_basis"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Recommendation: Codable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let ticker: String
|
||||||
|
let strategy: String
|
||||||
|
let timeHorizon: String
|
||||||
|
let currentPrice: Double
|
||||||
|
let recommendedStrike: Double
|
||||||
|
let recommendedExpiration: String // ISO date "YYYY-MM-DD"
|
||||||
|
let estimatedPremium: Double
|
||||||
|
let delta: Double
|
||||||
|
let theta: Double
|
||||||
|
let ivRank: Double
|
||||||
|
let signalStrength: String // "strong" | "moderate" | "weak"
|
||||||
|
let earningsWarning: Bool
|
||||||
|
let earningsDate: String?
|
||||||
|
let rationale: String
|
||||||
|
let signalHash: String
|
||||||
|
let createdAt: Date
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, ticker, strategy, rationale
|
||||||
|
case timeHorizon = "time_horizon"
|
||||||
|
case currentPrice = "current_price"
|
||||||
|
case recommendedStrike = "recommended_strike"
|
||||||
|
case recommendedExpiration = "recommended_expiration"
|
||||||
|
case estimatedPremium = "estimated_premium"
|
||||||
|
case delta, theta
|
||||||
|
case ivRank = "iv_rank"
|
||||||
|
case signalStrength = "signal_strength"
|
||||||
|
case earningsWarning = "earnings_warning"
|
||||||
|
case earningsDate = "earnings_date"
|
||||||
|
case signalHash = "signal_hash"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
var strategyLabel: String {
|
||||||
|
strategy == "covered_call" ? "Covered Call" : "Cash-Secured Put"
|
||||||
|
}
|
||||||
|
|
||||||
|
var horizonLabel: String {
|
||||||
|
switch timeHorizon {
|
||||||
|
case "0dte": return "0DTE"
|
||||||
|
case "1dte": return "1DTE"
|
||||||
|
case "weekly": return "Weekly"
|
||||||
|
case "monthly": return "Monthly"
|
||||||
|
default: return timeHorizon.capitalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var annualizedPremiumPct: Double {
|
||||||
|
guard currentPrice > 0 else { return 0 }
|
||||||
|
let daysToExpiry = expirationDate.map { Calendar.current.dateComponents([.day], from: Date(), to: $0).day ?? 30 } ?? 30
|
||||||
|
let dailyReturn = estimatedPremium / currentPrice
|
||||||
|
return dailyReturn * (365.0 / max(1, Double(daysToExpiry))) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
var expirationDate: Date? {
|
||||||
|
let fmt = DateFormatter()
|
||||||
|
fmt.dateFormat = "yyyy-MM-dd"
|
||||||
|
return fmt.date(from: recommendedExpiration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SignalSnapshot: Codable {
|
||||||
|
let ticker: String
|
||||||
|
let currentPrice: Double
|
||||||
|
let ivRank: Double
|
||||||
|
let sma50: Double
|
||||||
|
let sma200: Double
|
||||||
|
let nearestSupport: Double?
|
||||||
|
let nearestResistance: Double?
|
||||||
|
let trend: String
|
||||||
|
let earningsDate: String?
|
||||||
|
let computedAt: Date
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case ticker
|
||||||
|
case currentPrice = "current_price"
|
||||||
|
case ivRank = "iv_rank"
|
||||||
|
case sma50 = "sma_50"
|
||||||
|
case sma200 = "sma_200"
|
||||||
|
case nearestSupport = "nearest_support"
|
||||||
|
case nearestResistance = "nearest_resistance"
|
||||||
|
case trend
|
||||||
|
case earningsDate = "earnings_date"
|
||||||
|
case computedAt = "computed_at"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RecommendationWithSignals: Codable {
|
||||||
|
let recommendation: Recommendation
|
||||||
|
let signals: SignalSnapshot
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Central networking client.
|
||||||
|
/// Adds X-Device-Token header automatically from LocalStore.
|
||||||
|
/// All requests are async/await.
|
||||||
|
final class APIClient {
|
||||||
|
static let shared = APIClient()
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
private let session: URLSession = {
|
||||||
|
let config = URLSessionConfiguration.default
|
||||||
|
config.timeoutIntervalForRequest = 15
|
||||||
|
config.timeoutIntervalForResource = 30
|
||||||
|
return URLSession(configuration: config)
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var decoder: JSONDecoder = {
|
||||||
|
let d = JSONDecoder()
|
||||||
|
d.dateDecodingStrategy = .custom { decoder in
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
let str = try container.decode(String.self)
|
||||||
|
// Try ISO8601 with fractional seconds first, then without
|
||||||
|
let formatters: [ISO8601DateFormatter] = [
|
||||||
|
{
|
||||||
|
let f = ISO8601DateFormatter()
|
||||||
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
return f
|
||||||
|
}(),
|
||||||
|
{
|
||||||
|
let f = ISO8601DateFormatter()
|
||||||
|
f.formatOptions = [.withInternetDateTime]
|
||||||
|
return f
|
||||||
|
}(),
|
||||||
|
]
|
||||||
|
for fmt in formatters {
|
||||||
|
if let date = fmt.date(from: str) { return date }
|
||||||
|
}
|
||||||
|
// Try plain date (YYYY-MM-DD) for date-only fields decoded as Date
|
||||||
|
let df = DateFormatter()
|
||||||
|
df.dateFormat = "yyyy-MM-dd"
|
||||||
|
if let date = df.date(from: str) { return date }
|
||||||
|
throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Cannot parse date: \(str)"))
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var encoder: JSONEncoder = {
|
||||||
|
let e = JSONEncoder()
|
||||||
|
e.dateEncodingStrategy = .iso8601
|
||||||
|
return e
|
||||||
|
}()
|
||||||
|
|
||||||
|
// ─── Core request builder ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func request<T: Decodable>(
|
||||||
|
_ endpoint: Endpoint,
|
||||||
|
body: (some Encodable)? = Optional<EmptyBody>.none
|
||||||
|
) async throws -> T {
|
||||||
|
guard let token = LocalStore.shared.deviceToken else {
|
||||||
|
throw APIError.noDeviceToken
|
||||||
|
}
|
||||||
|
let req = try buildRequest(endpoint, deviceToken: token, body: body)
|
||||||
|
let (data, response) = try await session.data(for: req)
|
||||||
|
try validateResponse(response, data: data)
|
||||||
|
return try decoder.decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version that doesn't return a body (e.g. DELETE 204)
|
||||||
|
func requestVoid(_ endpoint: Endpoint, body: (some Encodable)? = Optional<EmptyBody>.none) async throws {
|
||||||
|
guard let token = LocalStore.shared.deviceToken else {
|
||||||
|
throw APIError.noDeviceToken
|
||||||
|
}
|
||||||
|
let req = try buildRequest(endpoint, deviceToken: token, body: body)
|
||||||
|
let (data, response) = try await session.data(for: req)
|
||||||
|
try validateResponse(response, data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For device registration (no token yet)
|
||||||
|
func requestNoAuth<T: Decodable>(_ endpoint: Endpoint, body: (some Encodable)? = Optional<EmptyBody>.none) async throws -> T {
|
||||||
|
let req = try buildRequest(endpoint, deviceToken: nil, body: body)
|
||||||
|
let (data, response) = try await session.data(for: req)
|
||||||
|
try validateResponse(response, data: data)
|
||||||
|
return try decoder.decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private func buildRequest(_ endpoint: Endpoint, deviceToken: String?, body: (some Encodable)?) throws -> URLRequest {
|
||||||
|
guard let url = URL(string: Constants.apiBaseURL + endpoint.path) else {
|
||||||
|
throw APIError.invalidURL
|
||||||
|
}
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = endpoint.method
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
if let token = deviceToken {
|
||||||
|
request.setValue(token, forHTTPHeaderField: "X-Device-Token")
|
||||||
|
}
|
||||||
|
if let body {
|
||||||
|
request.httpBody = try encoder.encode(body)
|
||||||
|
}
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
private func validateResponse(_ response: URLResponse, data: Data) throws {
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw APIError.invalidResponse
|
||||||
|
}
|
||||||
|
switch httpResponse.statusCode {
|
||||||
|
case 200...299:
|
||||||
|
break
|
||||||
|
case 404:
|
||||||
|
throw APIError.notFound
|
||||||
|
case 503:
|
||||||
|
throw APIError.serviceUnavailable
|
||||||
|
default:
|
||||||
|
let message = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||||
|
throw APIError.serverError(httpResponse.statusCode, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Supporting types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private struct EmptyBody: Encodable {}
|
||||||
|
|
||||||
|
enum APIError: LocalizedError {
|
||||||
|
case noDeviceToken
|
||||||
|
case invalidURL
|
||||||
|
case invalidResponse
|
||||||
|
case notFound
|
||||||
|
case serviceUnavailable
|
||||||
|
case serverError(Int, String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .noDeviceToken: return "Device not registered. Please restart the app."
|
||||||
|
case .invalidURL: return "Invalid API URL."
|
||||||
|
case .invalidResponse: return "Invalid response from server."
|
||||||
|
case .notFound: return "Resource not found."
|
||||||
|
case .serviceUnavailable: return "Market data unavailable. Try again shortly."
|
||||||
|
case .serverError(let code, let msg): return "Server error \(code): \(msg)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Endpoint {
|
||||||
|
let path: String
|
||||||
|
let method: String
|
||||||
|
|
||||||
|
// ─── Devices ──────────────────────────────────────────────────────────────
|
||||||
|
static let registerDevice = Endpoint(path: "/devices/register", method: "POST")
|
||||||
|
|
||||||
|
// ─── Portfolio ────────────────────────────────────────────────────────────
|
||||||
|
static let getPortfolio = Endpoint(path: "/portfolio", method: "GET")
|
||||||
|
static let setPortfolio = Endpoint(path: "/portfolio", method: "POST")
|
||||||
|
static func deleteTicker(_ ticker: String) -> Endpoint {
|
||||||
|
Endpoint(path: "/portfolio/\(ticker)", method: "DELETE")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Recommendations ──────────────────────────────────────────────────────
|
||||||
|
static func getRecommendations(timeHorizon: String? = nil) -> Endpoint {
|
||||||
|
let query = timeHorizon.map { "?time_horizon=\($0)" } ?? ""
|
||||||
|
return Endpoint(path: "/recommendations\(query)", method: "GET")
|
||||||
|
}
|
||||||
|
static func getRecommendation(ticker: String, strategy: String, timeHorizon: String) -> Endpoint {
|
||||||
|
Endpoint(path: "/recommendations/\(ticker)?strategy=\(strategy)&time_horizon=\(timeHorizon)", method: "GET")
|
||||||
|
}
|
||||||
|
static let refreshRecommendations = Endpoint(path: "/recommendations/refresh", method: "POST")
|
||||||
|
|
||||||
|
// ─── Positions ────────────────────────────────────────────────────────────
|
||||||
|
static func getPositions(status: String? = nil) -> Endpoint {
|
||||||
|
let query = status.map { "?status=\($0)" } ?? ""
|
||||||
|
return Endpoint(path: "/positions\(query)", method: "GET")
|
||||||
|
}
|
||||||
|
static let logPosition = Endpoint(path: "/positions", method: "POST")
|
||||||
|
static func closePosition(_ id: Int) -> Endpoint {
|
||||||
|
Endpoint(path: "/positions/\(id)", method: "PATCH")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Signals ──────────────────────────────────────────────────────────────
|
||||||
|
static func getSignals(_ ticker: String) -> Endpoint {
|
||||||
|
Endpoint(path: "/signals/\(ticker)", method: "GET")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Alerts ───────────────────────────────────────────────────────────────
|
||||||
|
static func getAlerts(unreadOnly: Bool = false) -> Endpoint {
|
||||||
|
let query = unreadOnly ? "?unread_only=true" : ""
|
||||||
|
return Endpoint(path: "/alerts\(query)", method: "GET")
|
||||||
|
}
|
||||||
|
static func acknowledgeAlert(_ id: Int) -> Endpoint {
|
||||||
|
Endpoint(path: "/alerts/\(id)/acknowledge", method: "PATCH")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Health ───────────────────────────────────────────────────────────────
|
||||||
|
static let health = Endpoint(path: "/health", method: "GET")
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
/// Handles incoming push notifications — both foreground and background tap.
|
||||||
|
@MainActor
|
||||||
|
final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||||
|
static let shared = NotificationHandler()
|
||||||
|
|
||||||
|
/// Published so views can react to a deep-link navigation request.
|
||||||
|
@Published var navigateToPositionId: Int? = nil
|
||||||
|
@Published var inAppAlertMessage: String? = nil
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setup() {
|
||||||
|
UNUserNotificationCenter.current().delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Foreground notification ───────────────────────────────────────────────
|
||||||
|
func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
willPresent notification: UNNotification,
|
||||||
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||||
|
) {
|
||||||
|
let userInfo = notification.request.content.userInfo
|
||||||
|
if let message = notification.request.content.body as String? {
|
||||||
|
inAppAlertMessage = message
|
||||||
|
}
|
||||||
|
// Still show the banner even when foregrounded so user doesn't miss it
|
||||||
|
completionHandler([.banner, .sound, .badge])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Background tap / action tap ──────────────────────────────────────────
|
||||||
|
func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
didReceive response: UNNotificationResponse,
|
||||||
|
withCompletionHandler completionHandler: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
let userInfo = response.notification.request.content.userInfo
|
||||||
|
if let positionId = userInfo["position_id"] as? Int {
|
||||||
|
navigateToPositionId = positionId
|
||||||
|
}
|
||||||
|
completionHandler()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class NotificationPermissions {
|
||||||
|
static let shared = NotificationPermissions()
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func requestIfNeeded() async {
|
||||||
|
guard !LocalStore.shared.notificationPermissionRequested else { return }
|
||||||
|
LocalStore.shared.notificationPermissionRequested = true
|
||||||
|
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
|
||||||
|
// Register a custom category with a "View" action for deep linking
|
||||||
|
let viewAction = UNNotificationAction(
|
||||||
|
identifier: Constants.notificationActionView,
|
||||||
|
title: "View Position",
|
||||||
|
options: [.foreground]
|
||||||
|
)
|
||||||
|
let category = UNNotificationCategory(
|
||||||
|
identifier: Constants.notificationCategory,
|
||||||
|
actions: [viewAction],
|
||||||
|
intentIdentifiers: [],
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
center.setNotificationCategories([category])
|
||||||
|
|
||||||
|
do {
|
||||||
|
let granted = try await center.requestAuthorization(options: [.alert, .badge, .sound])
|
||||||
|
if granted {
|
||||||
|
await MainActor.run {
|
||||||
|
UIApplication.shared.registerForRemoteNotifications()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Notification permission error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct OptionsSidekickApp: App {
|
||||||
|
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.environmentObject(NotificationHandler.shared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Lightweight UserDefaults wrapper for device-local state.
|
||||||
|
final class LocalStore {
|
||||||
|
static let shared = LocalStore()
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
private let defaults = UserDefaults.standard
|
||||||
|
|
||||||
|
// ─── APNs device token ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var deviceToken: String? {
|
||||||
|
get { defaults.string(forKey: "apns_device_token") }
|
||||||
|
set { defaults.set(newValue, forKey: "apns_device_token") }
|
||||||
|
}
|
||||||
|
|
||||||
|
var deviceId: Int? {
|
||||||
|
get {
|
||||||
|
let v = defaults.integer(forKey: "device_id")
|
||||||
|
return v == 0 ? nil : v
|
||||||
|
}
|
||||||
|
set { defaults.set(newValue, forKey: "device_id") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Notification permission ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
var notificationPermissionRequested: Bool {
|
||||||
|
get { defaults.bool(forKey: "notification_permission_requested") }
|
||||||
|
set { defaults.set(newValue, forKey: "notification_permission_requested") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Unread alert badge count ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
var unreadAlertCount: Int {
|
||||||
|
get { defaults.integer(forKey: "unread_alert_count") }
|
||||||
|
set { defaults.set(newValue, forKey: "unread_alert_count") }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?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>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Options Sidekick</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>UILaunchStoryboardName</key>
|
||||||
|
<string>LaunchScreen</string>
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array>
|
||||||
|
<string>armv7</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
</array>
|
||||||
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>remote-notification</string>
|
||||||
|
</array>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AlertsViewModel: ObservableObject {
|
||||||
|
@Published var alerts: [AppAlert] = []
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var error: String? = nil
|
||||||
|
|
||||||
|
var unreadCount: Int { alerts.filter { !$0.acknowledged }.count }
|
||||||
|
|
||||||
|
func load(unreadOnly: Bool = false) async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
do {
|
||||||
|
alerts = try await APIClient.shared.request(
|
||||||
|
.getAlerts(unreadOnly: unreadOnly),
|
||||||
|
body: Optional<String>.none
|
||||||
|
)
|
||||||
|
LocalStore.shared.unreadAlertCount = unreadCount
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func acknowledge(_ alert: AppAlert) async {
|
||||||
|
do {
|
||||||
|
let updated: AppAlert = try await APIClient.shared.request(
|
||||||
|
.acknowledgeAlert(alert.id),
|
||||||
|
body: Optional<String>.none
|
||||||
|
)
|
||||||
|
if let idx = alerts.firstIndex(where: { $0.id == alert.id }) {
|
||||||
|
alerts[idx] = updated
|
||||||
|
}
|
||||||
|
LocalStore.shared.unreadAlertCount = unreadCount
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func acknowledgeAll() async {
|
||||||
|
for alert in alerts where !alert.acknowledged {
|
||||||
|
await acknowledge(alert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class DashboardViewModel: ObservableObject {
|
||||||
|
@Published var stockPositions: [PortfolioPosition] = []
|
||||||
|
@Published var openOptionPositions: [OptionPosition] = []
|
||||||
|
@Published var recommendations: [Recommendation] = []
|
||||||
|
@Published var unreadAlerts: [AppAlert] = []
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var error: String? = nil
|
||||||
|
|
||||||
|
var urgentAlerts: [AppAlert] { unreadAlerts.filter { $0.isUrgent } }
|
||||||
|
|
||||||
|
/// Top-level recommendation per ticker (best signal strength)
|
||||||
|
var topRecommendations: [Recommendation] {
|
||||||
|
let order = ["strong": 0, "moderate": 1, "weak": 2]
|
||||||
|
var best: [String: Recommendation] = [:]
|
||||||
|
for rec in recommendations {
|
||||||
|
let current = best[rec.ticker]
|
||||||
|
if current == nil || (order[rec.signalStrength] ?? 2) < (order[current!.signalStrength] ?? 2) {
|
||||||
|
best[rec.ticker] = rec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array(best.values).sorted { $0.ticker < $1.ticker }
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAll() async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
async let stocks: [PortfolioPosition] = loadStocks()
|
||||||
|
async let options: [OptionPosition] = loadOptions()
|
||||||
|
async let recs: [Recommendation] = loadRecommendations()
|
||||||
|
async let alerts: [AppAlert] = loadAlerts()
|
||||||
|
|
||||||
|
(stockPositions, openOptionPositions, recommendations, unreadAlerts) = await (stocks, options, recs, alerts)
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() async {
|
||||||
|
await loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Private loaders ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private func loadStocks() async -> [PortfolioPosition] {
|
||||||
|
(try? await APIClient.shared.request(.getPortfolio, body: Optional<String>.none)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadOptions() async -> [OptionPosition] {
|
||||||
|
(try? await APIClient.shared.request(.getPositions(status: "open"), body: Optional<String>.none)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadRecommendations() async -> [Recommendation] {
|
||||||
|
(try? await APIClient.shared.request(.getRecommendations(timeHorizon: nil), body: Optional<String>.none)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadAlerts() async -> [AppAlert] {
|
||||||
|
(try? await APIClient.shared.request(.getAlerts(unreadOnly: true), body: Optional<String>.none)) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PortfolioViewModel: ObservableObject {
|
||||||
|
@Published var positions: [PortfolioPosition] = []
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var error: String? = nil
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
do {
|
||||||
|
positions = try await APIClient.shared.request(.getPortfolio, body: Optional<String>.none)
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(_ newPositions: [PortfolioPosition]) async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
struct PositionBody: Encodable {
|
||||||
|
let ticker: String
|
||||||
|
let shares: Int
|
||||||
|
let cost_basis: Double?
|
||||||
|
}
|
||||||
|
let body = newPositions.map { PositionBody(ticker: $0.ticker, shares: $0.shares, cost_basis: $0.costBasis) }
|
||||||
|
do {
|
||||||
|
positions = try await APIClient.shared.request(.setPortfolio, body: body)
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func add(ticker: String, shares: Int, costBasis: Double?) async {
|
||||||
|
var updated = positions
|
||||||
|
struct AddBody: Encodable {
|
||||||
|
let ticker: String
|
||||||
|
let shares: Int
|
||||||
|
let cost_basis: Double?
|
||||||
|
}
|
||||||
|
let allBody = updated.map { AddBody(ticker: $0.ticker, shares: $0.shares, cost_basis: $0.costBasis) }
|
||||||
|
+ [AddBody(ticker: ticker.uppercased(), shares: shares, cost_basis: costBasis)]
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
do {
|
||||||
|
positions = try await APIClient.shared.request(.setPortfolio, body: allBody)
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(ticker: String) async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
do {
|
||||||
|
try await APIClient.shared.requestVoid(.deleteTicker(ticker), body: Optional<String>.none)
|
||||||
|
positions.removeAll { $0.ticker == ticker }
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PositionsViewModel: ObservableObject {
|
||||||
|
@Published var positions: [OptionPosition] = []
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var error: String? = nil
|
||||||
|
|
||||||
|
var openPositions: [OptionPosition] { positions.filter { $0.status == "open" } }
|
||||||
|
var closedPositions: [OptionPosition] { positions.filter { $0.status != "open" } }
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
do {
|
||||||
|
positions = try await APIClient.shared.request(
|
||||||
|
.getPositions(status: nil),
|
||||||
|
body: Optional<String>.none
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func log(create: OptionPositionCreate) async -> Bool {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
do {
|
||||||
|
let new: OptionPosition = try await APIClient.shared.request(.logPosition, body: create)
|
||||||
|
positions.insert(new, at: 0)
|
||||||
|
isLoading = false
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
isLoading = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func close(position: OptionPosition, reason: String) async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
let body = OptionPositionClose(status: "closed", closeReason: reason)
|
||||||
|
do {
|
||||||
|
let updated: OptionPosition = try await APIClient.shared.request(
|
||||||
|
.closePosition(position.id),
|
||||||
|
body: body
|
||||||
|
)
|
||||||
|
if let idx = positions.firstIndex(where: { $0.id == position.id }) {
|
||||||
|
positions[idx] = updated
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func roll(position: OptionPosition) async {
|
||||||
|
let body = OptionPositionClose(status: "rolled", closeReason: "rolled")
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
do {
|
||||||
|
let updated: OptionPosition = try await APIClient.shared.request(
|
||||||
|
.closePosition(position.id),
|
||||||
|
body: body
|
||||||
|
)
|
||||||
|
if let idx = positions.firstIndex(where: { $0.id == position.id }) {
|
||||||
|
positions[idx] = updated
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class RecommendationsViewModel: ObservableObject {
|
||||||
|
@Published var recommendations: [Recommendation] = []
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var isRefreshing = false
|
||||||
|
@Published var error: String? = nil
|
||||||
|
@Published var selectedHorizon: String = "weekly"
|
||||||
|
@Published var selectedStrategy: String = "covered_call"
|
||||||
|
|
||||||
|
var filtered: [Recommendation] {
|
||||||
|
recommendations.filter {
|
||||||
|
$0.timeHorizon == selectedHorizon && $0.strategy == selectedStrategy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
do {
|
||||||
|
recommendations = try await APIClient.shared.request(
|
||||||
|
.getRecommendations(timeHorizon: nil),
|
||||||
|
body: Optional<String>.none
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() async {
|
||||||
|
isRefreshing = true
|
||||||
|
error = nil
|
||||||
|
do {
|
||||||
|
recommendations = try await APIClient.shared.request(
|
||||||
|
.refreshRecommendations,
|
||||||
|
body: Optional<String>.none
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDetail(ticker: String) async -> RecommendationWithSignals? {
|
||||||
|
do {
|
||||||
|
return try await APIClient.shared.request(
|
||||||
|
.getRecommendation(ticker: ticker, strategy: selectedStrategy, timeHorizon: selectedHorizon),
|
||||||
|
body: Optional<String>.none
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AlertsView: View {
|
||||||
|
@StateObject private var vm = AlertsViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if vm.isLoading && vm.alerts.isEmpty {
|
||||||
|
LoadingView(message: "Loading alerts...")
|
||||||
|
} else if vm.alerts.isEmpty {
|
||||||
|
EmptyStateView(
|
||||||
|
icon: "bell.slash",
|
||||||
|
title: "No alerts",
|
||||||
|
subtitle: "Alerts appear here when signal changes on your open positions."
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
List(vm.alerts) { alert in
|
||||||
|
AlertRowView(alert: alert) {
|
||||||
|
Task { await vm.acknowledge(alert) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.refreshable { await vm.load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Alerts")
|
||||||
|
.toolbar {
|
||||||
|
if vm.unreadCount > 0 {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Mark All Read") {
|
||||||
|
Task { await vm.acknowledgeAll() }
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await vm.load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Row ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct AlertRowView: View {
|
||||||
|
let alert: AppAlert
|
||||||
|
let onAcknowledge: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
// Unread indicator
|
||||||
|
Circle()
|
||||||
|
.fill(alert.acknowledged ? Color.clear : Constants.Color.accent)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
.padding(.top, 5)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(alert.ticker)
|
||||||
|
.font(.headline)
|
||||||
|
AlertTypeBadge(alertType: alert.alertType)
|
||||||
|
}
|
||||||
|
Text(alert.message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Text(RelativeDateTimeFormatter().localizedString(for: alert.sentAt, relativeTo: Date()))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if !alert.acknowledged {
|
||||||
|
Button {
|
||||||
|
onAcknowledge()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "checkmark.circle")
|
||||||
|
.foregroundStyle(Constants.Color.accent)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.opacity(alert.acknowledged ? 0.6 : 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LoadingView: View {
|
||||||
|
var message: String = "Loading..."
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ProgressView()
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ErrorView: View {
|
||||||
|
let message: String
|
||||||
|
let retry: () async -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.foregroundStyle(Constants.Color.destructive)
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal)
|
||||||
|
Button("Retry") {
|
||||||
|
Task { await retry() }
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EmptyStateView: View {
|
||||||
|
let icon: String
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundStyle(.quaternary)
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SignalBadge: View {
|
||||||
|
let strength: String // "strong" | "moderate" | "weak"
|
||||||
|
|
||||||
|
var color: Color {
|
||||||
|
switch strength {
|
||||||
|
case "strong": return Constants.Color.strong
|
||||||
|
case "moderate": return Constants.Color.moderate
|
||||||
|
default: return Constants.Color.weak
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(strength.capitalized)
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(color.opacity(0.18))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct IVRankBadge: View {
|
||||||
|
let ivRank: Double
|
||||||
|
|
||||||
|
var color: Color {
|
||||||
|
if ivRank >= 50 { return Constants.Color.strong }
|
||||||
|
if ivRank >= 30 { return Constants.Color.moderate }
|
||||||
|
return Constants.Color.weak
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
Text("IV")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(String(format: "%.0f%%", ivRank))
|
||||||
|
.font(.caption2.weight(.bold))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(color.opacity(0.12))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AlertTypeBadge: View {
|
||||||
|
let alertType: String
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch alertType {
|
||||||
|
case "close_early": return "Close"
|
||||||
|
case "roll_out": return "Roll Out"
|
||||||
|
case "roll_up_down": return "Roll"
|
||||||
|
case "earnings_warning": return "Earnings"
|
||||||
|
default: return alertType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var color: Color {
|
||||||
|
alertType == "close_early" || alertType == "earnings_warning"
|
||||||
|
? Constants.Color.destructive
|
||||||
|
: Constants.Color.warning
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2.weight(.bold))
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(color.opacity(0.15))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@EnvironmentObject private var notificationHandler: NotificationHandler
|
||||||
|
@State private var selectedTab = 0
|
||||||
|
@State private var navigateToPositionId: Int? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabView(selection: $selectedTab) {
|
||||||
|
DashboardView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Dashboard", systemImage: "house.fill")
|
||||||
|
}
|
||||||
|
.tag(0)
|
||||||
|
|
||||||
|
RecommendationsView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Setups", systemImage: "lightbulb.fill")
|
||||||
|
}
|
||||||
|
.tag(1)
|
||||||
|
|
||||||
|
OpenPositionsView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Trades", systemImage: "doc.text.fill")
|
||||||
|
}
|
||||||
|
.tag(2)
|
||||||
|
|
||||||
|
PortfolioView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Portfolio", systemImage: "briefcase.fill")
|
||||||
|
}
|
||||||
|
.tag(3)
|
||||||
|
|
||||||
|
AlertsView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Alerts", systemImage: "bell.fill")
|
||||||
|
}
|
||||||
|
.badge(LocalStore.shared.unreadAlertCount > 0 ? "\(LocalStore.shared.unreadAlertCount)" : nil)
|
||||||
|
.tag(4)
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await NotificationPermissions.shared.requestIfNeeded()
|
||||||
|
}
|
||||||
|
.onChange(of: notificationHandler.navigateToPositionId) { _, posId in
|
||||||
|
if posId != nil {
|
||||||
|
// Deep link: switch to Trades tab
|
||||||
|
selectedTab = 2
|
||||||
|
navigateToPositionId = posId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DashboardView: View {
|
||||||
|
@StateObject private var vm = DashboardViewModel()
|
||||||
|
@EnvironmentObject private var notificationHandler: NotificationHandler
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if vm.isLoading && vm.stockPositions.isEmpty {
|
||||||
|
LoadingView(message: "Loading your positions...")
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 0) {
|
||||||
|
// ─── Urgent alert banners ──────────────────────
|
||||||
|
if !vm.urgentAlerts.isEmpty {
|
||||||
|
urgentAlertSection
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Open options positions ────────────────────
|
||||||
|
if !vm.openOptionPositions.isEmpty {
|
||||||
|
openPositionsSection
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Recommendations ──────────────────────────
|
||||||
|
if !vm.topRecommendations.isEmpty {
|
||||||
|
recommendationsSection
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Empty state ──────────────────────────────
|
||||||
|
if vm.stockPositions.isEmpty {
|
||||||
|
emptyState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
.refreshable { await vm.refresh() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Options Sidekick")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
if vm.unreadAlerts.count > 0 {
|
||||||
|
alertBell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await vm.loadAll() }
|
||||||
|
.onChange(of: notificationHandler.inAppAlertMessage) { _, msg in
|
||||||
|
if msg != nil { Task { await vm.refresh() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sections ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private var urgentAlertSection: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(vm.urgentAlerts.prefix(3)) { alert in
|
||||||
|
AlertBannerView(alert: alert)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var openPositionsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
sectionHeader("Open Positions", systemImage: "chart.line.uptrend.xyaxis")
|
||||||
|
ForEach(vm.openOptionPositions) { position in
|
||||||
|
NavigationLink(destination: PositionDetailView(position: position)) {
|
||||||
|
OpenPositionRowView(position: position)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var recommendationsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
sectionHeader("Today's Setups", systemImage: "lightbulb")
|
||||||
|
ForEach(vm.topRecommendations) { rec in
|
||||||
|
NavigationLink(destination: RecommendationDetailView(ticker: rec.ticker)) {
|
||||||
|
RecommendationCardView(recommendation: rec)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
EmptyStateView(
|
||||||
|
icon: "tray",
|
||||||
|
title: "No positions yet",
|
||||||
|
subtitle: "Add stocks to your portfolio to get recommendations."
|
||||||
|
)
|
||||||
|
.padding(.top, 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var alertBell: some View {
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
Image(systemName: "bell.fill")
|
||||||
|
.foregroundStyle(Constants.Color.warning)
|
||||||
|
Circle()
|
||||||
|
.fill(Constants.Color.destructive)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
.offset(x: 4, y: -4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sectionHeader(_ title: String, systemImage: String) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Alert Banner ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct AlertBannerView: View {
|
||||||
|
let alert: AppAlert
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: alert.isUrgent ? "exclamationmark.triangle.fill" : "bell.fill")
|
||||||
|
.foregroundStyle(alert.isUrgent ? Constants.Color.destructive : Constants.Color.warning)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack {
|
||||||
|
Text(alert.ticker)
|
||||||
|
.font(.subheadline.weight(.bold))
|
||||||
|
AlertTypeBadge(alertType: alert.alertType)
|
||||||
|
}
|
||||||
|
Text(alert.message)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 10).stroke(
|
||||||
|
alert.isUrgent ? Constants.Color.destructive.opacity(0.4) : Constants.Color.warning.opacity(0.3),
|
||||||
|
lineWidth: 1
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Recommendation Card ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct RecommendationCardView: View {
|
||||||
|
let recommendation: Recommendation
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(recommendation.ticker)
|
||||||
|
.font(.headline)
|
||||||
|
Text(recommendation.strategyLabel)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
SignalBadge(strength: recommendation.signalStrength)
|
||||||
|
if recommendation.earningsWarning {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Constants.Color.warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
label("Strike", value: String(format: "$%.0f", recommendation.recommendedStrike))
|
||||||
|
label("Exp", value: recommendation.recommendedExpiration)
|
||||||
|
label("Premium", value: String(format: "$%.2f", recommendation.estimatedPremium))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
IVRankBadge(ivRank: recommendation.ivRank)
|
||||||
|
Text(String(format: "Δ %.2f", recommendation.delta))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func label(_ title: String, value: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(title).font(.caption2).foregroundStyle(.tertiary)
|
||||||
|
Text(value).font(.caption.weight(.medium))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Open Position Row ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct OpenPositionRowView: View {
|
||||||
|
let position: OptionPosition
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(position.ticker).font(.headline)
|
||||||
|
Text(position.strategyLabel).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text(String(format: "$%.0f strike", position.strike)).font(.caption)
|
||||||
|
if let dte = position.daysToExpiry {
|
||||||
|
Text("\(dte)d").font(.caption).foregroundStyle(dte <= 3 ? Constants.Color.warning : .secondary)
|
||||||
|
}
|
||||||
|
Text(String(format: "×%d", position.contracts)).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text(String(format: "$%.2f", position.premiumReceived))
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text("per contract").font(.caption2).foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PortfolioView: View {
|
||||||
|
@StateObject private var vm = PortfolioViewModel()
|
||||||
|
@State private var showAddSheet = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if vm.isLoading && vm.positions.isEmpty {
|
||||||
|
LoadingView(message: "Loading portfolio...")
|
||||||
|
} else if let error = vm.error {
|
||||||
|
ErrorView(message: error) { await vm.load() }
|
||||||
|
} else if vm.positions.isEmpty {
|
||||||
|
EmptyStateView(
|
||||||
|
icon: "briefcase",
|
||||||
|
title: "No stocks yet",
|
||||||
|
subtitle: "Add the tickers you hold shares in to get covered call and cash-secured put recommendations."
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
ForEach(vm.positions) { position in
|
||||||
|
PortfolioRowView(position: position)
|
||||||
|
}
|
||||||
|
.onDelete { indexSet in
|
||||||
|
Task {
|
||||||
|
for i in indexSet {
|
||||||
|
await vm.delete(ticker: vm.positions[i].ticker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("My Stocks")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button {
|
||||||
|
showAddSheet = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showAddSheet) {
|
||||||
|
AddPositionSheet(vm: vm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await vm.load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Row ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct PortfolioRowView: View {
|
||||||
|
let position: PortfolioPosition
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(position.ticker)
|
||||||
|
.font(.headline)
|
||||||
|
if let cost = position.costBasis {
|
||||||
|
Text(String(format: "Avg cost $%.2f", cost))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text("\(position.shares) shares")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
Text("\(position.shares / 100) contracts available")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Add Sheet ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct AddPositionSheet: View {
|
||||||
|
@ObservedObject var vm: PortfolioViewModel
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
@State private var ticker = ""
|
||||||
|
@State private var sharesText = ""
|
||||||
|
@State private var costBasisText = ""
|
||||||
|
@FocusState private var focusedField: Field?
|
||||||
|
|
||||||
|
enum Field { case ticker, shares, cost }
|
||||||
|
|
||||||
|
var sharesInt: Int? { Int(sharesText) }
|
||||||
|
var costDouble: Double? { costBasisText.isEmpty ? nil : Double(costBasisText) }
|
||||||
|
var isValid: Bool { !ticker.isEmpty && (sharesInt ?? 0) > 0 }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Stock") {
|
||||||
|
TextField("Ticker (e.g. AAPL)", text: $ticker)
|
||||||
|
.textInputAutocapitalization(.characters)
|
||||||
|
.focused($focusedField, equals: .ticker)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Position") {
|
||||||
|
TextField("Shares owned", text: $sharesText)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.focused($focusedField, equals: .shares)
|
||||||
|
|
||||||
|
TextField("Avg cost basis (optional)", text: $costBasisText)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.focused($focusedField, equals: .cost)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let shares = sharesInt, shares > 0 {
|
||||||
|
Section("") {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("You can sell up to \(shares / 100) covered call contracts.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Add Stock")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Add") {
|
||||||
|
Task {
|
||||||
|
await vm.add(ticker: ticker, shares: sharesInt!, costBasis: costDouble)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(!isValid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { focusedField = .ticker }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LogTradeSheet: View {
|
||||||
|
@ObservedObject var vm: PositionsViewModel
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
@State private var ticker = ""
|
||||||
|
@State private var strategy = "covered_call"
|
||||||
|
@State private var strikeText = ""
|
||||||
|
@State private var expiration = Date().addingTimeInterval(14 * 86400)
|
||||||
|
@State private var premiumText = ""
|
||||||
|
@State private var contractsText = "1"
|
||||||
|
@FocusState private var focusedField: Field?
|
||||||
|
|
||||||
|
enum Field { case ticker, strike, premium, contracts }
|
||||||
|
|
||||||
|
var strike: Double? { Double(strikeText) }
|
||||||
|
var premium: Double? { Double(premiumText) }
|
||||||
|
var contracts: Int { Int(contractsText) ?? 1 }
|
||||||
|
var isValid: Bool { !ticker.isEmpty && (strike ?? 0) > 0 && (premium ?? 0) > 0 }
|
||||||
|
|
||||||
|
private let dateFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "yyyy-MM-dd"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Trade") {
|
||||||
|
TextField("Ticker (e.g. AAPL)", text: $ticker)
|
||||||
|
.textInputAutocapitalization(.characters)
|
||||||
|
.focused($focusedField, equals: .ticker)
|
||||||
|
|
||||||
|
Picker("Strategy", selection: $strategy) {
|
||||||
|
Text("Covered Call").tag("covered_call")
|
||||||
|
Text("Cash-Secured Put").tag("cash_secured_put")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Details") {
|
||||||
|
TextField("Strike price", text: $strikeText)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.focused($focusedField, equals: .strike)
|
||||||
|
|
||||||
|
DatePicker("Expiration", selection: $expiration, in: Date()..., displayedComponents: .date)
|
||||||
|
|
||||||
|
TextField("Premium received (per share)", text: $premiumText)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.focused($focusedField, equals: .premium)
|
||||||
|
|
||||||
|
TextField("Contracts", text: $contractsText)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.focused($focusedField, equals: .contracts)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let p = premium, let s = strike, contracts > 0 {
|
||||||
|
Section("") {
|
||||||
|
HStack {
|
||||||
|
Text("Total credit received")
|
||||||
|
Spacer()
|
||||||
|
Text(String(format: "$%.2f", p * Double(contracts) * 100))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Constants.Color.strong)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Max profit per contract")
|
||||||
|
Spacer()
|
||||||
|
Text(String(format: "$%.2f", p * 100))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Log Trade")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Log") {
|
||||||
|
guard isValid else { return }
|
||||||
|
Task {
|
||||||
|
let create = OptionPositionCreate(
|
||||||
|
ticker: ticker.uppercased(),
|
||||||
|
strategy: strategy,
|
||||||
|
strike: strike!,
|
||||||
|
expiration: dateFormatter.string(from: expiration),
|
||||||
|
premiumReceived: premium!,
|
||||||
|
contracts: contracts
|
||||||
|
)
|
||||||
|
let success = await vm.log(create: create)
|
||||||
|
if success { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(!isValid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { focusedField = .ticker }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct OpenPositionsView: View {
|
||||||
|
@StateObject private var vm = PositionsViewModel()
|
||||||
|
@State private var showLogSheet = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if vm.isLoading && vm.positions.isEmpty {
|
||||||
|
LoadingView(message: "Loading positions...")
|
||||||
|
} else if let error = vm.error {
|
||||||
|
ErrorView(message: error) { await vm.load() }
|
||||||
|
} else if vm.positions.isEmpty {
|
||||||
|
EmptyStateView(
|
||||||
|
icon: "doc.text",
|
||||||
|
title: "No logged trades",
|
||||||
|
subtitle: "Log your executed options trades here. The app will monitor them and alert you when signals change."
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
if !vm.openPositions.isEmpty {
|
||||||
|
Section("Open") {
|
||||||
|
ForEach(vm.openPositions) { position in
|
||||||
|
NavigationLink(destination: PositionDetailView(position: position, vm: vm)) {
|
||||||
|
LoggedPositionRow(position: position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !vm.closedPositions.isEmpty {
|
||||||
|
Section("Closed / Rolled") {
|
||||||
|
ForEach(vm.closedPositions) { position in
|
||||||
|
LoggedPositionRow(position: position)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.refreshable { await vm.load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("My Trades")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button {
|
||||||
|
showLogSheet = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showLogSheet) {
|
||||||
|
LogTradeSheet(vm: vm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await vm.load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Row ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct LoggedPositionRow: View {
|
||||||
|
let position: OptionPosition
|
||||||
|
|
||||||
|
var statusColor: Color {
|
||||||
|
switch position.status {
|
||||||
|
case "open": return Constants.Color.strong
|
||||||
|
case "rolled": return Constants.Color.moderate
|
||||||
|
default: return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(position.ticker).font(.headline)
|
||||||
|
Text(position.strategyLabel).font(.caption).foregroundStyle(.secondary)
|
||||||
|
Circle().fill(statusColor).frame(width: 6, height: 6)
|
||||||
|
}
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Text(String(format: "$%.0f", position.strike)).font(.caption)
|
||||||
|
if let dte = position.daysToExpiry {
|
||||||
|
Text("\(dte)d to expiry")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(dte <= 5 ? Constants.Color.warning : .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text(String(format: "$%.2f", position.premiumReceived))
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text(String(format: "×%d", position.contracts))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PositionDetailView: View {
|
||||||
|
let position: OptionPosition
|
||||||
|
@ObservedObject var vm: PositionsViewModel
|
||||||
|
@State private var signals: SignalSnapshot? = nil
|
||||||
|
@State private var isLoadingSignals = false
|
||||||
|
@State private var showCloseConfirm = false
|
||||||
|
@State private var showRollConfirm = false
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
init(position: OptionPosition, vm: PositionsViewModel = PositionsViewModel()) {
|
||||||
|
self.position = position
|
||||||
|
self.vm = vm
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
|
||||||
|
// ─── Position summary ──────────────────────────────────────
|
||||||
|
summarySection
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// ─── Current signals ───────────────────────────────────────
|
||||||
|
if isLoadingSignals {
|
||||||
|
HStack {
|
||||||
|
ProgressView()
|
||||||
|
Text("Loading signals...").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
} else if let signals {
|
||||||
|
signalsSection(signals)
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Actions ──────────────────────────────────────────────
|
||||||
|
if position.status == "open" {
|
||||||
|
actionSection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle("\(position.ticker) \(position.strategyLabel)")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.task { await loadSignals() }
|
||||||
|
.confirmationDialog("Close Position", isPresented: $showCloseConfirm) {
|
||||||
|
Button("Bought Back", role: .destructive) {
|
||||||
|
Task { await vm.close(position: position, reason: "bought_back"); dismiss() }
|
||||||
|
}
|
||||||
|
Button("Expired Worthless") {
|
||||||
|
Task { await vm.close(position: position, reason: "expired"); dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.confirmationDialog("Roll Position", isPresented: $showRollConfirm) {
|
||||||
|
Button("Mark as Rolled", role: .destructive) {
|
||||||
|
Task { await vm.roll(position: position); dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sections ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private var summarySection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
|
||||||
|
metricCard("Strike", String(format: "$%.2f", position.strike))
|
||||||
|
metricCard("Expiration", position.expiration)
|
||||||
|
metricCard("Premium", String(format: "$%.2f", position.premiumReceived))
|
||||||
|
metricCard("Contracts", "\(position.contracts)")
|
||||||
|
metricCard("Total Credit", String(format: "$%.2f", position.totalCredit))
|
||||||
|
if let dte = position.daysToExpiry {
|
||||||
|
metricCard("Days Left", "\(dte)d", highlight: dte <= 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func signalsSection(_ snap: SignalSnapshot) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Current Signals")
|
||||||
|
.font(.headline)
|
||||||
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
|
||||||
|
metricCard("IV Rank", String(format: "%.0f%%", snap.ivRank), highlight: snap.ivRank >= 50)
|
||||||
|
metricCard("Trend", snap.trend.capitalized)
|
||||||
|
if let support = snap.nearestSupport {
|
||||||
|
metricCard("Support", String(format: "$%.2f", support))
|
||||||
|
}
|
||||||
|
if let resistance = snap.nearestResistance {
|
||||||
|
metricCard("Resistance", String(format: "$%.2f", resistance))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let earnings = snap.earningsDate {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "calendar.badge.exclamationmark")
|
||||||
|
.foregroundStyle(Constants.Color.warning)
|
||||||
|
Text("Earnings: \(earnings)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Constants.Color.warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var actionSection: some View {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Button {
|
||||||
|
showCloseConfirm = true
|
||||||
|
} label: {
|
||||||
|
Label("Close Position", systemImage: "xmark.circle")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(Constants.Color.destructive)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showRollConfirm = true
|
||||||
|
} label: {
|
||||||
|
Label("Roll Position", systemImage: "arrow.2.circlepath")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(Constants.Color.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private func metricCard(_ label: String, _ value: String, highlight: Bool = false) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(value)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(highlight ? Constants.Color.strong : .primary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(10)
|
||||||
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadSignals() async {
|
||||||
|
isLoadingSignals = true
|
||||||
|
do {
|
||||||
|
signals = try await APIClient.shared.request(
|
||||||
|
.getSignals(position.ticker),
|
||||||
|
body: Optional<String>.none
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Non-critical — just don't show signals
|
||||||
|
}
|
||||||
|
isLoadingSignals = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RecommendationDetailView: View {
|
||||||
|
let ticker: String
|
||||||
|
var initialRec: Recommendation? = nil
|
||||||
|
|
||||||
|
@StateObject private var vm = RecommendationsViewModel()
|
||||||
|
@State private var detail: RecommendationWithSignals? = nil
|
||||||
|
@State private var isLoading = false
|
||||||
|
|
||||||
|
var rec: Recommendation? { detail?.recommendation ?? initialRec }
|
||||||
|
var signals: SignalSnapshot? { detail?.signals }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
if isLoading {
|
||||||
|
LoadingView()
|
||||||
|
} else if let rec {
|
||||||
|
// ─── Header ───────────────────────────────────────────
|
||||||
|
headerSection(rec)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// ─── Trade Setup ──────────────────────────────────────
|
||||||
|
tradeSetupSection(rec)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// ─── Signal Detail ────────────────────────────────────
|
||||||
|
if let signals {
|
||||||
|
signalDetailSection(signals)
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Rationale ────────────────────────────────────────
|
||||||
|
rationaleSection(rec)
|
||||||
|
|
||||||
|
if rec.earningsWarning {
|
||||||
|
earningsWarningBanner(rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle(ticker)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.task { await loadDetail() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sections ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private func headerSection(_ rec: Recommendation) -> some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(rec.strategyLabel)
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
Text(rec.horizonLabel)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
SignalBadge(strength: rec.signalStrength)
|
||||||
|
Text(String(format: "$%.2f", rec.currentPrice))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tradeSetupSection(_ rec: Recommendation) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Trade Setup")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
|
||||||
|
metricCard("Strike", String(format: "$%.2f", rec.recommendedStrike))
|
||||||
|
metricCard("Expiration", rec.recommendedExpiration)
|
||||||
|
metricCard("Est. Premium", String(format: "$%.2f", rec.estimatedPremium))
|
||||||
|
metricCard("Ann. Return", String(format: "%.1f%%", rec.annualizedPremiumPct))
|
||||||
|
metricCard("Delta", String(format: "%.3f", rec.delta))
|
||||||
|
metricCard("Theta", String(format: "%.3f", rec.theta))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func signalDetailSection(_ signals: SignalSnapshot) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Market Signals")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
|
||||||
|
metricCard("IV Rank", String(format: "%.0f%%", signals.ivRank), highlight: signals.ivRank >= 50)
|
||||||
|
metricCard("Trend", signals.trend.capitalized)
|
||||||
|
metricCard("SMA-50", String(format: "$%.2f", signals.sma50))
|
||||||
|
metricCard("SMA-200", String(format: "$%.2f", signals.sma200))
|
||||||
|
if let support = signals.nearestSupport {
|
||||||
|
metricCard("Support", String(format: "$%.2f", support))
|
||||||
|
}
|
||||||
|
if let resistance = signals.nearestResistance {
|
||||||
|
metricCard("Resistance", String(format: "$%.2f", resistance))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rationaleSection(_ rec: Recommendation) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Rationale")
|
||||||
|
.font(.headline)
|
||||||
|
Text(rec.rationale)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func earningsWarningBanner(_ rec: Recommendation) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(Constants.Color.warning)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Earnings Warning")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
if let earningsDate = rec.earningsDate {
|
||||||
|
Text("Earnings on \(earningsDate) fall within this expiry window.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Constants.Color.warning.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Constants.Color.warning.opacity(0.3), lineWidth: 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helper views ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private func metricCard(_ label: String, _ value: String, highlight: Bool = false) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(value)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(highlight ? Constants.Color.strong : .primary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(10)
|
||||||
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadDetail() async {
|
||||||
|
isLoading = true
|
||||||
|
vm.selectedStrategy = initialRec?.strategy ?? "covered_call"
|
||||||
|
vm.selectedHorizon = initialRec?.timeHorizon ?? "weekly"
|
||||||
|
detail = await vm.getDetail(ticker: ticker)
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RecommendationsView: View {
|
||||||
|
@StateObject private var vm = RecommendationsViewModel()
|
||||||
|
|
||||||
|
private let horizons = ["weekly", "monthly", "0dte", "1dte"]
|
||||||
|
private let strategies = ["covered_call", "cash_secured_put"]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// ─── Pickers ──────────────────────────────────────────────
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Picker("Horizon", selection: $vm.selectedHorizon) {
|
||||||
|
ForEach(horizons, id: \.self) { h in
|
||||||
|
Text(horizonLabel(h)).tag(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
|
||||||
|
Picker("Strategy", selection: $vm.selectedStrategy) {
|
||||||
|
Text("Covered Call").tag("covered_call")
|
||||||
|
Text("Cash-Secured Put").tag("cash_secured_put")
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.bar)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// ─── Content ──────────────────────────────────────────────
|
||||||
|
if vm.isLoading {
|
||||||
|
LoadingView(message: "Getting recommendations...")
|
||||||
|
} else if let error = vm.error {
|
||||||
|
ErrorView(message: error) { await vm.load() }
|
||||||
|
} else if vm.filtered.isEmpty {
|
||||||
|
EmptyStateView(
|
||||||
|
icon: "chart.xyaxis.line",
|
||||||
|
title: "No recommendations",
|
||||||
|
subtitle: "Add stocks to your portfolio or try a different horizon."
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
List(vm.filtered) { rec in
|
||||||
|
NavigationLink(destination: RecommendationDetailView(ticker: rec.ticker, initialRec: rec)) {
|
||||||
|
RecommendationListRow(rec: rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.refreshable { await vm.refresh() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Recommendations")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
if vm.isRefreshing {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
Task { await vm.refresh() }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await vm.load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func horizonLabel(_ h: String) -> String {
|
||||||
|
switch h {
|
||||||
|
case "0dte": return "0DTE"
|
||||||
|
case "1dte": return "1DTE"
|
||||||
|
case "weekly": return "Weekly"
|
||||||
|
case "monthly": return "Monthly"
|
||||||
|
default: return h.capitalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── List Row ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct RecommendationListRow: View {
|
||||||
|
let rec: Recommendation
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(rec.ticker).font(.headline)
|
||||||
|
SignalBadge(strength: rec.signalStrength)
|
||||||
|
if rec.earningsWarning {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Constants.Color.warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
dataPoint("Strike", String(format: "$%.0f", rec.recommendedStrike))
|
||||||
|
dataPoint("Exp", rec.recommendedExpiration)
|
||||||
|
dataPoint("Credit", String(format: "$%.2f", rec.estimatedPremium))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
IVRankBadge(ivRank: rec.ivRank)
|
||||||
|
Text(String(format: "%.0f%% ann.", rec.annualizedPremiumPct))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dataPoint(_ label: String, _ value: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(label).font(.caption2).foregroundStyle(.tertiary)
|
||||||
|
Text(value).font(.caption.weight(.medium))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class AlertsViewModel: ObservableObject {
|
final class AlertsViewModel: ObservableObject {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class PortfolioViewModel: ObservableObject {
|
final class PortfolioViewModel: ObservableObject {
|
||||||
@@ -35,7 +36,7 @@ final class PortfolioViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func add(ticker: String, shares: Int, costBasis: Double?) async {
|
func add(ticker: String, shares: Int, costBasis: Double?) async {
|
||||||
var updated = positions
|
let updated = positions
|
||||||
struct AddBody: Encodable {
|
struct AddBody: Encodable {
|
||||||
let ticker: String
|
let ticker: String
|
||||||
let shares: Int
|
let shares: Int
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class PositionsViewModel: ObservableObject {
|
final class PositionsViewModel: ObservableObject {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class RecommendationsViewModel: ObservableObject {
|
final class RecommendationsViewModel: ObservableObject {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ struct OpenPositionsView: View {
|
|||||||
if !vm.openPositions.isEmpty {
|
if !vm.openPositions.isEmpty {
|
||||||
Section("Open") {
|
Section("Open") {
|
||||||
ForEach(vm.openPositions) { position in
|
ForEach(vm.openPositions) { position in
|
||||||
NavigationLink(destination: PositionDetailView(position: position, vm: vm)) {
|
NavigationLink(destination: PositionDetailView(position: position, parentVM: vm)) {
|
||||||
LoggedPositionRow(position: position)
|
LoggedPositionRow(position: position)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,17 @@ import SwiftUI
|
|||||||
|
|
||||||
struct PositionDetailView: View {
|
struct PositionDetailView: View {
|
||||||
let position: OptionPosition
|
let position: OptionPosition
|
||||||
@ObservedObject var vm: PositionsViewModel
|
/// Pass in from parent (OpenPositionsView) so close/roll updates the parent list.
|
||||||
|
/// If nil, a local vm is used (e.g. when navigating from DashboardView).
|
||||||
|
var parentVM: PositionsViewModel? = nil
|
||||||
|
@StateObject private var localVM = PositionsViewModel()
|
||||||
@State private var signals: SignalSnapshot? = nil
|
@State private var signals: SignalSnapshot? = nil
|
||||||
@State private var isLoadingSignals = false
|
@State private var isLoadingSignals = false
|
||||||
@State private var showCloseConfirm = false
|
@State private var showCloseConfirm = false
|
||||||
@State private var showRollConfirm = false
|
@State private var showRollConfirm = false
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
init(position: OptionPosition, vm: PositionsViewModel = PositionsViewModel()) {
|
private var vm: PositionsViewModel { parentVM ?? localVM }
|
||||||
self.position = position
|
|
||||||
self.vm = vm
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
|||||||
Reference in New Issue
Block a user