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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user