Fix 25 Xcode build errors and 2 warnings

- Add `import Combine` to PortfolioViewModel, RecommendationsViewModel,
  PositionsViewModel, AlertsViewModel, and NotificationHandler — required
  for @Published and ObservableObject to resolve correctly in Swift
- Fix @MainActor isolation error in PositionDetailView: replace broken
  default-parameter init (PositionsViewModel() in sync context) with
  @StateObject private var localVM and an optional parentVM parameter
- Update OpenPositionsView call site to use new parentVM: label
- Fix var→let warning in PortfolioViewModel.add()
- Remove unused `old` variable in AppDelegate.registerDevice()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 15:46:16 -04:00
parent b7d4e900cc
commit 14d715ed14
44 changed files with 2734 additions and 10 deletions

View File

@@ -42,9 +42,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
apns_token: token, apns_token: token,
device_name: UIDevice.current.name device_name: UIDevice.current.name
) )
// Temporarily set the token so APIClient has it, but use requestNoAuth // Ensure token is stored before the request so APIClient can use it
// since this is the registration call itself
let old = LocalStore.shared.deviceToken
LocalStore.shared.deviceToken = token LocalStore.shared.deviceToken = token
let response: DeviceResponse = try await APIClient.shared.requestNoAuth( let response: DeviceResponse = try await APIClient.shared.requestNoAuth(
.registerDevice, .registerDevice,

View File

@@ -1,4 +1,5 @@
import Foundation import Foundation
import Combine
import UserNotifications import UserNotifications
/// Handles incoming push notifications both foreground and background tap. /// Handles incoming push notifications both foreground and background tap.

View File

@@ -0,0 +1,348 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXFileReference section */
683501E12F882DB900EECA1B /* OptionsSidekick.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OptionsSidekick.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
683502382F88384800EECA1B /* Exceptions for "OptionsSidekick" folder in "OptionsSidekick" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Resources/Info.plist,
);
target = 683501E02F882DB900EECA1B /* OptionsSidekick */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
683501E32F882DB900EECA1B /* OptionsSidekick */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
683502382F88384800EECA1B /* Exceptions for "OptionsSidekick" folder in "OptionsSidekick" target */,
);
path = OptionsSidekick;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
683501DE2F882DB900EECA1B /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
683501D82F882DB900EECA1B = {
isa = PBXGroup;
children = (
683501E32F882DB900EECA1B /* OptionsSidekick */,
683501E22F882DB900EECA1B /* Products */,
);
sourceTree = "<group>";
};
683501E22F882DB900EECA1B /* Products */ = {
isa = PBXGroup;
children = (
683501E12F882DB900EECA1B /* OptionsSidekick.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
683501E02F882DB900EECA1B /* OptionsSidekick */ = {
isa = PBXNativeTarget;
buildConfigurationList = 683501EC2F882DBA00EECA1B /* Build configuration list for PBXNativeTarget "OptionsSidekick" */;
buildPhases = (
683501DD2F882DB900EECA1B /* Sources */,
683501DE2F882DB900EECA1B /* Frameworks */,
683501DF2F882DB900EECA1B /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
683501E32F882DB900EECA1B /* OptionsSidekick */,
);
name = OptionsSidekick;
packageProductDependencies = (
);
productName = OptionsSidekick;
productReference = 683501E12F882DB900EECA1B /* OptionsSidekick.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
683501D92F882DB900EECA1B /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2620;
LastUpgradeCheck = 2620;
TargetAttributes = {
683501E02F882DB900EECA1B = {
CreatedOnToolsVersion = 26.2;
};
};
};
buildConfigurationList = 683501DC2F882DB900EECA1B /* Build configuration list for PBXProject "OptionsSidekick" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 683501D82F882DB900EECA1B;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = 683501E22F882DB900EECA1B /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
683501E02F882DB900EECA1B /* OptionsSidekick */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
683501DF2F882DB900EECA1B /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
683501DD2F882DB900EECA1B /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
683501EA2F882DBA00EECA1B /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
683501EB2F882DBA00EECA1B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
683501ED2F882DBA00EECA1B /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = OptionsSidekick/Resources/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = olson2cm.OptionsSidekick;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
683501EE2F882DBA00EECA1B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = OptionsSidekick/Resources/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = olson2cm.OptionsSidekick;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
683501DC2F882DB900EECA1B /* Build configuration list for PBXProject "OptionsSidekick" */ = {
isa = XCConfigurationList;
buildConfigurations = (
683501EA2F882DBA00EECA1B /* Debug */,
683501EB2F882DBA00EECA1B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
683501EC2F882DBA00EECA1B /* Build configuration list for PBXNativeTarget "OptionsSidekick" */ = {
isa = XCConfigurationList;
buildConfigurations = (
683501ED2F882DBA00EECA1B /* Debug */,
683501EE2F882DBA00EECA1B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 683501D92F882DB900EECA1B /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>OptionsSidekick.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

@@ -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)")
}
}
}

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -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"
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,144 @@
import Foundation
/// Central networking client.
/// Adds X-Device-Token header automatically from LocalStore.
/// All requests are async/await.
final class APIClient {
static let shared = APIClient()
private init() {}
private let session: URLSession = {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15
config.timeoutIntervalForResource = 30
return URLSession(configuration: config)
}()
private var decoder: JSONDecoder = {
let d = JSONDecoder()
d.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let str = try container.decode(String.self)
// Try ISO8601 with fractional seconds first, then without
let formatters: [ISO8601DateFormatter] = [
{
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}(),
{
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}(),
]
for fmt in formatters {
if let date = fmt.date(from: str) { return date }
}
// Try plain date (YYYY-MM-DD) for date-only fields decoded as Date
let df = DateFormatter()
df.dateFormat = "yyyy-MM-dd"
if let date = df.date(from: str) { return date }
throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Cannot parse date: \(str)"))
}
return d
}()
private var encoder: JSONEncoder = {
let e = JSONEncoder()
e.dateEncodingStrategy = .iso8601
return e
}()
// Core request builder
func request<T: Decodable>(
_ endpoint: Endpoint,
body: (some Encodable)? = Optional<EmptyBody>.none
) async throws -> T {
guard let token = LocalStore.shared.deviceToken else {
throw APIError.noDeviceToken
}
let req = try buildRequest(endpoint, deviceToken: token, body: body)
let (data, response) = try await session.data(for: req)
try validateResponse(response, data: data)
return try decoder.decode(T.self, from: data)
}
/// Version that doesn't return a body (e.g. DELETE 204)
func requestVoid(_ endpoint: Endpoint, body: (some Encodable)? = Optional<EmptyBody>.none) async throws {
guard let token = LocalStore.shared.deviceToken else {
throw APIError.noDeviceToken
}
let req = try buildRequest(endpoint, deviceToken: token, body: body)
let (data, response) = try await session.data(for: req)
try validateResponse(response, data: data)
}
/// For device registration (no token yet)
func requestNoAuth<T: Decodable>(_ endpoint: Endpoint, body: (some Encodable)? = Optional<EmptyBody>.none) async throws -> T {
let req = try buildRequest(endpoint, deviceToken: nil, body: body)
let (data, response) = try await session.data(for: req)
try validateResponse(response, data: data)
return try decoder.decode(T.self, from: data)
}
// Helpers
private func buildRequest(_ endpoint: Endpoint, deviceToken: String?, body: (some Encodable)?) throws -> URLRequest {
guard let url = URL(string: Constants.apiBaseURL + endpoint.path) else {
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = endpoint.method
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let token = deviceToken {
request.setValue(token, forHTTPHeaderField: "X-Device-Token")
}
if let body {
request.httpBody = try encoder.encode(body)
}
return request
}
private func validateResponse(_ response: URLResponse, data: Data) throws {
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
switch httpResponse.statusCode {
case 200...299:
break
case 404:
throw APIError.notFound
case 503:
throw APIError.serviceUnavailable
default:
let message = String(data: data, encoding: .utf8) ?? "Unknown error"
throw APIError.serverError(httpResponse.statusCode, message)
}
}
}
// Supporting types
private struct EmptyBody: Encodable {}
enum APIError: LocalizedError {
case noDeviceToken
case invalidURL
case invalidResponse
case notFound
case serviceUnavailable
case serverError(Int, String)
var errorDescription: String? {
switch self {
case .noDeviceToken: return "Device not registered. Please restart the app."
case .invalidURL: return "Invalid API URL."
case .invalidResponse: return "Invalid response from server."
case .notFound: return "Resource not found."
case .serviceUnavailable: return "Market data unavailable. Try again shortly."
case .serverError(let code, let msg): return "Server error \(code): \(msg)"
}
}
}

View File

@@ -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")
}

View File

@@ -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()
}
}

View File

@@ -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)")
}
}
}

