Release 1.4.0 (build 31): retención, quick update, streak, what's new

- 1A: Notificación mensual de resumen de portfolio (día 5 de cada mes)
- 1B: Badge de racha mensual en Dashboard (streak counter)
- 1C: Empty states mejorados en Goals y Journal con CTAs claros
- 2A: Quick Update sheet — actualiza todas las fuentes desde una pantalla
- 2B: Notificación batch update redirige al Quick Update sheet
- 2C: Widget deep link portfoliojournal://quickupdate abre Quick Update
- 3A: App Store version check banner en Settings
- 3C: What's New sheet en primer launch de versión nueva
- Localización completa en 7 idiomas para todas las nuevas strings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alexandrev-tibco
2026-05-21 23:09:36 +02:00
parent 773da6800b
commit b48a47ce10
41 changed files with 2659 additions and 483 deletions
@@ -7,6 +7,7 @@ struct ChartsContainerView: View {
@StateObject private var goalsViewModel = GoalsViewModel()
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
@AppStorage("showForecast") private var showForecast = true
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
init(iapService: IAPService) {
_viewModel = StateObject(wrappedValue: ChartsViewModel(iapService: iapService))
@@ -14,50 +15,19 @@ struct ChartsContainerView: View {
var body: some View {
NavigationStack {
ZStack {
AppBackground()
ScrollView {
VStack(spacing: 20) {
// Chart Type Selector
chartTypeSelector
// Paywall nudge for free users
if !viewModel.isPremium {
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
.onAppear {
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
}
}
// Unified Filters
filtersSection
// Chart Content
chartContent
}
.padding()
Group {
if horizontalSizeClass == .regular {
iPadChartsLayout
} else {
iPhoneChartsLayout
}
}
.navigationTitle("Charts")
.sheet(isPresented: $viewModel.showingPaywall) {
PaywallView()
}
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
accountFilterMenu
}
ToolbarItem(placement: .navigationBarTrailing) { accountFilterMenu }
}
.onAppear {
viewModel.selectedAccount = accountStore.selectedAccount
viewModel.showAllAccounts = accountStore.showAllAccounts
viewModel.loadData()
goalsViewModel.selectedAccount = accountStore.selectedAccount
goalsViewModel.showAllAccounts = accountStore.showAllAccounts
goalsViewModel.refresh()
viewModel.updatePredictionTargetDate(goalsViewModel.goals)
}
// Performance: Use onChange instead of onReceive for cleaner state updates
.onAppear { syncState() }
.onChange(of: accountStore.selectedAccount) { _, newAccount in
viewModel.selectedAccount = newAccount
goalsViewModel.selectedAccount = newAccount
@@ -76,6 +46,128 @@ struct ChartsContainerView: View {
}
}
// MARK: - iPad Layout (sidebar + full-width chart)
private var iPadChartsLayout: some View {
HStack(spacing: 0) {
// Left sidebar: chart type list + filters
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("Chart Type")
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 8)
let types = viewModel.availableChartTypes(calmModeEnabled: calmModeEnabled)
.filter { showForecast || $0 != .prediction }
ForEach(types) { chartType in
iPadChartTypeRow(chartType)
}
if hasAnyFilter {
Divider().padding(.vertical, 12)
filtersSection
.padding(.horizontal, 16)
.padding(.bottom, 16)
}
}
}
.frame(width: 220)
.background(Color(.systemBackground))
Divider()
// Right panel: full-width chart
ZStack {
AppBackground()
ScrollView {
VStack(spacing: 20) {
if !viewModel.isPremium {
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
.onAppear {
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
}
}
chartContent
}
.padding()
}
}
}
.ignoresSafeArea(edges: .bottom)
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
}
// MARK: - iPhone Layout
private var iPhoneChartsLayout: some View {
ZStack {
AppBackground()
ScrollView {
VStack(spacing: 20) {
chartTypeSelector
if !viewModel.isPremium {
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
.onAppear {
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
}
}
filtersSection
chartContent
}
.padding()
}
}
}
// MARK: - iPad Chart Type Row
private func iPadChartTypeRow(_ chartType: ChartsViewModel.ChartType) -> some View {
let isSelected = viewModel.selectedChartType == chartType
let isPremiumLocked = chartType.isPremium && !viewModel.isPremium
return Button {
viewModel.selectChart(chartType)
} label: {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(isSelected ? Color.appPrimary : Color(.systemGray5))
.frame(width: 32, height: 32)
Image(systemName: chartType.icon)
.font(.system(size: 14))
.foregroundColor(isSelected ? .white : .primary)
}
Text(chartType.rawValue)
.font(.subheadline)
.foregroundColor(isPremiumLocked ? .secondary : .primary)
Spacer()
if isPremiumLocked {
Image(systemName: "lock.fill")
.font(.caption2)
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(isSelected ? Color.appPrimary.opacity(0.08) : Color.clear)
}
.buttonStyle(.plain)
}
// MARK: - Shared
private func syncState() {
viewModel.selectedAccount = accountStore.selectedAccount
viewModel.showAllAccounts = accountStore.showAllAccounts
viewModel.loadData()
goalsViewModel.selectedAccount = accountStore.selectedAccount
goalsViewModel.showAllAccounts = accountStore.showAllAccounts
goalsViewModel.refresh()
viewModel.updatePredictionTargetDate(goalsViewModel.goals)
}
// MARK: - Chart Type Selector
private var chartTypeSelector: some View {
@@ -167,86 +259,76 @@ struct ChartsContainerView: View {
// MARK: - Time Range Selector
private var timeRangeSelector: some View {
HStack(spacing: 8) {
ForEach(viewModel.availableTimeRanges(for: viewModel.selectedChartType)) { range in
Button {
viewModel.selectedTimeRange = range
} label: {
Text(range.rawValue)
.font(.subheadline.weight(.medium))
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(
viewModel.selectedTimeRange == range
? Color.appPrimary
: Color.gray.opacity(0.1)
)
.foregroundColor(
viewModel.selectedTimeRange == range
? .white
: .primary
)
.cornerRadius(20)
Group {
if horizontalSizeClass == .regular {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 6) {
ForEach(viewModel.availableTimeRanges(for: viewModel.selectedChartType)) { range in
timeRangeButton(range, fullWidth: true)
}
}
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(viewModel.availableTimeRanges(for: viewModel.selectedChartType)) { range in
timeRangeButton(range, fullWidth: false)
}
}
}
}
}
}
private func timeRangeButton(_ range: ChartsViewModel.TimeRange, fullWidth: Bool) -> some View {
Button {
viewModel.selectedTimeRange = range
} label: {
Text(range.rawValue)
.font(.subheadline.weight(.medium))
.frame(maxWidth: fullWidth ? .infinity : nil)
.padding(.horizontal, fullWidth ? 8 : 16)
.padding(.vertical, 8)
.background(viewModel.selectedTimeRange == range ? Color.appPrimary : Color.gray.opacity(0.1))
.foregroundColor(viewModel.selectedTimeRange == range ? .white : .primary)
.cornerRadius(20)
}
}
// MARK: - Category Filter
@ViewBuilder
private var categoryFilter: some View {
let availableCategories = viewModel.availableCategories(for: viewModel.selectedChartType)
if !availableCategories.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
if horizontalSizeClass == .regular {
VStack(spacing: 4) {
if availableCategories.count > 1 {
Button {
categoryPill(label: "All", color: .appPrimary, isSelected: viewModel.selectedCategory == nil) {
viewModel.selectedCategory = nil
} label: {
Text("All")
.font(.caption.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
viewModel.selectedCategory == nil
? Color.appPrimary
: Color.gray.opacity(0.1)
)
.foregroundColor(
viewModel.selectedCategory == nil
? .white
: .primary
)
.cornerRadius(16)
}
}
ForEach(availableCategories) { category in
Button {
categoryPill(label: category.name, color: category.color,
isSelected: viewModel.selectedCategory?.id == category.id) {
viewModel.selectedCategory = category
viewModel.selectedSource = nil
} label: {
HStack(spacing: 4) {
Circle()
.fill(category.color)
.frame(width: 8, height: 8)
Text(category.name)
}
}
}
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
if availableCategories.count > 1 {
categoryPill(label: "All", color: .appPrimary,
isSelected: viewModel.selectedCategory == nil) {
viewModel.selectedCategory = nil
}
}
ForEach(availableCategories) { category in
categoryPill(label: category.name, color: category.color,
isSelected: viewModel.selectedCategory?.id == category.id) {
viewModel.selectedCategory = category
viewModel.selectedSource = nil
}
.font(.caption.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
viewModel.selectedCategory?.id == category.id
? category.color
: Color.gray.opacity(0.1)
)
.foregroundColor(
viewModel.selectedCategory?.id == category.id
? .white
: .primary
)
.cornerRadius(16)
}
}
}
@@ -254,69 +336,60 @@ struct ChartsContainerView: View {
}
}
private func categoryPill(label: String, color: Color, isSelected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.caption.weight(.medium))
.frame(maxWidth: horizontalSizeClass == .regular ? .infinity : nil)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(isSelected ? color : Color.gray.opacity(0.1))
.foregroundColor(isSelected ? .white : .primary)
.cornerRadius(16)
}
}
// MARK: - Source Filter
@ViewBuilder
private var sourceFilter: some View {
let availableSources = viewModel.availableSources(for: viewModel.selectedChartType)
if !availableSources.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
if horizontalSizeClass == .regular {
VStack(spacing: 4) {
if availableSources.count > 1 {
Button {
sourcePill(label: "All Sources", color: .appPrimary,
isSelected: viewModel.selectedSourceIds.isEmpty && viewModel.selectedSource == nil) {
viewModel.selectedSource = nil
viewModel.selectedSourceIds.removeAll()
} label: {
Text("All Sources")
.font(.caption.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
viewModel.selectedSourceIds.isEmpty && viewModel.selectedSource == nil
? Color.appPrimary
: Color.gray.opacity(0.1)
)
.foregroundColor(
viewModel.selectedSourceIds.isEmpty && viewModel.selectedSource == nil
? .white
: .primary
)
.cornerRadius(16)
}
}
ForEach(Array(availableSources.enumerated()), id: \.element.id) { index, source in
let sourceId = source.id
let isSelected = viewModel.selectedSourceIds.contains(sourceId)
let sourceColor = Color.sourceColor(at: index)
Button {
let isSelected = viewModel.selectedSourceIds.contains(source.id)
sourcePill(label: source.name, color: Color.sourceColor(at: index), isSelected: isSelected) {
viewModel.selectedSource = nil
if isSelected {
viewModel.selectedSourceIds.remove(sourceId)
} else {
viewModel.selectedSourceIds.insert(sourceId)
if isSelected { viewModel.selectedSourceIds.remove(source.id) }
else { viewModel.selectedSourceIds.insert(source.id) }
}
}
}
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
if availableSources.count > 1 {
sourcePill(label: "All Sources", color: .appPrimary,
isSelected: viewModel.selectedSourceIds.isEmpty && viewModel.selectedSource == nil) {
viewModel.selectedSource = nil
viewModel.selectedSourceIds.removeAll()
}
} label: {
HStack(spacing: 4) {
Circle()
.fill(sourceColor)
.frame(width: 8, height: 8)
Text(source.name)
}
ForEach(Array(availableSources.enumerated()), id: \.element.id) { index, source in
let isSelected = viewModel.selectedSourceIds.contains(source.id)
sourcePill(label: source.name, color: Color.sourceColor(at: index), isSelected: isSelected) {
viewModel.selectedSource = nil
if isSelected { viewModel.selectedSourceIds.remove(source.id) }
else { viewModel.selectedSourceIds.insert(source.id) }
}
.font(.caption.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
isSelected
? sourceColor
: Color.gray.opacity(0.1)
)
.foregroundColor(
isSelected
? .white
: .primary
)
.cornerRadius(16)
}
}
}
@@ -324,34 +397,54 @@ struct ChartsContainerView: View {
}
}
private func sourcePill(label: String, color: Color, isSelected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.caption.weight(.medium))
.frame(maxWidth: horizontalSizeClass == .regular ? .infinity : nil)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(isSelected ? color : Color.gray.opacity(0.1))
.foregroundColor(isSelected ? .white : .primary)
.cornerRadius(16)
}
}
// MARK: - Breakdown Selector
private var breakdownSelector: some View {
HStack(spacing: 8) {
ForEach(ChartsViewModel.BreakdownMode.allCases) { mode in
Button {
viewModel.selectedBreakdown = mode
} label: {
Text(mode.rawValue)
.font(.subheadline.weight(.medium))
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(
viewModel.selectedBreakdown == mode
? Color.appPrimary
: Color.gray.opacity(0.1)
)
.foregroundColor(
viewModel.selectedBreakdown == mode
? .white
: .primary
)
.cornerRadius(18)
Group {
if horizontalSizeClass == .regular {
VStack(spacing: 4) {
ForEach(ChartsViewModel.BreakdownMode.allCases) { mode in
breakdownButton(mode)
}
}
} else {
HStack(spacing: 8) {
ForEach(ChartsViewModel.BreakdownMode.allCases) { mode in
breakdownButton(mode)
}
}
}
}
}
private func breakdownButton(_ mode: ChartsViewModel.BreakdownMode) -> some View {
Button {
viewModel.selectedBreakdown = mode
} label: {
Text(mode.rawValue)
.font(.subheadline.weight(.medium))
.frame(maxWidth: horizontalSizeClass == .regular ? .infinity : nil)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(viewModel.selectedBreakdown == mode ? Color.appPrimary : Color.gray.opacity(0.1))
.foregroundColor(viewModel.selectedBreakdown == mode ? .white : .primary)
.cornerRadius(18)
}
}
// MARK: - Chart Content
@ViewBuilder
@@ -403,6 +496,8 @@ struct ChartsContainerView: View {
predictions: viewModel.predictionData,
historicalData: viewModel.evolutionData
)
case .yearOverYear:
YearOverYearChartView(series: viewModel.yearOverYearData)
}
}
}
@@ -13,6 +13,10 @@ struct DashboardView: View {
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
@AppStorage("showForecast") private var showForecast = true
@State private var pendingAlertDismissed = false
@State private var fullScreenSection: DashboardSection? = nil
@State private var dragTargetID: String? = nil
@State private var showingQuickUpdate = false
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
init() {
_viewModel = StateObject(wrappedValue: DashboardViewModel())
@@ -25,6 +29,10 @@ struct DashboardView: View {
ScrollView {
VStack(spacing: 20) {
if viewModel.updateStreak >= 2 {
streakBadge
}
if viewModel.hasData {
if !pendingAlertDismissed && !viewModel.sourcesNeedingUpdate.isEmpty {
PendingUpdatesAlertBanner(
@@ -38,8 +46,12 @@ struct DashboardView: View {
.transition(.move(edge: .top).combined(with: .opacity))
}
ForEach(visibleSections) { config in
sectionView(for: config)
if horizontalSizeClass == .regular {
iPadDashboardLayout
} else {
ForEach(visibleSections) { config in
sectionView(for: config)
}
}
} else {
EmptyDashboardView(
@@ -51,7 +63,14 @@ struct DashboardView: View {
}
.padding()
}
if horizontalSizeClass == .regular, let section = fullScreenSection {
iPadFullScreenOverlay(for: section)
.transition(.opacity.combined(with: .scale(scale: 0.98)))
.zIndex(10)
}
}
.animation(.easeInOut(duration: 0.25), value: fullScreenSection)
.navigationTitle("Home")
.refreshable {
viewModel.refreshData()
@@ -62,6 +81,13 @@ struct DashboardView: View {
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showingQuickUpdate = true
} label: {
Image(systemName: "plus.circle.fill")
}
}
ToolbarItem(placement: .navigationBarTrailing) {
accountFilterMenu
}
@@ -103,12 +129,34 @@ struct DashboardView: View {
.sheet(isPresented: $showingCustomize) {
DashboardCustomizeView(configs: $sectionConfigs)
}
.sheet(isPresented: $showingQuickUpdate) {
QuickUpdateView()
.environment(\.managedObjectContext, CoreDataStack.shared.viewContext)
}
.sheet(isPresented: $viewModel.showingPaywall) {
PaywallView()
}
.onReceive(NotificationCenter.default.publisher(for: .openQuickUpdate)) { _ in
showingQuickUpdate = true
}
}
}
private var streakBadge: some View {
HStack(spacing: 6) {
Image(systemName: "flame.fill")
.font(.caption.weight(.semibold))
Text(String(format: String(localized: "streak_badge"), viewModel.updateStreak))
.font(.caption.weight(.semibold))
}
.foregroundColor(.orange)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.orange.opacity(0.15))
.cornerRadius(20)
.frame(maxWidth: .infinity, alignment: .leading)
}
private var accountFilterMenu: some View {
Menu {
Button {
@@ -149,6 +197,127 @@ struct DashboardView: View {
sectionConfigs.filter { $0.isVisible }
}
// MARK: - iPad Layout Helpers
private var iPadGridSections: [DashboardSectionConfig] {
visibleSections.filter { $0.id != DashboardSection.totalValue.id }
}
@ViewBuilder
private var iPadDashboardLayout: some View {
// Total Portfolio Value always full width, never in the grid
let totalConfig = sectionConfigs.first(where: { $0.id == DashboardSection.totalValue.id })
?? DashboardSectionConfig(id: DashboardSection.totalValue.id, isVisible: true, isCollapsed: false)
sectionView(for: DashboardSectionConfig(id: totalConfig.id, isVisible: true, isCollapsed: totalConfig.isCollapsed))
// Other cards 2-column grid with drag-and-drop
if !iPadGridSections.isEmpty {
LazyVGrid(
columns: [GridItem(.flexible()), GridItem(.flexible())],
spacing: 16
) {
ForEach(iPadGridSections) { config in
sectionView(for: config)
.gridCellColumns(config.columnSpan)
.overlay {
if dragTargetID == config.id {
RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)
.stroke(Color.appPrimary, lineWidth: 2)
}
}
.draggable(config.id)
.dropDestination(for: String.self) { items, _ in
guard let fromID = items.first else { return false }
return handleIPadDrop(fromID: fromID, toID: config.id)
} isTargeted: { isTargeted in
dragTargetID = isTargeted ? config.id : nil
}
.contextMenu {
Button {
toggleColumnSpan(for: config.id)
} label: {
Label(
config.columnSpan == 2 ? "Normal width" : "Expand to full width",
systemImage: config.columnSpan == 2 ? "rectangle" : "rectangle.expand.diagonal"
)
}
Button {
withAnimation(.easeInOut(duration: 0.25)) {
fullScreenSection = DashboardSection(rawValue: config.id)
}
} label: {
Label("View full screen", systemImage: "arrow.up.left.and.arrow.down.right")
}
}
}
}
}
}
@ViewBuilder
private func iPadFullScreenOverlay(for section: DashboardSection) -> some View {
ZStack {
Color(.systemBackground)
.opacity(0.97)
.ignoresSafeArea()
VStack(spacing: 0) {
HStack {
Text(section.title)
.font(.title3.weight(.semibold))
Spacer()
Button {
withAnimation(.easeInOut(duration: 0.25)) {
fullScreenSection = nil
}
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundColor(.secondary)
}
}
.padding()
Divider()
ScrollView {
VStack(spacing: 20) {
let totalConfig = sectionConfigs.first(where: { $0.id == DashboardSection.totalValue.id })
?? DashboardSectionConfig(id: DashboardSection.totalValue.id, isVisible: true, isCollapsed: false)
sectionView(for: DashboardSectionConfig(id: totalConfig.id, isVisible: true, isCollapsed: totalConfig.isCollapsed))
if let config = sectionConfigs.first(where: { $0.id == section.id }) {
sectionView(for: DashboardSectionConfig(id: config.id, isVisible: true, isCollapsed: false))
}
}
.padding()
.frame(maxWidth: 800)
.frame(maxWidth: .infinity)
}
}
}
}
private func handleIPadDrop(fromID: String, toID: String) -> Bool {
guard fromID != toID,
let fromIndex = sectionConfigs.firstIndex(where: { $0.id == fromID }),
let toIndex = sectionConfigs.firstIndex(where: { $0.id == toID }) else { return false }
withAnimation {
sectionConfigs.move(fromOffsets: IndexSet(integer: fromIndex), toOffset: toIndex > fromIndex ? toIndex + 1 : toIndex)
}
DashboardLayoutStore.save(sectionConfigs)
dragTargetID = nil
return true
}
private func toggleColumnSpan(for id: String) {
guard let index = sectionConfigs.firstIndex(where: { $0.id == id }) else { return }
withAnimation {
sectionConfigs[index].columnSpan = sectionConfigs[index].columnSpan == 2 ? 1 : 2
}
DashboardLayoutStore.save(sectionConfigs)
}
@ViewBuilder
private func sectionView(for config: DashboardSectionConfig) -> some View {
if let section = DashboardSection(rawValue: config.id) {
@@ -271,6 +440,19 @@ struct DashboardView: View {
)
}
}
case .contributionsVsReturns:
let contrib = viewModel.portfolioSummary.totalContributions
let returns = viewModel.portfolioSummary.allTimeReturn
if contrib > 0 || returns != 0 {
if config.isCollapsed {
CompactCard(title: "Invested vs. Returns", subtitle: contrib.currencyString)
} else {
ContributionsVsReturnsCard(
totalContributions: contrib,
totalReturns: returns
)
}
}
}
} else {
EmptyView()
@@ -1262,6 +1444,72 @@ struct EmptyGoalsCard: View {
}
}
// MARK: - Contributions vs Returns Card
struct ContributionsVsReturnsCard: View {
let totalContributions: Decimal
let totalReturns: Decimal
private var total: Decimal { totalContributions + totalReturns }
private var contributionFraction: Double {
guard total > 0 else { return 0.5 }
return NSDecimalNumber(decimal: totalContributions / total).doubleValue
}
private var returnFraction: Double { 1 - contributionFraction }
private var isReturnPositive: Bool { totalReturns >= 0 }
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(String(localized: "contributions_vs_returns_title"))
.font(.headline)
// Bar
GeometryReader { geo in
HStack(spacing: 2) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.appSecondary)
.frame(width: max(4, geo.size.width * CGFloat(contributionFraction)))
RoundedRectangle(cornerRadius: 4)
.fill(isReturnPositive ? Color.positiveGreen : Color.negativeRed)
.frame(maxWidth: .infinity)
}
.frame(height: 16)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.frame(height: 16)
HStack {
HStack(spacing: 6) {
Circle().fill(Color.appSecondary).frame(width: 10, height: 10)
VStack(alignment: .leading, spacing: 2) {
Text(String(localized: "contributions_vs_returns_invested"))
.font(.caption).foregroundColor(.secondary)
Text(totalContributions.currencyString)
.font(.subheadline.weight(.semibold))
}
}
Spacer()
HStack(spacing: 6) {
Circle().fill(isReturnPositive ? Color.positiveGreen : Color.negativeRed).frame(width: 10, height: 10)
VStack(alignment: .trailing, spacing: 2) {
Text(String(localized: "contributions_vs_returns_returns"))
.font(.caption).foregroundColor(.secondary)
let prefix = totalReturns >= 0 ? "+" : ""
Text("\(prefix)\(totalReturns.currencyString)")
.font(.subheadline.weight(.semibold))
.foregroundColor(isReturnPositive ? .positiveGreen : .negativeRed)
}
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
#Preview {
DashboardView()
.environmentObject(IAPService())
@@ -0,0 +1,127 @@
import SwiftUI
import CoreData
struct QuickUpdateView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.managedObjectContext) private var context
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)],
predicate: NSPredicate(format: "isActive == YES"),
animation: .default
) private var sources: FetchedResults<InvestmentSource>
@State private var values: [NSManagedObjectID: String] = [:]
@State private var isSaving = false
@State private var saveError: String?
var body: some View {
NavigationStack {
ZStack {
AppBackground()
if sources.isEmpty {
ContentUnavailableView(
String(localized: "quick_update_no_sources"),
systemImage: "list.bullet",
description: Text(String(localized: "quick_update_no_sources_body"))
)
} else {
List {
Section {
ForEach(sources) { source in
sourceRow(source)
}
} header: {
Text(String(localized: "quick_update_section_header"))
} footer: {
Text(String(localized: "quick_update_section_footer"))
}
}
.scrollContentBackground(.hidden)
}
}
.navigationTitle(String(localized: "quick_update_title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "cancel")) { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "quick_update_save")) {
saveAll()
}
.disabled(isSaving || filledValues.isEmpty)
.fontWeight(.semibold)
}
}
.alert("Error", isPresented: Binding(
get: { saveError != nil },
set: { if !$0 { saveError = nil } }
)) {
Button("OK", role: .cancel) { saveError = nil }
} message: {
Text(saveError ?? "")
}
}
}
@ViewBuilder
private func sourceRow(_ source: InvestmentSource) -> some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(source.name)
.font(.subheadline.weight(.medium))
if source.latestValue != .zero {
Text(source.latestValue.currencyString)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
TextField(
String(localized: "quick_update_placeholder"),
text: binding(for: source)
)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 120)
.font(.subheadline)
}
}
private var filledValues: [NSManagedObjectID: String] {
values.filter { !$0.value.trimmingCharacters(in: .whitespaces).isEmpty }
}
private func binding(for source: InvestmentSource) -> Binding<String> {
Binding(
get: { values[source.objectID] ?? "" },
set: { values[source.objectID] = $0 }
)
}
private func saveAll() {
isSaving = true
let now = Date()
for source in sources {
guard let raw = values[source.objectID],
!raw.trimmingCharacters(in: .whitespaces).isEmpty,
let value = CurrencyFormatter.parseUserInput(raw) else { continue }
let snapshot = Snapshot(context: context)
snapshot.id = UUID()
snapshot.value = NSDecimalNumber(decimal: value)
snapshot.date = now
snapshot.source = source
}
do {
try context.save()
dismiss()
} catch {
saveError = error.localizedDescription
}
isSaving = false
}
}
+126 -42
View File
@@ -5,7 +5,20 @@ struct GoalsView: View {
@StateObject private var viewModel = GoalsViewModel()
@State private var showingAddGoal = false
@State private var editingGoal: Goal?
@State private var showAchievedGoals = false
@State private var goalFilter: GoalFilter = .active
@State private var goalToDelete: Goal?
private enum GoalFilter: String, CaseIterable, Identifiable {
case active, archived, all
var id: String { rawValue }
var label: String {
switch self {
case .active: return String(localized: "goals_filter_active")
case .archived: return String(localized: "goals_filter_archived")
case .all: return String(localized: "goals_filter_all")
}
}
}
var body: some View {
NavigationStack {
@@ -14,11 +27,7 @@ struct GoalsView: View {
List {
if filteredGoals.isEmpty {
if viewModel.goals.isEmpty {
emptyState
} else {
hiddenAchievedState
}
emptyState(for: goalFilter)
} else {
Section {
ForEach(filteredGoals) { goal in
@@ -30,14 +39,24 @@ struct GoalsView: View {
estimatedCompletionDate: viewModel.estimateCompletionDate(for: goal),
onEdit: { editingGoal = goal }
)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
viewModel.deleteGoal(goal)
goalToDelete = goal
} label: {
Label("Delete", systemImage: "trash")
}
}
.swipeActions(edge: .leading, allowsFullSwipe: false) {
Button {
viewModel.archiveGoal(goal)
} label: {
Label(
goal.isActive ? String(localized: "goal_archive") : String(localized: "goal_unarchive"),
systemImage: goal.isActive ? "archivebox" : "arrow.uturn.backward"
)
}
.tint(goal.isActive ? .orange : .blue)
Button {
editingGoal = goal
} label: {
@@ -54,10 +73,12 @@ struct GoalsView: View {
.navigationTitle("Goals")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(showAchievedGoals ? "Hide Achieved" : "Show Achieved") {
showAchievedGoals.toggle()
Picker(String(localized: "goals_filter_active"), selection: $goalFilter) {
ForEach(GoalFilter.allCases) { filter in
Text(filter.label).tag(filter)
}
}
.font(.caption.weight(.semibold))
.pickerStyle(.menu)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button {
@@ -86,44 +107,107 @@ struct GoalsView: View {
viewModel.showAllAccounts = showAll
viewModel.refresh()
}
.alert(String(localized: "goal_delete_title"), isPresented: Binding(
get: { goalToDelete != nil },
set: { if !$0 { goalToDelete = nil } }
)) {
Button(String(localized: "goal_delete_confirm"), role: .destructive) {
if let goal = goalToDelete {
viewModel.deleteGoal(goal)
}
goalToDelete = nil
}
Button(String(localized: "cancel"), role: .cancel) {
goalToDelete = nil
}
} message: {
Text(String(localized: "goal_delete_message"))
}
}
}
private var filteredGoals: [Goal] {
guard !showAchievedGoals else { return viewModel.goals }
return viewModel.goals.filter { !viewModel.isAchieved($0) }
switch goalFilter {
case .active:
return viewModel.goals.filter { $0.isActive }
case .archived:
return viewModel.goals.filter { !$0.isActive }
case .all:
return viewModel.goals
}
}
private var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: "target")
.font(.system(size: 48))
.foregroundColor(.secondary)
Text("Set your first goal")
.font(.headline)
Text("Track progress toward milestones like \(AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol)1M and share your wins.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
@ViewBuilder
private func emptyState(for filter: GoalFilter) -> some View {
switch filter {
case .active:
if viewModel.goals.isEmpty {
VStack(spacing: 16) {
Image(systemName: "target")
.font(.system(size: 48))
.foregroundColor(.secondary)
Text("Set your first goal")
.font(.headline)
Text("Track progress toward milestones like \(AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol)1M and share your wins.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button {
showingAddGoal = true
} label: {
Text(String(localized: "goals_empty_add_cta"))
.font(.subheadline.weight(.semibold))
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(Color.appPrimary)
.clipShape(Capsule())
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
} else {
VStack(spacing: 12) {
Image(systemName: "archivebox")
.font(.system(size: 40))
.foregroundColor(.secondary)
Text(String(localized: "goals_all_active_achieved"))
.font(.headline)
.foregroundColor(.secondary)
Text("Switch to \"Archived\" or \"All\" to see other goals.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
}
case .archived:
VStack(spacing: 12) {
Image(systemName: "archivebox")
.font(.system(size: 40))
.foregroundColor(.secondary)
Text(String(localized: "goals_empty_archived"))
.font(.headline)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
case .all:
VStack(spacing: 16) {
Image(systemName: "target")
.font(.system(size: 48))
.foregroundColor(.secondary)
Text("Set your first goal")
.font(.headline)
Text("Track progress toward milestones like \(AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol)1M and share your wins.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
}
private var hiddenAchievedState: some View {
VStack(spacing: 12) {
Image(systemName: "party.popper")
.font(.system(size: 40))
.foregroundColor(.appSecondary)
Text("All visible goals are achieved")
.font(.headline)
Text("Tap \"Show Achieved\" to review completed goals.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
}
}
+145 -23
View File
@@ -7,49 +7,171 @@ struct JournalView: View {
@State private var scrubberLabel = ""
@State private var scrubberOffset: CGFloat = 0
@State private var currentVisibleMonth: Date?
@State private var selectedDate: Date?
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
NavigationStack {
ScrollViewReader { proxy in
ZStack {
AppBackground()
if horizontalSizeClass == .regular {
iPadJournalLayout
} else {
iPhoneJournalLayout
}
}
List {
Section("Monthly Check-ins") {
if filteredMonthlyNotes.isEmpty {
Text(searchText.isEmpty ? "No monthly notes yet." : "No matching notes.")
// MARK: - iPad Layout (list + inline detail)
private var iPadJournalLayout: some View {
HStack(spacing: 0) {
// Left: month list
NavigationStack {
journalListContent(isPad: true)
.navigationTitle("Journal")
.searchable(text: $searchText, prompt: "Search monthly notes")
.onAppear { viewModel.refresh() }
}
.frame(width: 320)
Divider()
// Right: monthly check-in detail
NavigationStack {
if let date = selectedDate {
MonthlyCheckInView(referenceDate: date)
} else {
noMonthSelectedView
}
}
}
}
// MARK: - iPhone Layout (push navigation)
private var iPhoneJournalLayout: some View {
NavigationStack {
journalListContent(isPad: false)
.navigationTitle("Journal")
.searchable(text: $searchText, prompt: "Search monthly notes")
.onAppear { viewModel.refresh() }
}
}
// MARK: - Shared List Content
private func journalListContent(isPad: Bool) -> some View {
ScrollViewReader { proxy in
ZStack {
AppBackground()
List {
Section("Monthly Check-ins") {
if filteredMonthlyNotes.isEmpty {
if searchText.isEmpty {
VStack(spacing: 12) {
Image(systemName: "book.closed")
.font(.system(size: 36))
.foregroundColor(.appSecondary)
Text(String(localized: "journal_empty_title"))
.font(.headline)
Text(String(localized: "journal_empty_body"))
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.padding(.vertical, 16)
.frame(maxWidth: .infinity)
} else {
Text("No matching notes.")
.font(.subheadline)
.foregroundColor(.secondary)
} else {
ForEach(filteredMonthlyNotes) { entry in
}
} else {
ForEach(filteredMonthlyNotes) { entry in
if isPad {
Button {
selectedDate = entry.date
} label: {
monthlyNoteRow(entry)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.listRowBackground(
selectedDate.map { $0.isSameMonth(as: entry.date) } == true
? Color.appPrimary.opacity(0.08)
: Color.clear
)
.id(entry.date)
.onAppear { currentVisibleMonth = entry.date }
} else {
NavigationLink {
MonthlyCheckInView(referenceDate: entry.date)
} label: {
monthlyNoteRow(entry)
}
.id(entry.date)
.onAppear {
currentVisibleMonth = entry.date
}
.onAppear { currentVisibleMonth = entry.date }
}
}
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
}
.overlay(alignment: .trailing) {
monthScrubber(proxy: proxy)
}
.navigationTitle("Journal")
.searchable(text: $searchText, prompt: "Search monthly notes")
.onAppear {
viewModel.refresh()
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
}
.overlay(alignment: .trailing) {
monthScrubber(proxy: proxy)
}
}
}
private var noMonthSelectedView: some View {
ZStack {
AppBackground()
VStack(spacing: 32) {
// Icon badge con gradiente
ZStack {
Circle()
.fill(LinearGradient(
colors: [Color.appSecondary, Color.appSecondary.lighter()],
startPoint: .topLeading,
endPoint: .bottomTrailing
))
.frame(width: 120, height: 120)
.shadow(color: Color.appSecondary.opacity(0.25), radius: 28, y: 10)
Circle()
.fill(Color.white.opacity(0.12))
.frame(width: 120, height: 120)
Image(systemName: "book.closed.fill")
.font(.system(size: 50, weight: .light))
.foregroundColor(.white)
}
VStack(spacing: 10) {
Text("Select a Month")
.font(.title2.weight(.semibold))
Text("Choose a month from the list on the\nleft to view your check-in notes.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
Label("Select from the list", systemImage: "arrow.left")
.font(.subheadline.weight(.semibold))
.foregroundColor(.appSecondary)
.padding(.horizontal, 22)
.padding(.vertical, 11)
.background(Color.appSecondary.opacity(0.1))
.clipShape(Capsule())
.overlay(Capsule().stroke(Color.appSecondary.opacity(0.2), lineWidth: 1))
}
.padding(48)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var filteredMonthlyNotes: [MonthlyNoteItem] {
let trimmedQuery = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
let hasQuery = !trimmedQuery.isEmpty
@@ -0,0 +1,96 @@
import SwiftUI
struct WhatsNewFeature {
let icon: String
let color: Color
let title: String
let description: String
}
struct WhatsNewView: View {
@Environment(\.dismiss) private var dismiss
private let features: [WhatsNewFeature] = [
WhatsNewFeature(
icon: "bolt.circle.fill",
color: .appPrimary,
title: String(localized: "whats_new_quick_update_title"),
description: String(localized: "whats_new_quick_update_body")
),
WhatsNewFeature(
icon: "flame.fill",
color: .orange,
title: String(localized: "whats_new_streak_title"),
description: String(localized: "whats_new_streak_body")
),
WhatsNewFeature(
icon: "target",
color: .appSecondary,
title: String(localized: "whats_new_goals_title"),
description: String(localized: "whats_new_goals_body")
)
]
var body: some View {
ZStack {
AppBackground()
VStack(spacing: 0) {
Spacer()
VStack(spacing: 32) {
VStack(spacing: 8) {
Text("🎉")
.font(.system(size: 56))
Text(String(localized: "whats_new_title"))
.font(.title.weight(.bold))
Text(String(localized: "whats_new_subtitle"))
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
VStack(spacing: 20) {
ForEach(features, id: \.title) { feature in
HStack(spacing: 16) {
Image(systemName: feature.icon)
.font(.title2)
.foregroundColor(feature.color)
.frame(width: 40)
VStack(alignment: .leading, spacing: 2) {
Text(feature.title)
.font(.subheadline.weight(.semibold))
Text(feature.description)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(16)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
.padding()
Spacer()
Button {
dismiss()
} label: {
Text(String(localized: "whats_new_continue"))
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.cornerRadius(14)
}
.padding()
}
}
.interactiveDismissDisabled()
}
}
@@ -34,6 +34,10 @@ struct ImportDataView: View {
@State private var newAccountName = ""
@State private var accountErrorMessage: String?
// CSV column mapping flow
@State private var pendingCSVContent: String?
@State private var showingCSVMapping = false
private let accountRepository = AccountRepository()
private var availableAccounts: [Account] {
@@ -127,6 +131,13 @@ struct ImportDataView: View {
) { result in
handleImport(result)
}
.sheet(isPresented: $showingCSVMapping) {
if let content = pendingCSVContent {
CSVMappingView(csvContent: content) { mapping in
performMappedCSVImport(content: content, mapping: mapping)
}
}
}
.alert(
"Import Complete",
isPresented: Binding(
@@ -366,12 +377,8 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
handleImportContent(content)
}
private func handleImportContent(_ content: String) {
// For non-Premium users or when only one account exists, use the Default account
// For Premium users with multiple accounts, respect their selection
private func resolvedAccountName() -> String {
let useAccountSelection = shouldShowAccountSelection && iapService.isPremium
let allowMultipleAccounts = false // Always import into a single account
var defaultAccountName = accountStore.accounts.first(where: { $0.isDefaultAccount })?.name
?? accountStore.selectedAccount?.name
?? accountStore.accounts.first?.name
@@ -379,23 +386,64 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
if useAccountSelection {
if accountSelection == .new {
accountErrorMessage = validateNewAccountName()
guard accountErrorMessage == nil else { return }
let trimmed = newAccountName.trimmingCharacters(in: .whitespacesAndNewlines)
let currency = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
let account = accountRepository.createAccount(
name: trimmed,
currency: currency,
inputMode: .simple,
notificationFrequency: .monthly,
customFrequencyMonths: 1
)
defaultAccountName = account.name
if !trimmed.isEmpty {
let currency = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
let account = accountRepository.createAccount(
name: trimmed,
currency: currency,
inputMode: .simple,
notificationFrequency: .monthly,
customFrequencyMonths: 1
)
defaultAccountName = account.name
}
} else {
defaultAccountName = selectedAccountName ?? defaultAccountName
}
}
return defaultAccountName
}
private func handleImportContent(_ content: String) {
let useAccountSelection = shouldShowAccountSelection && iapService.isPremium
if useAccountSelection && accountSelection == .new {
accountErrorMessage = validateNewAccountName()
guard accountErrorMessage == nil else { return }
}
// CSV show column mapping sheet first
if selectedFormat == .csv {
pendingCSVContent = content
showingCSVMapping = true
return
}
// JSON import directly
runImport(content: content, format: .json, defaultAccountName: resolvedAccountName())
}
private func performMappedCSVImport(content: String, mapping: ImportService.CSVMappingConfig) {
let defaultAccountName = resolvedAccountName()
isImporting = true
importProgress = 0
importStatus = "Parsing file"
Task {
let importResult = await ImportService.shared.importCSVWithMappingAsync(
content: content,
mapping: mapping,
defaultAccountName: defaultAccountName
) { progress in
importProgress = progress.fraction
importStatus = progress.message
}
finishImport(importResult)
}
}
private func runImport(content: String, format: ImportService.ImportFormat, defaultAccountName: String) {
isImporting = true
importProgress = 0
importStatus = "Parsing file"
@@ -403,27 +451,30 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
Task {
let importResult = await ImportService.shared.importDataAsync(
content: content,
format: selectedFormat,
allowMultipleAccounts: allowMultipleAccounts,
format: format,
allowMultipleAccounts: false,
defaultAccountName: defaultAccountName
) { progress in
importProgress = progress.fraction
importStatus = progress.message
}
finishImport(importResult)
}
}
isImporting = false
private func finishImport(_ importResult: ImportService.ImportResult) {
isImporting = false
if importResult.errors.isEmpty {
var message = "Imported \(importResult.sourcesCreated) sources and \(importResult.snapshotsCreated) snapshots."
if importResult.snapshotsUpdated > 0 {
message += " Updated \(importResult.snapshotsUpdated) existing snapshots."
}
resultMessage = message
tabSelection.selectedTab = 0
dismiss()
} else {
errorMessage = importResult.errors.joined(separator: "\n")
if importResult.errors.isEmpty {
var message = "Imported \(importResult.sourcesCreated) sources and \(importResult.snapshotsCreated) snapshots."
if importResult.snapshotsUpdated > 0 {
message += " Updated \(importResult.snapshotsUpdated) existing snapshots."
}
resultMessage = message
tabSelection.selectedTab = 0
dismiss()
} else {
errorMessage = importResult.errors.joined(separator: "\n")
}
}
@@ -15,6 +15,7 @@ struct SettingsView: View {
@AppStorage("lockOnBackground") private var lockOnBackground = false
@ObservedObject private var cloudStack = CoreDataStack.shared
@ObservedObject private var updateService = AppUpdateService.shared
@State private var showingPinSetup = false
@State private var showingPinChange = false
@@ -39,6 +40,9 @@ struct SettingsView: View {
AppBackground()
List {
if updateService.updateAvailable {
updateAvailableSection
}
brandSection
// Premium Section
premiumSection
@@ -181,6 +185,33 @@ struct SettingsView: View {
if viewModel.backupsEnabled {
viewModel.refreshBackups()
}
updateService.checkForUpdate()
}
}
}
// MARK: - Update Available Section
private var updateAvailableSection: some View {
Section {
Link(destination: URL(string: "https://apps.apple.com/app/id6741412965")!) {
HStack {
Image(systemName: "arrow.down.circle.fill")
.foregroundColor(.appPrimary)
VStack(alignment: .leading, spacing: 2) {
Text(String(localized: "update_available_title"))
.font(.subheadline.weight(.semibold))
if let v = updateService.latestVersion {
Text(String(format: String(localized: "update_available_body"), v))
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
.font(.caption)
}
}
}
}
@@ -460,6 +491,12 @@ struct SettingsView: View {
}
}
NavigationLink {
CategoriesView()
} label: {
Label("Categories", systemImage: "tag")
}
Button {
if viewModel.canExport {
viewModel.showingExportOptions = true
@@ -390,6 +390,7 @@ struct AddSnapshotView: View {
let snapshot: Snapshot?
@StateObject private var viewModel: SnapshotFormViewModel
@State private var showingDuplicateAlert = false
init(source: InvestmentSource, snapshot: Snapshot? = nil) {
self.source = source
@@ -515,10 +516,47 @@ struct AddSnapshotView: View {
viewModel.checkClipboard()
}
}
.alert(
String(localized: "snapshot_duplicate_title"),
isPresented: $showingDuplicateAlert
) {
Button(String(localized: "snapshot_duplicate_replace"), role: .destructive) {
performSave()
}
Button(String(localized: "snapshot_duplicate_add")) {
performSave()
}
Button("Cancel", role: .cancel) {}
} message: {
Text(String(localized: "snapshot_duplicate_message"))
}
}
}
private func saveSnapshot() {
guard snapshot == nil else {
performSave()
return
}
// Check for existing snapshot in the same month
let calendar = Calendar.current
let selectedMonth = calendar.dateComponents([.year, .month], from: viewModel.date)
let repo = SnapshotRepository()
let existing = repo.fetchSnapshots(for: source, limitedToMonths: nil)
let hasDuplicate = existing.contains { snap in
let snapMonth = calendar.dateComponents([.year, .month], from: snap.date)
return snapMonth == selectedMonth
}
if hasDuplicate {
showingDuplicateAlert = true
} else {
performSave()
}
}
private func performSave() {
guard let value = viewModel.value else { return }
let repository = SnapshotRepository()
@@ -536,6 +574,11 @@ struct AddSnapshotView: View {
clearNotes: viewModel.notes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
)
} else {
// Capture pre-save goal state for achievement detection
let goalRepo = GoalRepository()
let sourceRepo = InvestmentSourceRepository()
let prevAchievedIds = goalsAchievedBeforeSave(goalRepo: goalRepo, sourceRepo: sourceRepo)
repository.createSnapshot(
for: source,
date: viewModel.date,
@@ -543,6 +586,9 @@ struct AddSnapshotView: View {
contribution: viewModel.contribution,
notes: viewModel.notes.isEmpty ? nil : viewModel.notes
)
// Check if any goals became achieved
checkGoalAchievements(goalRepo: goalRepo, sourceRepo: sourceRepo, prevAchievedIds: prevAchievedIds)
}
// Reschedule notification
@@ -553,6 +599,46 @@ struct AddSnapshotView: View {
dismiss()
}
private func goalsAchievedBeforeSave(goalRepo: GoalRepository, sourceRepo: InvestmentSourceRepository) -> Set<UUID> {
let goals = goalRepo.goals.filter { $0.isActive }
var achieved = Set<UUID>()
for goal in goals {
let currentValue: Decimal
if let accountId = goal.account?.safeId {
currentValue = sourceRepo.sources
.filter { $0.account?.id == accountId }
.reduce(Decimal.zero) { $0 + $1.latestValue }
} else {
currentValue = sourceRepo.sources.reduce(Decimal.zero) { $0 + $1.latestValue }
}
let target = goal.targetDecimal
if target > 0 && currentValue / target >= 1 {
achieved.insert(goal.id)
}
}
return achieved
}
private func checkGoalAchievements(goalRepo: GoalRepository, sourceRepo: InvestmentSourceRepository, prevAchievedIds: Set<UUID>) {
// Reload sources to pick up new snapshot value
let goals = goalRepo.goals.filter { $0.isActive }
for goal in goals {
guard !prevAchievedIds.contains(goal.id) else { continue }
let currentValue: Decimal
if let accountId = goal.account?.safeId {
currentValue = sourceRepo.sources
.filter { $0.account?.id == accountId }
.reduce(Decimal.zero) { $0 + $1.latestValue }
} else {
currentValue = sourceRepo.sources.reduce(Decimal.zero) { $0 + $1.latestValue }
}
let target = goal.targetDecimal
if target > 0 && currentValue / target >= 1 {
NotificationService.shared.scheduleGoalAchievedNotification(goalName: goal.name)
}
}
}
}
#Preview {
@@ -40,6 +40,13 @@ struct SourceDetailView: View {
// Header Card
headerCard
// Performance Summary full width, above quick actions
if calmModeEnabled {
simpleMetricsSection
} else {
metricsSection
}
// Quick Actions
quickActions
@@ -48,13 +55,6 @@ struct SourceDetailView: View {
chartSection
}
// Metrics
if calmModeEnabled {
simpleMetricsSection
} else {
metricsSection
}
// Snapshots List
snapshotsSection
}
@@ -323,10 +323,14 @@ struct SourceDetailView: View {
HStack(spacing: 12) {
MetricChip(title: "CAGR", value: viewModel.metrics.formattedCAGR)
.frame(maxWidth: .infinity)
MetricChip(title: "Avg Monthly", value: viewModel.metrics.formattedAverageMonthlyReturn)
.frame(maxWidth: .infinity)
MetricChip(title: "Contributions", value: viewModel.source.totalContributions.compactCurrencyString)
.frame(maxWidth: .infinity)
}
}
.frame(maxWidth: .infinity)
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
@@ -501,8 +505,9 @@ struct MetricChip: View {
Text(value)
.font(.caption.weight(.semibold))
}
.padding(.vertical, 8)
.padding(.horizontal, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 10)
.padding(.horizontal, 12)
.background(Color.gray.opacity(0.1))
.cornerRadius(10)
}
@@ -10,69 +10,78 @@ struct SourceListView: View {
@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)
} else {
noSourceSelectedView
}
}
}
}
}
// MARK: - iPhone Layout (push navigation)
private var iPhoneSourcesLayout: some View {
NavigationStack(path: $navigationPath) {
ZStack {
AppBackground()
Group {
if viewModel.isEmpty {
emptyStateView
} else {
sourcesList
.safeAreaInset(edge: .top, spacing: 0) {
filterBar
}
}
}
if viewModel.isEmpty { emptyStateView } else { sourcesList }
}
.navigationTitle("Sources")
.toolbar {
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
}
}
.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
}
.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 {
@@ -83,33 +92,117 @@ struct SourceListView: View {
}
.confirmationDialog(
"Delete Source",
isPresented: Binding(
get: { sourceToDelete != nil },
set: { if !$0 { sourceToDelete = nil } }
),
isPresented: Binding(get: { sourceToDelete != nil }, set: { if !$0 { sourceToDelete = nil } }),
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
if let source = sourceToDelete {
viewModel.deleteSource(source)
sourceToDelete = nil
}
}
Button("Cancel", role: .cancel) {
sourceToDelete = nil
}
} message: {
if let source = sourceToDelete {
Text("This will permanently delete \(source.name) and all its snapshots.")
}
) { 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 {
@@ -162,8 +255,17 @@ struct SourceListView: View {
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 {
navigationPath.append(source.objectID)
if horizontalSizeClass == .regular {
selectedSourceID = source.objectID
} else {
navigationPath.append(source.objectID)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
@@ -226,14 +328,14 @@ struct SourceListView: View {
// MARK: - Filter Bar (chips + search)
private var filterBar: some View {
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.selectedCategory == nil
isSelected: viewModel.selectedCategoryIds.isEmpty
) {
viewModel.selectCategory(nil)
}
@@ -242,7 +344,7 @@ struct SourceListView: View {
CategoryChip(
title: category.name,
icon: category.icon,
isSelected: viewModel.selectedCategory?.id == category.id
isSelected: viewModel.selectedCategoryIds.contains(category.id)
) {
viewModel.selectCategory(category)
}
@@ -278,10 +380,7 @@ struct SourceListView: View {
.padding(.bottom, 10)
.transition(.move(edge: .top).combined(with: .opacity))
}
Divider()
}
.background(.bar)
}
// MARK: - Account Filter Menu