diff --git a/.DS_Store b/.DS_Store index f9d4794..12e4aa2 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/PortfolioJournal.xcodeproj/project.pbxproj b/PortfolioJournal.xcodeproj/project.pbxproj index 9a0d653..7e26ebe 100644 --- a/PortfolioJournal.xcodeproj/project.pbxproj +++ b/PortfolioJournal.xcodeproj/project.pbxproj @@ -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; diff --git a/PortfolioJournal.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate b/PortfolioJournal.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate index 3376fbd..caab3b9 100644 Binary files a/PortfolioJournal.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate and b/PortfolioJournal.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/PortfolioJournal/App/ContentView.swift b/PortfolioJournal/App/ContentView.swift index caf0362..7f4c59b 100644 --- a/PortfolioJournal/App/ContentView.swift +++ b/PortfolioJournal/App/ContentView.swift @@ -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) { diff --git a/PortfolioJournal/Models/CoreData/Category+CoreDataClass.swift b/PortfolioJournal/Models/CoreData/Category+CoreDataClass.swift index 05f0d58..d74a350 100644 --- a/PortfolioJournal/Models/CoreData/Category+CoreDataClass.swift +++ b/PortfolioJournal/Models/CoreData/Category+CoreDataClass.swift @@ -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 { diff --git a/PortfolioJournal/Resources/de.lproj/Localizable.strings b/PortfolioJournal/Resources/de.lproj/Localizable.strings index 4d64304..dda0244 100644 --- a/PortfolioJournal/Resources/de.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/de.lproj/Localizable.strings @@ -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"; diff --git a/PortfolioJournal/Resources/en.lproj/Localizable.strings b/PortfolioJournal/Resources/en.lproj/Localizable.strings index 27e422d..6bed3f5 100644 --- a/PortfolioJournal/Resources/en.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/en.lproj/Localizable.strings @@ -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"; diff --git a/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings b/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings index d4db6c4..4a8c265 100644 --- a/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings @@ -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"; diff --git a/PortfolioJournal/Resources/fr.lproj/Localizable.strings b/PortfolioJournal/Resources/fr.lproj/Localizable.strings index bd91c72..8db691d 100644 --- a/PortfolioJournal/Resources/fr.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/fr.lproj/Localizable.strings @@ -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 !"; diff --git a/PortfolioJournal/Resources/it.lproj/Localizable.strings b/PortfolioJournal/Resources/it.lproj/Localizable.strings index 13402e8..38ffc1d 100644 --- a/PortfolioJournal/Resources/it.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/it.lproj/Localizable.strings @@ -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!"; diff --git a/PortfolioJournal/Resources/ja.lproj/Localizable.strings b/PortfolioJournal/Resources/ja.lproj/Localizable.strings index 1524b66..105fa20 100644 --- a/PortfolioJournal/Resources/ja.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/ja.lproj/Localizable.strings @@ -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" = "始めよう"; diff --git a/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings b/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings index f7a57b7..0da29e5 100644 --- a/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings @@ -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á!"; diff --git a/PortfolioJournal/Services/AppUpdateService.swift b/PortfolioJournal/Services/AppUpdateService.swift new file mode 100644 index 0000000..5709be0 --- /dev/null +++ b/PortfolioJournal/Services/AppUpdateService.swift @@ -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 + } + } + } +} diff --git a/PortfolioJournal/Services/ImportService.swift b/PortfolioJournal/Services/ImportService.swift index db27d1b..11fd3a5 100644 --- a/PortfolioJournal/Services/ImportService.swift +++ b/PortfolioJournal/Services/ImportService.swift @@ -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.. [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]] { diff --git a/PortfolioJournal/Services/NotificationService.swift b/PortfolioJournal/Services/NotificationService.swift index 9239c33..000c06e 100644 --- a/PortfolioJournal/Services/NotificationService.swift +++ b/PortfolioJournal/Services/NotificationService.swift @@ -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 diff --git a/PortfolioJournal/Utilities/DashboardLayoutStore.swift b/PortfolioJournal/Utilities/DashboardLayoutStore.swift index 8126e0f..f108441 100644 --- a/PortfolioJournal/Utilities/DashboardLayoutStore.swift +++ b/PortfolioJournal/Utilities/DashboardLayoutStore.swift @@ -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" } } } diff --git a/PortfolioJournal/ViewModels/ChartsViewModel.swift b/PortfolioJournal/ViewModels/ChartsViewModel.swift index e046919..fbead2f 100644 --- a/PortfolioJournal/ViewModels/ChartsViewModel.swift +++ b/PortfolioJournal/ViewModels/ChartsViewModel.swift @@ -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) diff --git a/PortfolioJournal/ViewModels/DashboardViewModel.swift b/PortfolioJournal/ViewModels/DashboardViewModel.swift index f6614a7..a12d031 100644 --- a/PortfolioJournal/ViewModels/DashboardViewModel.swift +++ b/PortfolioJournal/ViewModels/DashboardViewModel.swift @@ -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 = 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.. Date? { // Performance: Use cached completion date if available if let cached = cachedCompletionDates[goal.id] { diff --git a/PortfolioJournal/ViewModels/SourceListViewModel.swift b/PortfolioJournal/ViewModels/SourceListViewModel.swift index a93e23b..cbe86f7 100644 --- a/PortfolioJournal/ViewModels/SourceListViewModel.swift +++ b/PortfolioJournal/ViewModels/SourceListViewModel.swift @@ -8,7 +8,7 @@ class SourceListViewModel: ObservableObject { @Published var sources: [InvestmentSource] = [] @Published var categories: [Category] = [] - @Published var selectedCategory: Category? + @Published var selectedCategoryIds: Set = [] @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 = [] } } diff --git a/PortfolioJournal/Views/Charts/ChartsContainerView.swift b/PortfolioJournal/Views/Charts/ChartsContainerView.swift index 41df107..0688243 100644 --- a/PortfolioJournal/Views/Charts/ChartsContainerView.swift +++ b/PortfolioJournal/Views/Charts/ChartsContainerView.swift @@ -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) } } } diff --git a/PortfolioJournal/Views/Dashboard/DashboardView.swift b/PortfolioJournal/Views/Dashboard/DashboardView.swift index c522776..5ee8b25 100644 --- a/PortfolioJournal/Views/Dashboard/DashboardView.swift +++ b/PortfolioJournal/Views/Dashboard/DashboardView.swift @@ -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()) diff --git a/PortfolioJournal/Views/Dashboard/QuickUpdateView.swift b/PortfolioJournal/Views/Dashboard/QuickUpdateView.swift new file mode 100644 index 0000000..505810b --- /dev/null +++ b/PortfolioJournal/Views/Dashboard/QuickUpdateView.swift @@ -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 + + @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 { + 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 + } +} diff --git a/PortfolioJournal/Views/Goals/GoalsView.swift b/PortfolioJournal/Views/Goals/GoalsView.swift index 99d701b..fe721ce 100644 --- a/PortfolioJournal/Views/Goals/GoalsView.swift +++ b/PortfolioJournal/Views/Goals/GoalsView.swift @@ -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) } } diff --git a/PortfolioJournal/Views/Journal/JournalView.swift b/PortfolioJournal/Views/Journal/JournalView.swift index fd77ae5..b25c8c0 100644 --- a/PortfolioJournal/Views/Journal/JournalView.swift +++ b/PortfolioJournal/Views/Journal/JournalView.swift @@ -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 diff --git a/PortfolioJournal/Views/Onboarding/WhatsNewView.swift b/PortfolioJournal/Views/Onboarding/WhatsNewView.swift new file mode 100644 index 0000000..b3c1c60 --- /dev/null +++ b/PortfolioJournal/Views/Onboarding/WhatsNewView.swift @@ -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() + } +} diff --git a/PortfolioJournal/Views/Settings/ImportDataView.swift b/PortfolioJournal/Views/Settings/ImportDataView.swift index 8e07628..1905541 100644 --- a/PortfolioJournal/Views/Settings/ImportDataView.swift +++ b/PortfolioJournal/Views/Settings/ImportDataView.swift @@ -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") } } diff --git a/PortfolioJournal/Views/Settings/SettingsView.swift b/PortfolioJournal/Views/Settings/SettingsView.swift index 27b26aa..de6f9ab 100644 --- a/PortfolioJournal/Views/Settings/SettingsView.swift +++ b/PortfolioJournal/Views/Settings/SettingsView.swift @@ -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 diff --git a/PortfolioJournal/Views/Sources/AddSourceView.swift b/PortfolioJournal/Views/Sources/AddSourceView.swift index 592e515..c3f62c1 100644 --- a/PortfolioJournal/Views/Sources/AddSourceView.swift +++ b/PortfolioJournal/Views/Sources/AddSourceView.swift @@ -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 { + let goals = goalRepo.goals.filter { $0.isActive } + var achieved = Set() + 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) { + // 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 { diff --git a/PortfolioJournal/Views/Sources/SourceDetailView.swift b/PortfolioJournal/Views/Sources/SourceDetailView.swift index 640a71f..9009901 100644 --- a/PortfolioJournal/Views/Sources/SourceDetailView.swift +++ b/PortfolioJournal/Views/Sources/SourceDetailView.swift @@ -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) } diff --git a/PortfolioJournal/Views/Sources/SourceListView.swift b/PortfolioJournal/Views/Sources/SourceListView.swift index 27cbd99..8ea9e4b 100644 --- a/PortfolioJournal/Views/Sources/SourceListView.swift +++ b/PortfolioJournal/Views/Sources/SourceListView.swift @@ -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 diff --git a/PortfolioJournalWidget/InvestmentWidget.swift b/PortfolioJournalWidget/InvestmentWidget.swift index 6583534..8e15558 100644 --- a/PortfolioJournalWidget/InvestmentWidget.swift +++ b/PortfolioJournalWidget/InvestmentWidget.swift @@ -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")!) } } } diff --git a/fastlane/Deliverfile b/fastlane/Deliverfile index 465c2c4..dd16523 100644 --- a/fastlane/Deliverfile +++ b/fastlane/Deliverfile @@ -1 +1 @@ -app_version "1.3.1" +app_version "1.3.2" diff --git a/fastlane/Preview.html b/fastlane/Preview.html index 978de77..259d7de 100644 --- a/fastlane/Preview.html +++ b/fastlane/Preview.html @@ -222,7 +222,7 @@
Changelog
- Verbesserungen bei der iCloud-Synchronisierung:
• Daten werden jetzt sofort synchronisiert, wenn du zwischen Apple-Geräten wechselst
• Neue Geräteerkennung: Die App erkennt vorhandene iCloud-Daten vor dem Onboarding, sodass du auf einem neuen Gerät nie von vorne anfangen musst
• Behoben: Änderungen von einem anderen Gerät wurden in einigen Fällen nicht korrekt übernommen
• Allgemeine Stabilitätsverbesserungen und Fehlerbehebungen + Verbessertes Onboarding und monatliche Erinnerungen:

• Neu gestaltete Ersteinrichtung — eine klare Aktion zum Hinzufügen deiner ersten Investition, ohne unnötige Schritte
• Monatliche Erinnerungen — optionale Benachrichtigungen, damit du das monatliche Portfolio-Update nicht vergisst
• Charts-Vorschau — kostenlose Nutzer sehen, welche Charts verfügbar sind, bevor sie upgraden
• Verbesserter Paywall — eine echte Portfolio-Grafik zeigt, wie der Vollzugang aussieht
• Klarere Quelleneinrichtung — kurze Erklärung beim Erstellen deiner ersten Anlageposition
@@ -237,17 +237,9 @@
Screenshots
- -
-

No Screenshots Found

+

Skipped Screenshots

- 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 --overwrite_screenshots flag. - -

- If you want to download your existing screenshots, run deliver download_screenshots. + Options to skip_screenshots activated. deliver will not modify your app screenshots.

@@ -340,7 +332,7 @@
Changelog
- iCloud Sync improvements:
• Data now syncs instantly when switching between Apple devices
• New device detection: the app now detects existing iCloud data before onboarding, so you never start from scratch on a new phone
• Fixed cases where updates made on another device weren't reflected correctly
• General stability improvements and bug fixes + Smarter onboarding and monthly reminders:

• Redesigned first-run experience — a single clear action to add your first investment, no setup friction
• Monthly reminders — optional notifications so you never forget your monthly portfolio update
• Charts preview — free users now see which charts are available before upgrading
• Improved paywall — a live portfolio chart preview shows what full access looks like
• Clearer source setup — added a short description when creating your first investment source
@@ -355,17 +347,9 @@
Screenshots
- -
-

No Screenshots Found

+

Skipped Screenshots

- 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 --overwrite_screenshots flag. - -

- If you want to download your existing screenshots, run deliver download_screenshots. + Options to skip_screenshots activated. deliver will not modify your app screenshots.

@@ -451,7 +435,7 @@
Changelog
- Mejoras en la sincronización con iCloud:
• Los datos ahora se sincronizan al instante al cambiar entre dispositivos Apple
• 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
• Corregidos casos en los que los cambios realizados en otro dispositivo no se reflejaban correctamente
• Mejoras generales de estabilidad y corrección de errores + Incorporación más clara y recordatorios mensuales:

• Primera experiencia rediseñada — una acción clara para añadir tu primera inversión, sin pasos innecesarios
• Recordatorios mensuales — notificaciones opcionales para no olvidar actualizar tu portfolio cada mes
• Vista previa de gráficos — los usuarios gratuitos ven qué gráficos están disponibles antes de desbloquear
• Paywall mejorado — un gráfico real de portfolio muestra cómo se ve el acceso completo
• Configuración de fuentes más clara — descripción breve al crear tu primera fuente de inversión
@@ -466,17 +450,9 @@
Screenshots
- -
-

No Screenshots Found

+

Skipped Screenshots

- 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 --overwrite_screenshots flag. - -

- If you want to download your existing screenshots, run deliver download_screenshots. + Options to skip_screenshots activated. deliver will not modify your app screenshots.

@@ -558,7 +534,7 @@
Changelog
- Améliorations de la synchronisation iCloud :
• Les données se synchronisent désormais instantanément lors du passage entre appareils Apple
• 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
• Correction des cas où les modifications effectuées sur un autre appareil n'étaient pas correctement reflétées
• Améliorations générales de la stabilité et corrections de bugs + Intégration améliorée et rappels mensuels :

• Première expérience repensée — une action claire pour ajouter votre premier investissement, sans étapes superflues
• Rappels mensuels — notifications facultatives pour ne jamais oublier la mise à jour mensuelle de votre portefeuille
• Aperçu des graphiques — les utilisateurs gratuits voient quels graphiques sont disponibles avant de passer à la version complète
• Paywall amélioré — un vrai graphique de portefeuille montre à quoi ressemble l'accès complet
• Configuration des sources clarifiée — courte description lors de la création de votre première source d'investissement
@@ -573,17 +549,9 @@
Screenshots
- -
-

No Screenshots Found

+

Skipped Screenshots

- 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 --overwrite_screenshots flag. - -

- If you want to download your existing screenshots, run deliver download_screenshots. + Options to skip_screenshots activated. deliver will not modify your app screenshots.

@@ -665,7 +633,7 @@
Changelog
- Miglioramenti alla sincronizzazione iCloud:
• I dati ora si sincronizzano immediatamente quando passi da un dispositivo Apple all'altro
• Rilevamento nuovo dispositivo: l'app rileva i dati iCloud esistenti prima dell'onboarding, così non devi ricominciare da zero su un nuovo iPhone
• Risolti casi in cui le modifiche apportate su un altro dispositivo non venivano recepite correttamente
• Miglioramenti generali alla stabilità e correzioni di bug + Onboarding migliorato e promemoria mensili:

• Prima esperienza riprogettata — un'azione chiara per aggiungere il tuo primo investimento, senza passaggi inutili
• Promemoria mensili — notifiche opzionali per non dimenticare l'aggiornamento mensile del portafoglio
• Anteprima grafici — gli utenti gratuiti vedono quali grafici sono disponibili prima di passare alla versione completa
• Paywall migliorato — un vero grafico del portafoglio mostra come appare l'accesso completo
• Configurazione delle fonti più chiara — breve descrizione durante la creazione della prima fonte di investimento
@@ -680,17 +648,9 @@
Screenshots
- -
-

No Screenshots Found

+

Skipped Screenshots

- 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 --overwrite_screenshots flag. - -

- If you want to download your existing screenshots, run deliver download_screenshots. + Options to skip_screenshots activated. deliver will not modify your app screenshots.

@@ -790,7 +750,7 @@
Changelog
- iCloud同期の改善:
• Appleデバイス間を切り替えたとき、データが即座に同期されるようになりました
• 新デバイス検出機能:オンボーディング前にiCloudの既存データを検出するようになり、新しいiPhoneでもゼロからやり直す必要がありません
• 別のデバイスでの変更が正しく反映されないケースを修正
• 全般的な安定性の向上とバグ修正 + 改善されたオンボーディングと月次リマインダー:

• 初回セットアップを刷新 — 最初の投資を追加するためのシンプルな1ステップ、余計な手順なし
• 月次リマインダー — 毎月のポートフォリオ更新を忘れないためのオプション通知
• グラフのプレビュー — 無料ユーザーがアップグレード前に利用可能なグラフを確認できるように
• パイウォールを改善 — 実際のポートフォリオグラフでフルアクセスの内容をプレビュー
• 投資元の作成をわかりやすく — 最初の投資元を追加する際に簡単な説明文を表示
@@ -805,17 +765,9 @@
Screenshots
- -
-

No Screenshots Found

+

Skipped Screenshots

- 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 --overwrite_screenshots flag. - -

- If you want to download your existing screenshots, run deliver download_screenshots. + Options to skip_screenshots activated. deliver will not modify your app screenshots.

@@ -899,7 +851,7 @@
Changelog
- Melhorias na sincronização com iCloud:
• Os dados agora sincronizam instantaneamente ao trocar entre dispositivos Apple
• 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
• Corrigidos casos em que alterações feitas em outro dispositivo não eram refletidas corretamente
• Melhorias gerais de estabilidade e correções de bugs + Integração mais clara e lembretes mensais:

• Primeira experiência redesenhada — uma ação clara para adicionar seu primeiro investimento, sem etapas desnecessárias
• Lembretes mensais — notificações opcionais para nunca esquecer de atualizar sua carteira todo mês
• Prévia de gráficos — usuários gratuitos veem quais gráficos estão disponíveis antes de fazer upgrade
• Paywall melhorado — um gráfico real da carteira mostra como é o acesso completo
• Configuração de fontes mais clara — breve descrição ao criar sua primeira fonte de investimento
@@ -914,17 +866,9 @@
Screenshots
- -
-

No Screenshots Found

+

Skipped Screenshots

- 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 --overwrite_screenshots flag. - -

- If you want to download your existing screenshots, run deliver download_screenshots. + Options to skip_screenshots activated. deliver will not modify your app screenshots.

diff --git a/fastlane/metadata/de-DE/release_notes.txt b/fastlane/metadata/de-DE/release_notes.txt index 93cf4f0..e112315 100644 --- a/fastlane/metadata/de-DE/release_notes.txt +++ b/fastlane/metadata/de-DE/release_notes.txt @@ -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 diff --git a/fastlane/metadata/en-US/release_notes.txt b/fastlane/metadata/en-US/release_notes.txt index b04fa6a..e87a5cd 100644 --- a/fastlane/metadata/en-US/release_notes.txt +++ b/fastlane/metadata/en-US/release_notes.txt @@ -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 diff --git a/fastlane/metadata/es-ES/release_notes.txt b/fastlane/metadata/es-ES/release_notes.txt index c610bfe..e186988 100644 --- a/fastlane/metadata/es-ES/release_notes.txt +++ b/fastlane/metadata/es-ES/release_notes.txt @@ -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ías — crea, 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 diff --git a/fastlane/metadata/fr-FR/release_notes.txt b/fastlane/metadata/fr-FR/release_notes.txt index 4190b83..24fed1f 100644 --- a/fastlane/metadata/fr-FR/release_notes.txt +++ b/fastlane/metadata/fr-FR/release_notes.txt @@ -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égories — cré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 diff --git a/fastlane/metadata/it/release_notes.txt b/fastlane/metadata/it/release_notes.txt index d15ec06..aa08b90 100644 --- a/fastlane/metadata/it/release_notes.txt +++ b/fastlane/metadata/it/release_notes.txt @@ -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 categoria — filtra 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 categorie — crea, 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 diff --git a/fastlane/metadata/ja/release_notes.txt b/fastlane/metadata/ja/release_notes.txt index d56b3c0..f87adc9 100644 --- a/fastlane/metadata/ja/release_notes.txt +++ b/fastlane/metadata/ja/release_notes.txt @@ -1,6 +1,8 @@ -iCloud同期とソース管理の改善: +ネイティブiPadサポートと新しいポートフォリオツール: -• iCloud同期 — すべてのAppleデバイス間でデータが確実に同期されるようになりました -• ソース検索 — 名前で素早くソースを検索 -• カテゴリフィルター — 資産クラス別にソースをフィルタリング +• iPad — スプリットビューナビゲーションとダッシュボード、ソース、チャート、ジャーナルに最適化されたレイアウトを備えた完全なネイティブ体験 +• 目標のアーカイブ — 達成した目標をアーカイブしてリストを整理 +• カテゴリ管理 — 設定から投資カテゴリを作成・編集・カスタマイズ +• 年別比較チャート — 複数年にわたるポートフォリオの価値を一目で比較(プレミアム) +• 柔軟なCSVインポート — 任意の証券会社のデータをインポートするために列形式をマッピング • バグ修正とパフォーマンス改善 diff --git a/fastlane/metadata/pt-BR/release_notes.txt b/fastlane/metadata/pt-BR/release_notes.txt index 11b6711..9884a50 100644 --- a/fastlane/metadata/pt-BR/release_notes.txt +++ b/fastlane/metadata/pt-BR/release_notes.txt @@ -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 categorias — crie, 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