View File

@@ -0,0 +1,13 @@
import SwiftUI
@main
struct OptionsSidekickApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(NotificationHandler.shared)
}
}
}

View File

@@ -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") }
}
}

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Options Sidekick</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>

View File

@@ -0,0 +1,46 @@
import Foundation
@MainActor
final class AlertsViewModel: ObservableObject {
@Published var alerts: [AppAlert] = []
@Published var isLoading = false
@Published var error: String? = nil
var unreadCount: Int { alerts.filter { !$0.acknowledged }.count }
func load(unreadOnly: Bool = false) async {
isLoading = true
error = nil
do {
alerts = try await APIClient.shared.request(
.getAlerts(unreadOnly: unreadOnly),
body: Optional<String>.none
)
LocalStore.shared.unreadAlertCount = unreadCount
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func acknowledge(_ alert: AppAlert) async {
do {
let updated: AppAlert = try await APIClient.shared.request(
.acknowledgeAlert(alert.id),
body: Optional<String>.none
)
if let idx = alerts.firstIndex(where: { $0.id == alert.id }) {
alerts[idx] = updated
}
LocalStore.shared.unreadAlertCount = unreadCount
} catch {
self.error = error.localizedDescription
}
}
func acknowledgeAll() async {
for alert in alerts where !alert.acknowledged {
await acknowledge(alert)
}
}
}

View File

@@ -0,0 +1,62 @@
import Foundation
import Combine
@MainActor
final class DashboardViewModel: ObservableObject {
@Published var stockPositions: [PortfolioPosition] = []
@Published var openOptionPositions: [OptionPosition] = []
@Published var recommendations: [Recommendation] = []
@Published var unreadAlerts: [AppAlert] = []
@Published var isLoading = false
@Published var error: String? = nil
var urgentAlerts: [AppAlert] { unreadAlerts.filter { $0.isUrgent } }
/// Top-level recommendation per ticker (best signal strength)
var topRecommendations: [Recommendation] {
let order = ["strong": 0, "moderate": 1, "weak": 2]
var best: [String: Recommendation] = [:]
for rec in recommendations {
let current = best[rec.ticker]
if current == nil || (order[rec.signalStrength] ?? 2) < (order[current!.signalStrength] ?? 2) {
best[rec.ticker] = rec
}
}
return Array(best.values).sorted { $0.ticker < $1.ticker }
}
func loadAll() async {
isLoading = true
error = nil
async let stocks: [PortfolioPosition] = loadStocks()
async let options: [OptionPosition] = loadOptions()
async let recs: [Recommendation] = loadRecommendations()
async let alerts: [AppAlert] = loadAlerts()
(stockPositions, openOptionPositions, recommendations, unreadAlerts) = await (stocks, options, recs, alerts)
isLoading = false
}
func refresh() async {
await loadAll()
}
// Private loaders
private func loadStocks() async -> [PortfolioPosition] {
(try? await APIClient.shared.request(.getPortfolio, body: Optional<String>.none)) ?? []
}
private func loadOptions() async -> [OptionPosition] {
(try? await APIClient.shared.request(.getPositions(status: "open"), body: Optional<String>.none)) ?? []
}
private func loadRecommendations() async -> [Recommendation] {
(try? await APIClient.shared.request(.getRecommendations(timeHorizon: nil), body: Optional<String>.none)) ?? []
}
private func loadAlerts() async -> [AppAlert] {
(try? await APIClient.shared.request(.getAlerts(unreadOnly: true), body: Optional<String>.none)) ?? []
}
}

View File

@@ -0,0 +1,67 @@
import Foundation
@MainActor
final class PortfolioViewModel: ObservableObject {
@Published var positions: [PortfolioPosition] = []
@Published var isLoading = false
@Published var error: String? = nil
func load() async {
isLoading = true
error = nil
do {
positions = try await APIClient.shared.request(.getPortfolio, body: Optional<String>.none)
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func save(_ newPositions: [PortfolioPosition]) async {
isLoading = true
error = nil
struct PositionBody: Encodable {
let ticker: String
let shares: Int
let cost_basis: Double?
}
let body = newPositions.map { PositionBody(ticker: $0.ticker, shares: $0.shares, cost_basis: $0.costBasis) }
do {
positions = try await APIClient.shared.request(.setPortfolio, body: body)
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func add(ticker: String, shares: Int, costBasis: Double?) async {
var updated = positions
struct AddBody: Encodable {
let ticker: String
let shares: Int
let cost_basis: Double?
}
let allBody = updated.map { AddBody(ticker: $0.ticker, shares: $0.shares, cost_basis: $0.costBasis) }
+ [AddBody(ticker: ticker.uppercased(), shares: shares, cost_basis: costBasis)]
isLoading = true
error = nil
do {
positions = try await APIClient.shared.request(.setPortfolio, body: allBody)
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func delete(ticker: String) async {
isLoading = true
error = nil
do {
try await APIClient.shared.requestVoid(.deleteTicker(ticker), body: Optional<String>.none)
positions.removeAll { $0.ticker == ticker }
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}

View File

@@ -0,0 +1,76 @@
import Foundation
@MainActor
final class PositionsViewModel: ObservableObject {
@Published var positions: [OptionPosition] = []
@Published var isLoading = false
@Published var error: String? = nil
var openPositions: [OptionPosition] { positions.filter { $0.status == "open" } }
var closedPositions: [OptionPosition] { positions.filter { $0.status != "open" } }
func load() async {
isLoading = true
error = nil
do {
positions = try await APIClient.shared.request(
.getPositions(status: nil),
body: Optional<String>.none
)
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func log(create: OptionPositionCreate) async -> Bool {
isLoading = true
error = nil
do {
let new: OptionPosition = try await APIClient.shared.request(.logPosition, body: create)
positions.insert(new, at: 0)
isLoading = false
return true
} catch {
self.error = error.localizedDescription
isLoading = false
return false
}
}
func close(position: OptionPosition, reason: String) async {
isLoading = true
error = nil
let body = OptionPositionClose(status: "closed", closeReason: reason)
do {
let updated: OptionPosition = try await APIClient.shared.request(
.closePosition(position.id),
body: body
)
if let idx = positions.firstIndex(where: { $0.id == position.id }) {
positions[idx] = updated
}
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func roll(position: OptionPosition) async {
let body = OptionPositionClose(status: "rolled", closeReason: "rolled")
isLoading = true
error = nil
do {
let updated: OptionPosition = try await APIClient.shared.request(
.closePosition(position.id),
body: body
)
if let idx = positions.firstIndex(where: { $0.id == position.id }) {
positions[idx] = updated
}
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}

View File

@@ -0,0 +1,57 @@
import Foundation
@MainActor
final class RecommendationsViewModel: ObservableObject {
@Published var recommendations: [Recommendation] = []
@Published var isLoading = false
@Published var isRefreshing = false
@Published var error: String? = nil
@Published var selectedHorizon: String = "weekly"
@Published var selectedStrategy: String = "covered_call"
var filtered: [Recommendation] {
recommendations.filter {
$0.timeHorizon == selectedHorizon && $0.strategy == selectedStrategy
}
}
func load() async {
isLoading = true
error = nil
do {
recommendations = try await APIClient.shared.request(
.getRecommendations(timeHorizon: nil),
body: Optional<String>.none
)
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func refresh() async {
isRefreshing = true
error = nil
do {
recommendations = try await APIClient.shared.request(
.refreshRecommendations,
body: Optional<String>.none
)
} catch {
self.error = error.localizedDescription
}
isRefreshing = false
}
func getDetail(ticker: String) async -> RecommendationWithSignals? {
do {
return try await APIClient.shared.request(
.getRecommendation(ticker: ticker, strategy: selectedStrategy, timeHorizon: selectedHorizon),
body: Optional<String>.none
)
} catch {
self.error = error.localizedDescription
return nil
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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())
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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 }
}
}

View File

@@ -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 }
}
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,156 @@
import SwiftUI
struct PositionDetailView: View {
let position: OptionPosition
@ObservedObject var vm: PositionsViewModel
@State private var signals: SignalSnapshot? = nil
@State private var isLoadingSignals = false
@State private var showCloseConfirm = false
@State private var showRollConfirm = false
@Environment(\.dismiss) var dismiss
init(position: OptionPosition, vm: PositionsViewModel = PositionsViewModel()) {
self.position = position
self.vm = vm
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// Position summary
summarySection
Divider()
// Current signals
if isLoadingSignals {
HStack {
ProgressView()
Text("Loading signals...").font(.caption).foregroundStyle(.secondary)
}
.padding()
} else if let signals {
signalsSection(signals)
Divider()
}
// Actions
if position.status == "open" {
actionSection
}
}
.padding()
}
.navigationTitle("\(position.ticker) \(position.strategyLabel)")
.navigationBarTitleDisplayMode(.inline)
.task { await loadSignals() }
.confirmationDialog("Close Position", isPresented: $showCloseConfirm) {
Button("Bought Back", role: .destructive) {
Task { await vm.close(position: position, reason: "bought_back"); dismiss() }
}
Button("Expired Worthless") {
Task { await vm.close(position: position, reason: "expired"); dismiss() }
}
}
.confirmationDialog("Roll Position", isPresented: $showRollConfirm) {
Button("Mark as Rolled", role: .destructive) {
Task { await vm.roll(position: position); dismiss() }
}
}
}
// Sections
private var summarySection: some View {
VStack(alignment: .leading, spacing: 10) {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
metricCard("Strike", String(format: "$%.2f", position.strike))
metricCard("Expiration", position.expiration)
metricCard("Premium", String(format: "$%.2f", position.premiumReceived))
metricCard("Contracts", "\(position.contracts)")
metricCard("Total Credit", String(format: "$%.2f", position.totalCredit))
if let dte = position.daysToExpiry {
metricCard("Days Left", "\(dte)d", highlight: dte <= 5)
}
}
}
}
private func signalsSection(_ snap: SignalSnapshot) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text("Current Signals")
.font(.headline)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
metricCard("IV Rank", String(format: "%.0f%%", snap.ivRank), highlight: snap.ivRank >= 50)
metricCard("Trend", snap.trend.capitalized)
if let support = snap.nearestSupport {
metricCard("Support", String(format: "$%.2f", support))
}
if let resistance = snap.nearestResistance {
metricCard("Resistance", String(format: "$%.2f", resistance))
}
}
if let earnings = snap.earningsDate {
HStack(spacing: 6) {
Image(systemName: "calendar.badge.exclamationmark")
.foregroundStyle(Constants.Color.warning)
Text("Earnings: \(earnings)")
.font(.caption)
.foregroundStyle(Constants.Color.warning)
}
}
}
}
private var actionSection: some View {
VStack(spacing: 10) {
Button {
showCloseConfirm = true
} label: {
Label("Close Position", systemImage: "xmark.circle")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(Constants.Color.destructive)
Button {
showRollConfirm = true
} label: {
Label("Roll Position", systemImage: "arrow.2.circlepath")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(Constants.Color.accent)
}
}
// Helpers
private func metricCard(_ label: String, _ value: String, highlight: Bool = false) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.subheadline.weight(.semibold))
.foregroundStyle(highlight ? Constants.Color.strong : .primary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8))
}
private func loadSignals() async {
isLoadingSignals = true
do {
signals = try await APIClient.shared.request(
.getSignals(position.ticker),
body: Optional<String>.none
)
} catch {
// Non-critical just don't show signals
}
isLoadingSignals = false
}
}

View File

@@ -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
}
}

View File

@@ -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))
}
}
}

View File

@@ -1,4 +1,5 @@
import Foundation import Foundation
import Combine
@MainActor @MainActor
final class AlertsViewModel: ObservableObject { final class AlertsViewModel: ObservableObject {

View File

@@ -1,4 +1,5 @@
import Foundation import Foundation
import Combine
@MainActor @MainActor
final class PortfolioViewModel: ObservableObject { final class PortfolioViewModel: ObservableObject {
@@ -35,7 +36,7 @@ final class PortfolioViewModel: ObservableObject {
} }
func add(ticker: String, shares: Int, costBasis: Double?) async { func add(ticker: String, shares: Int, costBasis: Double?) async {
var updated = positions let updated = positions
struct AddBody: Encodable { struct AddBody: Encodable {
let ticker: String let ticker: String
let shares: Int let shares: Int

View File

@@ -1,4 +1,5 @@
import Foundation import Foundation
import Combine
@MainActor @MainActor
final class PositionsViewModel: ObservableObject { final class PositionsViewModel: ObservableObject {

View File

@@ -1,4 +1,5 @@
import Foundation import Foundation
import Combine
@MainActor @MainActor
final class RecommendationsViewModel: ObservableObject { final class RecommendationsViewModel: ObservableObject {

View File

@@ -22,7 +22,7 @@ struct OpenPositionsView: View {
if !vm.openPositions.isEmpty { if !vm.openPositions.isEmpty {
Section("Open") { Section("Open") {
ForEach(vm.openPositions) { position in ForEach(vm.openPositions) { position in
NavigationLink(destination: PositionDetailView(position: position, vm: vm)) { NavigationLink(destination: PositionDetailView(position: position, parentVM: vm)) {
LoggedPositionRow(position: position) LoggedPositionRow(position: position)
} }
} }

View File

@@ -2,17 +2,17 @@ import SwiftUI
struct PositionDetailView: View { struct PositionDetailView: View {
let position: OptionPosition let position: OptionPosition
@ObservedObject var vm: PositionsViewModel /// Pass in from parent (OpenPositionsView) so close/roll updates the parent list.
/// If nil, a local vm is used (e.g. when navigating from DashboardView).
var parentVM: PositionsViewModel? = nil
@StateObject private var localVM = PositionsViewModel()
@State private var signals: SignalSnapshot? = nil @State private var signals: SignalSnapshot? = nil
@State private var isLoadingSignals = false @State private var isLoadingSignals = false
@State private var showCloseConfirm = false @State private var showCloseConfirm = false
@State private var showRollConfirm = false @State private var showRollConfirm = false
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
init(position: OptionPosition, vm: PositionsViewModel = PositionsViewModel()) { private var vm: PositionsViewModel { parentVM ?? localVM }
self.position = position
self.vm = vm
}
var body: some View { var body: some View {
ScrollView { ScrollView {