import SwiftUI import CoreData struct SourceListView: View { @EnvironmentObject var iapService: IAPService @EnvironmentObject var accountStore: AccountStore @Environment(\.managedObjectContext) private var context @StateObject private var viewModel: SourceListViewModel @AppStorage("calmModeEnabled") private var calmModeEnabled = true @State private var sourceToDelete: InvestmentSource? @State private var navigationPath = NavigationPath() @State private var showingSearch = false @State private var selectedSourceID: NSManagedObjectID? @Environment(\.horizontalSizeClass) private var horizontalSizeClass init(iapService: IAPService) { _viewModel = StateObject(wrappedValue: SourceListViewModel(iapService: iapService)) } var body: some View { if horizontalSizeClass == .regular { iPadSourcesLayout } else { iPhoneSourcesLayout } } // MARK: - iPad Layout (master-detail) private var iPadSourcesLayout: some View { HStack(spacing: 0) { // Left: source list panel NavigationStack { ZStack { AppBackground() if viewModel.isEmpty { emptyStateView } else { sourcesList } } .navigationTitle("Sources") .toolbar { sourcesToolbar } .sheet(isPresented: $viewModel.showingAddSource) { AddSourceView() } .sheet(isPresented: $viewModel.showingPaywall) { PaywallView() } .onAppear { syncAccountState() } .onReceive(accountStore.$selectedAccount) { viewModel.selectedAccount = $0 } .onReceive(accountStore.$showAllAccounts) { viewModel.showAllAccounts = $0 } .confirmationDialog( "Delete Source", isPresented: Binding(get: { sourceToDelete != nil }, set: { if !$0 { sourceToDelete = nil } }), titleVisibility: .visible ) { deleteSourceDialogButtons } message: { deleteSourceDialogMessage } } .frame(width: 340) Divider() // Right: detail panel NavigationStack { Group { if let id = selectedSourceID, let source = try? context.existingObject(with: id) as? InvestmentSource, !source.isDeleted { SourceDetailView(source: source, iapService: iapService) .id(id) } else { noSourceSelectedView } } } } } // MARK: - iPhone Layout (push navigation) private var iPhoneSourcesLayout: some View { NavigationStack(path: $navigationPath) { ZStack { AppBackground() if viewModel.isEmpty { emptyStateView } else { sourcesList } } .navigationTitle("Sources") .toolbar { sourcesToolbar } .sheet(isPresented: $viewModel.showingAddSource) { AddSourceView() } .sheet(isPresented: $viewModel.showingPaywall) { PaywallView() } .onAppear { syncAccountState() } .onReceive(accountStore.$selectedAccount) { viewModel.selectedAccount = $0 } .onReceive(accountStore.$showAllAccounts) { viewModel.showAllAccounts = $0 } .navigationDestination(for: NSManagedObjectID.self) { objectID in if let source = try? context.existingObject(with: objectID) as? InvestmentSource, !source.isDeleted { SourceDetailView(source: source, iapService: iapService) } else { MissingSourceView() } } .confirmationDialog( "Delete Source", isPresented: Binding(get: { sourceToDelete != nil }, set: { if !$0 { sourceToDelete = nil } }), titleVisibility: .visible ) { deleteSourceDialogButtons } message: { deleteSourceDialogMessage } } } // MARK: - Shared Helpers private func syncAccountState() { viewModel.selectedAccount = accountStore.selectedAccount viewModel.showAllAccounts = accountStore.showAllAccounts } @ToolbarContentBuilder private var sourcesToolbar: some ToolbarContent { ToolbarItem(placement: .navigationBarTrailing) { Button { viewModel.addSourceTapped() } label: { Image(systemName: "plus") } } ToolbarItem(placement: .navigationBarTrailing) { Button { withAnimation(.easeInOut(duration: 0.2)) { showingSearch.toggle() } if !showingSearch { viewModel.searchText = "" } } label: { Image(systemName: showingSearch ? "xmark.circle.fill" : "magnifyingglass") .foregroundColor(showingSearch ? .secondary : .primary) } } ToolbarItem(placement: .navigationBarLeading) { accountFilterMenu } } @ViewBuilder private var deleteSourceDialogButtons: some View { Button("Delete", role: .destructive) { if let source = sourceToDelete { if selectedSourceID == source.objectID { selectedSourceID = nil } viewModel.deleteSource(source) sourceToDelete = nil } } Button("Cancel", role: .cancel) { sourceToDelete = nil } } @ViewBuilder private var deleteSourceDialogMessage: some View { if let source = sourceToDelete { Text("This will permanently delete \(source.name) and all its snapshots.") } } private var noSourceSelectedView: some View { ZStack { AppBackground() VStack(spacing: 32) { // Icon badge con gradiente ZStack { Circle() .fill(LinearGradient.appPrimaryGradient) .frame(width: 120, height: 120) .shadow(color: Color.appPrimary.opacity(0.25), radius: 28, y: 10) Circle() .fill(Color.white.opacity(0.12)) .frame(width: 120, height: 120) Image(systemName: "chart.line.uptrend.xyaxis") .font(.system(size: 50, weight: .light)) .foregroundColor(.white) } VStack(spacing: 10) { Text("Select a Source") .font(.title2.weight(.semibold)) Text("Pick an investment source from the\npanel on the left to see its details.") .font(.subheadline) .foregroundColor(.secondary) .multilineTextAlignment(.center) } // Call-to-action pill Label("Select from the list", systemImage: "arrow.left") .font(.subheadline.weight(.semibold)) .foregroundColor(.appPrimary) .padding(.horizontal, 22) .padding(.vertical, 11) .background(Color.appPrimary.opacity(0.1)) .clipShape(Capsule()) .overlay(Capsule().stroke(Color.appPrimary.opacity(0.2), lineWidth: 1)) } .padding(48) } .frame(maxWidth: .infinity, maxHeight: .infinity) } // MARK: - Sources List private var sourcesList: some View { List { // Filter Bar Section { filterBarContent } .listRowInsets(EdgeInsets()) .listRowBackground(Color.clear) .listSectionSeparator(.hidden) // 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, id: \.objectID) { source in SourceRowView(source: source, calmModeEnabled: calmModeEnabled) .contentShape(Rectangle()) .background( horizontalSizeClass == .regular && selectedSourceID == source.objectID ? Color.appPrimary.opacity(0.08) : Color.clear ) .onTapGesture { if horizontalSizeClass == .regular { selectedSourceID = source.objectID } else { navigationPath.append(source.objectID) } } .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive) { sourceToDelete = source } label: { Label("Delete", systemImage: "trash") } } } } 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: - Filter Bar (chips + search) private var filterBarContent: some View { VStack(spacing: 0) { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { CategoryChip( title: String(localized: "sources_filter_all"), icon: nil, isSelected: viewModel.selectedCategoryIds.isEmpty ) { viewModel.selectCategory(nil) } ForEach(viewModel.categories) { category in CategoryChip( title: category.name, icon: category.icon, isSelected: viewModel.selectedCategoryIds.contains(category.id) ) { viewModel.selectCategory(category) } } } .padding(.horizontal, 16) .padding(.vertical, 10) } if showingSearch { HStack(spacing: 8) { Image(systemName: "magnifyingglass") .foregroundColor(.secondary) TextField("Search sources", text: $viewModel.searchText) .textFieldStyle(.plain) .autocorrectionDisabled() if !viewModel.searchText.isEmpty { Button { viewModel.searchText = "" } label: { Image(systemName: "xmark.circle.fill") .foregroundColor(.secondary) } } } .padding(.horizontal, 12) .padding(.vertical, 9) .background(Color(.systemGray6)) .cornerRadius(10) .padding(.horizontal, 16) .padding(.bottom, 10) .transition(.move(edge: .top).combined(with: .opacity)) } } } // 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(availableAccounts, id: \.objectID) { account in Button { accountStore.selectAccount(account) } label: { HStack { Text(account.name) if accountStore.selectedAccount?.safeId == account.safeId && !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) } } } } private var availableAccounts: [Account] { accountStore.accounts.filter { $0.safeId != nil } } } // MARK: - Category Chip private struct CategoryChip: View { let title: String let icon: String? let isSelected: Bool let action: () -> Void var body: some View { Button(action: action) { HStack(spacing: 4) { if let icon { Image(systemName: icon) .font(.caption2) } Text(title) .font(.subheadline) .fontWeight(isSelected ? .semibold : .regular) } .padding(.horizontal, 12) .padding(.vertical, 6) .background(isSelected ? Color.appPrimary : Color(.systemGray5)) .foregroundColor(isSelected ? .white : .primary) .cornerRadius(20) } .buttonStyle(.plain) } } private struct MissingSourceView: View { var body: some View { VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 42)) .foregroundColor(.secondary) Text("Source not available") .font(.headline) Text("This source was deleted.") .font(.subheadline) .foregroundColor(.secondary) } .padding() } } // 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())) }