diff --git a/ios/OptionsSidekick/OptionsSidekick/AppDelegate.swift b/ios/OptionsSidekick/OptionsSidekick/AppDelegate.swift index 788c0e1..af54bf0 100644 --- a/ios/OptionsSidekick/OptionsSidekick/AppDelegate.swift +++ b/ios/OptionsSidekick/OptionsSidekick/AppDelegate.swift @@ -42,9 +42,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate { 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 + // Ensure token is stored before the request so APIClient can use it LocalStore.shared.deviceToken = token let response: DeviceResponse = try await APIClient.shared.requestNoAuth( .registerDevice, diff --git a/ios/OptionsSidekick/OptionsSidekick/Notifications/NotificationHandler.swift b/ios/OptionsSidekick/OptionsSidekick/Notifications/NotificationHandler.swift index b534bed..9e335ce 100644 --- a/ios/OptionsSidekick/OptionsSidekick/Notifications/NotificationHandler.swift +++ b/ios/OptionsSidekick/OptionsSidekick/Notifications/NotificationHandler.swift @@ -1,4 +1,5 @@ import Foundation +import Combine import UserNotifications /// Handles incoming push notifications — both foreground and background tap. diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick.xcodeproj/project.pbxproj b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick.xcodeproj/project.pbxproj new file mode 100644 index 0000000..01f1a92 --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick.xcodeproj/project.pbxproj @@ -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 = ""; + }; +/* 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 = ""; + }; + 683501E22F882DB900EECA1B /* Products */ = { + isa = PBXGroup; + children = ( + 683501E12F882DB900EECA1B /* OptionsSidekick.app */, + ); + name = Products; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick.xcodeproj/project.xcworkspace/xcuserdata/claw.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick.xcodeproj/project.xcworkspace/xcuserdata/claw.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..0dd3a61 Binary files /dev/null and b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick.xcodeproj/project.xcworkspace/xcuserdata/claw.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick.xcodeproj/xcuserdata/claw.xcuserdatad/xcschemes/xcschememanagement.plist b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick.xcodeproj/xcuserdata/claw.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..5daf5d2 --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick.xcodeproj/xcuserdata/claw.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + OptionsSidekick.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/AppDelegate.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/AppDelegate.swift new file mode 100644 index 0000000..788c0e1 --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/AppDelegate.swift @@ -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)") + } + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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 + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Assets.xcassets/Contents.json b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Config/Constants.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Config/Constants.swift new file mode 100644 index 0000000..2ee86d3 --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Config/Constants.swift @@ -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" +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/AppAlert.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/AppAlert.swift new file mode 100644 index 0000000..c980d48 --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/AppAlert.swift @@ -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" + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/OptionPosition.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/OptionPosition.swift new file mode 100644 index 0000000..31a9e5a --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/OptionPosition.swift @@ -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" + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/PortfolioPosition.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/PortfolioPosition.swift new file mode 100644 index 0000000..c0daf59 --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/PortfolioPosition.swift @@ -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" + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/Recommendation.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/Recommendation.swift new file mode 100644 index 0000000..b9f09cb --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Models/Recommendation.swift @@ -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 +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Networking/APIClient.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Networking/APIClient.swift new file mode 100644 index 0000000..11005d4 --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Networking/APIClient.swift @@ -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( + _ endpoint: Endpoint, + body: (some Encodable)? = Optional.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.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(_ endpoint: Endpoint, body: (some Encodable)? = Optional.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)" + } + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Networking/Endpoints.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Networking/Endpoints.swift new file mode 100644 index 0000000..e11257a --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Networking/Endpoints.swift @@ -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") +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Notifications/NotificationHandler.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Notifications/NotificationHandler.swift new file mode 100644 index 0000000..b534bed --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Notifications/NotificationHandler.swift @@ -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() + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Notifications/NotificationPermissions.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Notifications/NotificationPermissions.swift new file mode 100644 index 0000000..64649b8 --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Notifications/NotificationPermissions.swift @@ -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)") + } + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekickApp.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekickApp.swift new file mode 100644 index 0000000..0c207ed --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekickApp.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@main +struct OptionsSidekickApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(NotificationHandler.shared) + } + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Persistence/LocalStore.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Persistence/LocalStore.swift new file mode 100644 index 0000000..bbd7be5 --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Persistence/LocalStore.swift @@ -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") } + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Resources/Info.plist b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Resources/Info.plist new file mode 100644 index 0000000..9aad648 --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Resources/Info.plist @@ -0,0 +1,47 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Options Sidekick + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UIApplicationSupportsIndirectInputEvents + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + UIBackgroundModes + + remote-notification + + aps-environment + development + + diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/AlertsViewModel.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/AlertsViewModel.swift new file mode 100644 index 0000000..4e3924c --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/AlertsViewModel.swift @@ -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.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.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) + } + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/DashboardViewModel.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/DashboardViewModel.swift new file mode 100644 index 0000000..20f6762 --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/DashboardViewModel.swift @@ -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.none)) ?? [] + } + + private func loadOptions() async -> [OptionPosition] { + (try? await APIClient.shared.request(.getPositions(status: "open"), body: Optional.none)) ?? [] + } + + private func loadRecommendations() async -> [Recommendation] { + (try? await APIClient.shared.request(.getRecommendations(timeHorizon: nil), body: Optional.none)) ?? [] + } + + private func loadAlerts() async -> [AppAlert] { + (try? await APIClient.shared.request(.getAlerts(unreadOnly: true), body: Optional.none)) ?? [] + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/PortfolioViewModel.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/PortfolioViewModel.swift new file mode 100644 index 0000000..1b8ae0e --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/PortfolioViewModel.swift @@ -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.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.none) + positions.removeAll { $0.ticker == ticker } + } catch { + self.error = error.localizedDescription + } + isLoading = false + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/PositionsViewModel.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/PositionsViewModel.swift new file mode 100644 index 0000000..d8c1106 --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/PositionsViewModel.swift @@ -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.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 + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/RecommendationsViewModel.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/RecommendationsViewModel.swift new file mode 100644 index 0000000..fb26253 --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/ViewModels/RecommendationsViewModel.swift @@ -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.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.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.none + ) + } catch { + self.error = error.localizedDescription + return nil + } + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Alerts/AlertsView.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Alerts/AlertsView.swift new file mode 100644 index 0000000..44aeec8 --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Alerts/AlertsView.swift @@ -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) + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Common/LoadingErrorView.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Common/LoadingErrorView.swift new file mode 100644 index 0000000..a360406 --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Common/LoadingErrorView.swift @@ -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) + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Common/SignalBadge.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Common/SignalBadge.swift new file mode 100644 index 0000000..f0e30f9 --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Common/SignalBadge.swift @@ -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()) + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/ContentView.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/ContentView.swift new file mode 100644 index 0000000..34bb04c --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/ContentView.swift @@ -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 + } + } + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Dashboard/DashboardView.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Dashboard/DashboardView.swift new file mode 100644 index 0000000..06be6dc --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Dashboard/DashboardView.swift @@ -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) + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Portfolio/PortfolioView.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Portfolio/PortfolioView.swift new file mode 100644 index 0000000..417a7fb --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Portfolio/PortfolioView.swift @@ -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 } + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Positions/LogTradeSheet.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Positions/LogTradeSheet.swift new file mode 100644 index 0000000..0b6e2cd --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Positions/LogTradeSheet.swift @@ -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 } + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Positions/OpenPositionsView.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Positions/OpenPositionsView.swift new file mode 100644 index 0000000..c32c121 --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Positions/OpenPositionsView.swift @@ -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) + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Positions/PositionDetailView.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Positions/PositionDetailView.swift new file mode 100644 index 0000000..58e7de5 --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Positions/PositionDetailView.swift @@ -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.none + ) + } catch { + // Non-critical — just don't show signals + } + isLoadingSignals = false + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Recommendations/RecommendationDetailView.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Recommendations/RecommendationDetailView.swift new file mode 100644 index 0000000..9a0d67b --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Recommendations/RecommendationDetailView.swift @@ -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 + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Recommendations/RecommendationsView.swift b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Recommendations/RecommendationsView.swift new file mode 100644 index 0000000..a39449e --- /dev/null +++ b/ios/OptionsSidekick/OptionsSidekick/OptionsSidekick/OptionsSidekick/Views/Recommendations/RecommendationsView.swift @@ -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)) + } + } +} diff --git a/ios/OptionsSidekick/OptionsSidekick/ViewModels/AlertsViewModel.swift b/ios/OptionsSidekick/OptionsSidekick/ViewModels/AlertsViewModel.swift index 4e3924c..c8178a7 100644 --- a/ios/OptionsSidekick/OptionsSidekick/ViewModels/AlertsViewModel.swift +++ b/ios/OptionsSidekick/OptionsSidekick/ViewModels/AlertsViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import Combine @MainActor final class AlertsViewModel: ObservableObject { diff --git a/ios/OptionsSidekick/OptionsSidekick/ViewModels/PortfolioViewModel.swift b/ios/OptionsSidekick/OptionsSidekick/ViewModels/PortfolioViewModel.swift index 1b8ae0e..cd0893f 100644 --- a/ios/OptionsSidekick/OptionsSidekick/ViewModels/PortfolioViewModel.swift +++ b/ios/OptionsSidekick/OptionsSidekick/ViewModels/PortfolioViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import Combine @MainActor final class PortfolioViewModel: ObservableObject { @@ -35,7 +36,7 @@ final class PortfolioViewModel: ObservableObject { } func add(ticker: String, shares: Int, costBasis: Double?) async { - var updated = positions + let updated = positions struct AddBody: Encodable { let ticker: String let shares: Int diff --git a/ios/OptionsSidekick/OptionsSidekick/ViewModels/PositionsViewModel.swift b/ios/OptionsSidekick/OptionsSidekick/ViewModels/PositionsViewModel.swift index d8c1106..32e97bf 100644 --- a/ios/OptionsSidekick/OptionsSidekick/ViewModels/PositionsViewModel.swift +++ b/ios/OptionsSidekick/OptionsSidekick/ViewModels/PositionsViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import Combine @MainActor final class PositionsViewModel: ObservableObject { diff --git a/ios/OptionsSidekick/OptionsSidekick/ViewModels/RecommendationsViewModel.swift b/ios/OptionsSidekick/OptionsSidekick/ViewModels/RecommendationsViewModel.swift index fb26253..c1ae4b3 100644 --- a/ios/OptionsSidekick/OptionsSidekick/ViewModels/RecommendationsViewModel.swift +++ b/ios/OptionsSidekick/OptionsSidekick/ViewModels/RecommendationsViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import Combine @MainActor final class RecommendationsViewModel: ObservableObject { diff --git a/ios/OptionsSidekick/OptionsSidekick/Views/Positions/OpenPositionsView.swift b/ios/OptionsSidekick/OptionsSidekick/Views/Positions/OpenPositionsView.swift index c32c121..095fa5a 100644 --- a/ios/OptionsSidekick/OptionsSidekick/Views/Positions/OpenPositionsView.swift +++ b/ios/OptionsSidekick/OptionsSidekick/Views/Positions/OpenPositionsView.swift @@ -22,7 +22,7 @@ struct OpenPositionsView: View { if !vm.openPositions.isEmpty { Section("Open") { ForEach(vm.openPositions) { position in - NavigationLink(destination: PositionDetailView(position: position, vm: vm)) { + NavigationLink(destination: PositionDetailView(position: position, parentVM: vm)) { LoggedPositionRow(position: position) } } diff --git a/ios/OptionsSidekick/OptionsSidekick/Views/Positions/PositionDetailView.swift b/ios/OptionsSidekick/OptionsSidekick/Views/Positions/PositionDetailView.swift index 58e7de5..6e2de66 100644 --- a/ios/OptionsSidekick/OptionsSidekick/Views/Positions/PositionDetailView.swift +++ b/ios/OptionsSidekick/OptionsSidekick/Views/Positions/PositionDetailView.swift @@ -2,17 +2,17 @@ import SwiftUI struct PositionDetailView: View { 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 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 - } + private var vm: PositionsViewModel { parentVM ?? localVM } var body: some View { ScrollView {