- 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>
157 lines
6.5 KiB
Swift
157 lines
6.5 KiB
Swift
import SwiftUI
|
|
|
|
struct PositionDetailView: View {
|
|
let position: OptionPosition
|
|
/// 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
|
|
|
|
private var vm: PositionsViewModel { parentVM ?? localVM }
|
|
|
|
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
|
|
}
|
|
}
|