initial version
This commit is contained in:
@@ -0,0 +1,592 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct SourceDetailView: View {
|
||||
@StateObject private var viewModel: SourceDetailViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
||||
|
||||
@State private var showingDeleteConfirmation = false
|
||||
@State private var editingSnapshot: Snapshot?
|
||||
|
||||
init(source: InvestmentSource, iapService: IAPService) {
|
||||
_viewModel = StateObject(wrappedValue: SourceDetailViewModel(
|
||||
source: source,
|
||||
iapService: iapService
|
||||
))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// Header Card
|
||||
headerCard
|
||||
|
||||
// Quick Actions
|
||||
quickActions
|
||||
|
||||
// Chart
|
||||
if !viewModel.chartData.isEmpty {
|
||||
chartSection
|
||||
}
|
||||
|
||||
// Metrics
|
||||
if calmModeEnabled {
|
||||
simpleMetricsSection
|
||||
} else {
|
||||
metricsSection
|
||||
}
|
||||
|
||||
// Transactions
|
||||
transactionsSection
|
||||
|
||||
// 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(item: $editingSnapshot) { snapshot in
|
||||
AddSnapshotView(source: viewModel.source, snapshot: snapshot)
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showingAddTransaction) {
|
||||
AddTransactionView { type, date, shares, price, amount, notes in
|
||||
viewModel.addTransaction(
|
||||
type: type,
|
||||
date: date,
|
||||
shares: shares,
|
||||
price: price,
|
||||
amount: amount,
|
||||
notes: notes
|
||||
)
|
||||
}
|
||||
}
|
||||
.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
|
||||
if calmModeEnabled {
|
||||
VStack(spacing: 2) {
|
||||
Text("All-time return")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(viewModel.formattedTotalReturn) (\(viewModel.formattedPercentageReturn))")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.showingAddTransaction = true
|
||||
} label: {
|
||||
Label("Add Transaction", systemImage: "arrow.left.arrow.right.circle")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appSecondary.opacity(0.15))
|
||||
.foregroundColor(.appSecondary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chart Section
|
||||
|
||||
private var chartSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Value History")
|
||||
.font(.headline)
|
||||
|
||||
Chart {
|
||||
ForEach(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)
|
||||
}
|
||||
|
||||
if viewModel.canViewPredictions, !viewModel.predictions.isEmpty {
|
||||
ForEach(viewModel.predictions, id: \.id) { prediction in
|
||||
AreaMark(
|
||||
x: .value("Prediction Date", prediction.date),
|
||||
yStart: .value("Lower", NSDecimalNumber(decimal: prediction.confidenceInterval.lower).doubleValue),
|
||||
yEnd: .value("Upper", NSDecimalNumber(decimal: prediction.confidenceInterval.upper).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary.opacity(0.18))
|
||||
|
||||
LineMark(
|
||||
x: .value("Prediction Date", prediction.date),
|
||||
y: .value("Predicted Value", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2, dash: [6, 4]))
|
||||
}
|
||||
|
||||
if let firstPrediction = viewModel.predictions.first {
|
||||
PointMark(
|
||||
x: .value("Forecast Start", firstPrediction.date),
|
||||
y: .value("Forecast Value", NSDecimalNumber(decimal: firstPrediction.predictedValue).doubleValue)
|
||||
)
|
||||
.symbolSize(30)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.annotation(position: .topTrailing) {
|
||||
Text("Forecast")
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.appSecondary.opacity(0.15))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.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)
|
||||
|
||||
if !viewModel.canViewPredictions && viewModel.hasEnoughDataForPredictions {
|
||||
Button {
|
||||
viewModel.showingPaywall = true
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "lock.fill")
|
||||
Text("Unlock predictions on the chart")
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 10)
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.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)
|
||||
}
|
||||
|
||||
private var simpleMetricsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Performance Summary")
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
MetricChip(title: "CAGR", value: viewModel.metrics.formattedCAGR)
|
||||
MetricChip(title: "Avg Monthly", value: viewModel.metrics.formattedAverageMonthlyReturn)
|
||||
MetricChip(title: "Contributions", value: viewModel.source.totalContributions.compactCurrencyString)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
// MARK: - Transactions Section
|
||||
|
||||
private var transactionsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Transactions")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(viewModel.transactions.count)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if viewModel.transactions.isEmpty {
|
||||
Text("Add buys, sells, dividends, and fees to track cashflows.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
MetricChip(title: "Invested", value: viewModel.source.totalInvested.compactCurrencyString)
|
||||
MetricChip(title: "Dividends", value: viewModel.source.totalDividends.compactCurrencyString)
|
||||
MetricChip(title: "Fees", value: viewModel.source.totalFees.compactCurrencyString)
|
||||
}
|
||||
|
||||
ForEach(viewModel.transactions.prefix(5)) { transaction in
|
||||
TransactionRow(transaction: transaction)
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
viewModel.deleteTransaction(transaction)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.transactions.count > 5 {
|
||||
Text("+ \(viewModel.transactions.count - 5) more")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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.visibleSnapshots) { snapshot in
|
||||
SnapshotRowView(snapshot: snapshot, onEdit: {
|
||||
editingSnapshot = snapshot
|
||||
})
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
editingSnapshot = snapshot
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
editingSnapshot = snapshot
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button {
|
||||
editingSnapshot = snapshot
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
viewModel.deleteSnapshot(snapshot)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
if snapshot.id != viewModel.visibleSnapshots.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
|
||||
let onEdit: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if let onEdit = onEdit {
|
||||
Button(action: onEdit) {
|
||||
Image(systemName: "pencil")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(6)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Transaction Row View
|
||||
|
||||
struct TransactionRow: View {
|
||||
let transaction: Transaction
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(transaction.transactionType.displayName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
Text(transaction.date.friendlyDescription)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(transaction.decimalAmount.compactCurrencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(transaction.transactionType == .fee ? .negativeRed : .primary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
struct MetricChip: 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(.caption.weight(.semibold))
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 10)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
Text("Source Detail Preview")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user