7a18dd8360
- 151: contribución editable por snapshot en cualquier modo (quitado gate detailed) con diálogo de propagación al editar: solo este / adelante / atrás / todos (SnapshotRepository.propagateContribution, SnapshotFormViewModel.contributionChanged) - 148: Period vs Period mostraba mal el último mes del period B — la agrupación usaba chartMonth(for:) que aplicaba el grace-period del check-in a snapshots históricos; ahora agrupa por mes calendario crudo - 152: iPad Sources no saltaba entre fuentes — añadido .id() al SourceDetailView para recrear el StateObject al cambiar de selección - 146/147: Year vs Year con forecast del año en curso (asterisco de estimado) y KPIs arriba / detalle debajo - 145: card de contribución mensual en SourceDetailView - Nuevas claves snapshot_contribution_propagate_* en los 7 idiomas Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
547 lines
19 KiB
Swift
547 lines
19 KiB
Swift
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()))
|
|
}
|