427 lines
14 KiB
Swift
427 lines
14 KiB
Swift
import SwiftUI
|
|
import Charts
|
|
|
|
struct SourceDetailView: View {
|
|
@EnvironmentObject var iapService: IAPService
|
|
@StateObject private var viewModel: SourceDetailViewModel
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@State private var showingDeleteConfirmation = false
|
|
|
|
init(source: InvestmentSource) {
|
|
_viewModel = StateObject(wrappedValue: SourceDetailViewModel(
|
|
source: source,
|
|
iapService: IAPService()
|
|
))
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 20) {
|
|
// Header Card
|
|
headerCard
|
|
|
|
// Quick Actions
|
|
quickActions
|
|
|
|
// Chart
|
|
if !viewModel.chartData.isEmpty {
|
|
chartSection
|
|
}
|
|
|
|
// Metrics
|
|
metricsSection
|
|
|
|
// Predictions (Premium)
|
|
if viewModel.hasEnoughDataForPredictions {
|
|
predictionsSection
|
|
}
|
|
|
|
// Snapshots List
|
|
snapshotsSection
|
|
}
|
|
.padding()
|
|
}
|
|
.navigationTitle(viewModel.source.name)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Menu {
|
|
Button {
|
|
viewModel.showingEditSource = true
|
|
} label: {
|
|
Label("Edit Source", systemImage: "pencil")
|
|
}
|
|
|
|
Button(role: .destructive) {
|
|
showingDeleteConfirmation = true
|
|
} label: {
|
|
Label("Delete Source", systemImage: "trash")
|
|
}
|
|
} label: {
|
|
Image(systemName: "ellipsis.circle")
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $viewModel.showingAddSnapshot) {
|
|
AddSnapshotView(source: viewModel.source)
|
|
}
|
|
.sheet(isPresented: $viewModel.showingEditSource) {
|
|
EditSourceView(source: viewModel.source)
|
|
}
|
|
.sheet(isPresented: $viewModel.showingPaywall) {
|
|
PaywallView()
|
|
}
|
|
.confirmationDialog(
|
|
"Delete Source",
|
|
isPresented: $showingDeleteConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Delete", role: .destructive) {
|
|
// Delete and dismiss
|
|
let repository = InvestmentSourceRepository()
|
|
repository.deleteSource(viewModel.source)
|
|
dismiss()
|
|
}
|
|
} message: {
|
|
Text("This will permanently delete \(viewModel.source.name) and all its snapshots.")
|
|
}
|
|
}
|
|
|
|
// MARK: - Header Card
|
|
|
|
private var headerCard: some View {
|
|
VStack(spacing: 12) {
|
|
// Category badge
|
|
HStack {
|
|
Image(systemName: viewModel.source.category?.icon ?? "questionmark")
|
|
Text(viewModel.categoryName)
|
|
}
|
|
.font(.caption)
|
|
.foregroundColor(Color(hex: viewModel.categoryColor) ?? .gray)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background((Color(hex: viewModel.categoryColor) ?? .gray).opacity(0.1))
|
|
.cornerRadius(20)
|
|
|
|
// Current value
|
|
Text(viewModel.formattedCurrentValue)
|
|
.font(.system(size: 36, weight: .bold, design: .rounded))
|
|
|
|
// Return
|
|
HStack(spacing: 4) {
|
|
Image(systemName: viewModel.isPositiveReturn ? "arrow.up.right" : "arrow.down.right")
|
|
Text(viewModel.formattedTotalReturn)
|
|
Text("(\(viewModel.formattedPercentageReturn))")
|
|
}
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundColor(viewModel.isPositiveReturn ? .positiveGreen : .negativeRed)
|
|
|
|
// Last updated
|
|
Text("Last updated: \(viewModel.lastUpdated)")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding()
|
|
.background(Color(.systemBackground))
|
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
|
}
|
|
|
|
// MARK: - Quick Actions
|
|
|
|
private var quickActions: some View {
|
|
HStack(spacing: 12) {
|
|
Button {
|
|
viewModel.showingAddSnapshot = true
|
|
} label: {
|
|
Label("Add Snapshot", systemImage: "plus.circle.fill")
|
|
.font(.subheadline.weight(.semibold))
|
|
.frame(maxWidth: .infinity)
|
|
.padding()
|
|
.background(Color.appPrimary)
|
|
.foregroundColor(.white)
|
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Chart Section
|
|
|
|
private var chartSection: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Value History")
|
|
.font(.headline)
|
|
|
|
Chart(viewModel.chartData, id: \.date) { item in
|
|
LineMark(
|
|
x: .value("Date", item.date),
|
|
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
|
)
|
|
.foregroundStyle(Color.appPrimary)
|
|
.interpolationMethod(.catmullRom)
|
|
|
|
AreaMark(
|
|
x: .value("Date", item.date),
|
|
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
|
)
|
|
.foregroundStyle(
|
|
LinearGradient(
|
|
colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
.interpolationMethod(.catmullRom)
|
|
}
|
|
.chartXAxis {
|
|
AxisMarks(values: .stride(by: .month, count: 2)) { value in
|
|
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
|
}
|
|
}
|
|
.chartYAxis {
|
|
AxisMarks(position: .leading) { value in
|
|
AxisValueLabel {
|
|
if let doubleValue = value.as(Double.self) {
|
|
Text(Decimal(doubleValue).shortCurrencyString)
|
|
.font(.caption)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(height: 200)
|
|
}
|
|
.padding()
|
|
.background(Color(.systemBackground))
|
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
|
}
|
|
|
|
// MARK: - Metrics Section
|
|
|
|
private var metricsSection: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Performance Metrics")
|
|
.font(.headline)
|
|
|
|
LazyVGrid(columns: [
|
|
GridItem(.flexible()),
|
|
GridItem(.flexible())
|
|
], spacing: 16) {
|
|
MetricCard(title: "CAGR", value: viewModel.metrics.formattedCAGR)
|
|
MetricCard(title: "Volatility", value: viewModel.metrics.formattedVolatility)
|
|
MetricCard(title: "Max Drawdown", value: viewModel.metrics.formattedMaxDrawdown)
|
|
MetricCard(title: "Sharpe Ratio", value: viewModel.metrics.formattedSharpeRatio)
|
|
MetricCard(title: "Win Rate", value: viewModel.metrics.formattedWinRate)
|
|
MetricCard(title: "Avg Monthly", value: viewModel.metrics.formattedAverageMonthlyReturn)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Color(.systemBackground))
|
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
|
}
|
|
|
|
// MARK: - Predictions Section
|
|
|
|
private var predictionsSection: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Text("Predictions")
|
|
.font(.headline)
|
|
|
|
Spacer()
|
|
|
|
if !viewModel.canViewPredictions {
|
|
Label("Premium", systemImage: "crown.fill")
|
|
.font(.caption)
|
|
.foregroundColor(.appWarning)
|
|
}
|
|
}
|
|
|
|
if viewModel.canViewPredictions {
|
|
if let result = viewModel.predictionResult, !viewModel.predictions.isEmpty {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Text("Algorithm: \(result.algorithm.displayName)")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
Spacer()
|
|
|
|
Text("Confidence: \(result.confidenceLevel.rawValue)")
|
|
.font(.caption)
|
|
.foregroundColor(Color(hex: result.confidenceLevel.color) ?? .gray)
|
|
}
|
|
|
|
// Show 12-month prediction
|
|
if let lastPrediction = viewModel.predictions.last {
|
|
HStack {
|
|
VStack(alignment: .leading) {
|
|
Text("12-Month Forecast")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
Text(lastPrediction.formattedValue)
|
|
.font(.title3.weight(.semibold))
|
|
}
|
|
|
|
Spacer()
|
|
|
|
VStack(alignment: .trailing) {
|
|
Text("Range")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
Text(lastPrediction.formattedConfidenceRange)
|
|
.font(.caption)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
Button {
|
|
viewModel.showingPaywall = true
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "lock.fill")
|
|
Text("Unlock Predictions with Premium")
|
|
}
|
|
.font(.subheadline)
|
|
.frame(maxWidth: .infinity)
|
|
.padding()
|
|
.background(Color.appPrimary.opacity(0.1))
|
|
.foregroundColor(.appPrimary)
|
|
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Color(.systemBackground))
|
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
|
}
|
|
|
|
// MARK: - Snapshots Section
|
|
|
|
private var snapshotsSection: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Text("Snapshots")
|
|
.font(.headline)
|
|
|
|
Spacer()
|
|
|
|
Text("\(viewModel.snapshotCount)")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
if viewModel.isHistoryLimited {
|
|
HStack {
|
|
Image(systemName: "info.circle")
|
|
.foregroundColor(.appWarning)
|
|
Text("\(viewModel.hiddenSnapshotCount) older snapshots hidden. Upgrade for full history.")
|
|
.font(.caption)
|
|
|
|
Spacer()
|
|
|
|
Button("Upgrade") {
|
|
viewModel.showingPaywall = true
|
|
}
|
|
.font(.caption.weight(.semibold))
|
|
}
|
|
.padding(8)
|
|
.background(Color.appWarning.opacity(0.1))
|
|
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
|
}
|
|
|
|
ForEach(viewModel.snapshots.prefix(10)) { snapshot in
|
|
SnapshotRowView(snapshot: snapshot)
|
|
.swipeActions(edge: .trailing) {
|
|
Button(role: .destructive) {
|
|
viewModel.deleteSnapshot(snapshot)
|
|
} label: {
|
|
Label("Delete", systemImage: "trash")
|
|
}
|
|
}
|
|
|
|
if snapshot.id != viewModel.snapshots.prefix(10).last?.id {
|
|
Divider()
|
|
}
|
|
}
|
|
|
|
if viewModel.snapshots.count > 10 {
|
|
Text("+ \(viewModel.snapshots.count - 10) more snapshots")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Color(.systemBackground))
|
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
|
}
|
|
}
|
|
|
|
// MARK: - Metric Card
|
|
|
|
struct MetricCard: View {
|
|
let title: String
|
|
let value: String
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(title)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
Text(value)
|
|
.font(.subheadline.weight(.semibold))
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
// MARK: - Snapshot Row View
|
|
|
|
struct SnapshotRowView: View {
|
|
let snapshot: Snapshot
|
|
|
|
var body: some View {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(snapshot.date.friendlyDescription)
|
|
.font(.subheadline)
|
|
|
|
if let notes = snapshot.notes, !notes.isEmpty {
|
|
Text(notes)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
Text(snapshot.formattedValue)
|
|
.font(.subheadline.weight(.medium))
|
|
|
|
if snapshot.contribution != nil && snapshot.decimalContribution > 0 {
|
|
Text("+ \(snapshot.decimalContribution.currencyString)")
|
|
.font(.caption)
|
|
.foregroundColor(.appPrimary)
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
Text("Source Detail Preview")
|
|
}
|
|
}
|