diff --git a/PortfolioJournal.xcodeproj/project.pbxproj b/PortfolioJournal.xcodeproj/project.pbxproj index 91265ff..b34fb77 100644 --- a/PortfolioJournal.xcodeproj/project.pbxproj +++ b/PortfolioJournal.xcodeproj/project.pbxproj @@ -541,7 +541,7 @@ CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements; ENABLE_USER_SCRIPT_SANDBOXING = NO; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 82; + CURRENT_PROJECT_VERSION = 83; DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; ENABLE_PREVIEWS = YES; @@ -582,7 +582,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal"; - CURRENT_PROJECT_VERSION = 82; + CURRENT_PROJECT_VERSION = 83; DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; ENABLE_PREVIEWS = YES; @@ -739,7 +739,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 82; + CURRENT_PROJECT_VERSION = 83; DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; @@ -774,7 +774,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journalwidget"; - CURRENT_PROJECT_VERSION = 82; + CURRENT_PROJECT_VERSION = 83; DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; @@ -805,7 +805,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 82; + CURRENT_PROJECT_VERSION = 83; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -829,7 +829,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 82; + CURRENT_PROJECT_VERSION = 83; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -852,7 +852,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 82; + CURRENT_PROJECT_VERSION = 83; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -875,7 +875,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 82; + CURRENT_PROJECT_VERSION = 83; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -899,7 +899,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 82; + CURRENT_PROJECT_VERSION = 83; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist; @@ -927,7 +927,7 @@ CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 82; + CURRENT_PROJECT_VERSION = 83; DEVELOPMENT_TEAM = 2825Q76T7H; PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore"; GENERATE_INFOPLIST_FILE = NO; diff --git a/PortfolioJournal/Resources/de.lproj/Localizable.strings b/PortfolioJournal/Resources/de.lproj/Localizable.strings index 2f20c42..1284716 100644 --- a/PortfolioJournal/Resources/de.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/de.lproj/Localizable.strings @@ -660,3 +660,15 @@ "share_format_post" = "Post"; "share_format_story" = "Story"; "share_card_cta" = "Teilen"; + +// Share cards 2.1 — brand header + download CTA (1.6.0) +"share_hero_eyebrow" = "Gesamtrendite"; +"share_metric_yoy" = "Dieses Jahr"; +"share_metric_since_inception" = "Seit Beginn"; +"share_cta_headline" = "Verfolge deine — kostenlos"; +"share_cta_sub" = "Scannen für Portfolio Journal"; + +// Share cards 2.2 — as-of date + theme (1.6.0) +"share_as_of" = "Stand %@"; +"share_theme_dark" = "Dunkel"; +"share_theme_light" = "Hell"; diff --git a/PortfolioJournal/Resources/en.lproj/Localizable.strings b/PortfolioJournal/Resources/en.lproj/Localizable.strings index c07b4cb..c88abbe 100644 --- a/PortfolioJournal/Resources/en.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/en.lproj/Localizable.strings @@ -661,3 +661,15 @@ "share_format_post" = "Post"; "share_format_story" = "Story"; "share_card_cta" = "Share"; + +// Share cards 2.1 — brand header + download CTA (1.6.0) +"share_hero_eyebrow" = "Total return"; +"share_metric_yoy" = "This year"; +"share_metric_since_inception" = "Since inception"; +"share_cta_headline" = "Track yours — free"; +"share_cta_sub" = "Scan to get Portfolio Journal"; + +// Share cards 2.2 — as-of date + theme (1.6.0) +"share_as_of" = "As of %@"; +"share_theme_dark" = "Dark"; +"share_theme_light" = "Light"; diff --git a/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings b/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings index fcb0adf..4fff733 100644 --- a/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings @@ -612,3 +612,15 @@ "share_format_post" = "Post"; "share_format_story" = "Historia"; "share_card_cta" = "Compartir"; + +// Share cards 2.1 — brand header + download CTA (1.6.0) +"share_hero_eyebrow" = "Rentabilidad total"; +"share_metric_yoy" = "Este año"; +"share_metric_since_inception" = "Desde el inicio"; +"share_cta_headline" = "Controla la tuya — gratis"; +"share_cta_sub" = "Escanea para descargar Portfolio Journal"; + +// Share cards 2.2 — as-of date + theme (1.6.0) +"share_as_of" = "A %@"; +"share_theme_dark" = "Oscuro"; +"share_theme_light" = "Claro"; diff --git a/PortfolioJournal/Resources/fr.lproj/Localizable.strings b/PortfolioJournal/Resources/fr.lproj/Localizable.strings index 40b4f4c..b1f91e1 100644 --- a/PortfolioJournal/Resources/fr.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/fr.lproj/Localizable.strings @@ -660,3 +660,15 @@ "share_format_post" = "Post"; "share_format_story" = "Story"; "share_card_cta" = "Partager"; + +// Share cards 2.1 — brand header + download CTA (1.6.0) +"share_hero_eyebrow" = "Rendement total"; +"share_metric_yoy" = "Cette année"; +"share_metric_since_inception" = "Depuis le début"; +"share_cta_headline" = "Suis le tien — gratuit"; +"share_cta_sub" = "Scanne pour Portfolio Journal"; + +// Share cards 2.2 — as-of date + theme (1.6.0) +"share_as_of" = "Au %@"; +"share_theme_dark" = "Sombre"; +"share_theme_light" = "Clair"; diff --git a/PortfolioJournal/Resources/it.lproj/Localizable.strings b/PortfolioJournal/Resources/it.lproj/Localizable.strings index 388cd01..1e62f1a 100644 --- a/PortfolioJournal/Resources/it.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/it.lproj/Localizable.strings @@ -660,3 +660,15 @@ "share_format_post" = "Post"; "share_format_story" = "Storia"; "share_card_cta" = "Condividi"; + +// Share cards 2.1 — brand header + download CTA (1.6.0) +"share_hero_eyebrow" = "Rendimento totale"; +"share_metric_yoy" = "Quest'anno"; +"share_metric_since_inception" = "Dall'inizio"; +"share_cta_headline" = "Monitora il tuo — gratis"; +"share_cta_sub" = "Scansiona per Portfolio Journal"; + +// Share cards 2.2 — as-of date + theme (1.6.0) +"share_as_of" = "Al %@"; +"share_theme_dark" = "Scuro"; +"share_theme_light" = "Chiaro"; diff --git a/PortfolioJournal/Resources/ja.lproj/Localizable.strings b/PortfolioJournal/Resources/ja.lproj/Localizable.strings index d91e971..80c2cdd 100644 --- a/PortfolioJournal/Resources/ja.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/ja.lproj/Localizable.strings @@ -660,3 +660,15 @@ "share_format_post" = "投稿"; "share_format_story" = "ストーリー"; "share_card_cta" = "シェア"; + +// Share cards 2.1 — brand header + download CTA (1.6.0) +"share_hero_eyebrow" = "トータルリターン"; +"share_metric_yoy" = "今年"; +"share_metric_since_inception" = "開始来"; +"share_cta_headline" = "あなたも無料で記録"; +"share_cta_sub" = "スキャンでPortfolio Journalを入手"; + +// Share cards 2.2 — as-of date + theme (1.6.0) +"share_as_of" = "%@時点"; +"share_theme_dark" = "ダーク"; +"share_theme_light" = "ライト"; diff --git a/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings b/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings index c148699..81d1e03 100644 --- a/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings @@ -660,3 +660,15 @@ "share_format_post" = "Post"; "share_format_story" = "Story"; "share_card_cta" = "Compartilhar"; + +// Share cards 2.1 — brand header + download CTA (1.6.0) +"share_hero_eyebrow" = "Retorno total"; +"share_metric_yoy" = "Este ano"; +"share_metric_since_inception" = "Desde o início"; +"share_cta_headline" = "Acompanhe o seu — grátis"; +"share_cta_sub" = "Escaneie para obter o Portfolio Journal"; + +// Share cards 2.2 — as-of date + theme (1.6.0) +"share_as_of" = "Em %@"; +"share_theme_dark" = "Escuro"; +"share_theme_light" = "Claro"; diff --git a/PortfolioJournal/Services/ShareService.swift b/PortfolioJournal/Services/ShareService.swift index 3abb5b2..832b422 100644 --- a/PortfolioJournal/Services/ShareService.swift +++ b/PortfolioJournal/Services/ShareService.swift @@ -22,15 +22,18 @@ class ShareService { } struct PortfolioCardStrings { + /// The big number (an all-time/return %) — the hero of the card. + let heroPercent: String? + /// Supporting absolute value (empty in percent-only mode). let totalValue: String let changeText: String let yearChange: String? let sinceInceptionChange: String? } - /// Privacy transform: with `percentOnly` the card leads with the all-time - /// return and every row drops absolute amounts. Nobody shares their net - /// worth in euros; everybody shares +42%. + /// The all-time return is always the hero (branding + shareable). With + /// `percentOnly` every absolute amount is dropped — nobody posts their net + /// worth in euros; everybody posts +42%. static func portfolioCardStrings( totalValue: String, changeText: String, @@ -38,20 +41,22 @@ class ShareService { sinceInceptionChange: String?, percentOnly: Bool ) -> PortfolioCardStrings { - guard percentOnly else { + let hero = percentPart(sinceInceptionChange) ?? percentPart(changeText) + if percentOnly { return PortfolioCardStrings( - totalValue: totalValue, changeText: changeText, - yearChange: yearChange, sinceInceptionChange: sinceInceptionChange + heroPercent: hero ?? totalValue, + totalValue: "", + changeText: percentPart(changeText) ?? changeText, + yearChange: percentPart(yearChange), + sinceInceptionChange: percentPart(sinceInceptionChange) ) } - let heroPercent = percentPart(sinceInceptionChange) - ?? percentPart(changeText) - ?? totalValue return PortfolioCardStrings( - totalValue: heroPercent, - changeText: percentPart(changeText) ?? changeText, - yearChange: percentPart(yearChange), - sinceInceptionChange: percentPart(sinceInceptionChange) + heroPercent: hero, + totalValue: totalValue, + changeText: changeText, + yearChange: yearChange, + sinceInceptionChange: sinceInceptionChange ) } @@ -168,6 +173,8 @@ class ShareService { sinceInceptionChange: String?, sparkline: [Double] = [], isPositive: Bool = true, + asOf: String? = nil, + theme: ShareCardTheme = .dark, percentOnly: Bool = false, storyFormat: Bool = false ) { @@ -205,6 +212,9 @@ class ShareService { qrCodeImage: GoalShareService.generateQRCode(for: Self.snapshotShareURL, size: 200), sparkline: sparkline, isPositive: isPositive, + heroPercent: display.heroPercent, + asOf: asOf, + theme: theme, storyFormat: storyFormat ) } diff --git a/PortfolioJournal/ViewModels/DashboardViewModel.swift b/PortfolioJournal/ViewModels/DashboardViewModel.swift index 590859a..582d3d4 100644 --- a/PortfolioJournal/ViewModels/DashboardViewModel.swift +++ b/PortfolioJournal/ViewModels/DashboardViewModel.swift @@ -809,4 +809,15 @@ class DashboardViewModel: ObservableObject { var formattedLastUpdate: String { portfolioSummary.lastUpdated?.friendlyDescription ?? "Not yet" } + + /// Absolute last-check-in date (e.g. "7 Aug 2026") for the share card — an + /// outsider needs a concrete date, not "2 days ago". + var shareAsOfDate: String? { + guard let date = portfolioSummary.lastUpdated else { return nil } + let f = DateFormatter() + f.locale = .autoupdatingCurrent + f.dateStyle = .medium + f.timeStyle = .none + return f.string(from: date) + } } diff --git a/PortfolioJournal/Views/Dashboard/DashboardView.swift b/PortfolioJournal/Views/Dashboard/DashboardView.swift index da11299..85f0dae 100644 --- a/PortfolioJournal/Views/Dashboard/DashboardView.swift +++ b/PortfolioJournal/Views/Dashboard/DashboardView.swift @@ -155,7 +155,8 @@ struct DashboardView: View { yearChange: viewModel.portfolioSummary.formattedYearChange, sinceInceptionChange: viewModel.portfolioSummary.formattedAllTimeReturn, sparkline: viewModel.evolutionData.map { NSDecimalNumber(decimal: $0.value).doubleValue }, - isPositive: viewModel.latestPortfolioChange.absolute >= 0 + isPositive: viewModel.latestPortfolioChange.absolute >= 0, + asOf: viewModel.shareAsOfDate ) } .sheet(isPresented: $showingDataGaps) { diff --git a/PortfolioJournal/Views/Dashboard/ShareCards.swift b/PortfolioJournal/Views/Dashboard/ShareCards.swift index 66e841d..3ef3e60 100644 --- a/PortfolioJournal/Views/Dashboard/ShareCards.swift +++ b/PortfolioJournal/Views/Dashboard/ShareCards.swift @@ -15,31 +15,55 @@ enum ShareCardStyle { static let green = Color(red: 16 / 255, green: 185 / 255, blue: 129 / 255) static let gridLine = Color(red: 93 / 255, green: 132 / 255, blue: 181 / 255) static let muted = Color(red: 155 / 255, green: 169 / 255, blue: 191 / 255) + // Light theme + static let lightTop = Color(red: 248 / 255, green: 250 / 255, blue: 252 / 255) + static let lightBottom = Color(red: 226 / 255, green: 232 / 255, blue: 240 / 255) + static let ink = Color(red: 15 / 255, green: 27 / 255, blue: 45 / 255) + static let lightMuted = Color(red: 100 / 255, green: 116 / 255, blue: 139 / 255) +} + +/// Light/dark variants of the card. Blue/green accents are shared; everything +/// else (surface, text, grid, panels) flips per theme. +enum ShareCardTheme: String, CaseIterable, Identifiable { + case dark, light + var id: String { rawValue } + + var ink: Color { self == .dark ? .white : ShareCardStyle.ink } + var mutedInk: Color { self == .dark ? ShareCardStyle.muted : ShareCardStyle.lightMuted } + var panelFill: Color { self == .dark ? Color.white.opacity(0.06) : ShareCardStyle.ink.opacity(0.05) } + var strokeColor: Color { self == .dark ? Color.white.opacity(0.12) : ShareCardStyle.ink.opacity(0.10) } + var footerFill: Color { self == .dark ? Color.white.opacity(0.08) : ShareCardStyle.ink.opacity(0.04) } + var gridOpacity: Double { self == .dark ? 0.10 : 0.06 } + var glowOpacityMul: Double { self == .dark ? 1.0 : 0.7 } } struct ShareCardBackground: View { + var theme: ShareCardTheme = .dark + var body: some View { ZStack { LinearGradient( - colors: [ShareCardStyle.navyTop, ShareCardStyle.navyBottom], + colors: theme == .dark + ? [ShareCardStyle.navyTop, ShareCardStyle.navyBottom] + : [ShareCardStyle.lightTop, ShareCardStyle.lightBottom], startPoint: .top, endPoint: .bottom ) // Atmospheric glows (blue top-left, green bottom-right) Circle() - .fill(ShareCardStyle.blue.opacity(0.30)) + .fill(ShareCardStyle.blue.opacity(0.30 * theme.glowOpacityMul)) .frame(width: 280, height: 280) .blur(radius: 70) .offset(x: -100, y: -120) Circle() - .fill(ShareCardStyle.green.opacity(0.22)) + .fill(ShareCardStyle.green.opacity(0.22 * theme.glowOpacityMul)) .frame(width: 260, height: 260) .blur(radius: 80) .offset(x: 130, y: 150) // Subtle technical grid ShareCardGrid() - .stroke(ShareCardStyle.gridLine.opacity(0.10), lineWidth: 1) + .stroke(ShareCardStyle.gridLine.opacity(theme.gridOpacity), lineWidth: 1) } } } @@ -136,69 +160,103 @@ private func eyebrow(_ text: String) -> some View { .foregroundColor(ShareCardStyle.green) } -private func deltaChip(_ text: String, positive: Bool) -> some View { - Text(text) - .font(.caption.weight(.bold)) - .foregroundColor(positive ? ShareCardStyle.green : Color(red: 1, green: 0.42, blue: 0.42)) - .padding(.horizontal, 9) - .padding(.vertical, 4) - .background( - Capsule().fill((positive ? ShareCardStyle.green : Color.red).opacity(0.14)) - ) +private func deltaChip(_ text: String, positive: Bool, large: Bool = false) -> some View { + HStack(spacing: 4) { + Image(systemName: positive ? "arrow.up.right" : "arrow.down.right") + .font(.system(size: large ? 13 : 10, weight: .bold)) + Text(text) + .font((large ? Font.headline : Font.caption).weight(.bold)) + } + .foregroundColor(positive ? ShareCardStyle.green : Color(red: 1, green: 0.42, blue: 0.42)) + .padding(.horizontal, large ? 12 : 9) + .padding(.vertical, large ? 6 : 4) + .background( + Capsule().fill((positive ? ShareCardStyle.green : Color.red).opacity(0.14)) + ) } -private func metricRow(_ title: String, _ value: String) -> some View { +private func metricRow(_ title: String, _ value: String, theme: ShareCardTheme) -> some View { HStack { Text(title) .font(.caption.weight(.semibold)) - .foregroundColor(ShareCardStyle.muted) + .foregroundColor(theme.mutedInk) Spacer() Text(value) .font(.subheadline.weight(.semibold)) - .foregroundColor(.white) + .foregroundColor(theme.ink) .multilineTextAlignment(.trailing) .minimumScaleFactor(0.7) .lineLimit(1) } } -private func shareFooter(appName: String, qrCodeImage: UIImage?) -> some View { - VStack(spacing: 10) { - Rectangle() - .fill(Color.white.opacity(0.12)) - .frame(height: 1) - - HStack(spacing: 12) { - Image("BrandMark") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 34, height: 34) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - - VStack(alignment: .leading, spacing: 1) { - Text("Powered by") +/// Brand lockup pinned at the TOP of every card — big and unmissable so the +/// name does the branding work (and never clips like the old footer mark did). +/// Optional `asOf` line gives an outsider the context ("what date is this?"). +private func brandHeader(theme: ShareCardTheme, asOf: String?) -> some View { + HStack(spacing: 12) { + Image("BrandMark") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 48, height: 48) + .clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous)) + VStack(alignment: .leading, spacing: 1) { + Text("Portfolio Journal") + .font(.system(.title2, design: .rounded).weight(.heavy)) + .foregroundColor(theme.ink) + .minimumScaleFactor(0.7) + .lineLimit(1) + if let asOf { + Text(String(format: String(localized: "share_as_of"), asOf)) .font(.caption2.weight(.medium)) - .foregroundColor(ShareCardStyle.muted) - Text(appName) - .font(.subheadline.weight(.bold)) - .foregroundColor(.white) - } - - Spacer() - - if let qrCodeImage { - Image(uiImage: qrCodeImage) - .interpolation(.none) - .resizable() - .frame(width: 46, height: 46) - .padding(3) - .background(Color.white) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .foregroundColor(theme.mutedInk) } } + Spacer(minLength: 0) } } +/// Download-focused footer: an explicit "get the app" CTA plus a scannable QR. +/// This is the conversion surface — every shared card should sell the install. +private func downloadFooter(qrCodeImage: UIImage?, theme: ShareCardTheme) -> some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(String(localized: "share_cta_headline")) + .font(.subheadline.weight(.bold)) + .foregroundColor(theme.ink) + .fixedSize(horizontal: false, vertical: true) + Text(String(localized: "share_cta_sub")) + .font(.caption2.weight(.medium)) + .foregroundColor(theme.mutedInk) + } + + Spacer(minLength: 8) + + if let qrCodeImage { + Image(uiImage: qrCodeImage) + .interpolation(.none) + .resizable() + .frame(width: 54, height: 54) + .padding(4) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .stroke(ShareCardStyle.ink.opacity(theme == .light ? 0.10 : 0), lineWidth: 1) + ) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(theme.footerFill) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(ShareCardStyle.green.opacity(0.35), lineWidth: 1) + ) +} + // MARK: - Portfolio value card struct PortfolioValueShareCardView: View { @@ -211,52 +269,81 @@ struct PortfolioValueShareCardView: View { var qrCodeImage: UIImage? = nil var sparkline: [Double] = [] var isPositive: Bool = true - /// Story = 9:16 (Instagram/WhatsApp status); otherwise square-ish post. + /// The all-time (or best available) return %, shown as the hero — this is + /// the number people actually want to post. Falls back to nil → uses value. + var heroPercent: String? = nil + /// Date of the latest check-in, shown under the brand so an outsider knows + /// what point in time the numbers describe. + var asOf: String? = nil + var theme: ShareCardTheme = .dark + /// Story = 9:16 (Instagram/WhatsApp status); otherwise 4:5 feed post. var storyFormat: Bool = false + // Exact aspect ratios so nothing clips: 9:16 story, 4:5 post. + private var cardWidth: CGFloat { storyFormat ? 360 : 360 } + private var cardHeight: CGFloat { storyFormat ? 640 : 450 } + var body: some View { - VStack(alignment: .leading, spacing: storyFormat ? 18 : 12) { - eyebrow("Portfolio Snapshot") + VStack(alignment: .leading, spacing: 0) { + brandHeader(theme: theme, asOf: asOf) - Text(totalValue) - .font(.system(size: storyFormat ? 52 : 40, weight: .heavy, design: .rounded)) - .foregroundColor(.white) - .minimumScaleFactor(0.5) - .lineLimit(1) + Spacer(minLength: storyFormat ? 24 : 14) - deltaChip("\(changeText) \(changeLabel)", positive: isPositive) + // Hero: big return % (branding-friendly, privacy-friendly), with the + // portfolio value as the supporting line. + VStack(alignment: .leading, spacing: 8) { + eyebrow(String(localized: "share_hero_eyebrow")) + Text(heroPercent ?? totalValue) + .font(.system(size: storyFormat ? 52 : 44, weight: .heavy, design: .rounded)) + .foregroundColor(heroIsPercent ? (isPositive ? ShareCardStyle.green : Color(red: 1, green: 0.42, blue: 0.42)) : theme.ink) + .minimumScaleFactor(0.5) + .lineLimit(1) + if heroIsPercent && !totalValue.isEmpty { + Text(totalValue) + .font(.title3.weight(.bold)) + .foregroundColor(theme.ink) + .minimumScaleFactor(0.5) + .lineLimit(1) + } + deltaChip("\(changeText) \(changeLabel)", positive: isPositive, large: true) + } if sparkline.count > 1 { ShareSparkline(values: sparkline) - .frame(height: storyFormat ? 130 : 74) + .frame(height: storyFormat ? 150 : 96) + .padding(.top, storyFormat ? 24 : 16) } - VStack(spacing: 8) { + VStack(spacing: 9) { if let yearChange { - metricRow("YoY", yearChange) + metricRow(String(localized: "share_metric_yoy"), yearChange, theme: theme) } if let sinceInceptionChange { - metricRow("Since inception", sinceInceptionChange) + metricRow(String(localized: "share_metric_since_inception"), sinceInceptionChange, theme: theme) } } - .padding(12) + .padding(13) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color.white.opacity(0.06)) + .fill(theme.panelFill) ) + .padding(.top, storyFormat ? 24 : 14) - Spacer(minLength: 0) - shareFooter(appName: appName, qrCodeImage: qrCodeImage) + Spacer(minLength: 14) + + downloadFooter(qrCodeImage: qrCodeImage, theme: theme) } - .padding(storyFormat ? 26 : 20) - .frame(width: storyFormat ? 270 : 340, height: storyFormat ? 480 : 340) - .background(ShareCardBackground()) - .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .padding(storyFormat ? 28 : 22) + .frame(width: cardWidth, height: cardHeight) + .background(ShareCardBackground(theme: theme)) + .clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .stroke(Color.white.opacity(0.12), lineWidth: 1) + RoundedRectangle(cornerRadius: 26, style: .continuous) + .stroke(theme.strokeColor, lineWidth: 1) ) } + + private var heroIsPercent: Bool { heroPercent != nil } } // MARK: - Monthly check-in card @@ -265,39 +352,45 @@ struct MonthlyCheckInShareCardView: View { let summary: MonthlySummary let appName: String var qrCodeImage: UIImage? = nil + var theme: ShareCardTheme = .dark var body: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 0) { + brandHeader(theme: theme, asOf: summary.formattedMonthYear) + + Spacer(minLength: 16) + eyebrow(summary.formattedMonthYear) - Text("Monthly Check-in") - .font(.title2.weight(.bold)) - .foregroundColor(.white) + .font(.title.weight(.bold)) + .foregroundColor(theme.ink) + .padding(.top, 4) - VStack(spacing: 8) { - metricRow("Starting", summary.formattedStartingValue) - metricRow("Ending", summary.formattedEndingValue) + VStack(spacing: 9) { + metricRow("Starting", summary.formattedStartingValue, theme: theme) + metricRow("Ending", summary.formattedEndingValue, theme: theme) if summary.contributions != 0 { - metricRow("Contributions", summary.formattedContributions) + metricRow("Contributions", summary.formattedContributions, theme: theme) } - metricRow("Net performance", "\(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))") + metricRow("Net performance", "\(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))", theme: theme) } - .padding(12) + .padding(13) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color.white.opacity(0.06)) + .fill(theme.panelFill) ) + .padding(.top, 16) - Spacer(minLength: 0) - shareFooter(appName: appName, qrCodeImage: qrCodeImage) + Spacer(minLength: 14) + downloadFooter(qrCodeImage: qrCodeImage, theme: theme) } - .padding(20) - .frame(width: 340, height: 330) - .background(ShareCardBackground()) - .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .padding(22) + .frame(width: 360, height: 420) + .background(ShareCardBackground(theme: theme)) + .clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .stroke(Color.white.opacity(0.12), lineWidth: 1) + RoundedRectangle(cornerRadius: 26, style: .continuous) + .stroke(theme.strokeColor, lineWidth: 1) ) } } @@ -315,9 +408,11 @@ struct PortfolioShareOptionsView: View { let sinceInceptionChange: String? let sparkline: [Double] let isPositive: Bool + var asOf: String? = nil @State private var percentOnly = false @State private var storyFormat = false + @State private var theme: ShareCardTheme = .dark @Environment(\.dismiss) private var dismiss var body: some View { @@ -331,6 +426,7 @@ struct PortfolioShareOptionsView: View { .frame(height: storyFormat ? 310 : 275) .animation(.snappy, value: percentOnly) .animation(.snappy, value: storyFormat) + .animation(.snappy, value: theme) Spacer(minLength: 0) @@ -341,6 +437,12 @@ struct PortfolioShareOptionsView: View { } .tint(.appPrimary) + Picker("Theme", selection: $theme.animation(.snappy)) { + Text(String(localized: "share_theme_dark")).tag(ShareCardTheme.dark) + Text(String(localized: "share_theme_light")).tag(ShareCardTheme.light) + } + .pickerStyle(.segmented) + Picker("Format", selection: $storyFormat.animation(.snappy)) { Text(String(localized: "share_format_post")).tag(false) Text(String(localized: "share_format_story")).tag(true) @@ -358,6 +460,8 @@ struct PortfolioShareOptionsView: View { sinceInceptionChange: sinceInceptionChange, sparkline: sparkline, isPositive: isPositive, + asOf: asOf, + theme: theme, percentOnly: percentOnly, storyFormat: storyFormat ) @@ -398,6 +502,9 @@ struct PortfolioShareOptionsView: View { qrCodeImage: GoalShareService.generateQRCode(for: ShareService.snapshotShareURL, size: 200), sparkline: sparkline, isPositive: isPositive, + heroPercent: display.heroPercent, + asOf: asOf, + theme: theme, storyFormat: storyFormat ) }