Share cards compartibles: brand header grande, fecha, Dark/Light (build 83) #35

- Header de marca arriba, grande (logo BrandMark 48px + nombre title2 rounded),
  nunca se corta en Story (9:16 real 360x640, Post 4:5 360x450).
- Héroe = retorno % (privacy/branding-friendly), valor como apoyo.
- Fecha del último check-in bajo el nombre (share_as_of) para dar contexto a
  quien no conoce la app; DashboardViewModel.shareAsOfDate.
- Tema Dark + Light (ShareCardTheme) con selector en el sheet + preview vivo.
- Sparkline real (viewModel.evolutionData), ya se pasaba.
- CTA de descarga + QR con ct=snapshot_share (atribución App Analytics).
- L10n x7 (as_of, theme_dark/light, cta, hero, metrics).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
This commit is contained in:
alexandrev-tibco
2026-08-07 09:45:10 +02:00
parent 8084bc6a82
commit 3b1f62cc26
12 changed files with 326 additions and 113 deletions
+10 -10
View File
@@ -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;
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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" = "ライト";
@@ -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";
+23 -13
View File
@@ -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
)
}
@@ -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)
}
}
@@ -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) {
+196 -89
View File
@@ -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
)
}