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
Vendored
BIN
View File
Binary file not shown.
+16 -16
View File
@@ -459,7 +459,7 @@
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 20;
CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
@@ -477,7 +477,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.1;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -498,7 +498,7 @@
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournal.entitlements;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 20;
CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
@@ -516,7 +516,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.1;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -655,7 +655,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 20;
CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
@@ -668,7 +668,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.3.1;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -688,7 +688,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 20;
CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
@@ -701,7 +701,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.3.1;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -719,7 +719,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 20;
CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -728,7 +728,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.3.1;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -743,7 +743,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 20;
CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -752,7 +752,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.3.1;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -766,7 +766,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 20;
CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -775,7 +775,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.3.1;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -789,7 +789,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 20;
CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -798,7 +798,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.3.1;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
+107
View File
@@ -1,6 +1,34 @@
import SwiftUI
import CoreData
// MARK: - App Tab
enum AppTab: Int, Hashable, CaseIterable {
case dashboard = 0, sources = 1, charts = 2, journal = 3, settings = 4
var title: String {
switch self {
case .dashboard: String(localized: "tab_dashboard")
case .sources: String(localized: "tab_sources")
case .charts: String(localized: "tab_charts")
case .journal: "Journal"
case .settings: String(localized: "tab_settings")
}
}
var icon: String {
switch self {
case .dashboard: "house.fill"
case .sources: "list.bullet"
case .charts: "chart.xyaxis.line"
case .journal: "book.closed"
case .settings: "gearshape.fill"
}
}
}
// MARK: - Content View
struct ContentView: View {
@EnvironmentObject var iapService: IAPService
@EnvironmentObject var adMobService: AdMobService
@@ -11,11 +39,19 @@ struct ContentView: View {
@AppStorage("pinEnabled") private var pinEnabled = false
@AppStorage("lockOnLaunch") private var lockOnLaunch = true
@AppStorage("lockOnBackground") private var lockOnBackground = false
@AppStorage("lastSeenWhatsNewVersion") private var lastSeenWhatsNewVersion = ""
@Environment(\.scenePhase) private var scenePhase
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var isUnlocked = false
@State private var resolvedOnboardingCompleted: Bool?
@State private var iCloudCheckDone = false
@State private var loadingMessageKey: LocalizedStringKey = "loading_data"
@State private var sidebarSelection: AppTab? = .dashboard
@State private var showingWhatsNew = false
private var currentVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
}
private var lockEnabled: Bool {
faceIdEnabled || pinEnabled
@@ -39,6 +75,8 @@ struct ContentView: View {
OnboardingICloudCheckView(onSkip: { iCloudCheckDone = true })
} else if resolvedOnboardingCompleted == false {
OnboardingView(onboardingCompleted: $onboardingCompleted)
} else if horizontalSizeClass == .regular {
iPadMainContent
} else {
mainContent
}
@@ -75,10 +113,36 @@ struct ContentView: View {
}
.task {
await waitForDataAndResolveOnboarding()
if (resolvedOnboardingCompleted == true) && currentVersion != lastSeenWhatsNewVersion {
// Small delay to let the UI settle before showing sheet
try? await Task.sleep(nanoseconds: 300_000_000)
showingWhatsNew = true
}
}
.onChange(of: onboardingCompleted) { _, completed in
resolvedOnboardingCompleted = completed
}
.sheet(isPresented: $showingWhatsNew, onDismiss: {
lastSeenWhatsNewVersion = currentVersion
}) {
WhatsNewView()
}
.onReceive(NotificationCenter.default.publisher(for: .openBatchUpdate)) { _ in
tabSelection.selectedTab = 1
sidebarSelection = .sources
NotificationCenter.default.post(name: .openQuickUpdate, object: nil)
}
.onReceive(NotificationCenter.default.publisher(for: .openDashboard)) { _ in
tabSelection.selectedTab = 0
sidebarSelection = .dashboard
}
.onOpenURL { url in
if url.host == "quickupdate" {
tabSelection.selectedTab = 0
sidebarSelection = .dashboard
NotificationCenter.default.post(name: .openQuickUpdate, object: nil)
}
}
}
private var isReadyForContent: Bool {
@@ -160,6 +224,49 @@ struct ContentView: View {
return ((try? context.count(for: accountRequest)) ?? 0) > 0
}
// MARK: - iPad Layout
private var iPadMainContent: some View {
NavigationSplitView(columnVisibility: .constant(.all)) {
List(AppTab.allCases, id: \.self, selection: $sidebarSelection) { tab in
Label(tab.title, systemImage: tab.icon)
}
.navigationTitle("Portfolio Journal")
.navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 280)
} detail: {
iPadDetailView(for: sidebarSelection ?? .dashboard)
}
.onChange(of: sidebarSelection) { _, tab in
guard let tab else { return }
if tabSelection.selectedTab != tab.rawValue {
tabSelection.selectedTab = tab.rawValue
}
}
.onChange(of: tabSelection.selectedTab) { _, value in
if let tab = AppTab(rawValue: value), sidebarSelection != tab {
sidebarSelection = tab
}
}
}
@ViewBuilder
private func iPadDetailView(for tab: AppTab) -> some View {
switch tab {
case .dashboard:
bannerInsetView(DashboardView())
case .sources:
bannerInsetView(SourceListView(iapService: iapService))
case .charts:
bannerInsetView(ChartsContainerView(iapService: iapService))
case .journal:
bannerInsetView(JournalView())
case .settings:
bannerInsetView(SettingsView(iapService: iapService))
}
}
// MARK: - iPhone Layout
private var mainContent: some View {
ZStack {
TabView(selection: $tabSelection.selectedTab) {
@@ -40,7 +40,8 @@ extension Category {
}
var sourceCount: Int {
sources?.count ?? 0
guard !isDeleted, !isFault else { return 0 }
return sources?.count ?? 0
}
var totalValue: Decimal {
@@ -292,3 +292,95 @@
"Best Contributor" = "Größter Beitrag";
"Update Check-in" = "Check-in aktualisieren";
"Completed %@" = "Abgeschlossen %@";
// MARK: - Missing keys added (1.3.x)
"Checking…" = "Wird geprüft…";
"Clear Filters" = "Filter löschen";
"Force Upload to iCloud" = "Zu iCloud hochladen";
"No export yet" = "Noch nicht exportiert";
"Not synced yet" = "Noch nicht synchronisiert";
"Refresh" = "Aktualisieren";
"Search sources" = "Quellen suchen";
"Syncing with iCloud..." = "Mit iCloud synchronisieren...";
"Uploading..." = "Wird hochgeladen...";
"Verify iCloud Setup" = "iCloud-Einrichtung prüfen";
"add_source_name_footer" = "Eine Quelle ist jede Investition, die du verfolgen möchtest: Aktien, ETFs, Sparkonten, Immobilien, Krypto und mehr.";
"add_source_name_placeholder" = "z.B. MSCI World ETF, Sparkonto, Wohnung...";
"categories_empty" = "Noch keine Kategorien.";
"category_has_sources_warning" = "Eine Kategorie mit Quellen kann nicht gelöscht werden. Entferne oder weise alle Quellen zuerst um.";
"category_name_placeholder" = "z.B. Notfallfonds";
"chart_yoy_empty" = "Füge Snapshots über verschiedene Jahre hinzu, um den Vergleich zu sehen.";
"chart_yoy_title" = "Jahr für Jahr";
"checking_icloud" = "iCloud wird geprüft...";
"contributions_vs_returns_invested" = "Investiert";
"contributions_vs_returns_returns" = "Marktrendite";
"contributions_vs_returns_title" = "Investiert vs. Rendite";
"csv_enter_value" = "Wert eingeben";
"csv_field_category" = "Kategorie";
"csv_field_contribution" = "Einlage";
"csv_field_date" = "Datum";
"csv_field_notes" = "Notizen";
"csv_field_source" = "Quellenname (erforderlich)";
"csv_field_value" = "Wert (erforderlich)";
"csv_mapping_subtitle" = "Ordne deine CSV-Spalten den Portfolio Journal-Feldern zu";
"csv_no_column" = "— Nicht zugeordnet —";
"csv_no_date_hint" = "Alle Zeilen werden als Snapshot für heute importiert.";
"csv_optional_section" = "Optionale Felder";
"csv_preview_section" = "CSV-Vorschau";
"csv_required_section" = "Pflichtfelder";
"csv_use_today" = "Heutiges Datum verwenden";
"goal_achieved_notification_body" = "Du hast dein Ziel erreicht: %@";
"goal_achieved_notification_title" = "Ziel erreicht! 🎉";
"goal_archive" = "Archivieren";
"goal_delete_confirm" = "Löschen";
"goal_delete_message" = "Dieses Ziel wird dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.";
"goal_delete_title" = "Ziel löschen";
"goal_unarchive" = "Wiederherstellen";
"goals_all_active_achieved" = "Alle aktiven Ziele wurden erreicht.";
"goals_empty_archived" = "Keine archivierten Ziele.";
"goals_filter_active" = "Aktiv";
"goals_filter_all" = "Alle";
"goals_filter_archived" = "Archiviert";
"icloud_check_description" = "Wenn du Daten auf einem anderen Gerät hast, aktiviere iCloud, um sie hier wiederherzustellen.";
"icloud_check_title" = "Nutzt du Portfolio Journal bereits?";
"icloud_enabled_description" = "Schließe die App und öffne sie erneut. Deine Daten werden automatisch von iCloud geladen.";
"icloud_enabled_title" = "iCloud aktiviert";
"monthly_checkin_notification_body" = "Trage die Werte dieses Monats ein und verfolge das Wachstum deines Portfolios.";
"monthly_checkin_notification_title" = "Zeit für dein monatliches Update";
"onboarding_add_first_source" = "Meine erste Investition hinzufügen";
"onboarding_import_data" = "Vorhandene Daten importieren";
"onboarding_quickstart_subtitle" = "Füge deine erste Investitionsquelle hinzu, um dein Portfolio zu verfolgen.";
"onboarding_quickstart_title" = "Fast fertig";
"reengagement_body" = "Ein paar Minuten reichen, um deine Investitionen im Blick zu behalten.";
"reengagement_title" = "Dein Portfolio wartet";
"snapshot_duplicate_add" = "Trotzdem hinzufügen";
"snapshot_duplicate_message" = "Du hast bereits einen Snapshot für diesen Monat. Möchtest du einen weiteren hinzufügen?";
"snapshot_duplicate_replace" = "Vorhandenen ersetzen";
"snapshot_duplicate_title" = "Snapshot bereits vorhanden";
"sources_filter_all" = "Alle";
// MARK: - 1.4.0 Features
"monthly_summary_notification_title" = "Deine monatliche Portfolio-Zusammenfassung";
"monthly_summary_notification_body" = "Sieh nach, wie deine Investitionen diesen Monat abgeschnitten haben.";
"streak_badge" = "%d-Monats-Serie";
"goals_empty_add_cta" = "Mein erstes Ziel hinzufügen";
"journal_empty_title" = "Noch keine Einträge";
"journal_empty_body" = "Snapshot-Aktualisierungen erstellen automatisch monatliche Einträge. Füge deine erste Quelle hinzu, um zu beginnen.";
"quick_update_title" = "Schnellaktualisierung";
"quick_update_section_header" = "Aktuelle Werte eingeben";
"quick_update_section_footer" = "Es werden nur Quellen aktualisiert, für die ein Wert eingegeben wurde.";
"quick_update_placeholder" = "Neuer Wert";
"quick_update_save" = "Alle speichern";
"quick_update_no_sources" = "Keine Quellen";
"quick_update_no_sources_body" = "Füge zuerst Investitionsquellen hinzu, um die Schnellaktualisierung zu nutzen.";
"update_available_title" = "Update verfügbar";
"update_available_body" = "Version %@ ist im App Store verfügbar.";
"whats_new_title" = "Neuheiten in 1.4";
"whats_new_subtitle" = "Verbesserungen, die dir helfen, auf Kurs zu bleiben.";
"whats_new_quick_update_title" = "Schnelles Portfolio-Update";
"whats_new_quick_update_body" = "Aktualisiere alle deine Quellen mit einem Tippen auf einem einzigen Bildschirm.";
"whats_new_streak_title" = "Update-Serie";
"whats_new_streak_body" = "Verfolge, wie viele Monate du dein Portfolio in Folge aktuell gehalten hast.";
"whats_new_goals_title" = "Intelligentere Ziele & Journal";
"whats_new_goals_body" = "Verbesserte Leerzustände helfen dir, schneller zu starten.";
"whats_new_continue" = "Los geht's";
@@ -315,3 +315,80 @@
// MARK: - AddSourceView (1.3.0)
"add_source_name_placeholder" = "e.g. MSCI World ETF, ING Savings, Apartment...";
"add_source_name_footer" = "A source is any investment you want to track: stocks, ETFs, savings accounts, real estate, crypto, and more.";
// MARK: - Goals Archive (1.3.2)
"goals_filter_active" = "Active";
"goals_filter_archived" = "Archived";
"goals_filter_all" = "All";
"goal_archive" = "Archive";
"goal_unarchive" = "Unarchive";
"goals_empty_archived" = "No archived goals.";
"goals_all_active_achieved" = "All active goals are achieved.";
// MARK: - Category Management (1.3.2)
"category_name_placeholder" = "e.g. Emergency Fund";
"category_has_sources_warning" = "Cannot delete a category that has sources. Remove or reassign all sources first.";
"categories_empty" = "No categories yet.";
// MARK: - CSV Column Mapping (1.3.2)
"csv_mapping_subtitle" = "Match your CSV columns to Portfolio Journal fields";
"csv_field_source" = "Source Name (required)";
"csv_field_value" = "Value (required)";
"csv_field_date" = "Date";
"csv_field_category" = "Category";
"csv_field_contribution" = "Contribution";
"csv_field_notes" = "Notes";
"csv_no_column" = "— Not mapped —";
"csv_enter_value" = "Enter value";
"csv_use_today" = "Use today's date";
"csv_no_date_hint" = "All rows will be imported as a snapshot for today.";
"csv_preview_section" = "CSV Preview";
"csv_required_section" = "Required Fields";
"csv_optional_section" = "Optional Fields";
// MARK: - 1.3.2 Features
"goal_achieved_notification_title" = "Goal achieved! 🎉";
"goal_achieved_notification_body" = "You reached your goal: %@";
"snapshot_duplicate_title" = "Snapshot Already Exists";
"snapshot_duplicate_message" = "You already have a snapshot for this month. Do you want to add another one?";
"snapshot_duplicate_replace" = "Replace Existing";
"snapshot_duplicate_add" = "Add Anyway";
"contributions_vs_returns_title" = "Invested vs. Returns";
"contributions_vs_returns_invested" = "Invested";
"contributions_vs_returns_returns" = "Market Returns";
"chart_yoy_title" = "Year over Year";
"chart_yoy_empty" = "Add more snapshots across different years to see the comparison.";
// MARK: - Goal Delete Confirmation (1.3.2)
"goal_delete_title" = "Delete Goal";
"goal_delete_message" = "This goal will be permanently deleted. This action cannot be undone.";
"goal_delete_confirm" = "Delete";
// MARK: - 1.4.0 Features
"monthly_summary_notification_title" = "Your Monthly Portfolio Summary";
"monthly_summary_notification_body" = "Check how your investments performed this month.";
"streak_badge" = "%d-month streak";
"goals_empty_add_cta" = "Add My First Goal";
"journal_empty_title" = "No check-ins yet";
"journal_empty_body" = "Snapshot updates automatically create monthly entries. Add your first source to get started.";
"quick_update_title" = "Quick Update";
"quick_update_section_header" = "Enter current values";
"quick_update_section_footer" = "Only sources with a value entered will be updated.";
"quick_update_placeholder" = "New value";
"quick_update_save" = "Save All";
"quick_update_no_sources" = "No sources";
"quick_update_no_sources_body" = "Add investment sources first to use Quick Update.";
"update_available_title" = "Update Available";
"update_available_body" = "Version %@ is ready on the App Store.";
"whats_new_title" = "What's New in 1.4";
"whats_new_subtitle" = "Improvements to help you stay on track.";
"whats_new_quick_update_title" = "Quick Portfolio Update";
"whats_new_quick_update_body" = "Update all your sources from a single screen with one tap.";
"whats_new_streak_title" = "Update Streak";
"whats_new_streak_body" = "Track how many months in a row you've kept your portfolio up to date.";
"whats_new_goals_title" = "Smarter Goals & Journal";
"whats_new_goals_body" = "Improved empty states help you get started faster.";
"whats_new_continue" = "Let's Go";
@@ -242,3 +242,97 @@
// MARK: - AddSourceView (1.3.0)
"add_source_name_placeholder" = "p.ej. ETF MSCI World, Cuenta ING, Piso...";
"add_source_name_footer" = "Una fuente es cualquier inversión que quieras seguir: acciones, ETFs, cuentas de ahorro, inmuebles, cripto y más.";
// MARK: - Goals Archive (1.3.2)
"goals_filter_active" = "Activos";
"goals_filter_archived" = "Archivados";
"goals_filter_all" = "Todos";
"goal_archive" = "Archivar";
"goal_unarchive" = "Restaurar";
"goals_empty_archived" = "Sin objetivos archivados.";
"goals_all_active_achieved" = "Todos los objetivos activos están logrados.";
// MARK: - Category Management (1.3.2)
"category_name_placeholder" = "p.ej. Fondo de emergencia";
"category_has_sources_warning" = "No se puede eliminar una categoría que tiene fuentes. Elimina o reasigna todas las fuentes primero.";
"categories_empty" = "Sin categorías aún.";
// MARK: - CSV Column Mapping (1.3.2)
"csv_mapping_subtitle" = "Relaciona tus columnas CSV con los campos de Portfolio Journal";
"csv_field_source" = "Nombre de fuente (obligatorio)";
"csv_field_value" = "Valor (obligatorio)";
"csv_field_date" = "Fecha";
"csv_field_category" = "Categoría";
"csv_field_contribution" = "Aportación";
"csv_field_notes" = "Notas";
"csv_no_column" = "— Sin mapear —";
"csv_enter_value" = "Valor fijo";
"csv_use_today" = "Fecha de hoy";
"csv_no_date_hint" = "Todas las filas se importarán como un snapshot de hoy.";
"csv_preview_section" = "Vista previa CSV";
"csv_required_section" = "Campos obligatorios";
"csv_optional_section" = "Campos opcionales";
// MARK: - Category Management SwiftUI literals (1.3.2)
"Categories" = "Categorías";
"Add Category" = "Añadir categoría";
"Edit Category" = "Editar categoría";
"Category Name" = "Nombre de categoría";
"Color" = "Color";
"Icon" = "Icono";
"Map Columns" = "Mapear columnas";
"Has Header Row" = "Tiene fila de encabezado";
"Required" = "Obligatorios";
"Optional" = "Opcionales";
"Preview" = "Vista previa";
"Any CSV" = "Cualquier CSV";
// MARK: - 1.3.2 Features
"goal_achieved_notification_title" = "¡Objetivo alcanzado! 🎉";
"goal_achieved_notification_body" = "Has alcanzado tu objetivo: %@";
"snapshot_duplicate_title" = "Ya existe un snapshot";
"snapshot_duplicate_message" = "Ya tienes un snapshot para este mes. ¿Quieres añadir otro?";
"snapshot_duplicate_replace" = "Reemplazar existente";
"snapshot_duplicate_add" = "Añadir de todos modos";
"contributions_vs_returns_title" = "Invertido vs. Rentabilidad";
"contributions_vs_returns_invested" = "Invertido";
"contributions_vs_returns_returns" = "Rentabilidad de mercado";
"chart_yoy_title" = "Año a año";
"chart_yoy_empty" = "Añade más snapshots en diferentes años para ver la comparación.";
"Year vs Year" = "Año a año";
// MARK: - Goal Delete Confirmation (1.3.2)
"goal_delete_title" = "Eliminar objetivo";
"goal_delete_message" = "Este objetivo se eliminará permanentemente. Esta acción no se puede deshacer.";
"goal_delete_confirm" = "Eliminar";
// MARK: - 1.4.0 Features
"monthly_summary_notification_title" = "Tu resumen mensual del portfolio";
"monthly_summary_notification_body" = "Comprueba cómo han rendido tus inversiones este mes.";
"streak_badge" = "Racha de %d meses";
"goals_empty_add_cta" = "Añadir mi primer objetivo";
"journal_empty_title" = "Sin registros aún";
"journal_empty_body" = "Las actualizaciones de snapshots crean entradas mensuales automáticamente. Añade tu primera fuente para empezar.";
"quick_update_title" = "Actualización rápida";
"quick_update_section_header" = "Introduce los valores actuales";
"quick_update_section_footer" = "Solo se actualizarán las fuentes con un valor introducido.";
"quick_update_placeholder" = "Nuevo valor";
"quick_update_save" = "Guardar todo";
"quick_update_no_sources" = "Sin fuentes";
"quick_update_no_sources_body" = "Añade fuentes de inversión primero para usar la actualización rápida.";
"update_available_title" = "Actualización disponible";
"update_available_body" = "La versión %@ está disponible en el App Store.";
"whats_new_title" = "Novedades en 1.4";
"whats_new_subtitle" = "Mejoras para ayudarte a mantener el rumbo.";
"whats_new_quick_update_title" = "Actualización rápida del portfolio";
"whats_new_quick_update_body" = "Actualiza todas tus fuentes desde una sola pantalla con un toque.";
"whats_new_streak_title" = "Racha de actualizaciones";
"whats_new_streak_body" = "Sigue cuántos meses consecutivos has mantenido tu portfolio al día.";
"whats_new_goals_title" = "Objetivos y diario más inteligentes";
"whats_new_goals_body" = "Los estados vacíos mejorados te ayudan a empezar más rápido.";
"whats_new_continue" = "¡Vamos!";
"goal_delete_confirm" = "Eliminar";
@@ -292,3 +292,95 @@
"Best Contributor" = "Meilleur contributeur";
"Update Check-in" = "Mettre à jour le check-in";
"Completed %@" = "Terminé %@";
// MARK: - Missing keys added (1.3.x)
"Checking…" = "Vérification…";
"Clear Filters" = "Effacer les filtres";
"Force Upload to iCloud" = "Forcer l'envoi vers iCloud";
"No export yet" = "Pas encore exporté";
"Not synced yet" = "Pas encore synchronisé";
"Refresh" = "Actualiser";
"Search sources" = "Rechercher des sources";
"Syncing with iCloud..." = "Synchronisation avec iCloud...";
"Uploading..." = "Envoi en cours...";
"Verify iCloud Setup" = "Vérifier la configuration iCloud";
"add_source_name_footer" = "Une source est tout investissement que vous souhaitez suivre : actions, ETFs, comptes d'épargne, immobilier, crypto et plus.";
"add_source_name_placeholder" = "ex. ETF MSCI World, Livret A, Appartement...";
"categories_empty" = "Aucune catégorie pour l'instant.";
"category_has_sources_warning" = "Impossible de supprimer une catégorie qui contient des sources. Supprimez ou réaffectez d'abord toutes les sources.";
"category_name_placeholder" = "ex. Fonds d'urgence";
"chart_yoy_empty" = "Ajoutez plus de snapshots sur différentes années pour voir la comparaison.";
"chart_yoy_title" = "Année par année";
"checking_icloud" = "Vérification d'iCloud...";
"contributions_vs_returns_invested" = "Investi";
"contributions_vs_returns_returns" = "Rendement du marché";
"contributions_vs_returns_title" = "Investi vs. Rendement";
"csv_enter_value" = "Saisir une valeur";
"csv_field_category" = "Catégorie";
"csv_field_contribution" = "Contribution";
"csv_field_date" = "Date";
"csv_field_notes" = "Notes";
"csv_field_source" = "Nom de la source (requis)";
"csv_field_value" = "Valeur (requise)";
"csv_mapping_subtitle" = "Associez vos colonnes CSV aux champs de Portfolio Journal";
"csv_no_column" = "— Non mappé —";
"csv_no_date_hint" = "Toutes les lignes seront importées comme un snapshot d'aujourd'hui.";
"csv_optional_section" = "Champs optionnels";
"csv_preview_section" = "Aperçu CSV";
"csv_required_section" = "Champs obligatoires";
"csv_use_today" = "Utiliser la date du jour";
"goal_achieved_notification_body" = "Vous avez atteint votre objectif : %@";
"goal_achieved_notification_title" = "Objectif atteint ! 🎉";
"goal_archive" = "Archiver";
"goal_delete_confirm" = "Supprimer";
"goal_delete_message" = "Cet objectif sera définitivement supprimé. Cette action est irréversible.";
"goal_delete_title" = "Supprimer l'objectif";
"goal_unarchive" = "Restaurer";
"goals_all_active_achieved" = "Tous les objectifs actifs sont atteints.";
"goals_empty_archived" = "Aucun objectif archivé.";
"goals_filter_active" = "Actifs";
"goals_filter_all" = "Tous";
"goals_filter_archived" = "Archivés";
"icloud_check_description" = "Si vous avez des données sur un autre appareil, activez iCloud pour les restaurer ici.";
"icloud_check_title" = "Vous utilisez déjà Portfolio Journal ?";
"icloud_enabled_description" = "Fermez l'application et rouvrez-la. Vos données se chargeront automatiquement depuis iCloud.";
"icloud_enabled_title" = "iCloud activé";
"monthly_checkin_notification_body" = "Enregistrez les valeurs de ce mois et suivez la croissance de votre portefeuille.";
"monthly_checkin_notification_title" = "C'est l'heure de votre mise à jour mensuelle";
"onboarding_add_first_source" = "Ajouter mon premier investissement";
"onboarding_import_data" = "Importer des données existantes";
"onboarding_quickstart_subtitle" = "Ajoutez votre première source d'investissement pour commencer à suivre votre portefeuille.";
"onboarding_quickstart_title" = "Presque prêt";
"reengagement_body" = "Quelques minutes suffisent pour rester au top de vos investissements.";
"reengagement_title" = "Votre portefeuille vous attend";
"snapshot_duplicate_add" = "Ajouter quand même";
"snapshot_duplicate_message" = "Vous avez déjà un snapshot pour ce mois. Voulez-vous en ajouter un autre ?";
"snapshot_duplicate_replace" = "Remplacer l'existant";
"snapshot_duplicate_title" = "Snapshot déjà existant";
"sources_filter_all" = "Toutes";
// MARK: - 1.4.0 Features
"monthly_summary_notification_title" = "Votre résumé mensuel de portefeuille";
"monthly_summary_notification_body" = "Vérifiez comment vos investissements ont performé ce mois-ci.";
"streak_badge" = "Série de %d mois";
"goals_empty_add_cta" = "Ajouter mon premier objectif";
"journal_empty_title" = "Aucune entrée pour l'instant";
"journal_empty_body" = "Les mises à jour de snapshots créent automatiquement des entrées mensuelles. Ajoutez votre première source pour commencer.";
"quick_update_title" = "Mise à jour rapide";
"quick_update_section_header" = "Saisir les valeurs actuelles";
"quick_update_section_footer" = "Seules les sources avec une valeur saisie seront mises à jour.";
"quick_update_placeholder" = "Nouvelle valeur";
"quick_update_save" = "Tout enregistrer";
"quick_update_no_sources" = "Aucune source";
"quick_update_no_sources_body" = "Ajoutez d'abord des sources d'investissement pour utiliser la mise à jour rapide.";
"update_available_title" = "Mise à jour disponible";
"update_available_body" = "La version %@ est disponible sur l'App Store.";
"whats_new_title" = "Nouveautés dans 1.4";
"whats_new_subtitle" = "Améliorations pour vous aider à rester sur la bonne voie.";
"whats_new_quick_update_title" = "Mise à jour rapide du portefeuille";
"whats_new_quick_update_body" = "Mettez à jour toutes vos sources depuis un seul écran en un geste.";
"whats_new_streak_title" = "Série de mises à jour";
"whats_new_streak_body" = "Suivez le nombre de mois consécutifs où vous avez tenu votre portefeuille à jour.";
"whats_new_goals_title" = "Objectifs et journal plus intelligents";
"whats_new_goals_body" = "Les états vides améliorés vous aident à démarrer plus rapidement.";
"whats_new_continue" = "C'est parti !";
@@ -292,3 +292,95 @@
"Best Contributor" = "Maggior contributore";
"Update Check-in" = "Aggiorna check-in";
"Completed %@" = "Completato %@";
// MARK: - Missing keys added (1.3.x)
"Checking…" = "Verifica…";
"Clear Filters" = "Cancella filtri";
"Force Upload to iCloud" = "Forza caricamento su iCloud";
"No export yet" = "Nessun export ancora";
"Not synced yet" = "Non ancora sincronizzato";
"Refresh" = "Aggiorna";
"Search sources" = "Cerca fonti";
"Syncing with iCloud..." = "Sincronizzazione con iCloud...";
"Uploading..." = "Caricamento...";
"Verify iCloud Setup" = "Verifica configurazione iCloud";
"add_source_name_footer" = "Una fonte è qualsiasi investimento che vuoi monitorare: azioni, ETF, conti di risparmio, immobili, crypto e altro.";
"add_source_name_placeholder" = "es. ETF MSCI World, Conto deposito, Appartamento...";
"categories_empty" = "Nessuna categoria ancora.";
"category_has_sources_warning" = "Non è possibile eliminare una categoria che ha fonti. Rimuovi o riassegna prima tutte le fonti.";
"category_name_placeholder" = "es. Fondo di emergenza";
"chart_yoy_empty" = "Aggiungi più snapshot in anni diversi per vedere il confronto.";
"chart_yoy_title" = "Anno su anno";
"checking_icloud" = "Verifica iCloud...";
"contributions_vs_returns_invested" = "Investito";
"contributions_vs_returns_returns" = "Rendimento di mercato";
"contributions_vs_returns_title" = "Investito vs. Rendimento";
"csv_enter_value" = "Inserisci valore";
"csv_field_category" = "Categoria";
"csv_field_contribution" = "Contributo";
"csv_field_date" = "Data";
"csv_field_notes" = "Note";
"csv_field_source" = "Nome fonte (obbligatorio)";
"csv_field_value" = "Valore (obbligatorio)";
"csv_mapping_subtitle" = "Associa le colonne CSV ai campi di Portfolio Journal";
"csv_no_column" = "— Non mappato —";
"csv_no_date_hint" = "Tutte le righe saranno importate come snapshot di oggi.";
"csv_optional_section" = "Campi opzionali";
"csv_preview_section" = "Anteprima CSV";
"csv_required_section" = "Campi obbligatori";
"csv_use_today" = "Usa la data di oggi";
"goal_achieved_notification_body" = "Hai raggiunto il tuo obiettivo: %@";
"goal_achieved_notification_title" = "Obiettivo raggiunto! 🎉";
"goal_archive" = "Archivia";
"goal_delete_confirm" = "Elimina";
"goal_delete_message" = "Questo obiettivo sarà eliminato definitivamente. Questa azione non può essere annullata.";
"goal_delete_title" = "Elimina obiettivo";
"goal_unarchive" = "Ripristina";
"goals_all_active_achieved" = "Tutti gli obiettivi attivi sono stati raggiunti.";
"goals_empty_archived" = "Nessun obiettivo archiviato.";
"goals_filter_active" = "Attivi";
"goals_filter_all" = "Tutti";
"goals_filter_archived" = "Archiviati";
"icloud_check_description" = "Se hai dati su un altro dispositivo, abilita iCloud per ripristinarli qui.";
"icloud_check_title" = "Usi già Portfolio Journal?";
"icloud_enabled_description" = "Chiudi l'app e riaprila. I tuoi dati si caricheranno automaticamente da iCloud.";
"icloud_enabled_title" = "iCloud abilitato";
"monthly_checkin_notification_body" = "Registra i valori di questo mese e monitora la crescita del tuo portafoglio.";
"monthly_checkin_notification_title" = "È ora del tuo aggiornamento mensile";
"onboarding_add_first_source" = "Aggiungi il mio primo investimento";
"onboarding_import_data" = "Importa dati esistenti";
"onboarding_quickstart_subtitle" = "Aggiungi la tua prima fonte di investimento per iniziare a monitorare il portafoglio.";
"onboarding_quickstart_title" = "Quasi pronto";
"reengagement_body" = "Pochi minuti bastano per tenere sotto controllo i tuoi investimenti.";
"reengagement_title" = "Il tuo portafoglio ti aspetta";
"snapshot_duplicate_add" = "Aggiungi comunque";
"snapshot_duplicate_message" = "Hai già uno snapshot per questo mese. Vuoi aggiungerne un altro?";
"snapshot_duplicate_replace" = "Sostituisci esistente";
"snapshot_duplicate_title" = "Snapshot già esistente";
"sources_filter_all" = "Tutte";
// MARK: - 1.4.0 Features
"monthly_summary_notification_title" = "Il tuo riepilogo mensile del portafoglio";
"monthly_summary_notification_body" = "Controlla come hanno performato i tuoi investimenti questo mese.";
"streak_badge" = "Serie di %d mesi";
"goals_empty_add_cta" = "Aggiungi il mio primo obiettivo";
"journal_empty_title" = "Nessun registro ancora";
"journal_empty_body" = "Gli aggiornamenti degli snapshot creano automaticamente voci mensili. Aggiungi la tua prima fonte per iniziare.";
"quick_update_title" = "Aggiornamento rapido";
"quick_update_section_header" = "Inserisci i valori correnti";
"quick_update_section_footer" = "Verranno aggiornate solo le fonti con un valore inserito.";
"quick_update_placeholder" = "Nuovo valore";
"quick_update_save" = "Salva tutto";
"quick_update_no_sources" = "Nessuna fonte";
"quick_update_no_sources_body" = "Aggiungi prima fonti di investimento per usare l'aggiornamento rapido.";
"update_available_title" = "Aggiornamento disponibile";
"update_available_body" = "La versione %@ è disponibile sull'App Store.";
"whats_new_title" = "Novità in 1.4";
"whats_new_subtitle" = "Miglioramenti per aiutarti a restare in carreggiata.";
"whats_new_quick_update_title" = "Aggiornamento rapido del portafoglio";
"whats_new_quick_update_body" = "Aggiorna tutte le tue fonti da un'unica schermata con un tocco.";
"whats_new_streak_title" = "Serie di aggiornamenti";
"whats_new_streak_body" = "Tieni traccia di quanti mesi consecutivi hai tenuto aggiornato il tuo portafoglio.";
"whats_new_goals_title" = "Obiettivi e diario più intelligenti";
"whats_new_goals_body" = "Gli stati vuoti migliorati ti aiutano a iniziare più velocemente.";
"whats_new_continue" = "Iniziamo!";
@@ -292,3 +292,95 @@
"Best Contributor" = "最大の貢献元";
"Update Check-in" = "チェックインを更新";
"Completed %@" = "%@ に完了";
// MARK: - Missing keys added (1.3.x)
"Checking…" = "確認中…";
"Clear Filters" = "フィルターをクリア";
"Force Upload to iCloud" = "iCloudへ強制アップロード";
"No export yet" = "まだエクスポートなし";
"Not synced yet" = "まだ同期されていません";
"Refresh" = "更新";
"Search sources" = "ソースを検索";
"Syncing with iCloud..." = "iCloudと同期中...";
"Uploading..." = "アップロード中...";
"Verify iCloud Setup" = "iCloud設定を確認";
"add_source_name_footer" = "ソースとは追跡したい投資のことです:株式、ETF、普通預金、不動産、暗号資産など。";
"add_source_name_placeholder" = "例:MSCI World ETF、普通預金、マンション...";
"categories_empty" = "カテゴリーがまだありません。";
"category_has_sources_warning" = "ソースがあるカテゴリーは削除できません。先にすべてのソースを削除または再割り当てしてください。";
"category_name_placeholder" = "例:緊急資金";
"chart_yoy_empty" = "比較を表示するには、異なる年のスナップショットを追加してください。";
"chart_yoy_title" = "年次比較";
"checking_icloud" = "iCloudを確認中...";
"contributions_vs_returns_invested" = "投資額";
"contributions_vs_returns_returns" = "市場リターン";
"contributions_vs_returns_title" = "投資額 vs. リターン";
"csv_enter_value" = "値を入力";
"csv_field_category" = "カテゴリー";
"csv_field_contribution" = "追加投資";
"csv_field_date" = "日付";
"csv_field_notes" = "メモ";
"csv_field_source" = "ソース名(必須)";
"csv_field_value" = "値(必須)";
"csv_mapping_subtitle" = "CSVの列をPortfolio Journalのフィールドに対応付けてください";
"csv_no_column" = "— 未マッピング —";
"csv_no_date_hint" = "すべての行が今日のスナップショットとしてインポートされます。";
"csv_optional_section" = "任意フィールド";
"csv_preview_section" = "CSVプレビュー";
"csv_required_section" = "必須フィールド";
"csv_use_today" = "今日の日付を使用";
"goal_achieved_notification_body" = "目標を達成しました:%@";
"goal_achieved_notification_title" = "目標達成! 🎉";
"goal_archive" = "アーカイブ";
"goal_delete_confirm" = "削除";
"goal_delete_message" = "この目標は完全に削除されます。この操作は取り消せません。";
"goal_delete_title" = "目標を削除";
"goal_unarchive" = "元に戻す";
"goals_all_active_achieved" = "すべてのアクティブな目標が達成されました。";
"goals_empty_archived" = "アーカイブされた目標はありません。";
"goals_filter_active" = "アクティブ";
"goals_filter_all" = "すべて";
"goals_filter_archived" = "アーカイブ済み";
"icloud_check_description" = "他のデバイスにデータがある場合は、iCloudを有効にしてここで復元してください。";
"icloud_check_title" = "すでにPortfolio Journalをお使いですか?";
"icloud_enabled_description" = "アプリを閉じて再度開いてください。データはiCloudから自動的に読み込まれます。";
"icloud_enabled_title" = "iCloudが有効になりました";
"monthly_checkin_notification_body" = "今月の値を記録して、ポートフォリオの成長を確認しましょう。";
"monthly_checkin_notification_title" = "月次アップデートの時間です";
"onboarding_add_first_source" = "最初の投資を追加";
"onboarding_import_data" = "既存のデータをインポート";
"onboarding_quickstart_subtitle" = "最初の投資ソースを追加して、ポートフォリオの追跡を始めましょう。";
"onboarding_quickstart_title" = "もうすぐ完了";
"reengagement_body" = "数分で投資の状況を把握できます。";
"reengagement_title" = "ポートフォリオが待っています";
"snapshot_duplicate_add" = "とにかく追加";
"snapshot_duplicate_message" = "今月のスナップショットはすでにあります。別のスナップショットを追加しますか?";
"snapshot_duplicate_replace" = "既存を置き換え";
"snapshot_duplicate_title" = "スナップショットがすでに存在します";
"sources_filter_all" = "すべて";
// MARK: - 1.4.0 Features
"monthly_summary_notification_title" = "毎月のポートフォリオサマリー";
"monthly_summary_notification_body" = "今月の投資パフォーマンスを確認しましょう。";
"streak_badge" = "%dヶ月連続";
"goals_empty_add_cta" = "最初の目標を追加";
"journal_empty_title" = "まだ記録がありません";
"journal_empty_body" = "スナップショットの更新により月次エントリが自動的に作成されます。最初のソースを追加して始めましょう。";
"quick_update_title" = "クイック更新";
"quick_update_section_header" = "現在の値を入力";
"quick_update_section_footer" = "値が入力されたソースのみ更新されます。";
"quick_update_placeholder" = "新しい値";
"quick_update_save" = "すべて保存";
"quick_update_no_sources" = "ソースなし";
"quick_update_no_sources_body" = "クイック更新を使用するには、まず投資ソースを追加してください。";
"update_available_title" = "アップデートあり";
"update_available_body" = "バージョン%@がApp Storeで利用可能です。";
"whats_new_title" = "1.4の新機能";
"whats_new_subtitle" = "軌道を保つための改善点。";
"whats_new_quick_update_title" = "ポートフォリオのクイック更新";
"whats_new_quick_update_body" = "一つの画面からすべてのソースをワンタップで更新できます。";
"whats_new_streak_title" = "更新ストリーク";
"whats_new_streak_body" = "何ヶ月連続でポートフォリオを最新の状態に保てたか追跡します。";
"whats_new_goals_title" = "スマートな目標とジャーナル";
"whats_new_goals_body" = "改善された空の状態でより素早く始められます。";
"whats_new_continue" = "始めよう";
@@ -292,3 +292,95 @@
"Best Contributor" = "Maior contribuinte";
"Update Check-in" = "Atualizar check-in";
"Completed %@" = "Concluído %@";
// MARK: - Missing keys added (1.3.x)
"Checking…" = "Verificando…";
"Clear Filters" = "Limpar filtros";
"Force Upload to iCloud" = "Forçar envio ao iCloud";
"No export yet" = "Ainda não exportado";
"Not synced yet" = "Ainda não sincronizado";
"Refresh" = "Atualizar";
"Search sources" = "Buscar fontes";
"Syncing with iCloud..." = "Sincronizando com iCloud...";
"Uploading..." = "Enviando...";
"Verify iCloud Setup" = "Verificar configuração do iCloud";
"add_source_name_footer" = "Uma fonte é qualquer investimento que você deseja acompanhar: ações, ETFs, poupança, imóveis, criptomoedas e mais.";
"add_source_name_placeholder" = "ex.: ETF MSCI World, Poupança, Apartamento...";
"categories_empty" = "Nenhuma categoria ainda.";
"category_has_sources_warning" = "Não é possível excluir uma categoria que possui fontes. Remova ou reatribua todas as fontes primeiro.";
"category_name_placeholder" = "ex.: Reserva de emergência";
"chart_yoy_empty" = "Adicione mais snapshots em anos diferentes para ver a comparação.";
"chart_yoy_title" = "Ano a ano";
"checking_icloud" = "Verificando iCloud...";
"contributions_vs_returns_invested" = "Investido";
"contributions_vs_returns_returns" = "Retorno do mercado";
"contributions_vs_returns_title" = "Investido vs. Retorno";
"csv_enter_value" = "Inserir valor";
"csv_field_category" = "Categoria";
"csv_field_contribution" = "Contribuição";
"csv_field_date" = "Data";
"csv_field_notes" = "Notas";
"csv_field_source" = "Nome da fonte (obrigatório)";
"csv_field_value" = "Valor (obrigatório)";
"csv_mapping_subtitle" = "Associe as colunas do CSV aos campos do Portfolio Journal";
"csv_no_column" = "— Não mapeado —";
"csv_no_date_hint" = "Todas as linhas serão importadas como um snapshot de hoje.";
"csv_optional_section" = "Campos opcionais";
"csv_preview_section" = "Prévia do CSV";
"csv_required_section" = "Campos obrigatórios";
"csv_use_today" = "Usar data de hoje";
"goal_achieved_notification_body" = "Você atingiu sua meta: %@";
"goal_achieved_notification_title" = "Meta atingida! 🎉";
"goal_archive" = "Arquivar";
"goal_delete_confirm" = "Excluir";
"goal_delete_message" = "Esta meta será excluída permanentemente. Esta ação não pode ser desfeita.";
"goal_delete_title" = "Excluir meta";
"goal_unarchive" = "Restaurar";
"goals_all_active_achieved" = "Todas as metas ativas foram alcançadas.";
"goals_empty_archived" = "Nenhuma meta arquivada.";
"goals_filter_active" = "Ativas";
"goals_filter_all" = "Todas";
"goals_filter_archived" = "Arquivadas";
"icloud_check_description" = "Se você tem dados em outro dispositivo, ative o iCloud para restaurá-los aqui.";
"icloud_check_title" = "Já usa o Portfolio Journal?";
"icloud_enabled_description" = "Feche o app e abra novamente. Seus dados serão carregados automaticamente do iCloud.";
"icloud_enabled_title" = "iCloud ativado";
"monthly_checkin_notification_body" = "Registre os valores deste mês e acompanhe o crescimento do seu portfólio.";
"monthly_checkin_notification_title" = "Hora da sua atualização mensal";
"onboarding_add_first_source" = "Adicionar meu primeiro investimento";
"onboarding_import_data" = "Importar dados existentes";
"onboarding_quickstart_subtitle" = "Adicione sua primeira fonte de investimento para começar a acompanhar seu portfólio.";
"onboarding_quickstart_title" = "Quase lá";
"reengagement_body" = "Alguns minutos são suficientes para manter o controle dos seus investimentos.";
"reengagement_title" = "Seu portfólio está esperando";
"snapshot_duplicate_add" = "Adicionar mesmo assim";
"snapshot_duplicate_message" = "Você já tem um snapshot para este mês. Deseja adicionar outro?";
"snapshot_duplicate_replace" = "Substituir existente";
"snapshot_duplicate_title" = "Snapshot já existe";
"sources_filter_all" = "Todas";
// MARK: - 1.4.0 Features
"monthly_summary_notification_title" = "Seu resumo mensal do portfólio";
"monthly_summary_notification_body" = "Veja como seus investimentos se saíram este mês.";
"streak_badge" = "Sequência de %d meses";
"goals_empty_add_cta" = "Adicionar minha primeira meta";
"journal_empty_title" = "Nenhum registro ainda";
"journal_empty_body" = "As atualizações de snapshots criam entradas mensais automaticamente. Adicione sua primeira fonte para começar.";
"quick_update_title" = "Atualização rápida";
"quick_update_section_header" = "Insira os valores atuais";
"quick_update_section_footer" = "Apenas as fontes com um valor inserido serão atualizadas.";
"quick_update_placeholder" = "Novo valor";
"quick_update_save" = "Salvar tudo";
"quick_update_no_sources" = "Sem fontes";
"quick_update_no_sources_body" = "Adicione fontes de investimento primeiro para usar a atualização rápida.";
"update_available_title" = "Atualização disponível";
"update_available_body" = "A versão %@ está disponível na App Store.";
"whats_new_title" = "Novidades no 1.4";
"whats_new_subtitle" = "Melhorias para te ajudar a manter o rumo.";
"whats_new_quick_update_title" = "Atualização rápida do portfólio";
"whats_new_quick_update_body" = "Atualize todas as suas fontes em uma única tela com um toque.";
"whats_new_streak_title" = "Sequência de atualizações";
"whats_new_streak_body" = "Acompanhe quantos meses consecutivos você manteve seu portfólio atualizado.";
"whats_new_goals_title" = "Metas e diário mais inteligentes";
"whats_new_goals_body" = "Estados vazios melhorados ajudam você a começar mais rápido.";
"whats_new_continue" = "Vamos lá!";
@@ -0,0 +1,36 @@
import Foundation
import Combine
@MainActor
class AppUpdateService: ObservableObject {
static let shared = AppUpdateService()
@Published var updateAvailable = false
@Published var latestVersion: String?
private init() {}
func checkForUpdate() {
guard let bundleId = Bundle.main.bundleIdentifier else { return }
let urlString = "https://itunes.apple.com/lookup?bundleId=\(bundleId)"
guard let url = URL(string: urlString) else { return }
Task {
do {
let (data, _) = try await URLSession.shared.data(from: url)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let results = json["results"] as? [[String: Any]],
let first = results.first,
let appStoreVersion = first["version"] as? String,
let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
else { return }
if appStoreVersion.compare(currentVersion, options: .numeric) == .orderedDescending {
self.updateAvailable = true
self.latestVersion = appStoreVersion
}
} catch {
// silently fail
}
}
}
}
@@ -655,6 +655,184 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
]
}
// MARK: - CSV Column Mapping
/// Parsed preview data for the mapping UI
struct CSVPreview {
let rows: [[String]]
let columnCount: Int
func headers(hasHeaderRow: Bool) -> [String] {
if hasHeaderRow, let first = rows.first {
return first
}
return (0..<columnCount).map { "Column \($0 + 1)" }
}
func sampleRow(hasHeaderRow: Bool) -> [String]? {
let start = hasHeaderRow ? 1 : 0
return rows.count > start ? rows[start] : nil
}
}
func previewCSV(_ content: String) -> CSVPreview {
let rows = parseCSVRows(content)
let columnCount = rows.first?.count ?? 0
return CSVPreview(rows: rows, columnCount: columnCount)
}
// Maps each app field to a CSV column index or a constant value
struct CSVMappingConfig {
// -1 = not mapped, -2 = constant, -3 = use today (date only), 0+ = column index
static let notMapped = -1
static let constant = -2
static let useToday = -3
var hasHeaderRow: Bool = true
var sourceIndex: Int = notMapped
var sourceConstant: String = ""
var valueIndex: Int = notMapped
var dateIndex: Int = useToday // default: use today
var categoryIndex: Int = constant
var categoryConstant: String = "Other" // default category name when constant
var contributionIndex: Int = notMapped
var notesIndex: Int = notMapped
var isValid: Bool {
let sourceOk: Bool = {
if sourceIndex == Self.constant { return !sourceConstant.isEmpty }
return sourceIndex >= 0
}()
let valueOk = valueIndex >= 0
return sourceOk && valueOk
}
}
func importCSVWithMapping(
content: String,
mapping: CSVMappingConfig,
defaultAccountName: String?
) -> ImportResult {
let preview = previewCSV(content)
let accounts = parseCSVWithMapping(preview: preview, mapping: mapping, defaultAccountName: defaultAccountName)
return applyImport(accounts, context: CoreDataStack.shared.viewContext)
}
func importCSVWithMappingAsync(
content: String,
mapping: CSVMappingConfig,
defaultAccountName: String?,
progress: @escaping (ImportProgress) -> Void
) async -> ImportResult {
await withCheckedContinuation { continuation in
CoreDataStack.shared.performBackgroundTask { context in
let preview = self.previewCSV(content)
let accounts = self.parseCSVWithMapping(preview: preview, mapping: mapping, defaultAccountName: defaultAccountName)
let totalSnapshots = accounts.reduce(0) { t, a in
t + a.categories.reduce(0) { s, c in s + c.sources.reduce(0) { $0 + $1.snapshots.count } }
}
DispatchQueue.main.async {
progress(ImportProgress(completed: 0, total: totalSnapshots, message: "Importing data"))
}
let result = self.applyImport(accounts, context: context) { completed in
DispatchQueue.main.async {
progress(ImportProgress(
completed: completed,
total: totalSnapshots,
message: "Imported \(completed) of \(totalSnapshots) snapshots"
))
}
}
continuation.resume(returning: result)
}
}
}
private func parseCSVWithMapping(
preview: CSVPreview,
mapping: CSVMappingConfig,
defaultAccountName: String?
) -> [ImportedAccount] {
let dataRows = mapping.hasHeaderRow ? Array(preview.rows.dropFirst()) : preview.rows
let fallbackAccount = defaultAccountName ?? "Personal"
var grouped: [String: [String: [String: [ImportedSnapshot]]]] = [:]
for row in dataRows {
// Source name
let sourceName: String
if mapping.sourceIndex == CSVMappingConfig.constant {
guard !mapping.sourceConstant.isEmpty else { continue }
sourceName = mapping.sourceConstant
} else if mapping.sourceIndex >= 0 {
guard let v = row.safeValue(at: mapping.sourceIndex), !v.isEmpty else { continue }
sourceName = v
} else {
continue
}
// Value
guard mapping.valueIndex >= 0,
let valStr = row.safeValue(at: mapping.valueIndex),
let value = parseDecimal(valStr) else { continue }
// Date
let date: Date
if mapping.dateIndex == CSVMappingConfig.useToday {
date = Calendar.current.startOfDay(for: Date())
} else if mapping.dateIndex >= 0,
let dateStr = row.safeValue(at: mapping.dateIndex),
let parsed = parseDate(dateStr) {
date = parsed
} else {
date = Calendar.current.startOfDay(for: Date())
}
// Category
let categoryName: String
if mapping.categoryIndex == CSVMappingConfig.constant {
categoryName = mapping.categoryConstant.isEmpty ? "Other" : mapping.categoryConstant
} else if mapping.categoryIndex >= 0,
let v = row.safeValue(at: mapping.categoryIndex), !v.isEmpty {
categoryName = v
} else {
categoryName = "Other"
}
// Contribution
let contribution: Decimal? = mapping.contributionIndex >= 0
? row.safeValue(at: mapping.contributionIndex).flatMap(parseDecimal)
: nil
// Notes
let notes: String? = mapping.notesIndex >= 0
? row.safeValue(at: mapping.notesIndex).flatMap { $0.isEmpty ? nil : $0 }
: nil
let snapshot = ImportedSnapshot(date: date, value: value, contribution: contribution, notes: notes)
grouped[fallbackAccount, default: [:]][categoryName, default: [:]][sourceName, default: []].append(snapshot)
}
return grouped.map { accountName, categories in
let importedCategories = categories.map { categoryName, sources in
let importedSources = sources.map { sourceName, snapshots in
ImportedSource(name: sourceName, snapshots: snapshots)
}
return ImportedCategory(name: categoryName, colorHex: nil, icon: nil, sources: importedSources)
}
return ImportedAccount(
name: accountName,
currency: nil,
inputMode: .simple,
notificationFrequency: .monthly,
customFrequencyMonths: 1,
categories: importedCategories
)
}
}
// MARK: - CSV Helpers
private func parseCSVRows(_ content: String) -> [[String]] {
@@ -31,6 +31,9 @@ class NotificationService: ObservableObject {
await MainActor.run {
self.isAuthorized = granted
}
if granted {
scheduleMonthlyPerformanceSummary()
}
return granted
} catch {
print("Notification authorization error: \(error)")
@@ -155,6 +158,18 @@ extension NotificationService {
func handleNotificationResponse(_ response: UNNotificationResponse) {
let userInfo = response.notification.request.content.userInfo
// Handle batch update deep link from monthly check-in
if let action = userInfo["action"] as? String, action == "batchUpdate" {
NotificationCenter.default.post(name: .openBatchUpdate, object: nil)
return
}
// Handle openDashboard deep link from monthly summary notification
if let action = userInfo["action"] as? String, action == "openDashboard" {
NotificationCenter.default.post(name: .openDashboard, object: nil)
return
}
guard let sourceIdString = userInfo["sourceId"] as? String,
let sourceId = UUID(uuidString: sourceIdString) else {
return
@@ -174,6 +189,9 @@ extension NotificationService {
extension Notification.Name {
static let openSourceDetail = Notification.Name("openSourceDetail")
static let didResetData = Notification.Name("didResetData")
static let openBatchUpdate = Notification.Name("openBatchUpdate")
static let openDashboard = Notification.Name("openDashboard")
static let openQuickUpdate = Notification.Name("openQuickUpdate")
}
// MARK: - Re-engagement Notifications
@@ -214,6 +232,7 @@ extension NotificationService {
content.title = String(localized: "monthly_checkin_notification_title")
content.body = String(localized: "monthly_checkin_notification_body")
content.sound = .default
content.userInfo = ["action": "batchUpdate"]
var components = DateComponents()
components.day = 1
@@ -230,6 +249,56 @@ extension NotificationService {
}
}
}
/// Schedules a monthly portfolio summary notification on the 5th of each month at 9am.
func scheduleMonthlyPerformanceSummary() {
guard isAuthorized else { return }
let identifier = "monthly_summary"
center.getPendingNotificationRequests { [weak self] requests in
guard let self, !requests.contains(where: { $0.identifier == identifier }) else { return }
let content = UNMutableNotificationContent()
content.title = String(localized: "monthly_summary_notification_title")
content.body = String(localized: "monthly_summary_notification_body")
content.sound = .default
content.userInfo = ["action": "openDashboard"]
var components = DateComponents()
components.day = 5
components.hour = 9
components.minute = 0
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
self.center.add(request) { error in
if let error = error {
print("Monthly summary notification error: \(error)")
}
}
}
}
/// Fires a one-time local notification celebrating goal achievement.
func scheduleGoalAchievedNotification(goalName: String) {
guard isAuthorized else { return }
let content = UNMutableNotificationContent()
content.title = String(localized: "goal_achieved_notification_title")
content.body = String(format: String(localized: "goal_achieved_notification_body"), goalName)
content.sound = .default
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let id = "goal_achieved_\(UUID().uuidString)"
let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger)
center.add(request) { error in
if let error = error {
print("Goal achieved notification error: \(error)")
}
}
}
}
// MARK: - Background Refresh
@@ -4,6 +4,22 @@ struct DashboardSectionConfig: Identifiable, Codable, Hashable {
let id: String
var isVisible: Bool
var isCollapsed: Bool
var columnSpan: Int
init(id: String, isVisible: Bool, isCollapsed: Bool, columnSpan: Int = 1) {
self.id = id
self.isVisible = isVisible
self.isCollapsed = isCollapsed
self.columnSpan = columnSpan
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
isVisible = try container.decode(Bool.self, forKey: .isVisible)
isCollapsed = try container.decode(Bool.self, forKey: .isCollapsed)
columnSpan = try container.decodeIfPresent(Int.self, forKey: .columnSpan) ?? 1
}
}
enum DashboardSection: String, CaseIterable, Identifiable {
@@ -16,6 +32,7 @@ enum DashboardSection: String, CaseIterable, Identifiable {
case goals
case pendingUpdates
case periodReturns
case contributionsVsReturns
var id: String { rawValue }
@@ -39,6 +56,8 @@ enum DashboardSection: String, CaseIterable, Identifiable {
return "Pending Updates"
case .periodReturns:
return "Returns"
case .contributionsVsReturns:
return "Invested vs. Returns"
}
}
}
@@ -17,6 +17,7 @@ class ChartsViewModel: ObservableObject {
case drawdown = "Drawdown"
case volatility = "Volatility"
case prediction = "Prediction"
case yearOverYear = "Year vs Year"
var id: String { rawValue }
@@ -32,6 +33,7 @@ class ChartsViewModel: ObservableObject {
case .drawdown: return "arrow.down.right.circle"
case .volatility: return "waveform.path.ecg"
case .prediction: return "wand.and.stars"
case .yearOverYear: return "calendar"
}
}
@@ -40,7 +42,7 @@ class ChartsViewModel: ObservableObject {
case .evolution:
return false
case .allocation, .performance, .contributions, .rollingReturn, .riskReturn, .cashflow,
.drawdown, .volatility, .prediction:
.drawdown, .volatility, .prediction, .yearOverYear:
return true
}
}
@@ -67,6 +69,8 @@ class ChartsViewModel: ObservableObject {
return "Understand investment risk levels"
case .prediction:
return "View 12-month forecasts"
case .yearOverYear:
return "Compare portfolio values across years"
}
}
}
@@ -104,6 +108,13 @@ class ChartsViewModel: ObservableObject {
@Published var volatilityData: [(date: Date, volatility: Double)] = []
@Published var predictionData: [Prediction] = []
@Published var allocationEvolutionData: [(date: Date, category: String, percentage: Double, color: String)] = []
@Published var yearOverYearData: [YearSeries] = []
struct YearSeries: Identifiable {
let id: Int // year
let year: Int
let values: [Decimal] // one value per month (Jan=0, Dec=11), 0 if no data
}
@Published var isLoading = false
@Published var showingPaywall = false
@@ -280,6 +291,8 @@ class ChartsViewModel: ObservableObject {
switch chartType {
case .evolution, .performance:
return [.all, .yearToDate, .year, .quarter]
case .yearOverYear:
return [] // No time range filter for YoY always uses all data
default:
return [.month, .quarter, .halfYear, .year, .all]
}
@@ -368,6 +381,8 @@ class ChartsViewModel: ObservableObject {
calculateVolatilityData(from: completedSnapshots)
case .prediction:
calculatePredictionData(from: completedSnapshots)
case .yearOverYear:
calculateYearOverYearData(from: completedSnapshots)
}
if let selected = selectedCategory,
@@ -955,6 +970,57 @@ class ChartsViewModel: ObservableObject {
predictionData = result.predictions
}
private func calculateYearOverYearData(from snapshots: [Snapshot]) {
guard !snapshots.isEmpty else {
yearOverYearData = []
return
}
// Build all monthly totals (no time range filter for YoY)
let allSourceIds = sourceRepository.sources.filter { shouldIncludeSource($0) }.compactMap { $0.id }
let allSnapshots: [Snapshot]
if let cached = cachedSnapshots {
allSnapshots = cached
} else {
allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
}
let calendar = Calendar.current
// group by (year, month), take latest per source
var byYearMonth: [Int: [Int: Decimal]] = [:]
var latestBySource: [UUID: Snapshot] = [:]
let sorted = allSnapshots.sorted { $0.date < $1.date }
for snap in sorted {
guard let sourceId = snap.source?.id else { continue }
latestBySource[sourceId] = snap
let year = calendar.component(.year, from: snap.date)
let month = calendar.component(.month, from: snap.date) // 1-based
let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue }
byYearMonth[year, default: [:]][month] = total
}
let years = byYearMonth.keys.sorted()
guard !years.isEmpty else {
yearOverYearData = []
return
}
// Forward-fill within each year: carry previous month if no data
yearOverYearData = years.map { year in
var values = [Decimal](repeating: .zero, count: 12)
var last: Decimal = .zero
for monthIdx in 0..<12 {
let month = monthIdx + 1
if let v = byYearMonth[year]?[month] {
last = v
}
values[monthIdx] = last
}
return YearSeries(id: year, year: year, values: values)
}
}
private func monthlyTotals(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> Date in
chartMonthStart(for: snapshot.date)
@@ -22,6 +22,7 @@ class DashboardViewModel: ObservableObject {
// MARK: - Chart Data
@Published var evolutionData: [(date: Date, value: Decimal)] = []
@Published var updateStreak: Int = 0
// MARK: - Portfolio Forecast
@@ -204,6 +205,9 @@ class DashboardViewModel: ObservableObject {
// Calculate portfolio forecast
updatePortfolioForecast()
// Compute update streak
updateStreak = computeStreak(from: evolutionData)
// Log screen view
FirebaseService.shared.logScreenView(screenName: "Dashboard")
}
@@ -535,6 +539,42 @@ class DashboardViewModel: ObservableObject {
return annualized
}
private func computeStreak(from evolutionData: [(date: Date, value: Decimal)]) -> Int {
guard !evolutionData.isEmpty else { return 0 }
let calendar = Calendar.current
// Group into (year, month) set
let monthSet: Set<DateComponents> = Set(evolutionData.map { point in
let comps = calendar.dateComponents([.year, .month], from: point.date)
return DateComponents(year: comps.year, month: comps.month)
})
// Get sorted unique months descending
let sortedMonths = monthSet
.compactMap { comps -> Date? in calendar.date(from: comps) }
.sorted(by: >)
guard let mostRecent = sortedMonths.first else { return 0 }
var streak = 1
var current = mostRecent
for i in 1..<sortedMonths.count {
guard let expected = calendar.date(byAdding: .month, value: -1, to: current) else { break }
let prev = sortedMonths[i]
let prevComps = calendar.dateComponents([.year, .month], from: prev)
let expectedComps = calendar.dateComponents([.year, .month], from: expected)
if prevComps.year == expectedComps.year && prevComps.month == expectedComps.month {
streak += 1
current = prev
} else {
break
}
}
return streak
}
// Virtual snapshot for forecast calculations
private struct VirtualSnapshot {
let date: Date
@@ -161,6 +161,10 @@ class GoalsViewModel: ObservableObject {
goalRepository.deleteGoal(goal)
}
func archiveGoal(_ goal: Goal) {
goalRepository.updateGoal(goal, isActive: !goal.isActive)
}
func estimateCompletionDate(for goal: Goal) -> Date? {
// Performance: Use cached completion date if available
if let cached = cachedCompletionDates[goal.id] {
@@ -8,7 +8,7 @@ class SourceListViewModel: ObservableObject {
@Published var sources: [InvestmentSource] = []
@Published var categories: [Category] = []
@Published var selectedCategory: Category?
@Published var selectedCategoryIds: Set<UUID> = []
@Published var searchText = ""
@Published var selectedAccount: Account?
@Published var showAllAccounts = true
@@ -55,7 +55,7 @@ class SourceListViewModel: ObservableObject {
Publishers.CombineLatest4(
sourceRepository.$sources,
$searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main),
$selectedCategory,
$selectedCategoryIds,
$selectedAccount
)
.combineLatest($showAllAccounts)
@@ -88,9 +88,12 @@ class SourceListViewModel: ObservableObject {
filtered = filtered.filter { $0.account?.id == selectedAccountId }
}
// Filter by category
if let category = selectedCategory {
filtered = filtered.filter { $0.category?.id == category.id }
// Filter by category (multi-select)
if !selectedCategoryIds.isEmpty {
filtered = filtered.filter {
guard let id = $0.category?.id else { return false }
return selectedCategoryIds.contains(id)
}
}
// Filter by search text
@@ -200,21 +203,29 @@ class SourceListViewModel: ObservableObject {
}
var isEmpty: Bool {
sources.isEmpty && searchText.isEmpty && selectedCategory == nil
sources.isEmpty && searchText.isEmpty && selectedCategoryIds.isEmpty
}
var isFiltered: Bool {
!searchText.isEmpty || selectedCategory != nil
!searchText.isEmpty || !selectedCategoryIds.isEmpty
}
// MARK: - Category Filter
func selectCategory(_ category: Category?) {
selectedCategory = category
guard let category else {
selectedCategoryIds = []
return
}
if selectedCategoryIds.contains(category.id) {
selectedCategoryIds.remove(category.id)
} else {
selectedCategoryIds.insert(category.id)
}
}
func clearFilters() {
searchText = ""
selectedCategory = nil
selectedCategoryIds = []
}
}
@@ -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
@@ -884,16 +884,22 @@ struct InvestmentWidgetEntryView: View {
switch family {
case .systemSmall:
SmallWidgetView(entry: entry)
.widgetURL(URL(string: "portfoliojournal://quickupdate")!)
case .systemMedium:
MediumWidgetView(entry: entry)
.widgetURL(URL(string: "portfoliojournal://quickupdate")!)
case .systemLarge:
LargeWidgetView(entry: entry)
.widgetURL(URL(string: "portfoliojournal://quickupdate")!)
case .accessoryCircular:
AccessoryCircularView(entry: entry)
.widgetURL(URL(string: "portfoliojournal://quickupdate")!)
case .accessoryRectangular:
AccessoryRectangularView(entry: entry)
.widgetURL(URL(string: "portfoliojournal://quickupdate")!)
default:
SmallWidgetView(entry: entry)
.widgetURL(URL(string: "portfoliojournal://quickupdate")!)
}
}
}
+1 -1
View File
@@ -1 +1 @@
app_version "1.3.1"
app_version "1.3.2"
+21 -77
View File
@@ -222,7 +222,7 @@
<div class="app-changelog">
<div class="cat-headline">Changelog</div>
Verbesserungen bei der iCloud-Synchronisierung:<br />• Daten werden jetzt sofort synchronisiert, wenn du zwischen Apple-Geräten wechselst<br />• Neue Geräteerkennung: Die App erkennt vorhandene iCloud-Daten vor dem Onboarding, sodass du auf einem neuen Gerät nie von vorne anfangen musst<br />• Behoben: Änderungen von einem anderen Gerät wurden in einigen Fällen nicht korrekt übernommen<br />• Allgemeine Stabilitätsverbesserungen und Fehlerbehebungen
Verbessertes Onboarding und monatliche Erinnerungen:<br /><br />• Neu gestaltete Ersteinrichtung — eine klare Aktion zum Hinzufügen deiner ersten Investition, ohne unnötige Schritte<br />• Monatliche Erinnerungen — optionale Benachrichtigungen, damit du das monatliche Portfolio-Update nicht vergisst<br />• Charts-Vorschau — kostenlose Nutzer sehen, welche Charts verfügbar sind, bevor sie upgraden<br />• Verbesserter Paywall — eine echte Portfolio-Grafik zeigt, wie der Vollzugang aussieht<br />• Klarere Quelleneinrichtung — kurze Erklärung beim Erstellen deiner ersten Anlageposition<br />
</div>
@@ -237,17 +237,9 @@
<div class="cat-headline">Screenshots</div>
<!-- no screenshots -->
<div style="border: 3px solid red; padding: 0px 20px">
<h2 style="color: red">No Screenshots Found</h2>
<h2 style="color: blue">Skipped Screenshots</h2>
<p>
deliver couldn't find any screenshots.
The existing screenshots on App Store Connect will be kept.
if you want to remove them you have to use the <i>--overwrite_screenshots</i> flag.
<p>
If you want to download your existing screenshots, run <i>deliver download_screenshots</i>.
Options to skip_screenshots activated. <i>deliver</i> will not modify your app screenshots.
</p>
</div>
@@ -340,7 +332,7 @@
<div class="app-changelog">
<div class="cat-headline">Changelog</div>
iCloud Sync improvements:<br />• Data now syncs instantly when switching between Apple devices<br />• New device detection: the app now detects existing iCloud data before onboarding, so you never start from scratch on a new phone<br />• Fixed cases where updates made on another device weren't reflected correctly<br />• General stability improvements and bug fixes
Smarter onboarding and monthly reminders:<br /><br />• Redesigned first-run experience — a single clear action to add your first investment, no setup friction<br />• Monthly reminders — optional notifications so you never forget your monthly portfolio update<br />• Charts preview — free users now see which charts are available before upgrading<br />• Improved paywall — a live portfolio chart preview shows what full access looks like<br />• Clearer source setup — added a short description when creating your first investment source<br />
</div>
@@ -355,17 +347,9 @@
<div class="cat-headline">Screenshots</div>
<!-- no screenshots -->
<div style="border: 3px solid red; padding: 0px 20px">
<h2 style="color: red">No Screenshots Found</h2>
<h2 style="color: blue">Skipped Screenshots</h2>
<p>
deliver couldn't find any screenshots.
The existing screenshots on App Store Connect will be kept.
if you want to remove them you have to use the <i>--overwrite_screenshots</i> flag.
<p>
If you want to download your existing screenshots, run <i>deliver download_screenshots</i>.
Options to skip_screenshots activated. <i>deliver</i> will not modify your app screenshots.
</p>
</div>
@@ -451,7 +435,7 @@
<div class="app-changelog">
<div class="cat-headline">Changelog</div>
Mejoras en la sincronización con iCloud:<br />• Los datos ahora se sincronizan al instante al cambiar entre dispositivos Apple<br />• Detección de dispositivo nuevo: la app detecta datos existentes en iCloud antes del proceso de bienvenida, para que nunca empieces desde cero en un teléfono nuevo<br />• Corregidos casos en los que los cambios realizados en otro dispositivo no se reflejaban correctamente<br />• Mejoras generales de estabilidad y corrección de errores
Incorporación más clara y recordatorios mensuales:<br /><br />• Primera experiencia rediseñada — una acción clara para añadir tu primera inversión, sin pasos innecesarios<br />• Recordatorios mensuales — notificaciones opcionales para no olvidar actualizar tu portfolio cada mes<br />• Vista previa de gráficos — los usuarios gratuitos ven qué gráficos están disponibles antes de desbloquear<br />• Paywall mejorado — un gráfico real de portfolio muestra cómo se ve el acceso completo<br />• Configuración de fuentes más clara — descripción breve al crear tu primera fuente de inversión<br />
</div>
@@ -466,17 +450,9 @@
<div class="cat-headline">Screenshots</div>
<!-- no screenshots -->
<div style="border: 3px solid red; padding: 0px 20px">
<h2 style="color: red">No Screenshots Found</h2>
<h2 style="color: blue">Skipped Screenshots</h2>
<p>
deliver couldn't find any screenshots.
The existing screenshots on App Store Connect will be kept.
if you want to remove them you have to use the <i>--overwrite_screenshots</i> flag.
<p>
If you want to download your existing screenshots, run <i>deliver download_screenshots</i>.
Options to skip_screenshots activated. <i>deliver</i> will not modify your app screenshots.
</p>
</div>
@@ -558,7 +534,7 @@
<div class="app-changelog">
<div class="cat-headline">Changelog</div>
Améliorations de la synchronisation iCloud :<br />• Les données se synchronisent désormais instantanément lors du passage entre appareils Apple<br />• Détection de nouvel appareil : l'app détecte les données iCloud existantes avant l'intégration, pour ne jamais repartir de zéro sur un nouvel iPhone<br />• Correction des cas où les modifications effectuées sur un autre appareil n'étaient pas correctement reflétées<br />• Améliorations générales de la stabilité et corrections de bugs
Intégration améliorée et rappels mensuels :<br /><br />• Première expérience repensée — une action claire pour ajouter votre premier investissement, sans étapes superflues<br />• Rappels mensuels — notifications facultatives pour ne jamais oublier la mise à jour mensuelle de votre portefeuille<br />• Aperçu des graphiques — les utilisateurs gratuits voient quels graphiques sont disponibles avant de passer à la version complète<br />• Paywall amélioré — un vrai graphique de portefeuille montre à quoi ressemble l'accès complet<br />• Configuration des sources clarifiée — courte description lors de la création de votre première source d'investissement<br />
</div>
@@ -573,17 +549,9 @@
<div class="cat-headline">Screenshots</div>
<!-- no screenshots -->
<div style="border: 3px solid red; padding: 0px 20px">
<h2 style="color: red">No Screenshots Found</h2>
<h2 style="color: blue">Skipped Screenshots</h2>
<p>
deliver couldn't find any screenshots.
The existing screenshots on App Store Connect will be kept.
if you want to remove them you have to use the <i>--overwrite_screenshots</i> flag.
<p>
If you want to download your existing screenshots, run <i>deliver download_screenshots</i>.
Options to skip_screenshots activated. <i>deliver</i> will not modify your app screenshots.
</p>
</div>
@@ -665,7 +633,7 @@
<div class="app-changelog">
<div class="cat-headline">Changelog</div>
Miglioramenti alla sincronizzazione iCloud:<br />• I dati ora si sincronizzano immediatamente quando passi da un dispositivo Apple all'altro<br />• Rilevamento nuovo dispositivo: l'app rileva i dati iCloud esistenti prima dell'onboarding, così non devi ricominciare da zero su un nuovo iPhone<br />• Risolti casi in cui le modifiche apportate su un altro dispositivo non venivano recepite correttamente<br />• Miglioramenti generali alla stabilità e correzioni di bug
Onboarding migliorato e promemoria mensili:<br /><br />• Prima esperienza riprogettata — un'azione chiara per aggiungere il tuo primo investimento, senza passaggi inutili<br />• Promemoria mensili — notifiche opzionali per non dimenticare l'aggiornamento mensile del portafoglio<br />• Anteprima grafici — gli utenti gratuiti vedono quali grafici sono disponibili prima di passare alla versione completa<br />• Paywall migliorato — un vero grafico del portafoglio mostra come appare l'accesso completo<br />• Configurazione delle fonti più chiara — breve descrizione durante la creazione della prima fonte di investimento<br />
</div>
@@ -680,17 +648,9 @@
<div class="cat-headline">Screenshots</div>
<!-- no screenshots -->
<div style="border: 3px solid red; padding: 0px 20px">
<h2 style="color: red">No Screenshots Found</h2>
<h2 style="color: blue">Skipped Screenshots</h2>
<p>
deliver couldn't find any screenshots.
The existing screenshots on App Store Connect will be kept.
if you want to remove them you have to use the <i>--overwrite_screenshots</i> flag.
<p>
If you want to download your existing screenshots, run <i>deliver download_screenshots</i>.
Options to skip_screenshots activated. <i>deliver</i> will not modify your app screenshots.
</p>
</div>
@@ -790,7 +750,7 @@
<div class="app-changelog">
<div class="cat-headline">Changelog</div>
iCloud同期の改善:<br />• Appleデバイス間を切り替えたとき、データが即座に同期されるようになりました<br />• 新デバイス検出機能:オンボーディング前にiCloudの既存データを検出するようになり、新しいiPhoneでもゼロからやり直す必要がありません<br />• 別のデバイスでの変更が正しく反映されないケースを修正<br />• 全般的な安定性の向上とバグ修正
改善されたオンボーディングと月次リマインダー:<br /><br />• 初回セットアップを刷新 — 最初の投資を追加するためのシンプルな1ステップ、余計な手順なし<br />• 月次リマインダー — 毎月のポートフォリオ更新を忘れないためのオプション通知<br />• グラフのプレビュー — 無料ユーザーがアップグレード前に利用可能なグラフを確認できるように<br />• パイウォールを改善 — 実際のポートフォリオグラフでフルアクセスの内容をプレビュー<br />• 投資元の作成をわかりやすく — 最初の投資元を追加する際に簡単な説明文を表示<br />
</div>
@@ -805,17 +765,9 @@
<div class="cat-headline">Screenshots</div>
<!-- no screenshots -->
<div style="border: 3px solid red; padding: 0px 20px">
<h2 style="color: red">No Screenshots Found</h2>
<h2 style="color: blue">Skipped Screenshots</h2>
<p>
deliver couldn't find any screenshots.
The existing screenshots on App Store Connect will be kept.
if you want to remove them you have to use the <i>--overwrite_screenshots</i> flag.
<p>
If you want to download your existing screenshots, run <i>deliver download_screenshots</i>.
Options to skip_screenshots activated. <i>deliver</i> will not modify your app screenshots.
</p>
</div>
@@ -899,7 +851,7 @@
<div class="app-changelog">
<div class="cat-headline">Changelog</div>
Melhorias na sincronização com iCloud:<br />• Os dados agora sincronizam instantaneamente ao trocar entre dispositivos Apple<br />• Detecção de novo dispositivo: o app detecta dados existentes no iCloud antes do onboarding, para você nunca começar do zero em um novo iPhone<br />• Corrigidos casos em que alterações feitas em outro dispositivo não eram refletidas corretamente<br />• Melhorias gerais de estabilidade e correções de bugs
Integração mais clara e lembretes mensais:<br /><br />• Primeira experiência redesenhada — uma ação clara para adicionar seu primeiro investimento, sem etapas desnecessárias<br />• Lembretes mensais — notificações opcionais para nunca esquecer de atualizar sua carteira todo mês<br />• Prévia de gráficos — usuários gratuitos veem quais gráficos estão disponíveis antes de fazer upgrade<br />• Paywall melhorado — um gráfico real da carteira mostra como é o acesso completo<br />• Configuração de fontes mais clara — breve descrição ao criar sua primeira fonte de investimento<br />
</div>
@@ -914,17 +866,9 @@
<div class="cat-headline">Screenshots</div>
<!-- no screenshots -->
<div style="border: 3px solid red; padding: 0px 20px">
<h2 style="color: red">No Screenshots Found</h2>
<h2 style="color: blue">Skipped Screenshots</h2>
<p>
deliver couldn't find any screenshots.
The existing screenshots on App Store Connect will be kept.
if you want to remove them you have to use the <i>--overwrite_screenshots</i> flag.
<p>
If you want to download your existing screenshots, run <i>deliver download_screenshots</i>.
Options to skip_screenshots activated. <i>deliver</i> will not modify your app screenshots.
</p>
</div>
+6 -4
View File
@@ -1,6 +1,8 @@
Verbesserungen bei iCloud-Sync und Quellenverwaltung:
Native iPad-Unterstützung und neue Portfolio-Tools:
• iCloud-Sync — deine Daten synchronisieren sich jetzt zuverlässig auf allen Apple-Geräten
Quellensuche — finde jede Quelle schnell nach Namen
• Kategoriefilter — filtere deine Quellen nach Anlageklasse
• iPad — vollständig native Erfahrung mit Split-View-Navigation und optimierten Layouts für Dashboard, Quellen, Charts und Journal
Ziele archivieren — erledigte Ziele archivieren, um die Liste übersichtlich zu halten
• Kategorieverwaltung — eigene Anlagekategorien in den Einstellungen erstellen und anpassen
• Jahresvergleichs-Chart — Portfolio-Wert über mehrere Jahre auf einen Blick vergleichen (Premium)
• Flexibler CSV-Import — beliebiges Spaltenformat für den Import von jedem Broker zuordnen
• Fehlerbehebungen und Leistungsverbesserungen
+6 -4
View File
@@ -1,6 +1,8 @@
iCloud sync and source management improvements:
Native iPad support and new portfolio tools:
• iCloud sync — your data now syncs reliably across all your Apple devices
Source search — quickly find any source by name
• Category filters — filter your sources by asset category
• iPad — full native experience with split-view navigation, optimised layouts for Dashboard, Sources, Charts and Journal
Goal archiving — archive completed goals to keep your list focused
• Category management — create, edit and customise your investment categories from Settings
• Year-over-year chart — compare your portfolio value across multiple years at a glance (Premium)
• Flexible CSV import — map any column format to import data from any broker
• Bug fixes and performance improvements
+6 -4
View File
@@ -1,6 +1,8 @@
Mejoras en la sincronización iCloud y la gestión de fuentes:
Soporte nativo para iPad y nuevas herramientas de cartera:
Sincronización iCloud — tus datos se sincronizan de forma fiable en todos tus dispositivos Apple
Búsqueda de fuentes — encuentra cualquier fuente por nombre rápidamente
Filtros por categoría — filtra tus fuentes por tipo de activo
iPad — experiencia nativa completa con navegación en vista dividida y diseños optimizados para Dashboard, Fuentes, Gráficos y Diario
Archivado de objetivos — archiva los objetivos completados para mantener tu lista organizada
Gestión de categoríascrea, edita y personaliza tus categorías de inversión desde Ajustes
• Gráfico año a año — compara el valor de tu cartera en múltiples años de un vistazo (Premium)
• Importación CSV flexible — mapea cualquier formato de columnas para importar datos de cualquier bróker
• Correcciones de errores y mejoras de rendimiento
+6 -4
View File
@@ -1,6 +1,8 @@
Améliorations de la synchronisation iCloud et de la gestion des sources :
Support iPad natif et nouveaux outils de portefeuille :
Synchronisation iCloud — vos données se synchronisent désormais de manière fiable sur tous vos appareils Apple
Recherche de sources — trouvez rapidement n'importe quelle source par nom
Filtres par catégorie — filtrez vos sources par classe d'actifs
iPad — expérience native complète avec navigation en vue fractionnée et mises en page optimisées pour le Tableau de bord, les Sources, les Graphiques et le Journal
Archivage des objectifs — archivez les objectifs atteints pour garder votre liste organisée
Gestion des catégoriescréez, modifiez et personnalisez vos catégories d'investissement depuis les Réglages
• Graphique année par année — comparez la valeur de votre portefeuille sur plusieurs années d'un coup d'œil (Premium)
• Import CSV flexible — mappez n'importe quel format de colonnes pour importer des données de n'importe quel courtier
• Corrections de bugs et améliorations des performances
+6 -4
View File
@@ -1,6 +1,8 @@
Miglioramenti alla sincronizzazione iCloud e alla gestione delle fonti:
Supporto nativo iPad e nuovi strumenti per il portafoglio:
Sincronizzazione iCloud — i tuoi dati si sincronizzano in modo affidabile su tutti i dispositivi Apple
Ricerca fonti — trova rapidamente qualsiasi fonte per nome
Filtri per categoriafiltra le fonti per classe di asset
iPad — esperienza nativa completa con navigazione a vista divisa e layout ottimizzati per Dashboard, Fonti, Grafici e Diario
Archiviazione obiettivi — archivia gli obiettivi raggiunti per mantenere la lista organizzata
Gestione categoriecrea, modifica e personalizza le tue categorie di investimento dalle Impostazioni
• Grafico anno su anno — confronta il valore del portafoglio su più anni in un colpo d'occhio (Premium)
• Import CSV flessibile — mappa qualsiasi formato di colonne per importare dati da qualsiasi broker
• Correzioni di bug e miglioramenti delle prestazioni
+6 -4
View File
@@ -1,6 +1,8 @@
iCloud同期とソース管理の改善
ネイティブiPadサポートと新しいポートフォリオツール
• iCloud同期 — すべてのAppleデバイス間でデータが確実に同期されるようになりました
ソース検索 — 名前で素早くソースを検索
• カテゴリフィルター — 資産クラス別にソースをフィルタリング
• iPad — スプリットビューナビゲーションとダッシュボード、ソース、チャート、ジャーナルに最適化されたレイアウトを備えた完全なネイティブ体験
目標のアーカイブ — 達成した目標をアーカイブしてリストを整理
• カテゴリ管理 — 設定から投資カテゴリを作成・編集・カスタマイズ
• 年別比較チャート — 複数年にわたるポートフォリオの価値を一目で比較(プレミアム)
• 柔軟なCSVインポート — 任意の証券会社のデータをインポートするために列形式をマッピング
• バグ修正とパフォーマンス改善
+6 -4
View File
@@ -1,6 +1,8 @@
Melhorias na sincronização iCloud e no gerenciamento de fontes:
Suporte nativo ao iPad e novas ferramentas de portfólio:
Sincronização iCloud — seus dados agora sincronizam de forma confiável em todos os dispositivos Apple
Pesquisa de fontes — encontre qualquer fonte rapidamente por nome
Filtros por categoria — filtre suas fontes por classe de ativo
iPad — experiência nativa completa com navegação em tela dividida e layouts otimizados para Dashboard, Fontes, Gráficos e Diário
Arquivamento de objetivos — arquive objetivos concluídos para manter sua lista organizada
Gerenciamento de categoriascrie, edite e personalize suas categorias de investimento nas Configurações
• Gráfico ano a ano — compare o valor do portfólio em vários anos de forma rápida (Premium)
• Importação CSV flexível — mapeie qualquer formato de colunas para importar dados de qualquer corretora
• Correções de bugs e melhorias de desempenho