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