331 lines
11 KiB
Swift
331 lines
11 KiB
Swift
import SwiftUI
|
|
|
|
struct SourceListView: View {
|
|
@EnvironmentObject var iapService: IAPService
|
|
@EnvironmentObject var accountStore: AccountStore
|
|
@StateObject private var viewModel: SourceListViewModel
|
|
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
|
|
|
init(iapService: IAPService) {
|
|
_viewModel = StateObject(wrappedValue: SourceListViewModel(iapService: iapService))
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
AppBackground()
|
|
|
|
Group {
|
|
if viewModel.isEmpty {
|
|
emptyStateView
|
|
} else {
|
|
sourcesList
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Sources")
|
|
.searchable(text: $viewModel.searchText, prompt: "Search sources")
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button {
|
|
viewModel.addSourceTapped()
|
|
} label: {
|
|
Image(systemName: "plus")
|
|
}
|
|
}
|
|
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
categoryFilterMenu
|
|
}
|
|
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
accountFilterMenu
|
|
}
|
|
}
|
|
.sheet(isPresented: $viewModel.showingAddSource) {
|
|
AddSourceView()
|
|
}
|
|
.sheet(isPresented: $viewModel.showingPaywall) {
|
|
PaywallView()
|
|
}
|
|
.onAppear {
|
|
viewModel.selectedAccount = accountStore.selectedAccount
|
|
viewModel.showAllAccounts = accountStore.showAllAccounts
|
|
}
|
|
.onReceive(accountStore.$selectedAccount) { account in
|
|
viewModel.selectedAccount = account
|
|
}
|
|
.onReceive(accountStore.$showAllAccounts) { showAll in
|
|
viewModel.showAllAccounts = showAll
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Sources List
|
|
|
|
private var sourcesList: some View {
|
|
List {
|
|
// Summary Header
|
|
if !viewModel.isFiltered {
|
|
Section {
|
|
HStack {
|
|
VStack(alignment: .leading) {
|
|
Text("Total Value")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
Text(viewModel.formattedTotalValue)
|
|
.font(.title2.weight(.bold))
|
|
}
|
|
|
|
Spacer()
|
|
|
|
VStack(alignment: .trailing) {
|
|
Text("Sources")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
Text("\(viewModel.sources.count)")
|
|
.font(.title2.weight(.bold))
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
|
|
// Source limit warning
|
|
if viewModel.sourceLimitReached {
|
|
Section {
|
|
HStack {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundColor(.appWarning)
|
|
|
|
Text("Source limit reached. Upgrade to Premium for unlimited sources.")
|
|
.font(.subheadline)
|
|
|
|
Spacer()
|
|
|
|
Button("Upgrade") {
|
|
viewModel.showingPaywall = true
|
|
}
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundColor(.appPrimary)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sources
|
|
Section {
|
|
ForEach(viewModel.sources) { source in
|
|
NavigationLink {
|
|
SourceDetailView(source: source, iapService: iapService)
|
|
} label: {
|
|
SourceRowView(source: source, calmModeEnabled: calmModeEnabled)
|
|
}
|
|
}
|
|
.onDelete { indexSet in
|
|
viewModel.deleteSource(at: indexSet)
|
|
}
|
|
} header: {
|
|
if viewModel.isFiltered {
|
|
HStack {
|
|
Text("\(viewModel.sources.count) results")
|
|
Spacer()
|
|
Button("Clear Filters") {
|
|
viewModel.clearFilters()
|
|
}
|
|
.font(.caption)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.insetGrouped)
|
|
.scrollContentBackground(.hidden)
|
|
.refreshable {
|
|
viewModel.loadData()
|
|
}
|
|
}
|
|
|
|
// MARK: - Empty State
|
|
|
|
private var emptyStateView: some View {
|
|
VStack(spacing: 20) {
|
|
Image(systemName: "tray")
|
|
.font(.system(size: 60))
|
|
.foregroundColor(.secondary)
|
|
|
|
Text("No Investment Sources")
|
|
.font(.custom("Avenir Next", size: 24).weight(.semibold))
|
|
|
|
Text("Add your first investment source to start tracking your portfolio.")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, 40)
|
|
|
|
Button {
|
|
viewModel.showingAddSource = true
|
|
} label: {
|
|
Label("Add Source", systemImage: "plus")
|
|
.font(.headline)
|
|
.foregroundColor(.white)
|
|
.padding()
|
|
.frame(maxWidth: 200)
|
|
.background(Color.appPrimary)
|
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Category Filter Menu
|
|
|
|
private var categoryFilterMenu: some View {
|
|
Menu {
|
|
Button {
|
|
viewModel.selectCategory(nil)
|
|
} label: {
|
|
HStack {
|
|
Text("All Categories")
|
|
if viewModel.selectedCategory == nil {
|
|
Image(systemName: "checkmark")
|
|
}
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
|
|
ForEach(viewModel.categories) { category in
|
|
Button {
|
|
viewModel.selectCategory(category)
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: category.icon)
|
|
Text(category.name)
|
|
if viewModel.selectedCategory?.id == category.id {
|
|
Image(systemName: "checkmark")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "line.3.horizontal.decrease.circle")
|
|
if viewModel.selectedCategory != nil {
|
|
Circle()
|
|
.fill(Color.appPrimary)
|
|
.frame(width: 8, height: 8)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Account Filter Menu
|
|
|
|
private var accountFilterMenu: some View {
|
|
Menu {
|
|
Button {
|
|
accountStore.selectAllAccounts()
|
|
} label: {
|
|
HStack {
|
|
Text("All Accounts")
|
|
if accountStore.showAllAccounts {
|
|
Image(systemName: "checkmark")
|
|
}
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
|
|
ForEach(accountStore.accounts) { account in
|
|
Button {
|
|
accountStore.selectAccount(account)
|
|
} label: {
|
|
HStack {
|
|
Text(account.name)
|
|
if accountStore.selectedAccount?.id == account.id && !accountStore.showAllAccounts {
|
|
Image(systemName: "checkmark")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "person.2")
|
|
if !accountStore.showAllAccounts {
|
|
Circle()
|
|
.fill(Color.appPrimary)
|
|
.frame(width: 8, height: 8)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Source Row View
|
|
|
|
struct SourceRowView: View {
|
|
@ObservedObject var source: InvestmentSource
|
|
let calmModeEnabled: Bool
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
// Category indicator
|
|
ZStack {
|
|
Circle()
|
|
.fill((source.category?.color ?? .gray).opacity(0.2))
|
|
.frame(width: 44, height: 44)
|
|
|
|
Image(systemName: source.category?.icon ?? "questionmark")
|
|
.font(.system(size: 18))
|
|
.foregroundColor(source.category?.color ?? .gray)
|
|
}
|
|
|
|
// Source info
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(source.name)
|
|
.font(.headline)
|
|
|
|
HStack(spacing: 8) {
|
|
Text(source.category?.name ?? "Uncategorized")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
if source.needsUpdate {
|
|
Label("Needs update", systemImage: "bell.badge.fill")
|
|
.font(.caption2)
|
|
.foregroundColor(.appWarning)
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Value
|
|
VStack(alignment: .trailing, spacing: 4) {
|
|
Text(source.latestValue.compactCurrencyString)
|
|
.font(.subheadline.weight(.semibold))
|
|
|
|
if calmModeEnabled {
|
|
Text(String(format: "%.1f%%", NSDecimalNumber(decimal: source.totalReturn).doubleValue))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
} else {
|
|
HStack(spacing: 2) {
|
|
Image(systemName: source.totalReturn >= 0 ? "arrow.up.right" : "arrow.down.right")
|
|
.font(.caption2)
|
|
Text(String(format: "%.1f%%", NSDecimalNumber(decimal: source.totalReturn).doubleValue))
|
|
.font(.caption)
|
|
}
|
|
.foregroundColor(source.totalReturn >= 0 ? .positiveGreen : .negativeRed)
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
.opacity(source.isActive ? 1 : 0.5)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
SourceListView(iapService: IAPService())
|
|
.environmentObject(IAPService())
|
|
.environmentObject(AccountStore(iapService: IAPService()))
|
|
}
|