Release 1.2.1: iCloud sync improvements + ASO multilingual metadata

iCloud sync:
- Force viewContext.refreshAllObjects() on remote change notifications so
  data from other devices is picked up immediately without app restart
- Call refreshFromCloudKit() on foreground to merge any changes made while
  the device was inactive
- Wait up to 10s for initial CloudKit sync on launch before showing onboarding
  (shows "Checking iCloud..." during the wait)
- New OnboardingICloudCheckView: shown on fresh installs with iCloud available,
  lets user restore from iCloud before starting onboarding from scratch

Localization:
- Added de, fr, it, ja, pt-BR lproj folders
- New iCloud onboarding strings in en + es-ES (+ button literals)

ASO metadata (fastlane):
- Updated en-US: new subtitle, keywords, description (fixed "no cloud sync"
  claim), promotional text, release notes
- Added full metadata for es-ES, de-DE, fr-FR, it, ja, pt-BR (63 files total)
- All keyword fields validated ≤100 Unicode chars

Infrastructure:
- Gemfile + Gemfile.lock for fastlane
- Scripts/archive_and_upload_appstore.sh for CI/CD

Version: 1.2.1 (build 7)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
alexandrev-tibco
2026-03-25 11:54:35 +01:00
parent 7cb5f92cf4
commit 10f6d0ca20
108 changed files with 4069 additions and 238 deletions
+33 -1
View File
@@ -14,16 +14,29 @@ struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var isUnlocked = false
@State private var resolvedOnboardingCompleted: Bool?
@State private var iCloudCheckDone = false
@State private var loadingMessageKey: LocalizedStringKey = "loading_data"
private var lockEnabled: Bool {
faceIdEnabled || pinEnabled
}
/// True when a fresh install with iCloud available and sync not yet enabled.
/// Only relevant before onboarding is completed.
private var needsICloudCheck: Bool {
guard resolvedOnboardingCompleted == false else { return false }
guard !UserDefaults.standard.bool(forKey: "cloudSyncEnabled") else { return false }
return FileManager.default.ubiquityIdentityToken != nil
}
var body: some View {
ZStack {
Group {
if !isReadyForContent {
AppLaunchLoadingView(messageKey: "loading_data")
AppLaunchLoadingView(messageKey: loadingMessageKey)
} else if needsICloudCheck && !iCloudCheckDone {
// Fresh install with iCloud available: ask before showing onboarding
OnboardingICloudCheckView(onSkip: { iCloudCheckDone = true })
} else if resolvedOnboardingCompleted == false {
OnboardingView(onboardingCompleted: $onboardingCompleted)
} else {
@@ -78,12 +91,31 @@ struct ContentView: View {
try? await Task.sleep(nanoseconds: 50_000_000) // 50ms
}
// If CloudKit is enabled and no local data yet, wait briefly for the
// initial iCloud sync so data from other devices appears before we
// decide whether to show onboarding.
if UserDefaults.standard.bool(forKey: "cloudSyncEnabled") && !hasExistingData() {
await MainActor.run { loadingMessageKey = "checking_icloud" }
await waitForInitialCloudKitSync(timeout: 10)
await MainActor.run { loadingMessageKey = "loading_data" }
}
// Resolve onboarding state on main thread
await MainActor.run {
syncOnboardingState()
}
}
/// Polls for existing data up to `timeout` seconds, returning as soon as
/// any data appears. Used to wait for the initial CloudKit sync on launch.
private func waitForInitialCloudKitSync(timeout: TimeInterval) async {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if hasExistingData() { return }
try? await Task.sleep(nanoseconds: 300_000_000) // poll every 300ms
}
}
private func syncOnboardingState() {
let settings = AppSettings.getOrCreate(in: coreDataStack.viewContext)
var resolved = settings.onboardingCompleted || onboardingCompleted
@@ -34,6 +34,10 @@ struct PortfolioJournalApp: App {
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
coreDataStack.refreshWidgetData()
// Re-read all Core Data objects from the persistent store so that
// iCloud changes made on other devices while this device was inactive
// are reflected immediately without waiting for a remote-change notification.
coreDataStack.refreshFromCloudKit()
} else if newPhase == .background {
guard iapService.isPremium else { return }
guard UserDefaults.standard.bool(forKey: "backupsEnabled") else { return }
+19 -5
View File
@@ -258,12 +258,26 @@ class CoreDataStack: ObservableObject {
// MARK: - Remote Change Handling
@objc private func processRemoteChanges(_ notification: Notification) {
// Process remote changes on main context
DispatchQueue.main.async { [weak self] in
self?.objectWillChange.send()
// Ensure changes are persisted to disk before refreshing widget
self?.save()
self?.refreshWidgetData()
guard let self else { return }
// Force viewContext to re-read all objects from the persistent store,
// which now contains the changes CloudKit just synced from other devices.
// This triggers NSManagedObjectContextObjectsDidChange so all repositories
// automatically re-fetch and push fresh data to ViewModels.
self.viewContext.refreshAllObjects()
self.objectWillChange.send()
self.save()
self.refreshWidgetData()
}
}
/// Forces an immediate re-read of all Core Data objects from the persistent store.
/// Call this when the app returns to the foreground so any iCloud changes made on
/// other devices (while this device was inactive) are picked up right away.
func refreshFromCloudKit() {
guard Self.cloudKitEnabled else { return }
viewContext.perform { [weak self] in
self?.viewContext.refreshAllObjects()
}
}
+1 -1
View File
@@ -30,7 +30,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>3</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>GADApplicationIdentifier</key>
<string>ca-app-pub-1549720748100858~9632507420</string>
<key>GADDelayAppMeasurementInit</key>
@@ -0,0 +1,294 @@
"app_name" = "Portfolio Journal";
"ok" = "OK";
"cancel" = "Abbrechen";
"save" = "Speichern";
"delete" = "Löschen";
"edit" = "Bearbeiten";
"add" = "Hinzufügen";
"done" = "Fertig";
"close" = "Schließen";
"continue" = "Weiter";
"skip" = "Überspringen";
"error" = "Fehler";
"success" = "Erfolg";
"loading" = "Lädt...";
"loading_data" = "Deine Daten werden geladen...";
"tab_dashboard" = "Start";
"tab_sources" = "Quellen";
"tab_charts" = "Charts";
"tab_settings" = "Einstellungen";
"dashboard_title" = "Start";
"total_portfolio_value" = "Gesamtwert des Portfolios";
"today" = "heute";
"returns" = "Rendite";
"by_category" = "Nach Kategorie";
"pending_updates" = "Ausstehende Updates";
"see_all" = "Alle anzeigen";
"sources_title" = "Quellen";
"add_source" = "Quelle hinzufügen";
"source_name" = "Quellenname";
"select_category" = "Kategorie auswählen";
"initial_value" = "Anfangswert";
"initial_value_optional" = "Anfangswert (optional)";
"reminder_frequency" = "Erinnerungsintervall";
"source_limit_warning" = "Quellenlimit erreicht. Upgrade auf Premium für unbegrenzte Quellen.";
"no_sources" = "Keine Investmentquellen";
"no_sources_message" = "Füge deine erste Investmentquelle hinzu, um dein Portfolio zu verfolgen.";
"add_snapshot" = "Snapshot hinzufügen";
"edit_snapshot" = "Snapshot bearbeiten";
"snapshot_date" = "Datum";
"snapshot_value" = "Wert";
"snapshot_contribution" = "Beitrag";
"contribution_optional" = "Beitrag (optional)";
"notes" = "Notizen";
"notes_optional" = "Notizen (optional)";
"previous_value" = "Vorheriger Wert: %@";
"change_from_previous" = "Änderung zum vorherigen Wert";
"charts_title" = "Charts";
"evolution" = "Entwicklung";
"allocation" = "Allokation";
"performance" = "Performance";
"drawdown" = "Drawdown";
"volatility" = "Volatilität";
"prediction" = "Prognose";
"portfolio_evolution" = "Portfolioentwicklung";
"asset_allocation" = "Asset-Allokation";
"performance_by_category" = "Performance nach Kategorie";
"drawdown_analysis" = "Drawdown-Analyse";
"prediction_12_month" = "12-Monats-Prognose";
"not_enough_data" = "Nicht genügend Daten";
"cagr" = "CAGR";
"twr" = "TWR";
"max_drawdown" = "Max. Drawdown";
"sharpe_ratio" = "Sharpe Ratio";
"win_rate" = "Trefferquote";
"avg_monthly" = "Monatsdurchschnitt";
"best_month" = "Bester Monat";
"worst_month" = "Schlechtester Monat";
"premium" = "Premium";
"upgrade_to_premium" = "Auf Premium upgraden";
"unlock_full_potential" = "Volles Potenzial freischalten";
"one_time_purchase" = "Einmalkauf";
"includes_family_sharing" = "Inklusive Familienfreigabe";
"upgrade_now" = "Jetzt upgraden";
"restore_purchases" = "Käufe wiederherstellen";
"premium_active" = "Premium aktiv";
"premium_feature" = "Premium-Funktion";
"unlock" = "Freischalten";
"feature_unlimited_sources" = "Unbegrenzte Quellen";
"feature_unlimited_sources_desc" = "Verfolge so viele Investments, wie du möchtest";
"feature_full_history" = "Vollständige Historie";
"feature_full_history_desc" = "Greife auf deine komplette Investmenthistorie zu";
"feature_advanced_charts" = "Erweiterte Charts";
"feature_advanced_charts_desc" = "5 Arten detaillierter Analysecharts";
"feature_predictions" = "Prognosen";
"feature_predictions_desc" = "KI-gestützte 12-Monats-Prognosen";
"feature_export" = "Daten exportieren";
"feature_export_desc" = "Export nach CSV und JSON";
"feature_no_ads" = "Keine Werbung";
"feature_no_ads_desc" = "Dauerhaft werbefrei";
"paywall_benefit_history_title" = "Deine vollständige Historie";
"paywall_benefit_history_subtitle" = "Jeder Snapshot, Beitrag und Gewinn seit Tag eins";
"paywall_benefit_charts_title" = "Charts, die Muster zeigen";
"paywall_benefit_charts_subtitle" = "Allokation, Drawdown, Performance, alles an einem Ort";
"paywall_benefit_forecasts_title" = "12-Monats-Prognosen";
"paywall_benefit_forecasts_subtitle" = "Sieh, wohin sich dein Portfolio wahrscheinlich entwickelt";
"paywall_benefit_noads_title" = "Keine Werbung, jemals";
"paywall_benefit_noads_subtitle" = "Klares, fokussiertes Erlebnis ohne Ablenkung";
"settings_title" = "Einstellungen";
"subscription" = "Abo";
"notifications" = "Benachrichtigungen";
"default_reminder_time" = "Standard-Erinnerungszeit";
"data" = "Daten";
"export_data" = "Daten exportieren";
"total_sources" = "Anzahl Quellen";
"total_snapshots" = "Anzahl Snapshots";
"storage_used" = "Verwendeter Speicher";
"about" = "Info";
"version" = "Version";
"privacy_policy" = "Datenschutzerklärung";
"terms_of_service" = "Nutzungsbedingungen";
"support" = "Support";
"rate_app" = "App bewerten";
"danger_zone" = "Gefahrenzone";
"reset_all_data" = "Alle Daten zurücksetzen";
"reset_confirmation" = "Dadurch werden alle Investmentdaten dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.";
"frequency_monthly" = "Monatlich";
"frequency_quarterly" = "Vierteljährlich";
"frequency_semiannual" = "Halbjährlich";
"frequency_annual" = "Jährlich";
"frequency_custom" = "Benutzerdefiniert";
"frequency_never" = "Nie";
"every_n_months" = "Alle %d Monate";
"category_stocks" = "Aktien";
"category_bonds" = "Anleihen";
"category_real_estate" = "Immobilien";
"category_crypto" = "Krypto";
"category_cash" = "Cash";
"category_etfs" = "ETFs";
"category_retirement" = "Altersvorsorge";
"category_other" = "Sonstiges";
"uncategorized" = "Nicht kategorisiert";
"time_1m" = "1M";
"time_3m" = "3M";
"time_6m" = "6M";
"time_1y" = "1J";
"time_all" = "Alle";
"export_format" = "Format auswählen";
"export_csv" = "CSV";
"export_csv_desc" = "Kompatibel mit Excel und Google Sheets";
"export_json" = "JSON";
"export_json_desc" = "Vollständige Datenstruktur für Backups";
"onboarding_track_title" = "Verfolge deine Investments";
"onboarding_track_desc" = "Behalte alle deine Investmentquellen an einem Ort im Blick. Aktien, Anleihen, Immobilien, Krypto und mehr.";
"onboarding_visualize_title" = "Visualisiere dein Wachstum";
"onboarding_visualize_desc" = "Schöne Charts zeigen die Entwicklung, Allokation und Performance deines Portfolios im Zeitverlauf.";
"onboarding_reminders_title" = "Verpasse nie ein Update";
"onboarding_reminders_desc" = "Setze Erinnerungen, um deine Investments regelmäßig zu verfolgen. Monatlich, vierteljährlich oder individuell.";
"onboarding_sync_title" = "Überall synchron";
"onboarding_sync_desc" = "Deine Daten synchronisieren automatisch via iCloud auf all deinen Apple-Geräten.";
"get_started" = "Loslegen";
"onboarding_clarity_title" = "Wisse genau, wo du stehst";
"onboarding_clarity_desc" = "Sieh dein Gesamtvermögen, echte Renditen und Allokation, immer aktuell.";
"onboarding_habit_title" = "5 Minuten pro Monat reichen";
"onboarding_habit_desc" = "Erfasse deine Werte einmal im Monat. Portfolio Journal übernimmt die Berechnungen und zeigt deinen Fortschritt.";
"onboarding_calm_title" = "Ignoriere den Lärm. Verfolge den Trend.";
"onboarding_calm_desc" = "Tägliche Schwankungen erzählen nicht die echte Geschichte. Dein Wachstum über Monate und Jahre schon.";
"onboarding_goals_title" = "Erreiche deine finanziellen Ziele";
"onboarding_goals_desc" = "Setze Ziele, verfolge Meilensteine und sieh genau, wie weit du gekommen bist.";
"error_generic" = "Ein Fehler ist aufgetreten. Bitte versuche es erneut.";
"error_no_purchases" = "Keine Käufe zum Wiederherstellen gefunden";
"error_purchase_failed" = "Kauf fehlgeschlagen: %@";
"error_export_failed" = "Export fehlgeschlagen. Bitte versuche es erneut.";
"placeholder_source_name" = "z. B. Vanguard 401k";
"placeholder_value" = "0.00";
"placeholder_notes" = "Notizen hinzufügen...";
"mood_energized_title" = "Voller Energie";
"mood_confident_title" = "Selbstsicher";
"mood_balanced_title" = "Ausgeglichen";
"mood_cautious_title" = "Vorsichtig";
"mood_stressed_title" = "Gestresst";
"mood_energized_detail" = "Fühle mich unschlagbar";
"mood_confident_detail" = "Auf Kurs und gelassen";
"mood_balanced_detail" = "Ruhig und geduldig";
"mood_cautious_detail" = "Beobachte die Bewegungen";
"mood_stressed_detail" = "Brauche einen Reset";
"achievement_streak_3_title" = "3-Monats-Serie";
"achievement_streak_3_detail" = "Du hast drei Monate in Folge pünktlich eingecheckt.";
"achievement_streak_6_title" = "Halbjahres-Serie";
"achievement_streak_6_detail" = "Sechs pünktliche Check-ins in Folge.";
"achievement_streak_12_title" = "Ein Jahr Momentum";
"achievement_streak_12_detail" = "Ein ganzes Jahr ohne Fristversäumnis.";
"achievement_perfect_on_time_title" = "Nie zu spät";
"achievement_perfect_on_time_detail" = "Jeder Check-in kam vor der Frist an.";
"achievement_clutch_finish_title" = "Last-Minute-Erfolg";
"achievement_clutch_finish_detail" = "Mit wenigen Stunden Puffer, aber noch rechtzeitig abgegeben.";
"achievement_early_bird_title" = "Frühstarter";
"achievement_early_bird_detail" = "Im Schnitt beendest du deine Check-ins mit viel Zeit übrig.";
"achievements_title" = "Erfolge";
"achievements_view_all" = "Alle Erfolge anzeigen";
"achievements_nav_title" = "Erfolge";
"achievements_progress_title" = "Fortschritt";
"achievements_unlocked_title" = "Freigeschaltet";
"achievements_unlocked_empty" = "Schließe Check-ins ab, um Erfolge freizuschalten.";
"achievements_locked_title" = "Gesperrt";
"achievements_locked_empty" = "Alle Erfolge freigeschaltet. Gute Arbeit.";
"rating_accessibility" = "Bewertung %d von 5";
"achievements_unlocked_count" = "%d von %d freigeschaltet";
"last_check_in" = "Letzter Check-in: %@";
"next_check_in" = "Nächster Check-in: %@";
"on_time_rate" = "%@ pünktlich";
"on_time_count" = "%d/%d pünktlich";
"tightest_finish" = "Knappster Abschluss: %@ vor der Frist.";
"date_today" = "Heute";
"date_yesterday" = "Gestern";
"date_never" = "Nie";
"calendar_event_title" = "%@: Monatlicher Check-in";
"calendar_event_notes" = "Öffne %@ und erledige deinen monatlichen Check-in.";
"checkin_enjoying_dialog_title" = "Wie sehr gefällt dir Portfolio Journal?";
"checkin_enjoying_dialog_message" = "Glückwunsch zu deinem neuen Erfolg. Dein Feedback hilft uns, besser zu werden.";
"not_now" = "Nicht jetzt";
"rating_1_star" = "1 Stern";
"rating_n_stars" = "%d Sterne";
"app_store_review_title" = "Möchtest du eine Bewertung im App Store hinterlassen?";
"app_store_review_message" = "Danke für die 5 Sterne. Das hilft anderen Investoren sehr, die App zu entdecken.";
"write_review" = "Bewertung schreiben";
"save_1_snapshot" = "1 Snapshot speichern";
"save_n_snapshots" = "%d Snapshots speichern";
"checkin_update_month" = "%@ aktualisieren";
"checkin_start_new" = "Starten";
"Home" = "Start";
"Sources" = "Quellen";
"Charts" = "Charts";
"Settings" = "Einstellungen";
"Journal" = "Journal";
"Search monthly notes" = "Monatliche Notizen suchen";
"Monthly Check-ins" = "Monatliche Check-ins";
"No monthly notes yet." = "Noch keine monatlichen Notizen.";
"No matching notes." = "Keine passenden Notizen.";
"Jump to month" = "Zum Monat springen";
"Today" = "Heute";
"Mood not set" = "Stimmung nicht gesetzt";
"No rating" = "Keine Bewertung";
"No note yet." = "Noch keine Notiz.";
"Monthly Note" = "Monatliche Notiz";
"Open Full Note" = "Vollständige Notiz öffnen";
"Duplicate Previous" = "Vorherigen duplizieren";
"Save" = "Speichern";
"Monthly Check-in" = "Monatlicher Check-in";
"This Month" = "Diesen Monat";
"No check-in yet this month" = "Diesen Monat noch kein Check-in";
"Start your first check-in anytime." = "Starte deinen ersten Check-in jederzeit.";
"Mark Check-in Complete" = "Check-in als abgeschlossen markieren";
"Editing stays open. New check-ins unlock after 70% of the month." = "Die Bearbeitung bleibt offen. Neue Check-ins werden nach 70 % des Monats freigeschaltet.";
"Momentum & Streaks" = "Momentum & Serien";
"Log a check-in to start a streak" = "Erfasse einen Check-in, um eine Serie zu starten";
"Streak" = "Serie";
"On-time in a row" = "Pünktlich in Folge";
"Best" = "Best";
"Personal best" = "Persönlicher Bestwert";
"Avg early" = "Ø Puffer";
"vs deadline" = "vs. Frist";
"On-time score" = "Pünktlichkeitsscore";
"Achievements" = "Erfolge";
"View all achievements" = "Alle Erfolge anzeigen";
"Monthly Pulse" = "Monatlicher Puls";
"Optional" = "Optional";
"Rate this month" = "Diesen Monat bewerten";
"How did it feel?" = "Wie hat es sich angefühlt?";
"Monthly Summary" = "Monatsübersicht";
"Starting" = "Start";
"Ending" = "Ende";
"Contributions" = "Beiträge";
"Net Performance" = "Nettoperformance";
"Update Sources" = "Quellen aktualisieren";
"Add sources to start your monthly check-in." = "Füge Quellen hinzu, um deinen monatlichen Check-in zu starten.";
"Updated this cycle" = "In diesem Zyklus aktualisiert";
"Needs update" = "Benötigt Update";
"Snapshot Notes" = "Snapshot-Notizen";
"No snapshot notes for this month." = "Keine Snapshot-Notizen in diesem Monat.";
"Source" = "Quelle";
"Your full portfolio,\nfully clear" = "Dein komplettes Portfolio,\nvöllig klar";
"One payment. Every feature. Forever." = "Eine Zahlung. Jede Funktion. Für immer.";
"Get Full Access" = "Vollen Zugriff erhalten";
"Restore Purchases" = "Käufe wiederherstellen";
"Payment charged to your Apple ID account." = "Die Zahlung wird deinem Apple-ID-Konto belastet.";
"Terms" = "Bedingungen";
"Privacy" = "Datenschutz";
"· one-time · Family Sharing" = "· einmalig · Familienfreigabe";
"Full access, one payment" = "Voller Zugriff, eine Zahlung";
"Unlimited sources, advanced charts & more" = "Unbegrenzte Quellen, erweiterte Charts und mehr";
"See full access" = "Vollen Zugriff ansehen";
"Batch Update" = "Sammelupdate";
"Current value" = "Aktueller Wert";
"Contribution this period (optional)" = "Beitrag in diesem Zeitraum (optional)";
"Include Contribution" = "Beitrag einbeziehen";
"New capital added" = "Neues Kapital hinzugefügt";
"Contribution (Optional)" = "Beitrag (optional)";
"Track new capital added to separate it from investment growth." = "Erfasse neues Kapital, um es vom Investmentwachstum zu trennen.";
"Monthly Highlights" = "Monatliche Highlights";
"Best Performer" = "Bester Performer";
"Worst Performer" = "Schlechtester Performer";
"Best Contributor" = "Größter Beitrag";
"Update Check-in" = "Check-in aktualisieren";
"Completed %@" = "Abgeschlossen %@";
@@ -197,6 +197,13 @@
"onboarding_goals_title" = "Reach your financial goals";
"onboarding_goals_desc" = "Set targets, track milestones, and see exactly how far you've come.";
// iCloud check before onboarding (1.2.1)
"icloud_check_title" = "Already use Portfolio Journal?";
"icloud_check_description" = "If you have data on another device, enable iCloud to restore it here.";
"icloud_enabled_title" = "iCloud Enabled";
"icloud_enabled_description" = "Close the app and reopen it. Your data will load automatically from iCloud.";
"checking_icloud" = "Checking iCloud...";
// MARK: - Errors
"error_generic" = "An error occurred. Please try again.";
"error_no_purchases" = "No purchases found to restore";
@@ -255,3 +262,21 @@
"date_never" = "Never";
"calendar_event_title" = "%@: Monthly Check-in";
"calendar_event_notes" = "Open %@ and complete your monthly check-in.";
// MARK: - Satisfaction & Review Dialogs
"checkin_enjoying_dialog_title" = "How much are you enjoying Portfolio Journal?";
"checkin_enjoying_dialog_message" = "Congrats on your new achievement! Your feedback helps us improve.";
"not_now" = "Not Now";
"rating_1_star" = "1 Star";
"rating_n_stars" = "%d Stars";
"app_store_review_title" = "Would you like to leave an App Store review?";
"app_store_review_message" = "Thanks for the 5 stars. It really helps other investors discover the app.";
"write_review" = "Write a Review";
// MARK: - Batch Update Actions
"save_1_snapshot" = "Save 1 Snapshot";
"save_n_snapshots" = "Save %d Snapshots";
// MARK: - Monthly Check-in Card
"checkin_update_month" = "Update %@";
"checkin_start_new" = "Start";
@@ -124,6 +124,16 @@
"rating_accessibility" = "Valoración %d de 5";
"achievements_unlocked_count" = "%d de %d desbloqueados";
// MARK: - Satisfaction & Review Dialogs
"checkin_enjoying_dialog_title" = "¿Cuánto disfrutas Portfolio Journal?";
"checkin_enjoying_dialog_message" = "¡Felicidades por tu nuevo logro! Tu opinión nos ayuda a mejorar.";
"not_now" = "Ahora no";
"rating_1_star" = "1 estrella";
"rating_n_stars" = "%d estrellas";
"app_store_review_title" = "¿Te gustaría dejar una reseña en el App Store?";
"app_store_review_message" = "Gracias por las 5 estrellas. Ayuda mucho a que otros inversores descubran la app.";
"write_review" = "Escribir reseña";
// MARK: - Paywall (1.2.0)
"Your full portfolio,\nfully clear" = "Tu cartera completa,\ntotalmente clara";
"One payment. Every feature. Forever." = "Un pago. Todas las funciones. Para siempre.";
@@ -157,10 +167,27 @@
"onboarding_goals_title" = "Alcanza tus metas financieras";
"onboarding_goals_desc" = "Define objetivos, sigue los hitos y ve exactamente hasta dónde has llegado.";
// iCloud check antes del onboarding (1.2.1)
"icloud_check_title" = "¿Ya usas Portfolio Journal?";
"icloud_check_description" = "Si tienes datos en otro dispositivo, activa iCloud para restaurarlos aquí.";
"icloud_enabled_title" = "iCloud activado";
"icloud_enabled_description" = "Cierra la app y ábrela de nuevo. Tus datos se cargarán automáticamente desde iCloud.";
"checking_icloud" = "Comprobando iCloud...";
// Literales de botones (SwiftUI auto-lookup)
"Restore from iCloud" = "Restaurar desde iCloud";
"Start Fresh" = "Empezar desde cero";
"Got it" = "Entendido";
// MARK: - Batch Update (1.2.0)
"Batch Update" = "Actualización en bloque";
"Current value" = "Valor actual";
"Contribution this period (optional)" = "Aportación en este periodo (opcional)";
"save_1_snapshot" = "Guardar 1 snapshot";
"save_n_snapshots" = "Guardar %d snapshots";
// MARK: - Monthly Check-in Card
"checkin_update_month" = "Actualizar %@";
"checkin_start_new" = "Iniciar";
// MARK: - Contribution (1.2.0)
"Include Contribution" = "Incluir aportación";
@@ -0,0 +1,294 @@
"app_name" = "Portfolio Journal";
"ok" = "OK";
"cancel" = "Annuler";
"save" = "Enregistrer";
"delete" = "Supprimer";
"edit" = "Modifier";
"add" = "Ajouter";
"done" = "Terminé";
"close" = "Fermer";
"continue" = "Continuer";
"skip" = "Passer";
"error" = "Erreur";
"success" = "Succès";
"loading" = "Chargement...";
"loading_data" = "Chargement de vos données...";
"tab_dashboard" = "Accueil";
"tab_sources" = "Sources";
"tab_charts" = "Graphiques";
"tab_settings" = "Réglages";
"dashboard_title" = "Accueil";
"total_portfolio_value" = "Valeur totale du portefeuille";
"today" = "aujourd'hui";
"returns" = "Rendements";
"by_category" = "Par catégorie";
"pending_updates" = "Mises à jour en attente";
"see_all" = "Voir tout";
"sources_title" = "Sources";
"add_source" = "Ajouter une source";
"source_name" = "Nom de la source";
"select_category" = "Sélectionner une catégorie";
"initial_value" = "Valeur initiale";
"initial_value_optional" = "Valeur initiale (optionnelle)";
"reminder_frequency" = "Fréquence des rappels";
"source_limit_warning" = "Limite de sources atteinte. Passez à Premium pour des sources illimitées.";
"no_sources" = "Aucune source d'investissement";
"no_sources_message" = "Ajoutez votre première source d'investissement pour commencer à suivre votre portefeuille.";
"add_snapshot" = "Ajouter un snapshot";
"edit_snapshot" = "Modifier le snapshot";
"snapshot_date" = "Date";
"snapshot_value" = "Valeur";
"snapshot_contribution" = "Contribution";
"contribution_optional" = "Contribution (optionnelle)";
"notes" = "Notes";
"notes_optional" = "Notes (optionnelles)";
"previous_value" = "Précédent : %@";
"change_from_previous" = "Variation par rapport au précédent";
"charts_title" = "Graphiques";
"evolution" = "Évolution";
"allocation" = "Allocation";
"performance" = "Performance";
"drawdown" = "Drawdown";
"volatility" = "Volatilité";
"prediction" = "Prévision";
"portfolio_evolution" = "Évolution du portefeuille";
"asset_allocation" = "Allocation des actifs";
"performance_by_category" = "Performance par catégorie";
"drawdown_analysis" = "Analyse du drawdown";
"prediction_12_month" = "Prévision sur 12 mois";
"not_enough_data" = "Pas assez de données";
"cagr" = "CAGR";
"twr" = "TWR";
"max_drawdown" = "Drawdown max";
"sharpe_ratio" = "Ratio de Sharpe";
"win_rate" = "Taux de réussite";
"avg_monthly" = "Moyenne mensuelle";
"best_month" = "Meilleur mois";
"worst_month" = "Pire mois";
"premium" = "Premium";
"upgrade_to_premium" = "Passer à Premium";
"unlock_full_potential" = "Débloquez tout le potentiel";
"one_time_purchase" = "Achat unique";
"includes_family_sharing" = "Inclut le partage familial";
"upgrade_now" = "Passer maintenant";
"restore_purchases" = "Restaurer les achats";
"premium_active" = "Premium actif";
"premium_feature" = "Fonction Premium";
"unlock" = "Débloquer";
"feature_unlimited_sources" = "Sources illimitées";
"feature_unlimited_sources_desc" = "Suivez autant d'investissements que vous le souhaitez";
"feature_full_history" = "Historique complet";
"feature_full_history_desc" = "Accédez à tout votre historique d'investissement";
"feature_advanced_charts" = "Graphiques avancés";
"feature_advanced_charts_desc" = "5 types de graphiques analytiques détaillés";
"feature_predictions" = "Prévisions";
"feature_predictions_desc" = "Prévisions IA sur 12 mois";
"feature_export" = "Exporter les données";
"feature_export_desc" = "Export vers CSV et JSON";
"feature_no_ads" = "Sans publicité";
"feature_no_ads_desc" = "Expérience sans publicité à vie";
"paywall_benefit_history_title" = "Votre historique complet";
"paywall_benefit_history_subtitle" = "Chaque snapshot, contribution et gain depuis le premier jour";
"paywall_benefit_charts_title" = "Des graphiques qui révèlent des tendances";
"paywall_benefit_charts_subtitle" = "Allocation, drawdown, performance, le tout au même endroit";
"paywall_benefit_forecasts_title" = "Prévisions à 12 mois";
"paywall_benefit_forecasts_subtitle" = "Voyez où votre portefeuille pourrait aller";
"paywall_benefit_noads_title" = "Aucune pub, jamais";
"paywall_benefit_noads_subtitle" = "Une expérience propre et centrée, sans distractions";
"settings_title" = "Réglages";
"subscription" = "Abonnement";
"notifications" = "Notifications";
"default_reminder_time" = "Heure de rappel par défaut";
"data" = "Données";
"export_data" = "Exporter les données";
"total_sources" = "Nombre total de sources";
"total_snapshots" = "Nombre total de snapshots";
"storage_used" = "Stockage utilisé";
"about" = "À propos";
"version" = "Version";
"privacy_policy" = "Politique de confidentialité";
"terms_of_service" = "Conditions d'utilisation";
"support" = "Support";
"rate_app" = "Noter l'app";
"danger_zone" = "Zone de danger";
"reset_all_data" = "Réinitialiser toutes les données";
"reset_confirmation" = "Cela supprimera définitivement toutes vos données d'investissement. Cette action est irréversible.";
"frequency_monthly" = "Mensuel";
"frequency_quarterly" = "Trimestriel";
"frequency_semiannual" = "Semestriel";
"frequency_annual" = "Annuel";
"frequency_custom" = "Personnalisé";
"frequency_never" = "Jamais";
"every_n_months" = "Tous les %d mois";
"category_stocks" = "Actions";
"category_bonds" = "Obligations";
"category_real_estate" = "Immobilier";
"category_crypto" = "Crypto";
"category_cash" = "Espèces";
"category_etfs" = "ETF";
"category_retirement" = "Retraite";
"category_other" = "Autre";
"uncategorized" = "Non catégorisé";
"time_1m" = "1M";
"time_3m" = "3M";
"time_6m" = "6M";
"time_1y" = "1A";
"time_all" = "Tout";
"export_format" = "Sélectionner le format";
"export_csv" = "CSV";
"export_csv_desc" = "Compatible avec Excel et Google Sheets";
"export_json" = "JSON";
"export_json_desc" = "Structure complète pour sauvegarde";
"onboarding_track_title" = "Suivez vos investissements";
"onboarding_track_desc" = "Surveillez toutes vos sources d'investissement au même endroit. Actions, obligations, immobilier, crypto et plus.";
"onboarding_visualize_title" = "Visualisez votre croissance";
"onboarding_visualize_desc" = "De beaux graphiques montrent l'évolution, l'allocation et la performance de votre portefeuille dans le temps.";
"onboarding_reminders_title" = "Ne manquez jamais une mise à jour";
"onboarding_reminders_desc" = "Définissez des rappels pour suivre régulièrement vos investissements. Mensuels, trimestriels ou personnalisés.";
"onboarding_sync_title" = "Synchronisez partout";
"onboarding_sync_desc" = "Vos données se synchronisent automatiquement via iCloud sur tous vos appareils Apple.";
"get_started" = "Commencer";
"onboarding_clarity_title" = "Sachez exactement où vous en êtes";
"onboarding_clarity_desc" = "Voyez votre patrimoine total, vos rendements réels et votre allocation, toujours à jour.";
"onboarding_habit_title" = "5 minutes par mois suffisent";
"onboarding_habit_desc" = "Saisissez vos valeurs une fois par mois. Portfolio Journal s'occupe des calculs et montre vos progrès.";
"onboarding_calm_title" = "Ignorez le bruit. Suivez la tendance.";
"onboarding_calm_desc" = "Les variations quotidiennes ne racontent pas la vraie histoire. Votre croissance sur des mois et des années, si.";
"onboarding_goals_title" = "Atteignez vos objectifs financiers";
"onboarding_goals_desc" = "Fixez des objectifs, suivez les étapes et voyez exactement jusqu'où vous êtes arrivé.";
"error_generic" = "Une erreur s'est produite. Veuillez réessayer.";
"error_no_purchases" = "Aucun achat à restaurer";
"error_purchase_failed" = "Achat échoué : %@";
"error_export_failed" = "L'export a échoué. Veuillez réessayer.";
"placeholder_source_name" = "ex. Vanguard 401k";
"placeholder_value" = "0.00";
"placeholder_notes" = "Ajouter des notes...";
"mood_energized_title" = "En feu";
"mood_confident_title" = "Confiant";
"mood_balanced_title" = "Stable";
"mood_cautious_title" = "Prudent";
"mood_stressed_title" = "Stressé";
"mood_energized_detail" = "Je me sens imbattable";
"mood_confident_detail" = "Sur la bonne voie et serein";
"mood_balanced_detail" = "Calme et patient";
"mood_cautious_detail" = "J'observe les mouvements";
"mood_stressed_detail" = "Besoin de souffler";
"achievement_streak_3_title" = "Série de 3 mois";
"achievement_streak_3_detail" = "Vous avez gardé vos check-ins à l'heure pendant trois mois d'affilée.";
"achievement_streak_6_title" = "Série de six mois";
"achievement_streak_6_detail" = "Six check-ins consécutifs à l'heure.";
"achievement_streak_12_title" = "Une année d'élan";
"achievement_streak_12_detail" = "Une année complète sans manquer la date limite.";
"achievement_perfect_on_time_title" = "Jamais en retard";
"achievement_perfect_on_time_detail" = "Chaque check-in a été envoyé avant la date limite.";
"achievement_clutch_finish_title" = "Final serré";
"achievement_clutch_finish_detail" = "Envoyé avec quelques heures d'avance, mais toujours à temps.";
"achievement_early_bird_title" = "Lève-tôt";
"achievement_early_bird_detail" = "En moyenne, vous terminez avec une belle marge.";
"achievements_title" = "Succès";
"achievements_view_all" = "Voir tous les succès";
"achievements_nav_title" = "Succès";
"achievements_progress_title" = "Progression";
"achievements_unlocked_title" = "Débloqués";
"achievements_unlocked_empty" = "Complétez des check-ins pour débloquer des succès.";
"achievements_locked_title" = "Verrouillés";
"achievements_locked_empty" = "Tous les succès sont débloqués. Beau travail.";
"rating_accessibility" = "Note %d sur 5";
"achievements_unlocked_count" = "%d sur %d débloqués";
"last_check_in" = "Dernier check-in : %@";
"next_check_in" = "Prochain check-in : %@";
"on_time_rate" = "%@ à l'heure";
"on_time_count" = "%d/%d à l'heure";
"tightest_finish" = "Fin la plus serrée : %@ avant la date limite.";
"date_today" = "Aujourd'hui";
"date_yesterday" = "Hier";
"date_never" = "Jamais";
"calendar_event_title" = "%@: Check-in mensuel";
"calendar_event_notes" = "Ouvrez %@ et terminez votre check-in mensuel.";
"checkin_enjoying_dialog_title" = "À quel point appréciez-vous Portfolio Journal ?";
"checkin_enjoying_dialog_message" = "Félicitations pour votre nouveau succès. Votre avis nous aide à nous améliorer.";
"not_now" = "Pas maintenant";
"rating_1_star" = "1 étoile";
"rating_n_stars" = "%d étoiles";
"app_store_review_title" = "Souhaitez-vous laisser un avis sur l'App Store ?";
"app_store_review_message" = "Merci pour les 5 étoiles. Cela aide vraiment d'autres investisseurs à découvrir l'app.";
"write_review" = "Écrire un avis";
"save_1_snapshot" = "Enregistrer 1 snapshot";
"save_n_snapshots" = "Enregistrer %d snapshots";
"checkin_update_month" = "Mettre à jour %@";
"checkin_start_new" = "Commencer";
"Home" = "Accueil";
"Sources" = "Sources";
"Charts" = "Graphiques";
"Settings" = "Réglages";
"Journal" = "Journal";
"Search monthly notes" = "Rechercher des notes mensuelles";
"Monthly Check-ins" = "Check-ins mensuels";
"No monthly notes yet." = "Aucune note mensuelle pour l'instant.";
"No matching notes." = "Aucune note correspondante.";
"Jump to month" = "Aller au mois";
"Today" = "Aujourd'hui";
"Mood not set" = "Humeur non définie";
"No rating" = "Aucune note";
"No note yet." = "Pas encore de note.";
"Monthly Note" = "Note mensuelle";
"Open Full Note" = "Ouvrir la note complète";
"Duplicate Previous" = "Dupliquer le précédent";
"Save" = "Enregistrer";
"Monthly Check-in" = "Check-in mensuel";
"This Month" = "Ce mois-ci";
"No check-in yet this month" = "Aucun check-in ce mois-ci";
"Start your first check-in anytime." = "Commencez votre premier check-in quand vous voulez.";
"Mark Check-in Complete" = "Marquer le check-in comme terminé";
"Editing stays open. New check-ins unlock after 70% of the month." = "L'édition reste ouverte. Les nouveaux check-ins se débloquent après 70 % du mois.";
"Momentum & Streaks" = "Élan et séries";
"Log a check-in to start a streak" = "Enregistrez un check-in pour démarrer une série";
"Streak" = "Série";
"On-time in a row" = "À l'heure d'affilée";
"Best" = "Meilleur";
"Personal best" = "Meilleur record";
"Avg early" = "Marge moyenne";
"vs deadline" = "vs date limite";
"On-time score" = "Score de ponctualité";
"Achievements" = "Succès";
"View all achievements" = "Voir tous les succès";
"Monthly Pulse" = "Pouls mensuel";
"Optional" = "Optionnel";
"Rate this month" = "Notez ce mois";
"How did it feel?" = "Comment cela s'est-il passé ?";
"Monthly Summary" = "Résumé mensuel";
"Starting" = "Départ";
"Ending" = "Fin";
"Contributions" = "Contributions";
"Net Performance" = "Performance nette";
"Update Sources" = "Mettre à jour les sources";
"Add sources to start your monthly check-in." = "Ajoutez des sources pour commencer votre check-in mensuel.";
"Updated this cycle" = "Mis à jour sur ce cycle";
"Needs update" = "Nécessite une mise à jour";
"Snapshot Notes" = "Notes des snapshots";
"No snapshot notes for this month." = "Aucune note de snapshot pour ce mois.";
"Source" = "Source";
"Your full portfolio,\nfully clear" = "Votre portefeuille complet,\nen toute clarté";
"One payment. Every feature. Forever." = "Un paiement. Toutes les fonctionnalités. Pour toujours.";
"Get Full Access" = "Obtenir l'accès complet";
"Restore Purchases" = "Restaurer les achats";
"Payment charged to your Apple ID account." = "Le paiement sera facturé à votre compte Apple ID.";
"Terms" = "Conditions";
"Privacy" = "Confidentialité";
"· one-time · Family Sharing" = "· achat unique · Partage familial";
"Full access, one payment" = "Accès complet, un paiement";
"Unlimited sources, advanced charts & more" = "Sources illimitées, graphiques avancés et plus";
"See full access" = "Voir l'accès complet";
"Batch Update" = "Mise à jour groupée";
"Current value" = "Valeur actuelle";
"Contribution this period (optional)" = "Contribution sur cette période (optionnelle)";
"Include Contribution" = "Inclure une contribution";
"New capital added" = "Nouveau capital ajouté";
"Contribution (Optional)" = "Contribution (optionnelle)";
"Track new capital added to separate it from investment growth." = "Suivez le nouveau capital ajouté pour le distinguer de la croissance de l'investissement.";
"Monthly Highlights" = "Temps forts du mois";
"Best Performer" = "Meilleure performance";
"Worst Performer" = "Pire performance";
"Best Contributor" = "Meilleur contributeur";
"Update Check-in" = "Mettre à jour le check-in";
"Completed %@" = "Terminé %@";
@@ -0,0 +1,294 @@
"app_name" = "Portfolio Journal";
"ok" = "OK";
"cancel" = "Annulla";
"save" = "Salva";
"delete" = "Elimina";
"edit" = "Modifica";
"add" = "Aggiungi";
"done" = "Fatto";
"close" = "Chiudi";
"continue" = "Continua";
"skip" = "Salta";
"error" = "Errore";
"success" = "Successo";
"loading" = "Caricamento...";
"loading_data" = "Caricamento dei tuoi dati...";
"tab_dashboard" = "Home";
"tab_sources" = "Fonti";
"tab_charts" = "Grafici";
"tab_settings" = "Impostazioni";
"dashboard_title" = "Home";
"total_portfolio_value" = "Valore totale del portafoglio";
"today" = "oggi";
"returns" = "Rendimenti";
"by_category" = "Per categoria";
"pending_updates" = "Aggiornamenti in sospeso";
"see_all" = "Vedi tutto";
"sources_title" = "Fonti";
"add_source" = "Aggiungi fonte";
"source_name" = "Nome fonte";
"select_category" = "Seleziona categoria";
"initial_value" = "Valore iniziale";
"initial_value_optional" = "Valore iniziale (opzionale)";
"reminder_frequency" = "Frequenza promemoria";
"source_limit_warning" = "Limite di fonti raggiunto. Passa a Premium per fonti illimitate.";
"no_sources" = "Nessuna fonte di investimento";
"no_sources_message" = "Aggiungi la tua prima fonte di investimento per iniziare a monitorare il portafoglio.";
"add_snapshot" = "Aggiungi snapshot";
"edit_snapshot" = "Modifica snapshot";
"snapshot_date" = "Data";
"snapshot_value" = "Valore";
"snapshot_contribution" = "Contributo";
"contribution_optional" = "Contributo (opzionale)";
"notes" = "Note";
"notes_optional" = "Note (opzionali)";
"previous_value" = "Precedente: %@";
"change_from_previous" = "Variazione rispetto al precedente";
"charts_title" = "Grafici";
"evolution" = "Evoluzione";
"allocation" = "Allocazione";
"performance" = "Performance";
"drawdown" = "Drawdown";
"volatility" = "Volatilità";
"prediction" = "Previsione";
"portfolio_evolution" = "Evoluzione del portafoglio";
"asset_allocation" = "Allocazione degli asset";
"performance_by_category" = "Performance per categoria";
"drawdown_analysis" = "Analisi del drawdown";
"prediction_12_month" = "Previsione a 12 mesi";
"not_enough_data" = "Dati insufficienti";
"cagr" = "CAGR";
"twr" = "TWR";
"max_drawdown" = "Drawdown massimo";
"sharpe_ratio" = "Indice di Sharpe";
"win_rate" = "Tasso di successo";
"avg_monthly" = "Media mensile";
"best_month" = "Mese migliore";
"worst_month" = "Mese peggiore";
"premium" = "Premium";
"upgrade_to_premium" = "Passa a Premium";
"unlock_full_potential" = "Sblocca tutto il potenziale";
"one_time_purchase" = "Acquisto una tantum";
"includes_family_sharing" = "Include la condivisione in famiglia";
"upgrade_now" = "Aggiorna ora";
"restore_purchases" = "Ripristina acquisti";
"premium_active" = "Premium attivo";
"premium_feature" = "Funzione Premium";
"unlock" = "Sblocca";
"feature_unlimited_sources" = "Fonti illimitate";
"feature_unlimited_sources_desc" = "Monitora tutti gli investimenti che vuoi";
"feature_full_history" = "Cronologia completa";
"feature_full_history_desc" = "Accedi a tutta la tua cronologia di investimento";
"feature_advanced_charts" = "Grafici avanzati";
"feature_advanced_charts_desc" = "5 tipi di grafici analitici dettagliati";
"feature_predictions" = "Previsioni";
"feature_predictions_desc" = "Previsioni a 12 mesi basate su IA";
"feature_export" = "Esporta dati";
"feature_export_desc" = "Esporta in CSV e JSON";
"feature_no_ads" = "Niente pubblicità";
"feature_no_ads_desc" = "Esperienza senza pubblicità per sempre";
"paywall_benefit_history_title" = "La tua cronologia completa";
"paywall_benefit_history_subtitle" = "Ogni snapshot, contributo e guadagno dal primo giorno";
"paywall_benefit_charts_title" = "Grafici che rivelano schemi";
"paywall_benefit_charts_subtitle" = "Allocazione, drawdown, performance: tutto in un unico posto";
"paywall_benefit_forecasts_title" = "Previsioni a 12 mesi";
"paywall_benefit_forecasts_subtitle" = "Guarda dove probabilmente andrà il tuo portafoglio";
"paywall_benefit_noads_title" = "Niente pubblicità, mai";
"paywall_benefit_noads_subtitle" = "Esperienza pulita e focalizzata, senza distrazioni";
"settings_title" = "Impostazioni";
"subscription" = "Abbonamento";
"notifications" = "Notifiche";
"default_reminder_time" = "Ora promemoria predefinita";
"data" = "Dati";
"export_data" = "Esporta dati";
"total_sources" = "Fonti totali";
"total_snapshots" = "Snapshot totali";
"storage_used" = "Spazio utilizzato";
"about" = "Info";
"version" = "Versione";
"privacy_policy" = "Informativa sulla privacy";
"terms_of_service" = "Termini di servizio";
"support" = "Supporto";
"rate_app" = "Valuta l'app";
"danger_zone" = "Area pericolosa";
"reset_all_data" = "Reimposta tutti i dati";
"reset_confirmation" = "Questo eliminerà definitivamente tutti i dati di investimento. L'azione non può essere annullata.";
"frequency_monthly" = "Mensile";
"frequency_quarterly" = "Trimestrale";
"frequency_semiannual" = "Semestrale";
"frequency_annual" = "Annuale";
"frequency_custom" = "Personalizzata";
"frequency_never" = "Mai";
"every_n_months" = "Ogni %d mese/i";
"category_stocks" = "Azioni";
"category_bonds" = "Obbligazioni";
"category_real_estate" = "Immobiliare";
"category_crypto" = "Cripto";
"category_cash" = "Liquidità";
"category_etfs" = "ETF";
"category_retirement" = "Pensione";
"category_other" = "Altro";
"uncategorized" = "Senza categoria";
"time_1m" = "1M";
"time_3m" = "3M";
"time_6m" = "6M";
"time_1y" = "1A";
"time_all" = "Tutto";
"export_format" = "Seleziona formato";
"export_csv" = "CSV";
"export_csv_desc" = "Compatibile con Excel e Google Sheets";
"export_json" = "JSON";
"export_json_desc" = "Struttura dati completa per backup";
"onboarding_track_title" = "Monitora i tuoi investimenti";
"onboarding_track_desc" = "Controlla tutte le tue fonti di investimento in un unico posto. Azioni, obbligazioni, immobiliare, cripto e altro.";
"onboarding_visualize_title" = "Visualizza la tua crescita";
"onboarding_visualize_desc" = "Bellissimi grafici mostrano l'evoluzione, l'allocazione e la performance del portafoglio nel tempo.";
"onboarding_reminders_title" = "Non perdere mai un aggiornamento";
"onboarding_reminders_desc" = "Imposta promemoria per monitorare regolarmente i tuoi investimenti. Mensili, trimestrali o personalizzati.";
"onboarding_sync_title" = "Sincronizza ovunque";
"onboarding_sync_desc" = "I tuoi dati si sincronizzano automaticamente tramite iCloud su tutti i tuoi dispositivi Apple.";
"get_started" = "Inizia";
"onboarding_clarity_title" = "Sai esattamente a che punto sei";
"onboarding_clarity_desc" = "Vedi il tuo patrimonio totale, i rendimenti reali e l'allocazione, sempre aggiornati.";
"onboarding_habit_title" = "Bastano 5 minuti al mese";
"onboarding_habit_desc" = "Registra i tuoi valori una volta al mese. Portfolio Journal fa i calcoli e mostra i tuoi progressi.";
"onboarding_calm_title" = "Ignora il rumore. Segui il trend.";
"onboarding_calm_desc" = "Le oscillazioni giornaliere non raccontano la storia vera. La tua crescita in mesi e anni sì.";
"onboarding_goals_title" = "Raggiungi i tuoi obiettivi finanziari";
"onboarding_goals_desc" = "Imposta obiettivi, segui le tappe e vedi esattamente quanto hai fatto.";
"error_generic" = "Si è verificato un errore. Riprova.";
"error_no_purchases" = "Nessun acquisto da ripristinare";
"error_purchase_failed" = "Acquisto non riuscito: %@";
"error_export_failed" = "Esportazione non riuscita. Riprova.";
"placeholder_source_name" = "es. Vanguard 401k";
"placeholder_value" = "0.00";
"placeholder_notes" = "Aggiungi note...";
"mood_energized_title" = "Carico";
"mood_confident_title" = "Fiducioso";
"mood_balanced_title" = "Equilibrato";
"mood_cautious_title" = "Cauto";
"mood_stressed_title" = "Stressato";
"mood_energized_detail" = "Mi sento imbattibile";
"mood_confident_detail" = "In carreggiata e lucido";
"mood_balanced_detail" = "Calmo e paziente";
"mood_cautious_detail" = "Osservo i movimenti";
"mood_stressed_detail" = "Ho bisogno di resettare";
"achievement_streak_3_title" = "Serie di 3 mesi";
"achievement_streak_3_detail" = "Hai mantenuto i check-in puntuali per tre mesi di fila.";
"achievement_streak_6_title" = "Serie di mezzo anno";
"achievement_streak_6_detail" = "Sei check-in consecutivi puntuali.";
"achievement_streak_12_title" = "Un anno di slancio";
"achievement_streak_12_detail" = "Un anno intero senza perdere la scadenza.";
"achievement_perfect_on_time_title" = "Mai in ritardo";
"achievement_perfect_on_time_detail" = "Ogni check-in è arrivato prima della scadenza.";
"achievement_clutch_finish_title" = "Finale al limite";
"achievement_clutch_finish_detail" = "Inviato con poche ore di margine ma comunque in tempo.";
"achievement_early_bird_title" = "Mattiniero";
"achievement_early_bird_detail" = "In media completi tutto con molto margine.";
"achievements_title" = "Obiettivi";
"achievements_view_all" = "Vedi tutti gli obiettivi";
"achievements_nav_title" = "Obiettivi";
"achievements_progress_title" = "Progressi";
"achievements_unlocked_title" = "Sbloccati";
"achievements_unlocked_empty" = "Completa i check-in per sbloccare obiettivi.";
"achievements_locked_title" = "Bloccati";
"achievements_locked_empty" = "Tutti gli obiettivi sbloccati. Ottimo lavoro.";
"rating_accessibility" = "Valutazione %d su 5";
"achievements_unlocked_count" = "%d su %d sbloccati";
"last_check_in" = "Ultimo check-in: %@";
"next_check_in" = "Prossimo check-in: %@";
"on_time_rate" = "%@ puntuale";
"on_time_count" = "%d/%d in tempo";
"tightest_finish" = "Chiusura più tirata: %@ prima della scadenza.";
"date_today" = "Oggi";
"date_yesterday" = "Ieri";
"date_never" = "Mai";
"calendar_event_title" = "%@: Check-in mensile";
"calendar_event_notes" = "Apri %@ e completa il tuo check-in mensile.";
"checkin_enjoying_dialog_title" = "Quanto ti piace Portfolio Journal?";
"checkin_enjoying_dialog_message" = "Congratulazioni per il tuo nuovo obiettivo. Il tuo feedback ci aiuta a migliorare.";
"not_now" = "Non ora";
"rating_1_star" = "1 stella";
"rating_n_stars" = "%d stelle";
"app_store_review_title" = "Vuoi lasciare una recensione sull'App Store?";
"app_store_review_message" = "Grazie per le 5 stelle. Aiuta davvero altri investitori a scoprire l'app.";
"write_review" = "Scrivi una recensione";
"save_1_snapshot" = "Salva 1 snapshot";
"save_n_snapshots" = "Salva %d snapshot";
"checkin_update_month" = "Aggiorna %@";
"checkin_start_new" = "Inizia";
"Home" = "Home";
"Sources" = "Fonti";
"Charts" = "Grafici";
"Settings" = "Impostazioni";
"Journal" = "Diario";
"Search monthly notes" = "Cerca note mensili";
"Monthly Check-ins" = "Check-in mensili";
"No monthly notes yet." = "Ancora nessuna nota mensile.";
"No matching notes." = "Nessuna nota corrispondente.";
"Jump to month" = "Vai al mese";
"Today" = "Oggi";
"Mood not set" = "Stato d'animo non impostato";
"No rating" = "Nessuna valutazione";
"No note yet." = "Ancora nessuna nota.";
"Monthly Note" = "Nota mensile";
"Open Full Note" = "Apri nota completa";
"Duplicate Previous" = "Duplica precedente";
"Save" = "Salva";
"Monthly Check-in" = "Check-in mensile";
"This Month" = "Questo mese";
"No check-in yet this month" = "Nessun check-in questo mese";
"Start your first check-in anytime." = "Inizia il tuo primo check-in quando vuoi.";
"Mark Check-in Complete" = "Segna check-in come completato";
"Editing stays open. New check-ins unlock after 70% of the month." = "La modifica resta aperta. I nuovi check-in si sbloccano dopo il 70 % del mese.";
"Momentum & Streaks" = "Slancio e serie";
"Log a check-in to start a streak" = "Registra un check-in per iniziare una serie";
"Streak" = "Serie";
"On-time in a row" = "In tempo di fila";
"Best" = "Migliore";
"Personal best" = "Record personale";
"Avg early" = "Anticipo medio";
"vs deadline" = "vs scadenza";
"On-time score" = "Punteggio puntualità";
"Achievements" = "Obiettivi";
"View all achievements" = "Vedi tutti gli obiettivi";
"Monthly Pulse" = "Polso mensile";
"Optional" = "Opzionale";
"Rate this month" = "Valuta questo mese";
"How did it feel?" = "Come ti sei sentito?";
"Monthly Summary" = "Riepilogo mensile";
"Starting" = "Inizio";
"Ending" = "Fine";
"Contributions" = "Contributi";
"Net Performance" = "Performance netta";
"Update Sources" = "Aggiorna fonti";
"Add sources to start your monthly check-in." = "Aggiungi fonti per iniziare il tuo check-in mensile.";
"Updated this cycle" = "Aggiornato in questo ciclo";
"Needs update" = "Da aggiornare";
"Snapshot Notes" = "Note degli snapshot";
"No snapshot notes for this month." = "Nessuna nota snapshot per questo mese.";
"Source" = "Fonte";
"Your full portfolio,\nfully clear" = "Il tuo intero portafoglio,\ntutto chiaro";
"One payment. Every feature. Forever." = "Un pagamento. Ogni funzione. Per sempre.";
"Get Full Access" = "Ottieni accesso completo";
"Restore Purchases" = "Ripristina acquisti";
"Payment charged to your Apple ID account." = "Il pagamento verrà addebitato sul tuo account Apple ID.";
"Terms" = "Termini";
"Privacy" = "Privacy";
"· one-time · Family Sharing" = "· una tantum · In famiglia";
"Full access, one payment" = "Accesso completo, un solo pagamento";
"Unlimited sources, advanced charts & more" = "Fonti illimitate, grafici avanzati e altro";
"See full access" = "Vedi accesso completo";
"Batch Update" = "Aggiornamento in blocco";
"Current value" = "Valore attuale";
"Contribution this period (optional)" = "Contributo di questo periodo (opzionale)";
"Include Contribution" = "Includi contributo";
"New capital added" = "Nuovo capitale aggiunto";
"Contribution (Optional)" = "Contributo (opzionale)";
"Track new capital added to separate it from investment growth." = "Tieni traccia del nuovo capitale aggiunto per separarlo dalla crescita dell'investimento.";
"Monthly Highlights" = "Punti salienti del mese";
"Best Performer" = "Miglior performance";
"Worst Performer" = "Peggior performance";
"Best Contributor" = "Maggior contributore";
"Update Check-in" = "Aggiorna check-in";
"Completed %@" = "Completato %@";
@@ -0,0 +1,294 @@
"app_name" = "Portfolio Journal";
"ok" = "OK";
"cancel" = "キャンセル";
"save" = "保存";
"delete" = "削除";
"edit" = "編集";
"add" = "追加";
"done" = "完了";
"close" = "閉じる";
"continue" = "続ける";
"skip" = "スキップ";
"error" = "エラー";
"success" = "成功";
"loading" = "読み込み中...";
"loading_data" = "データを読み込み中...";
"tab_dashboard" = "ホーム";
"tab_sources" = "ソース";
"tab_charts" = "チャート";
"tab_settings" = "設定";
"dashboard_title" = "ホーム";
"total_portfolio_value" = "ポートフォリオ合計額";
"today" = "今日";
"returns" = "リターン";
"by_category" = "カテゴリ別";
"pending_updates" = "未更新";
"see_all" = "すべて表示";
"sources_title" = "ソース";
"add_source" = "ソースを追加";
"source_name" = "ソース名";
"select_category" = "カテゴリを選択";
"initial_value" = "初期金額";
"initial_value_optional" = "初期金額(任意)";
"reminder_frequency" = "リマインダー頻度";
"source_limit_warning" = "ソース数の上限に達しました。Premium にアップグレードすると無制限になります。";
"no_sources" = "投資ソースがありません";
"no_sources_message" = "最初の投資ソースを追加してポートフォリオの追跡を始めましょう。";
"add_snapshot" = "スナップショットを追加";
"edit_snapshot" = "スナップショットを編集";
"snapshot_date" = "日付";
"snapshot_value" = "金額";
"snapshot_contribution" = "追加資金";
"contribution_optional" = "追加資金(任意)";
"notes" = "メモ";
"notes_optional" = "メモ(任意)";
"previous_value" = "前回: %@";
"change_from_previous" = "前回からの変化";
"charts_title" = "チャート";
"evolution" = "推移";
"allocation" = "配分";
"performance" = "パフォーマンス";
"drawdown" = "ドローダウン";
"volatility" = "ボラティリティ";
"prediction" = "予測";
"portfolio_evolution" = "ポートフォリオ推移";
"asset_allocation" = "資産配分";
"performance_by_category" = "カテゴリ別パフォーマンス";
"drawdown_analysis" = "ドローダウン分析";
"prediction_12_month" = "12か月予測";
"not_enough_data" = "データが不足しています";
"cagr" = "CAGR";
"twr" = "TWR";
"max_drawdown" = "最大ドローダウン";
"sharpe_ratio" = "シャープレシオ";
"win_rate" = "勝率";
"avg_monthly" = "月平均";
"best_month" = "最高の月";
"worst_month" = "最悪の月";
"premium" = "Premium";
"upgrade_to_premium" = "Premium にアップグレード";
"unlock_full_potential" = "すべての機能を解放";
"one_time_purchase" = "買い切り";
"includes_family_sharing" = "ファミリー共有対応";
"upgrade_now" = "今すぐアップグレード";
"restore_purchases" = "購入を復元";
"premium_active" = "Premium 有効";
"premium_feature" = "Premium 機能";
"unlock" = "ロック解除";
"feature_unlimited_sources" = "無制限のソース";
"feature_unlimited_sources_desc" = "好きなだけ投資を追跡できます";
"feature_full_history" = "完全な履歴";
"feature_full_history_desc" = "投資履歴をすべて確認できます";
"feature_advanced_charts" = "高度なチャート";
"feature_advanced_charts_desc" = "詳細分析チャート 5 種類";
"feature_predictions" = "予測";
"feature_predictions_desc" = "AI による12か月予測";
"feature_export" = "データを書き出し";
"feature_export_desc" = "CSV と JSON に書き出し";
"feature_no_ads" = "広告なし";
"feature_no_ads_desc" = "ずっと広告なしで使えます";
"paywall_benefit_history_title" = "完全な履歴";
"paywall_benefit_history_subtitle" = "初日からのすべての snapshot、追加資金、利益を確認";
"paywall_benefit_charts_title" = "傾向が見えるチャート";
"paywall_benefit_charts_subtitle" = "配分、ドローダウン、パフォーマンスを一か所で確認";
"paywall_benefit_forecasts_title" = "12か月予測";
"paywall_benefit_forecasts_subtitle" = "ポートフォリオの行き先を見通せます";
"paywall_benefit_noads_title" = "広告は一切なし";
"paywall_benefit_noads_subtitle" = "気が散らない、集中できる体験";
"settings_title" = "設定";
"subscription" = "サブスクリプション";
"notifications" = "通知";
"default_reminder_time" = "デフォルトの通知時刻";
"data" = "データ";
"export_data" = "データを書き出し";
"total_sources" = "ソース合計";
"total_snapshots" = "スナップショット合計";
"storage_used" = "使用ストレージ";
"about" = "このアプリについて";
"version" = "バージョン";
"privacy_policy" = "プライバシーポリシー";
"terms_of_service" = "利用規約";
"support" = "サポート";
"rate_app" = "アプリを評価";
"danger_zone" = "危険ゾーン";
"reset_all_data" = "すべてのデータをリセット";
"reset_confirmation" = "すべての投資データが完全に削除されます。この操作は元に戻せません。";
"frequency_monthly" = "毎月";
"frequency_quarterly" = "四半期ごと";
"frequency_semiannual" = "半年ごと";
"frequency_annual" = "毎年";
"frequency_custom" = "カスタム";
"frequency_never" = "なし";
"every_n_months" = "%d か月ごと";
"category_stocks" = "株式";
"category_bonds" = "債券";
"category_real_estate" = "不動産";
"category_crypto" = "暗号資産";
"category_cash" = "現金";
"category_etfs" = "ETF";
"category_retirement" = "退職資産";
"category_other" = "その他";
"uncategorized" = "未分類";
"time_1m" = "1M";
"time_3m" = "3M";
"time_6m" = "6M";
"time_1y" = "1Y";
"time_all" = "すべて";
"export_format" = "形式を選択";
"export_csv" = "CSV";
"export_csv_desc" = "Excel、Google Sheets に対応";
"export_json" = "JSON";
"export_json_desc" = "バックアップ用の完全なデータ構造";
"onboarding_track_title" = "投資をまとめて管理";
"onboarding_track_desc" = "株式、債券、不動産、暗号資産など、すべての投資ソースを一か所で追跡できます。";
"onboarding_visualize_title" = "成長を可視化";
"onboarding_visualize_desc" = "美しいチャートでポートフォリオの推移、配分、パフォーマンスを確認できます。";
"onboarding_reminders_title" = "更新を忘れない";
"onboarding_reminders_desc" = "毎月、四半期、またはカスタムで、定期的に投資を記録するためのリマインダーを設定できます。";
"onboarding_sync_title" = "どこでも同期";
"onboarding_sync_desc" = "データは iCloud で自動同期され、すべての Apple デバイスで利用できます。";
"get_started" = "始める";
"onboarding_clarity_title" = "今の立ち位置がすぐ分かる";
"onboarding_clarity_desc" = "総資産、実質リターン、配分をいつでも最新の状態で確認できます。";
"onboarding_habit_title" = "月に5分で十分";
"onboarding_habit_desc" = "月に一度金額を入力するだけ。計算は Portfolio Journal が行い、進捗を見せてくれます。";
"onboarding_calm_title" = "ノイズを無視して、流れを見る。";
"onboarding_calm_desc" = "日々の値動きは本当の物語ではありません。数か月、数年の成長こそが大切です。";
"onboarding_goals_title" = "お金の目標に近づく";
"onboarding_goals_desc" = "目標を設定し、節目を追跡し、どこまで進んだかを把握できます。";
"error_generic" = "エラーが発生しました。もう一度お試しください。";
"error_no_purchases" = "復元できる購入が見つかりません";
"error_purchase_failed" = "購入に失敗しました: %@";
"error_export_failed" = "書き出しに失敗しました。もう一度お試しください。";
"placeholder_source_name" = "例: Vanguard 401k";
"placeholder_value" = "0.00";
"placeholder_notes" = "メモを追加...";
"mood_energized_title" = "絶好調";
"mood_confident_title" = "自信あり";
"mood_balanced_title" = "安定";
"mood_cautious_title" = "慎重";
"mood_stressed_title" = "ストレス";
"mood_energized_detail" = "無敵な気分";
"mood_confident_detail" = "順調で落ち着いている";
"mood_balanced_detail" = "冷静で辛抱強い";
"mood_cautious_detail" = "動きを見守っている";
"mood_stressed_detail" = "一度リセットしたい";
"achievement_streak_3_title" = "3か月連続";
"achievement_streak_3_detail" = "3か月連続で期限内にチェックインしました。";
"achievement_streak_6_title" = "半年連続";
"achievement_streak_6_detail" = "6回連続で期限内にチェックインしました。";
"achievement_streak_12_title" = "1年の勢い";
"achievement_streak_12_detail" = "1年間、一度も期限を逃しませんでした。";
"achievement_perfect_on_time_title" = "一度も遅れなし";
"achievement_perfect_on_time_detail" = "すべてのチェックインが期限前に完了しました。";
"achievement_clutch_finish_title" = "ギリギリ成功";
"achievement_clutch_finish_detail" = "残り数時間で提出、それでも期限内。";
"achievement_early_bird_title" = "早め派";
"achievement_early_bird_detail" = "平均するとかなり余裕を持って終えています。";
"achievements_title" = "実績";
"achievements_view_all" = "すべての実績を見る";
"achievements_nav_title" = "実績";
"achievements_progress_title" = "進捗";
"achievements_unlocked_title" = "解除済み";
"achievements_unlocked_empty" = "チェックインを完了して実績を解除しましょう。";
"achievements_locked_title" = "未解除";
"achievements_locked_empty" = "すべての実績を解除しました。すばらしいです。";
"rating_accessibility" = "評価 %d / 5";
"achievements_unlocked_count" = "%d / %d を解除";
"last_check_in" = "前回のチェックイン: %@";
"next_check_in" = "次回のチェックイン: %@";
"on_time_rate" = "%@ が期限内";
"on_time_count" = "%d/%d が期限内";
"tightest_finish" = "最もギリギリだった完了: 締切の %@ 前。";
"date_today" = "今日";
"date_yesterday" = "昨日";
"date_never" = "なし";
"calendar_event_title" = "%@: 月次チェックイン";
"calendar_event_notes" = "%@ を開いて月次チェックインを完了してください。";
"checkin_enjoying_dialog_title" = "Portfolio Journal をどのくらい気に入っていますか?";
"checkin_enjoying_dialog_message" = "新しい実績おめでとうございます。ご意見は改善に役立ちます。";
"not_now" = "今はしない";
"rating_1_star" = "1つ星";
"rating_n_stars" = "%dつ星";
"app_store_review_title" = "App Store にレビューを残しますか?";
"app_store_review_message" = "5つ星ありがとうございます。ほかの投資家がアプリを見つけやすくなります。";
"write_review" = "レビューを書く";
"save_1_snapshot" = "1件の snapshot を保存";
"save_n_snapshots" = "%d件の snapshot を保存";
"checkin_update_month" = "%@ を更新";
"checkin_start_new" = "開始";
"Home" = "ホーム";
"Sources" = "ソース";
"Charts" = "チャート";
"Settings" = "設定";
"Journal" = "ジャーナル";
"Search monthly notes" = "月次メモを検索";
"Monthly Check-ins" = "月次チェックイン";
"No monthly notes yet." = "まだ月次メモがありません。";
"No matching notes." = "一致するメモがありません。";
"Jump to month" = "月へ移動";
"Today" = "今日";
"Mood not set" = "気分未設定";
"No rating" = "評価なし";
"No note yet." = "まだメモがありません。";
"Monthly Note" = "月次メモ";
"Open Full Note" = "全文を開く";
"Duplicate Previous" = "前回を複製";
"Save" = "保存";
"Monthly Check-in" = "月次チェックイン";
"This Month" = "今月";
"No check-in yet this month" = "今月はまだチェックインがありません";
"Start your first check-in anytime." = "いつでも最初のチェックインを始められます。";
"Mark Check-in Complete" = "チェックインを完了にする";
"Editing stays open. New check-ins unlock after 70% of the month." = "編集は開いたままです。新しいチェックインは月の70%経過後に解放されます。";
"Momentum & Streaks" = "勢いと連続記録";
"Log a check-in to start a streak" = "チェックインを記録して連続記録を始めましょう";
"Streak" = "連続記録";
"On-time in a row" = "連続期限内";
"Best" = "最高";
"Personal best" = "自己ベスト";
"Avg early" = "平均余裕";
"vs deadline" = "締切比";
"On-time score" = "期限内スコア";
"Achievements" = "実績";
"View all achievements" = "すべての実績を見る";
"Monthly Pulse" = "月次パルス";
"Optional" = "任意";
"Rate this month" = "今月を評価";
"How did it feel?" = "どんな気分でしたか?";
"Monthly Summary" = "月次サマリー";
"Starting" = "開始";
"Ending" = "終了";
"Contributions" = "追加資金";
"Net Performance" = "純パフォーマンス";
"Update Sources" = "ソースを更新";
"Add sources to start your monthly check-in." = "月次チェックインを始めるにはソースを追加してください。";
"Updated this cycle" = "このサイクルで更新済み";
"Needs update" = "更新が必要";
"Snapshot Notes" = "スナップショットのメモ";
"No snapshot notes for this month." = "今月のスナップショットメモはありません。";
"Source" = "ソース";
"Your full portfolio,\nfully clear" = "あなたのポートフォリオ全体を、\nもっと明確に";
"One payment. Every feature. Forever." = "一度の支払い。すべての機能を。ずっと。";
"Get Full Access" = "フルアクセスを取得";
"Restore Purchases" = "購入を復元";
"Payment charged to your Apple ID account." = "料金は Apple ID アカウントに請求されます。";
"Terms" = "利用規約";
"Privacy" = "プライバシー";
"· one-time · Family Sharing" = "· 買い切り · ファミリー共有";
"Full access, one payment" = "フルアクセスを一度の支払いで";
"Unlimited sources, advanced charts & more" = "無制限のソース、高度なチャートなど";
"See full access" = "フルアクセスを見る";
"Batch Update" = "一括更新";
"Current value" = "現在の金額";
"Contribution this period (optional)" = "今回の追加資金(任意)";
"Include Contribution" = "追加資金を含める";
"New capital added" = "新たに追加した資金";
"Contribution (Optional)" = "追加資金(任意)";
"Track new capital added to separate it from investment growth." = "投資の成長と分けるため、新たに追加した資金を記録します。";
"Monthly Highlights" = "今月のハイライト";
"Best Performer" = "最高のパフォーマー";
"Worst Performer" = "最も低調だったもの";
"Best Contributor" = "最大の貢献元";
"Update Check-in" = "チェックインを更新";
"Completed %@" = "%@ に完了";
@@ -0,0 +1,294 @@
"app_name" = "Portfolio Journal";
"ok" = "OK";
"cancel" = "Cancelar";
"save" = "Salvar";
"delete" = "Excluir";
"edit" = "Editar";
"add" = "Adicionar";
"done" = "Concluído";
"close" = "Fechar";
"continue" = "Continuar";
"skip" = "Pular";
"error" = "Erro";
"success" = "Sucesso";
"loading" = "Carregando...";
"loading_data" = "Carregando seus dados...";
"tab_dashboard" = "Início";
"tab_sources" = "Fontes";
"tab_charts" = "Gráficos";
"tab_settings" = "Ajustes";
"dashboard_title" = "Início";
"total_portfolio_value" = "Valor total da carteira";
"today" = "hoje";
"returns" = "Retornos";
"by_category" = "Por categoria";
"pending_updates" = "Atualizações pendentes";
"see_all" = "Ver tudo";
"sources_title" = "Fontes";
"add_source" = "Adicionar fonte";
"source_name" = "Nome da fonte";
"select_category" = "Selecionar categoria";
"initial_value" = "Valor inicial";
"initial_value_optional" = "Valor inicial (opcional)";
"reminder_frequency" = "Frequência do lembrete";
"source_limit_warning" = "Limite de fontes atingido. Faça upgrade para Premium para fontes ilimitadas.";
"no_sources" = "Nenhuma fonte de investimento";
"no_sources_message" = "Adicione sua primeira fonte de investimento para começar a acompanhar sua carteira.";
"add_snapshot" = "Adicionar snapshot";
"edit_snapshot" = "Editar snapshot";
"snapshot_date" = "Data";
"snapshot_value" = "Valor";
"snapshot_contribution" = "Contribuição";
"contribution_optional" = "Contribuição (opcional)";
"notes" = "Notas";
"notes_optional" = "Notas (opcionais)";
"previous_value" = "Anterior: %@";
"change_from_previous" = "Mudança em relação ao anterior";
"charts_title" = "Gráficos";
"evolution" = "Evolução";
"allocation" = "Alocação";
"performance" = "Desempenho";
"drawdown" = "Drawdown";
"volatility" = "Volatilidade";
"prediction" = "Previsão";
"portfolio_evolution" = "Evolução da carteira";
"asset_allocation" = "Alocação de ativos";
"performance_by_category" = "Desempenho por categoria";
"drawdown_analysis" = "Análise de drawdown";
"prediction_12_month" = "Previsão de 12 meses";
"not_enough_data" = "Dados insuficientes";
"cagr" = "CAGR";
"twr" = "TWR";
"max_drawdown" = "Drawdown máximo";
"sharpe_ratio" = "Índice de Sharpe";
"win_rate" = "Taxa de acerto";
"avg_monthly" = "Média mensal";
"best_month" = "Melhor mês";
"worst_month" = "Pior mês";
"premium" = "Premium";
"upgrade_to_premium" = "Fazer upgrade para Premium";
"unlock_full_potential" = "Desbloqueie todo o potencial";
"one_time_purchase" = "Compra única";
"includes_family_sharing" = "Inclui Compartilhamento Familiar";
"upgrade_now" = "Fazer upgrade agora";
"restore_purchases" = "Restaurar compras";
"premium_active" = "Premium ativo";
"premium_feature" = "Recurso Premium";
"unlock" = "Desbloquear";
"feature_unlimited_sources" = "Fontes ilimitadas";
"feature_unlimited_sources_desc" = "Acompanhe quantos investimentos quiser";
"feature_full_history" = "Histórico completo";
"feature_full_history_desc" = "Acesse todo o seu histórico de investimentos";
"feature_advanced_charts" = "Gráficos avançados";
"feature_advanced_charts_desc" = "5 tipos de gráficos analíticos detalhados";
"feature_predictions" = "Previsões";
"feature_predictions_desc" = "Previsões de 12 meses com IA";
"feature_export" = "Exportar dados";
"feature_export_desc" = "Exporte para CSV e JSON";
"feature_no_ads" = "Sem anúncios";
"feature_no_ads_desc" = "Experiência sem anúncios para sempre";
"paywall_benefit_history_title" = "Seu histórico completo";
"paywall_benefit_history_subtitle" = "Cada snapshot, contribuição e ganho desde o primeiro dia";
"paywall_benefit_charts_title" = "Gráficos que revelam padrões";
"paywall_benefit_charts_subtitle" = "Alocação, drawdown, desempenho: tudo em um só lugar";
"paywall_benefit_forecasts_title" = "Previsões de 12 meses";
"paywall_benefit_forecasts_subtitle" = "Veja para onde sua carteira provavelmente está indo";
"paywall_benefit_noads_title" = "Sem anúncios, nunca";
"paywall_benefit_noads_subtitle" = "Experiência limpa e focada, sem distrações";
"settings_title" = "Ajustes";
"subscription" = "Assinatura";
"notifications" = "Notificações";
"default_reminder_time" = "Horário padrão do lembrete";
"data" = "Dados";
"export_data" = "Exportar dados";
"total_sources" = "Total de fontes";
"total_snapshots" = "Total de snapshots";
"storage_used" = "Armazenamento usado";
"about" = "Sobre";
"version" = "Versão";
"privacy_policy" = "Política de privacidade";
"terms_of_service" = "Termos de serviço";
"support" = "Suporte";
"rate_app" = "Avaliar app";
"danger_zone" = "Zona de perigo";
"reset_all_data" = "Redefinir todos os dados";
"reset_confirmation" = "Isso excluirá permanentemente todos os seus dados de investimento. Esta ação não pode ser desfeita.";
"frequency_monthly" = "Mensal";
"frequency_quarterly" = "Trimestral";
"frequency_semiannual" = "Semestral";
"frequency_annual" = "Anual";
"frequency_custom" = "Personalizada";
"frequency_never" = "Nunca";
"every_n_months" = "A cada %d mês(es)";
"category_stocks" = "Ações";
"category_bonds" = "Títulos";
"category_real_estate" = "Imóveis";
"category_crypto" = "Cripto";
"category_cash" = "Caixa";
"category_etfs" = "ETFs";
"category_retirement" = "Aposentadoria";
"category_other" = "Outro";
"uncategorized" = "Sem categoria";
"time_1m" = "1M";
"time_3m" = "3M";
"time_6m" = "6M";
"time_1y" = "1A";
"time_all" = "Tudo";
"export_format" = "Selecionar formato";
"export_csv" = "CSV";
"export_csv_desc" = "Compatível com Excel e Google Sheets";
"export_json" = "JSON";
"export_json_desc" = "Estrutura completa para backup";
"onboarding_track_title" = "Acompanhe seus investimentos";
"onboarding_track_desc" = "Monitore todas as suas fontes de investimento em um só lugar. Ações, títulos, imóveis, cripto e mais.";
"onboarding_visualize_title" = "Visualize seu crescimento";
"onboarding_visualize_desc" = "Belos gráficos mostram a evolução, alocação e desempenho da sua carteira ao longo do tempo.";
"onboarding_reminders_title" = "Nunca perca uma atualização";
"onboarding_reminders_desc" = "Defina lembretes para acompanhar seus investimentos regularmente. Mensal, trimestral ou personalizado.";
"onboarding_sync_title" = "Sincronize em todos os lugares";
"onboarding_sync_desc" = "Seus dados sincronizam automaticamente via iCloud em todos os seus dispositivos Apple.";
"get_started" = "Começar";
"onboarding_clarity_title" = "Saiba exatamente onde você está";
"onboarding_clarity_desc" = "Veja seu patrimônio total, retornos reais e alocação, sempre atualizados.";
"onboarding_habit_title" = "5 minutos por mês são suficientes";
"onboarding_habit_desc" = "Registre seus valores uma vez por mês. O Portfolio Journal faz as contas e mostra seu progresso.";
"onboarding_calm_title" = "Ignore o ruído. Acompanhe a tendência.";
"onboarding_calm_desc" = "Oscilações diárias não contam a história real. Seu crescimento ao longo de meses e anos conta.";
"onboarding_goals_title" = "Alcance seus objetivos financeiros";
"onboarding_goals_desc" = "Defina metas, acompanhe marcos e veja exatamente o quanto você avançou.";
"error_generic" = "Ocorreu um erro. Tente novamente.";
"error_no_purchases" = "Nenhuma compra encontrada para restaurar";
"error_purchase_failed" = "Compra falhou: %@";
"error_export_failed" = "A exportação falhou. Tente novamente.";
"placeholder_source_name" = "ex.: Vanguard 401k";
"placeholder_value" = "0.00";
"placeholder_notes" = "Adicionar notas...";
"mood_energized_title" = "A mil";
"mood_confident_title" = "Confiante";
"mood_balanced_title" = "Estável";
"mood_cautious_title" = "Cauteloso";
"mood_stressed_title" = "Estressado";
"mood_energized_detail" = "Me sentindo imbatível";
"mood_confident_detail" = "No caminho e tranquilo";
"mood_balanced_detail" = "Calmo e paciente";
"mood_cautious_detail" = "Observando os movimentos";
"mood_stressed_detail" = "Preciso respirar";
"achievement_streak_3_title" = "Sequência de 3 meses";
"achievement_streak_3_detail" = "Você manteve seus check-ins em dia por três meses seguidos.";
"achievement_streak_6_title" = "Boa fase de meio ano";
"achievement_streak_6_detail" = "Seis check-ins consecutivos no prazo.";
"achievement_streak_12_title" = "Um ano de ritmo";
"achievement_streak_12_detail" = "Um ano inteiro sem perder o prazo.";
"achievement_perfect_on_time_title" = "Nunca atrasado";
"achievement_perfect_on_time_detail" = "Todos os check-ins foram enviados antes do prazo.";
"achievement_clutch_finish_title" = "No limite";
"achievement_clutch_finish_detail" = "Enviado com poucas horas de sobra, mas ainda no prazo.";
"achievement_early_bird_title" = "Adiantado";
"achievement_early_bird_detail" = "Em média você termina com bastante tempo sobrando.";
"achievements_title" = "Conquistas";
"achievements_view_all" = "Ver todas as conquistas";
"achievements_nav_title" = "Conquistas";
"achievements_progress_title" = "Progresso";
"achievements_unlocked_title" = "Desbloqueadas";
"achievements_unlocked_empty" = "Complete check-ins para desbloquear conquistas.";
"achievements_locked_title" = "Bloqueadas";
"achievements_locked_empty" = "Todas as conquistas desbloqueadas. Bom trabalho.";
"rating_accessibility" = "Avaliação %d de 5";
"achievements_unlocked_count" = "%d de %d desbloqueadas";
"last_check_in" = "Último check-in: %@";
"next_check_in" = "Próximo check-in: %@";
"on_time_rate" = "%@ no prazo";
"on_time_count" = "%d/%d no prazo";
"tightest_finish" = "Fechamento mais apertado: %@ antes do prazo.";
"date_today" = "Hoje";
"date_yesterday" = "Ontem";
"date_never" = "Nunca";
"calendar_event_title" = "%@: Check-in mensal";
"calendar_event_notes" = "Abra %@ e conclua seu check-in mensal.";
"checkin_enjoying_dialog_title" = "Quanto você está gostando do Portfolio Journal?";
"checkin_enjoying_dialog_message" = "Parabéns pela sua nova conquista. Seu feedback nos ajuda a melhorar.";
"not_now" = "Agora não";
"rating_1_star" = "1 estrela";
"rating_n_stars" = "%d estrelas";
"app_store_review_title" = "Gostaria de deixar uma avaliação na App Store?";
"app_store_review_message" = "Obrigado pelas 5 estrelas. Isso realmente ajuda outros investidores a descobrir o app.";
"write_review" = "Escrever avaliação";
"save_1_snapshot" = "Salvar 1 snapshot";
"save_n_snapshots" = "Salvar %d snapshots";
"checkin_update_month" = "Atualizar %@";
"checkin_start_new" = "Iniciar";
"Home" = "Início";
"Sources" = "Fontes";
"Charts" = "Gráficos";
"Settings" = "Ajustes";
"Journal" = "Diário";
"Search monthly notes" = "Buscar notas mensais";
"Monthly Check-ins" = "Check-ins mensais";
"No monthly notes yet." = "Ainda não há notas mensais.";
"No matching notes." = "Nenhuma nota correspondente.";
"Jump to month" = "Ir para o mês";
"Today" = "Hoje";
"Mood not set" = "Humor não definido";
"No rating" = "Sem avaliação";
"No note yet." = "Ainda sem nota.";
"Monthly Note" = "Nota mensal";
"Open Full Note" = "Abrir nota completa";
"Duplicate Previous" = "Duplicar anterior";
"Save" = "Salvar";
"Monthly Check-in" = "Check-in mensal";
"This Month" = "Este mês";
"No check-in yet this month" = "Ainda não há check-in este mês";
"Start your first check-in anytime." = "Comece seu primeiro check-in quando quiser.";
"Mark Check-in Complete" = "Marcar check-in como concluído";
"Editing stays open. New check-ins unlock after 70% of the month." = "A edição permanece aberta. Novos check-ins são liberados após 70 % do mês.";
"Momentum & Streaks" = "Ritmo e sequências";
"Log a check-in to start a streak" = "Registre um check-in para começar uma sequência";
"Streak" = "Sequência";
"On-time in a row" = "No prazo em sequência";
"Best" = "Melhor";
"Personal best" = "Recorde pessoal";
"Avg early" = "Antecedência média";
"vs deadline" = "vs prazo";
"On-time score" = "Pontuação de prazo";
"Achievements" = "Conquistas";
"View all achievements" = "Ver todas as conquistas";
"Monthly Pulse" = "Pulso mensal";
"Optional" = "Opcional";
"Rate this month" = "Avalie este mês";
"How did it feel?" = "Como foi?";
"Monthly Summary" = "Resumo mensal";
"Starting" = "Início";
"Ending" = "Final";
"Contributions" = "Contribuições";
"Net Performance" = "Desempenho líquido";
"Update Sources" = "Atualizar fontes";
"Add sources to start your monthly check-in." = "Adicione fontes para começar seu check-in mensal.";
"Updated this cycle" = "Atualizado neste ciclo";
"Needs update" = "Precisa de atualização";
"Snapshot Notes" = "Notas de snapshot";
"No snapshot notes for this month." = "Não há notas de snapshot neste mês.";
"Source" = "Fonte";
"Your full portfolio,\nfully clear" = "Sua carteira completa,\ntotalmente clara";
"One payment. Every feature. Forever." = "Um pagamento. Todos os recursos. Para sempre.";
"Get Full Access" = "Obter acesso completo";
"Restore Purchases" = "Restaurar compras";
"Payment charged to your Apple ID account." = "O pagamento será cobrado na sua conta Apple ID.";
"Terms" = "Termos";
"Privacy" = "Privacidade";
"· one-time · Family Sharing" = "· pagamento único · Compartilhamento Familiar";
"Full access, one payment" = "Acesso total, um pagamento";
"Unlimited sources, advanced charts & more" = "Fontes ilimitadas, gráficos avançados e mais";
"See full access" = "Ver acesso completo";
"Batch Update" = "Atualização em lote";
"Current value" = "Valor atual";
"Contribution this period (optional)" = "Contribuição deste período (opcional)";
"Include Contribution" = "Incluir contribuição";
"New capital added" = "Novo capital adicionado";
"Contribution (Optional)" = "Contribuição (opcional)";
"Track new capital added to separate it from investment growth." = "Registre o novo capital adicionado para separar do crescimento do investimento.";
"Monthly Highlights" = "Destaques do mês";
"Best Performer" = "Melhor desempenho";
"Worst Performer" = "Pior desempenho";
"Best Contributor" = "Maior contribuinte";
"Update Check-in" = "Atualizar check-in";
"Completed %@" = "Concluído %@";
@@ -140,6 +140,13 @@ class IAPService: ObservableObject {
}
}
// MARK: - TestFlight Detection
/// TestFlight builds use a "sandboxReceipt" instead of the production "receipt".
private var isRunningOnTestFlight: Bool {
Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt"
}
// MARK: - Update Premium Status
func updatePremiumStatus() async {
@@ -154,6 +161,14 @@ class IAPService: ObservableObject {
}
#endif
// TestFlight builds always get Premium so testers can evaluate all features
if isRunningOnTestFlight {
isPremium = true
isFamilyShared = false
sharedDefaults?.set(true, forKey: "premiumUnlocked")
return
}
for await result in StoreKit.Transaction.currentEntitlements {
if case .verified(let transaction) = result {
if transaction.productID == Self.premiumProductID {
@@ -46,58 +46,23 @@ enum CurrencyFormatter {
return formatter.currencySymbol ?? code
}
/// Parses a user-typed numeric string using digit-position-based separator detection.
/// Locale-independent: a separator followed by 1-2 digits at the end is decimal,
/// otherwise it is a thousands separator.
/// Parses a user-typed numeric string accepting both `.` and `,` as decimal/grouping separators.
/// The parser is intentionally permissive to avoid turning decimal input into huge integers.
static func parseUserInput(_ string: String, currencySymbol: String = "") -> Decimal? {
let stripped = string
.replacingOccurrences(of: currencySymbol, with: "")
.trimmingCharacters(in: .whitespaces)
.filter { $0.isNumber || $0 == "." || $0 == "," }
.replacingOccurrences(of: "\u{00A0}", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
.filter { $0.isNumber || $0 == "." || $0 == "," || $0 == "-" }
guard !stripped.isEmpty else { return nil }
let hasDot = stripped.contains(".")
let hasComma = stripped.contains(",")
let isNegative = stripped.hasPrefix("-")
let unsigned = stripped.replacingOccurrences(of: "-", with: "")
guard !unsigned.isEmpty else { return nil }
let normalized: String
if hasDot && hasComma {
// Both separators: the one appearing last is the decimal
let lastDot = stripped.lastIndex(of: ".")!
let lastComma = stripped.lastIndex(of: ",")!
if lastComma > lastDot {
// e.g. 1.234,56 1234.56
normalized = stripped
.replacingOccurrences(of: ".", with: "")
.replacingOccurrences(of: ",", with: ".")
} else {
// e.g. 1,234.56 1234.56
normalized = stripped.replacingOccurrences(of: ",", with: "")
}
} else if hasComma {
// Only comma: decimal if exactly 2 parts and last has 2 digits
let parts = stripped.components(separatedBy: ",")
if parts.count == 2 && (parts.last?.count ?? 0) <= 2 {
// e.g. 408857,62 408857.62
normalized = stripped.replacingOccurrences(of: ",", with: ".")
} else {
// e.g. 408,857 or 1,234,567 thousands
normalized = stripped.replacingOccurrences(of: ",", with: "")
}
} else if hasDot {
// Only dot: decimal if exactly 2 parts and last has 2 digits
let parts = stripped.components(separatedBy: ".")
if parts.count == 2 && (parts.last?.count ?? 0) <= 2 {
// e.g. 408857.62 already correct
normalized = stripped
} else {
// e.g. 408.857 or 1.234.567 thousands
normalized = stripped.replacingOccurrences(of: ".", with: "")
}
} else {
normalized = stripped
}
let normalizedUnsigned = normalizeNumericInput(unsigned)
let normalized = isNegative ? "-\(normalizedUnsigned)" : normalizedUnsigned
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
@@ -105,6 +70,62 @@ enum CurrencyFormatter {
return formatter.number(from: normalized)?.decimalValue
}
private static func normalizeNumericInput(_ input: String) -> String {
let hasDot = input.contains(".")
let hasComma = input.contains(",")
if hasDot && hasComma {
// Both separators present: the last one is considered decimal.
let lastDot = input.lastIndex(of: ".")!
let lastComma = input.lastIndex(of: ",")!
if lastComma > lastDot {
return input
.replacingOccurrences(of: ".", with: "")
.replacingOccurrences(of: ",", with: ".")
}
return input.replacingOccurrences(of: ",", with: "")
}
if hasDot {
return normalizeSingleSeparator(input, separator: ".")
}
if hasComma {
return normalizeSingleSeparator(input, separator: ",")
}
return input
}
private static func normalizeSingleSeparator(_ input: String, separator: Character) -> String {
let parts = input.split(separator: separator, omittingEmptySubsequences: false)
guard parts.count > 1 else { return input }
if parts.count == 2 {
// A single separator is treated as decimal (e.g. 533.595).
return "\(parts[0]).\(parts[1])"
}
let integerParts = Array(parts.dropLast())
let fractionPart = String(parts.last ?? "")
// If all groups follow a strict thousands pattern and the last one has 3 digits,
// prefer grouping-only interpretation (e.g. 1.234.567).
if looksLikeGroupedThousands(integerParts), fractionPart.count == 3 {
return input.replacingOccurrences(of: String(separator), with: "")
}
// Otherwise, treat the last separator as decimal and previous ones as grouping.
let integer = integerParts.joined()
return "\(integer).\(fractionPart)"
}
private static func looksLikeGroupedThousands(_ groups: [Substring]) -> Bool {
guard let first = groups.first, !first.isEmpty, first.count <= 3 else { return false }
guard groups.count >= 2 else { return false }
return groups.dropFirst().allSatisfy { $0.count == 3 }
}
/// Formats a decimal for display in an input field (no grouping separator).
static func formatForInput(_ decimal: Decimal, currencyCode: String) -> String {
let formatter = NumberFormatter()
@@ -94,8 +94,11 @@ class SnapshotFormViewModel: ObservableObject {
// Contribution is optional but must be valid if included
if includeContribution {
guard let contribution = parseDecimal(contributionString), contribution >= 0 else {
return false
let trimmed = contributionString.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
guard let contribution = parseDecimal(trimmed), contribution >= 0 else {
return false
}
}
}
@@ -120,7 +123,9 @@ class SnapshotFormViewModel: ObservableObject {
var contribution: Decimal? {
guard includeContribution else { return nil }
return parseDecimal(contributionString)
let trimmed = contributionString.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return parseDecimal(trimmed)
}
var formattedValue: String {
@@ -518,6 +518,28 @@ struct EvolutionChartView: View {
var id: String { rawValue }
}
private static let compactXAxisDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = .autoupdatingCurrent
formatter.setLocalizedDateFormatFromTemplate("MMM yy")
return formatter
}()
private var xAxisMonthStride: Int {
switch data.count {
case ...8:
return 1
case ...16:
return 2
case ...30:
return 3
case ...48:
return 4
default:
return 6
}
}
private var stackedCategoryData: [CategoryEvolutionPoint] {
guard !categoryData.isEmpty else { return [] }
@@ -622,8 +644,17 @@ struct EvolutionChartView: View {
}
.chartForegroundStyleScale(domain: chartCategoryNames, range: chartCategoryColors)
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 2)) { value in
AxisValueLabel(format: .dateTime.month(.abbreviated).year())
AxisMarks(values: .stride(by: .month, count: xAxisMonthStride)) { value in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.8, dash: [3, 3]))
.foregroundStyle(Color.secondary.opacity(0.2))
AxisTick(stroke: StrokeStyle(lineWidth: 0.8))
.foregroundStyle(Color.secondary.opacity(0.28))
AxisValueLabel {
if let date = value.as(Date.self) {
Text(date, formatter: Self.compactXAxisDateFormatter)
.font(.caption2)
}
}
}
}
.chartYAxis {
@@ -413,9 +413,7 @@ struct TotalValueCard: View {
struct MonthlyCheckInCard: View {
let lastUpdated: String
let lastUpdatedDate: Date?
@State private var showingStartOptions = false
@State private var startDestinationActive = false
@State private var shouldDuplicatePrevious = false
@State private var cachedCompletionDate: Date?
private var effectiveLastCheckInDate: Date? {
@@ -466,11 +464,46 @@ struct MonthlyCheckInCard: View {
)
}
/// True during the first half of the month (before mid-month threshold) when the previous
/// period's check-in should be offered for update instead of starting a new one.
private var isBeforeMidMonth: Bool {
guard effectiveLastCheckInDate != nil else { return false }
let cal = Calendar.current
let day = cal.component(.day, from: Date())
let daysInMonth = cal.range(of: .day, in: .month, for: Date())?.count ?? 30
return day < daysInMonth / 2
}
/// Reference date to pass to MonthlyCheckInView depending on the current phase.
private var navigationReferenceDate: Date {
if isBeforeMidMonth {
return Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
}
// Use day 25 so effectiveMonth always resolves to the current calendar month
var comps = Calendar.current.dateComponents([.year, .month], from: Date())
comps.day = 25
return Calendar.current.date(from: comps) ?? Date()
}
private var buttonLabel: String {
if isBeforeMidMonth {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM"
formatter.locale = .current
let prev = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
return String(format: NSLocalizedString("checkin_update_month", comment: ""), formatter.string(from: prev))
}
return NSLocalizedString("checkin_start_new", comment: "")
}
private var currentMonthLabel: String {
let formatter = DateFormatter()
formatter.dateFormat = "LLLL yyyy"
let effectiveMonth = MonthlyCheckInStore.effectiveMonth(for: Date(), relativeTo: Date())
return formatter.string(from: effectiveMonth)
if isBeforeMidMonth {
let prev = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
return formatter.string(from: prev)
}
return formatter.string(from: Date())
}
var body: some View {
@@ -521,9 +554,9 @@ struct MonthlyCheckInCard: View {
}
Button {
showingStartOptions = true
startDestinationActive = true
} label: {
Text("Start")
Text(buttonLabel)
.font(.headline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
@@ -535,7 +568,7 @@ struct MonthlyCheckInCard: View {
NavigationLink(
isActive: $startDestinationActive
) {
MonthlyCheckInView(duplicatePrevious: shouldDuplicatePrevious)
MonthlyCheckInView(referenceDate: navigationReferenceDate)
} label: {
EmptyView()
}
@@ -544,47 +577,6 @@ struct MonthlyCheckInCard: View {
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
.sheet(isPresented: $showingStartOptions) {
VStack(spacing: 16) {
Text("Start Monthly Check-in")
.font(.title2.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 24)
Button {
showingStartOptions = false
shouldDuplicatePrevious = false
startDestinationActive = true
} label: {
Text("Start from scratch")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Color(.systemGray5))
.cornerRadius(AppConstants.UI.cornerRadius)
}
Button {
showingStartOptions = false
shouldDuplicatePrevious = true
startDestinationActive = true
} label: {
Text("Duplicate previous month")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Color(.systemGray5))
.cornerRadius(AppConstants.UI.cornerRadius)
}
Button("Cancel", role: .cancel) {
showingStartOptions = false
}
.padding(.bottom, 16)
}
.padding(.horizontal, 24)
.presentationDetents([.height(280)])
}
.onAppear {
cachedCompletionDate = MonthlyCheckInStore.latestCompletionDate()
}
@@ -9,6 +9,7 @@ struct EvolutionChartCard: View {
@State private var selectedDataPoint: (date: Date, value: Decimal)?
@State private var chartMode: ChartMode = .total
@State private var showGoalLines = true
@State private var chartWidth: CGFloat = 300
enum ChartMode: String, CaseIterable, Identifiable {
case total = "Total"
@@ -17,6 +18,30 @@ struct EvolutionChartCard: View {
var id: String { rawValue }
}
private static let compactXAxisDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = .autoupdatingCurrent
formatter.setLocalizedDateFormatFromTemplate("MMM yy")
return formatter
}()
/// Calculates the optimal month stride so labels never overlap,
/// using the actual rendered width of the chart instead of just data count.
private func xAxisMonthStride(for width: CGFloat) -> Int {
// ~50pt for Y-axis, ~44pt per "Jan 24" label
let usableWidth = max(width - 50, 80)
let maxLabels = max(2, Int(usableWidth / 44))
let rawStride = max(1, Int(ceil(Double(data.count) / Double(maxLabels))))
switch rawStride {
case ...1: return 1
case ...2: return 2
case ...3: return 3
case ...4: return 4
case ...6: return 6
default: return 12
}
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
headerView
@@ -84,8 +109,17 @@ struct EvolutionChartCard: View {
}
.chartForegroundStyleScale(domain: chartCategoryNames, range: chartCategoryColors)
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 3)) { value in
AxisValueLabel(format: .dateTime.month(.abbreviated).year())
AxisMarks(values: .stride(by: .month, count: xAxisMonthStride(for: chartWidth))) { value in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.8, dash: [3, 3]))
.foregroundStyle(Color.secondary.opacity(0.2))
AxisTick(stroke: StrokeStyle(lineWidth: 0.8))
.foregroundStyle(Color.secondary.opacity(0.28))
AxisValueLabel {
if let date = value.as(Date.self) {
Text(date, formatter: Self.compactXAxisDateFormatter)
.font(.caption2)
}
}
}
}
.chartYAxis {
@@ -124,6 +158,13 @@ struct EvolutionChartCard: View {
}
}
.frame(height: 200)
.background(
GeometryReader { geo in
Color.clear
.onAppear { chartWidth = geo.size.width }
.onChange(of: geo.size.width) { _, w in chartWidth = w }
}
)
// Performance: Use GPU rendering for smoother scrolling
.drawingGroup()
}
@@ -5,7 +5,6 @@ struct MonthlyCheckInView: View {
@EnvironmentObject var accountStore: AccountStore
@StateObject private var viewModel = MonthlyCheckInViewModel()
@State private var referenceDate: Date
let duplicatePrevious: Bool
@State private var monthlyNote: String
@State private var starRating: Int
@@ -13,14 +12,12 @@ struct MonthlyCheckInView: View {
@FocusState private var noteFocused: Bool
@State private var editingSnapshot: Snapshot?
@State private var addingSource: InvestmentSource?
@State private var didApplyDuplicate = false
@State private var showBatchUpdate = false
@State private var showAchievementSatisfactionDialog = false
@State private var showAppStoreReviewAlert = false
init(referenceDate: Date = Date(), duplicatePrevious: Bool = false) {
init(referenceDate: Date = Date()) {
_referenceDate = State(initialValue: referenceDate)
self.duplicatePrevious = duplicatePrevious
_monthlyNote = State(initialValue: MonthlyCheckInStore.note(for: referenceDate))
_starRating = State(initialValue: MonthlyCheckInStore.rating(for: referenceDate) ?? 0)
_selectedMood = State(initialValue: MonthlyCheckInStore.mood(for: referenceDate))
@@ -109,9 +106,32 @@ struct MonthlyCheckInView: View {
}
.padding()
}
.navigationTitle("Monthly Check-in")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
HStack(spacing: 12) {
Button {
navigateMonth(offset: -1)
} label: {
Image(systemName: "chevron.left")
.font(.subheadline.weight(.semibold))
.foregroundColor(.appPrimary)
}
Text(monthLabel)
.font(.headline)
Button {
navigateMonth(offset: 1)
} label: {
Image(systemName: "chevron.right")
.font(.subheadline.weight(.semibold))
.foregroundColor(canGoToNextMonth ? .appPrimary : .secondary.opacity(0.3))
}
.disabled(!canGoToNextMonth)
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button {
shareMonthlyCheckIn()
@@ -124,10 +144,6 @@ struct MonthlyCheckInView: View {
viewModel.selectedAccount = accountStore.selectedAccount
viewModel.showAllAccounts = accountStore.showAllAccounts
viewModel.selectedRange = DateRange.month(containing: referenceDate)
if duplicatePrevious, !didApplyDuplicate {
viewModel.duplicatePreviousMonthSnapshots(referenceDate: referenceDate)
didApplyDuplicate = true
}
viewModel.refresh()
monthlyNote = MonthlyCheckInStore.note(for: referenceDate)
starRating = MonthlyCheckInStore.rating(for: referenceDate) ?? 0
@@ -164,92 +180,56 @@ struct MonthlyCheckInView: View {
MonthlyCheckInStore.setMood(newValue, for: referenceDate)
}
.confirmationDialog(
"How much are you enjoying Portfolio Journal?",
"checkin_enjoying_dialog_title",
isPresented: $showAchievementSatisfactionDialog,
titleVisibility: .visible
) {
ForEach(1...5, id: \.self) { value in
Button("\(value) Star\(value > 1 ? "s" : "")") {
Button(value == 1
? String(localized: "rating_1_star")
: String(format: NSLocalizedString("rating_n_stars", comment: ""), value)
) {
if value == 5 {
showAppStoreReviewAlert = true
}
}
}
Button("Not Now", role: .cancel) {}
Button(String(localized: "not_now"), role: .cancel) {}
} message: {
Text("Congrats on your new achievement! Your feedback helps us improve.")
Text("checkin_enjoying_dialog_message")
}
.alert("Would you like to leave an App Store review?", isPresented: $showAppStoreReviewAlert) {
Button("Not Now", role: .cancel) {}
Button("Write a Review") {
.alert("app_store_review_title", isPresented: $showAppStoreReviewAlert) {
Button(String(localized: "not_now"), role: .cancel) {}
Button(String(localized: "write_review")) {
ReviewPromptService.shared.markStoreReviewCompleted()
openURL(ReviewPromptService.appStoreWriteReviewURL())
}
} message: {
Text("Thanks for the 5 stars. It really helps other investors discover the app.")
Text("app_store_review_message")
}
}
private var headerCard: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Button {
navigateMonth(offset: -1)
} label: {
Image(systemName: "chevron.left")
.font(.headline)
.foregroundColor(.appPrimary)
}
if referenceDate.isSameMonth(as: Date()) {
VStack(alignment: .leading, spacing: 6) {
ProgressView(value: checkInProgress)
.tint(progressBarTint)
Spacer()
Text(monthLabel)
.font(.title3.weight(.bold))
Spacer()
Button {
navigateMonth(offset: 1)
} label: {
Image(systemName: "chevron.right")
.font(.headline)
.foregroundColor(canGoToNextMonth ? .appPrimary : .secondary.opacity(0.3))
}
.disabled(!canGoToNextMonth)
}
if let date = lastCompletionDate {
Text(
String(
format: NSLocalizedString("last_check_in", comment: ""),
date.friendlyDescription
)
)
.font(.subheadline)
.foregroundColor(.secondary)
} else {
Text("No check-in yet this month")
.font(.subheadline)
.foregroundColor(.secondary)
}
VStack(alignment: .leading, spacing: 6) {
ProgressView(value: checkInProgress)
.tint(progressBarTint)
if let nextDate = nextCheckInDate {
Text(
String(
format: NSLocalizedString("next_check_in", comment: ""),
nextDate.mediumDateString
if let nextDate = nextCheckInDate {
Text(
String(
format: NSLocalizedString("next_check_in", comment: ""),
nextDate.mediumDateString
)
)
)
.font(.caption)
.foregroundColor(.secondary)
} else {
Text("Start your first check-in anytime.")
.font(.caption)
.foregroundColor(.secondary)
.font(.caption)
.foregroundColor(.secondary)
} else {
Text("Start your first check-in anytime.")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
@@ -968,7 +948,9 @@ struct BatchUpdateView: View {
Button {
saveAll()
} label: {
Text("Save \(filledCount) Snapshot\(filledCount == 1 ? "" : "s")")
Text(filledCount == 1
? String(localized: "save_1_snapshot")
: String(format: NSLocalizedString("save_n_snapshots", comment: ""), filledCount))
.font(.headline)
.frame(maxWidth: .infinity)
}
@@ -0,0 +1,141 @@
import SwiftUI
/// Shown before onboarding on a fresh install when iCloud is available.
/// Lets the user choose between restoring from iCloud or starting fresh.
struct OnboardingICloudCheckView: View {
/// Called when the user decides to start fresh (no iCloud restore).
let onSkip: () -> Void
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
@State private var showRestartPrompt = false
var body: some View {
if showRestartPrompt {
restartPromptView
} else {
checkView
}
}
// MARK: - Check View
private var checkView: some View {
VStack {
Spacer()
VStack(spacing: 28) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.12))
.frame(width: 140, height: 140)
Circle()
.fill(Color.appPrimary.opacity(0.22))
.frame(width: 100, height: 100)
Image(systemName: "icloud.fill")
.font(.system(size: 50))
.foregroundColor(.appPrimary)
}
VStack(spacing: 14) {
Text(String(localized: "icloud_check_title"))
.font(.title.weight(.bold))
.multilineTextAlignment(.center)
Text(String(localized: "icloud_check_description"))
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
}
Spacer()
Spacer()
VStack(spacing: 12) {
Button {
cloudSyncEnabled = true
showRestartPrompt = true
} label: {
Label("Restore from iCloud", systemImage: "icloud.and.arrow.down")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
}
Button {
onSkip()
} label: {
Text("Start Fresh")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 24)
.padding(.bottom, 40)
}
.background(AppBackground())
}
// MARK: - Restart Prompt View
private var restartPromptView: some View {
VStack {
Spacer()
VStack(spacing: 28) {
ZStack {
Circle()
.fill(Color.positiveGreen.opacity(0.12))
.frame(width: 140, height: 140)
Circle()
.fill(Color.positiveGreen.opacity(0.22))
.frame(width: 100, height: 100)
Image(systemName: "checkmark.icloud.fill")
.font(.system(size: 50))
.foregroundColor(.positiveGreen)
}
VStack(spacing: 14) {
Text(String(localized: "icloud_enabled_title"))
.font(.title.weight(.bold))
.multilineTextAlignment(.center)
Text(String(localized: "icloud_enabled_description"))
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
}
Spacer()
Spacer()
// "Got it" just acknowledges the user must close and reopen manually.
// The button stays active so it doesn't look broken.
Button {
// No-op: user needs to close and reopen the app.
// Nothing to navigate to; this session has no CloudKit container.
} label: {
Text("Got it")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.positiveGreen)
.cornerRadius(AppConstants.UI.cornerRadius)
}
.padding(.horizontal, 24)
.padding(.bottom, 40)
}
.background(AppBackground())
}
}
#Preview {
OnboardingICloudCheckView(onSkip: {})
}
@@ -185,46 +185,7 @@ struct AddSourceView: View {
}
private func parseDecimal(_ string: String) -> Decimal? {
let locale = CurrencyFormatter.locale(for: currencyCode)
let stripped = string
.replacingOccurrences(of: currencySymbol, with: "")
.trimmingCharacters(in: .whitespaces)
guard !stripped.isEmpty else { return nil }
let decimalSep = locale.decimalSeparator ?? "."
let groupingSep = locale.groupingSeparator ?? ""
// Detect alternate decimal BEFORE removing separators
let usesAlternateDecimal =
(decimalSep == "," && stripped.contains(".") && !stripped.contains(",")) ||
(decimalSep == "." && stripped.contains(",") && !stripped.contains("."))
if usesAlternateDecimal {
let normalized = stripped
.replacingOccurrences(of: groupingSep, with: "")
.replacingOccurrences(of: ",", with: ".")
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.number(from: normalized)?.decimalValue
}
let cleaned = stripped.replacingOccurrences(of: groupingSep, with: "")
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = locale
if let value = formatter.number(from: cleaned)?.decimalValue {
return value
}
// Fallback for mixed locale input
let normalized = cleaned
.replacingOccurrences(of: decimalSep, with: ".")
.replacingOccurrences(of: ",", with: ".")
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.number(from: normalized)?.decimalValue
CurrencyFormatter.parseUserInput(string, currencySymbol: currencySymbol)
}
private var currencySymbol: String {
@@ -559,13 +520,16 @@ struct AddSnapshotView: View {
let repository = SnapshotRepository()
if let snapshot = snapshot {
let contributionTextIsEmpty = viewModel.contributionString
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty
repository.updateSnapshot(
snapshot,
date: viewModel.date,
value: value,
contribution: viewModel.contribution,
notes: viewModel.notes.isEmpty ? nil : viewModel.notes,
clearContribution: !viewModel.includeContribution,
clearContribution: !viewModel.includeContribution || contributionTextIsEmpty,
clearNotes: viewModel.notes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
)
} else {