Share cards 2.0: dark panorama + privacidad + Story + atribución (build 82) #34

Rediseño de las cards de compartir con el sistema visual de las screenshots
ASO: fondo navy con glows y grid, sparkline de evolución con gradiente
azul→cian y punto final, chip de delta, footer con marca + QR. Aplica a
Portfolio Snapshot, Monthly Check-in y Monthly Summary.

Potencia referral: sheet de opciones previo con preview en vivo
(PortfolioShareOptionsView) — toggle «Solo porcentajes» (sin importes
absolutos, la fricción #1 para compartir) y formato Post/Story 9:16. QR y
link con ?ct=snapshot_share para atribución en App Analytics. Analytics:
share type portfolio_value_percent. L10n ×7.

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-06 23:09:59 +02:00
parent 6e6f4c5489
commit 8084bc6a82
12 changed files with 492 additions and 121 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 = 81;
CURRENT_PROJECT_VERSION = 82;
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 = 81;
CURRENT_PROJECT_VERSION = 82;
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 = 81;
CURRENT_PROJECT_VERSION = 82;
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 = 81;
CURRENT_PROJECT_VERSION = 82;
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 = 81;
CURRENT_PROJECT_VERSION = 82;
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 = 81;
CURRENT_PROJECT_VERSION = 82;
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 = 81;
CURRENT_PROJECT_VERSION = 82;
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 = 81;
CURRENT_PROJECT_VERSION = 82;
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 = 81;
CURRENT_PROJECT_VERSION = 82;
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 = 81;
CURRENT_PROJECT_VERSION = 82;
DEVELOPMENT_TEAM = 2825Q76T7H;
PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore";
GENERATE_INFOPLIST_FILE = NO;
@@ -653,3 +653,10 @@
"chart_filtered_prefix" = "Gefiltert: %@";
"chart_overlay_contributions" = "vs. Einzahlungen";
"chart_insight_best_month" = "Bester Monat im Zeitraum: %1$@ (%2$@)";
// Share cards 2.0 (1.6.0)
"share_options_title" = "Teile deinen Fortschritt";
"share_percent_only" = "Nur Prozente";
"share_format_post" = "Post";
"share_format_story" = "Story";
"share_card_cta" = "Teilen";
@@ -654,3 +654,10 @@
"chart_filtered_prefix" = "Filtered: %@";
"chart_overlay_contributions" = "vs Contributions";
"chart_insight_best_month" = "Best month in range: %1$@ (%2$@)";
// Share cards 2.0 (1.6.0)
"share_options_title" = "Share your progress";
"share_percent_only" = "Only percentages";
"share_format_post" = "Post";
"share_format_story" = "Story";
"share_card_cta" = "Share";
@@ -605,3 +605,10 @@
"chart_filtered_prefix" = "Filtrado: %@";
"chart_overlay_contributions" = "vs Aportaciones";
"chart_insight_best_month" = "Mejor mes del rango: %1$@ (%2$@)";
// Share cards 2.0 (1.6.0)
"share_options_title" = "Comparte tu progreso";
"share_percent_only" = "Solo porcentajes";
"share_format_post" = "Post";
"share_format_story" = "Historia";
"share_card_cta" = "Compartir";
@@ -653,3 +653,10 @@
"chart_filtered_prefix" = "Filtré : %@";
"chart_overlay_contributions" = "vs Versements";
"chart_insight_best_month" = "Meilleur mois de la période : %1$@ (%2$@)";
// Share cards 2.0 (1.6.0)
"share_options_title" = "Partage tes progrès";
"share_percent_only" = "Pourcentages uniquement";
"share_format_post" = "Post";
"share_format_story" = "Story";
"share_card_cta" = "Partager";
@@ -653,3 +653,10 @@
"chart_filtered_prefix" = "Filtrato: %@";
"chart_overlay_contributions" = "vs Versamenti";
"chart_insight_best_month" = "Miglior mese del periodo: %1$@ (%2$@)";
// Share cards 2.0 (1.6.0)
"share_options_title" = "Condividi i tuoi progressi";
"share_percent_only" = "Solo percentuali";
"share_format_post" = "Post";
"share_format_story" = "Storia";
"share_card_cta" = "Condividi";
@@ -653,3 +653,10 @@
"chart_filtered_prefix" = "フィルター: %@";
"chart_overlay_contributions" = "拠出額と比較";
"chart_insight_best_month" = "期間内のベスト月: %1$@%2$@";
// Share cards 2.0 (1.6.0)
"share_options_title" = "成果をシェア";
"share_percent_only" = "パーセントのみ";
"share_format_post" = "投稿";
"share_format_story" = "ストーリー";
"share_card_cta" = "シェア";
@@ -653,3 +653,10 @@
"chart_filtered_prefix" = "Filtrado: %@";
"chart_overlay_contributions" = "vs Aportes";
"chart_insight_best_month" = "Melhor mês do período: %1$@ (%2$@)";
// Share cards 2.0 (1.6.0)
"share_options_title" = "Compartilhe seu progresso";
"share_percent_only" = "Apenas porcentagens";
"share_format_post" = "Post";
"share_format_story" = "Story";
"share_card_cta" = "Compartilhar";
+74 -11
View File
@@ -6,8 +6,55 @@ import LinkPresentation
class ShareService {
static let shared = ShareService()
/// Attributed App Store link for shared cards (ct= shows up in App
/// Analytics as a campaign, so referral installs become measurable).
static let snapshotShareURL = URL(string: "https://apps.apple.com/app/portfolio-journal-tracker/id6757678318?ct=snapshot_share")!
private init() {}
/// Extracts the "(±X.XX%)" part of a combined "±1.234 (±X.XX%)" string.
static func percentPart(_ s: String?) -> String? {
guard let s else { return nil }
guard let open = s.lastIndex(of: "("), let close = s.lastIndex(of: ")"), open < close else {
return s
}
return String(s[s.index(after: open)..<close])
}
struct PortfolioCardStrings {
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%.
static func portfolioCardStrings(
totalValue: String,
changeText: String,
yearChange: String?,
sinceInceptionChange: String?,
percentOnly: Bool
) -> PortfolioCardStrings {
guard percentOnly else {
return PortfolioCardStrings(
totalValue: totalValue, changeText: changeText,
yearChange: yearChange, sinceInceptionChange: sinceInceptionChange
)
}
let heroPercent = percentPart(sinceInceptionChange)
?? percentPart(changeText)
?? totalValue
return PortfolioCardStrings(
totalValue: heroPercent,
changeText: percentPart(changeText) ?? changeText,
yearChange: percentPart(yearChange),
sinceInceptionChange: percentPart(sinceInceptionChange)
)
}
static func buildMonthlyCheckInShareText(summary: MonthlySummary, appName: String) -> String {
"""
\(summary.periodLabel) Check-in
@@ -118,31 +165,47 @@ class ShareService {
changeText: String,
changeLabel: String,
yearChange: String?,
sinceInceptionChange: String?
sinceInceptionChange: String?,
sparkline: [Double] = [],
isPositive: Bool = true,
percentOnly: Bool = false,
storyFormat: Bool = false
) {
FirebaseService.shared.logShare(type: "portfolio_value")
FirebaseService.shared.logShare(type: percentOnly ? "portfolio_value_percent" : "portfolio_value")
let appName = Self.appDisplayName
let text = Self.buildPortfolioValueShareText(
let display = Self.portfolioCardStrings(
totalValue: totalValue,
changeText: changeText,
changeLabel: changeLabel,
yearChange: yearChange,
sinceInceptionChange: sinceInceptionChange,
percentOnly: percentOnly
)
// Fallback text honors the privacy choice too.
let text = Self.buildPortfolioValueShareText(
totalValue: display.totalValue,
changeText: display.changeText,
changeLabel: changeLabel,
yearChange: display.yearChange,
sinceInceptionChange: display.sinceInceptionChange,
appName: appName
)
shareCard(
cardTitle: "Portfolio Snapshot",
fallbackText: text
fallbackText: text,
shareURL: Self.snapshotShareURL
) {
PortfolioValueShareCardView(
totalValue: totalValue,
changeText: changeText,
totalValue: display.totalValue,
changeText: display.changeText,
changeLabel: changeLabel,
yearChange: yearChange,
sinceInceptionChange: sinceInceptionChange,
yearChange: display.yearChange,
sinceInceptionChange: display.sinceInceptionChange,
appName: appName,
qrCodeImage: GoalShareService.generateQRCode(for: GoalShareService.appStoreURL, size: 200)
qrCodeImage: GoalShareService.generateQRCode(for: Self.snapshotShareURL, size: 200),
sparkline: sparkline,
isPositive: isPositive,
storyFormat: storyFormat
)
}
}
@@ -174,10 +237,10 @@ class ShareService {
private func shareCard<Content: View>(
cardTitle: String,
fallbackText: String,
shareURL: URL = GoalShareService.appStoreURL,
@ViewBuilder card: () -> Content
) {
guard let viewController = ShareService.topViewController() else { return }
let shareURL = GoalShareService.appStoreURL
if #available(iOS 16.0, *) {
let renderer = ImageRenderer(content: card())
@@ -17,6 +17,7 @@ struct DashboardView: View {
@AppStorage("showForecast") private var showForecast = true
@State private var showingQuickUpdate = false
@State private var showingDataGaps = false
@State private var showingShareOptions = false
/// Signature of the gap set last dismissed, so re-dismissal sticks until the
/// gaps actually change (e.g. the user filled some).
@AppStorage("dismissedDataGapsSignature") private var dismissedGapsSignature = ""
@@ -146,6 +147,17 @@ struct DashboardView: View {
.sheet(isPresented: $viewModel.showingPaywall) {
PaywallView()
}
.sheet(isPresented: $showingShareOptions) {
PortfolioShareOptionsView(
totalValue: viewModel.portfolioSummary.formattedTotalValue,
changeText: "\(viewModel.latestPortfolioChange.formattedAbsolute) (\(viewModel.latestPortfolioChange.formattedPercentage))",
changeLabel: "since last check-in",
yearChange: viewModel.portfolioSummary.formattedYearChange,
sinceInceptionChange: viewModel.portfolioSummary.formattedAllTimeReturn,
sparkline: viewModel.evolutionData.map { NSDecimalNumber(decimal: $0.value).doubleValue },
isPositive: viewModel.latestPortfolioChange.absolute >= 0
)
}
.sheet(isPresented: $showingDataGaps) {
DataGapsSheet(
gaps: viewModel.dataGaps,
@@ -313,13 +325,7 @@ struct DashboardView: View {
isYearPositive: viewModel.isYearChangePositive,
isSinceInceptionPositive: viewModel.portfolioSummary.allTimeReturn >= 0,
onShareTap: {
ShareService.shared.sharePortfolioValue(
totalValue: viewModel.portfolioSummary.formattedTotalValue,
changeText: "\(viewModel.latestPortfolioChange.formattedAbsolute) (\(viewModel.latestPortfolioChange.formattedPercentage))",
changeLabel: "since last check-in",
yearChange: viewModel.portfolioSummary.formattedYearChange,
sinceInceptionChange: viewModel.portfolioSummary.formattedAllTimeReturn
)
showingShareOptions = true
},
sparklineData: viewModel.evolutionData,
balancesHidden: balancePrivacy.balancesHidden
@@ -135,17 +135,11 @@ struct MonthlySummaryShareView: View {
}
.padding(24)
.frame(width: 340, height: 440)
.background(
LinearGradient(
colors: [Color.appSecondary, Color.appPrimary],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.background(ShareCardBackground())
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 28, style: .continuous)
.stroke(Color.white.opacity(0.2), lineWidth: 1)
.stroke(Color.white.opacity(0.12), lineWidth: 1)
)
}
}
+344 -85
View File
@@ -1,126 +1,184 @@
import SwiftUI
struct MonthlyCheckInShareCardView: View {
let summary: MonthlySummary
let appName: String
var qrCodeImage: UIImage? = nil
// MARK: - Share card design system ("dark panorama")
//
// Same visual language as the App Store screenshots the app's strongest
// brand asset: near-black navy base, blue/green atmospheric glows, a subtle
// technical grid and the gradient evolution sparkline. These cards are the
// main referral surface, so they are designed to look premium in a chat or a
// story, and to carry an attributed QR/link back to the App Store.
enum ShareCardStyle {
static let navyTop = Color(red: 10 / 255, green: 21 / 255, blue: 42 / 255)
static let navyBottom = Color(red: 3 / 255, green: 9 / 255, blue: 22 / 255)
static let blue = Color(red: 48 / 255, green: 132 / 255, blue: 255 / 255)
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)
}
struct ShareCardBackground: View {
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text(summary.formattedMonthYear)
.font(.caption.weight(.semibold))
.foregroundColor(.white.opacity(0.85))
ZStack {
LinearGradient(
colors: [ShareCardStyle.navyTop, ShareCardStyle.navyBottom],
startPoint: .top, endPoint: .bottom
)
Text("Monthly Check-in")
.font(.title2.weight(.bold))
.foregroundColor(.white)
// Atmospheric glows (blue top-left, green bottom-right)
Circle()
.fill(ShareCardStyle.blue.opacity(0.30))
.frame(width: 280, height: 280)
.blur(radius: 70)
.offset(x: -100, y: -120)
Circle()
.fill(ShareCardStyle.green.opacity(0.22))
.frame(width: 260, height: 260)
.blur(radius: 80)
.offset(x: 130, y: 150)
metricRow("Starting", summary.formattedStartingValue)
metricRow("Ending", summary.formattedEndingValue)
if summary.contributions != 0 {
metricRow("Contributions", summary.formattedContributions)
}
metricRow("Net performance", "\(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))")
Spacer(minLength: 0)
shareFooter(appName: appName, qrCodeImage: qrCodeImage)
// Subtle technical grid
ShareCardGrid()
.stroke(ShareCardStyle.gridLine.opacity(0.10), lineWidth: 1)
}
.padding(20)
.frame(width: 320, height: 300)
.background(LinearGradient.appPrimaryGradient)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(Color.white.opacity(0.2), lineWidth: 1)
)
}
}
struct PortfolioValueShareCardView: View {
let totalValue: String
let changeText: String
let changeLabel: String
let yearChange: String?
let sinceInceptionChange: String?
let appName: String
var qrCodeImage: UIImage? = nil
private struct ShareCardGrid: Shape {
func path(in rect: CGRect) -> Path {
var p = Path()
let step: CGFloat = 44
var x: CGFloat = 0
while x <= rect.width {
p.move(to: CGPoint(x: x, y: 0))
p.addLine(to: CGPoint(x: x, y: rect.height))
x += step
}
var y: CGFloat = 0
while y <= rect.height {
p.move(to: CGPoint(x: 0, y: y))
p.addLine(to: CGPoint(x: rect.width, y: y))
y += step
}
return p
}
}
/// Gradient evolution sparkline (line + soft area + endpoint dot).
struct ShareSparkline: View {
let values: [Double]
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Portfolio Snapshot")
.font(.caption.weight(.semibold))
.foregroundColor(.white.opacity(0.85))
GeometryReader { geo in
let pts = points(in: geo.size)
if pts.count > 1 {
ZStack {
// Area fill
Path { p in
p.move(to: CGPoint(x: pts[0].x, y: geo.size.height))
pts.forEach { p.addLine(to: $0) }
p.addLine(to: CGPoint(x: pts[pts.count - 1].x, y: geo.size.height))
p.closeSubpath()
}
.fill(
LinearGradient(colors: [ShareCardStyle.blue.opacity(0.30), .clear],
startPoint: .top, endPoint: .bottom)
)
Text("Total Portfolio Value")
.font(.headline.weight(.semibold))
.foregroundColor(.white.opacity(0.9))
// Gradient line
Path { p in
p.move(to: pts[0])
pts.dropFirst().forEach { p.addLine(to: $0) }
}
.stroke(
LinearGradient(colors: [ShareCardStyle.blue, .cyan],
startPoint: .leading, endPoint: .trailing),
style: StrokeStyle(lineWidth: 2.4, lineCap: .round, lineJoin: .round)
)
Text(totalValue)
.font(.system(size: 34, weight: .bold, design: .rounded))
.foregroundColor(.white)
Text("\(changeText) \(changeLabel)")
.font(.subheadline.weight(.semibold))
.foregroundColor(.white.opacity(0.9))
if let yearChange {
metricRow("YoY", yearChange)
// Endpoint dot ("now")
if let last = pts.last {
Circle()
.fill(Color.cyan)
.frame(width: 7, height: 7)
.position(last)
Circle()
.fill(Color.cyan.opacity(0.3))
.frame(width: 16, height: 16)
.position(last)
}
}
}
if let sinceInceptionChange {
metricRow("Since inception", sinceInceptionChange)
}
Spacer(minLength: 0)
shareFooter(appName: appName, qrCodeImage: qrCodeImage)
}
.padding(20)
.frame(width: 320, height: 290)
.background(
LinearGradient(
colors: [Color.appSecondary, Color.appPrimary],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(Color.white.opacity(0.2), lineWidth: 1)
)
}
private func points(in size: CGSize) -> [CGPoint] {
guard values.count > 1 else { return [] }
let minV = values.min() ?? 0
let maxV = values.max() ?? 1
let range = maxV - minV
let inset: CGFloat = 6
return values.indices.map { i in
let x = size.width * CGFloat(i) / CGFloat(values.count - 1)
let norm = range > 0 ? (values[i] - minV) / range : 0.5
let y = inset + (size.height - inset * 2) * (1 - CGFloat(norm))
return CGPoint(x: x, y: y)
}
}
}
// MARK: - Shared pieces
private func eyebrow(_ text: String) -> some View {
Text(text.uppercased())
.font(.caption2.weight(.bold))
.tracking(2.2)
.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 metricRow(_ title: String, _ value: String) -> some View {
HStack {
Text(title)
.font(.caption.weight(.semibold))
.foregroundColor(.white.opacity(0.8))
.foregroundColor(ShareCardStyle.muted)
Spacer()
Text(value)
.font(.subheadline.weight(.semibold))
.foregroundColor(.white)
.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.2))
.fill(Color.white.opacity(0.12))
.frame(height: 1)
HStack(spacing: 12) {
Image("BrandMark")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 36, height: 36)
.frame(width: 34, height: 34)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
VStack(alignment: .leading, spacing: 2) {
VStack(alignment: .leading, spacing: 1) {
Text("Powered by")
.font(.caption2.weight(.medium))
.foregroundColor(.white.opacity(0.7))
.foregroundColor(ShareCardStyle.muted)
Text(appName)
.font(.subheadline.weight(.bold))
.foregroundColor(.white)
@@ -132,14 +190,215 @@ private func shareFooter(appName: String, qrCodeImage: UIImage?) -> some View {
Image(uiImage: qrCodeImage)
.interpolation(.none)
.resizable()
.frame(width: 48, height: 48)
.frame(width: 46, height: 46)
.padding(3)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
} else {
Image(systemName: "qrcode")
.font(.title3)
.foregroundColor(.white.opacity(0.9))
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
}
}
}
}
// MARK: - Portfolio value card
struct PortfolioValueShareCardView: View {
let totalValue: String
let changeText: String
let changeLabel: String
let yearChange: String?
let sinceInceptionChange: String?
let appName: String
var qrCodeImage: UIImage? = nil
var sparkline: [Double] = []
var isPositive: Bool = true
/// Story = 9:16 (Instagram/WhatsApp status); otherwise square-ish post.
var storyFormat: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: storyFormat ? 18 : 12) {
eyebrow("Portfolio Snapshot")
Text(totalValue)
.font(.system(size: storyFormat ? 52 : 40, weight: .heavy, design: .rounded))
.foregroundColor(.white)
.minimumScaleFactor(0.5)
.lineLimit(1)
deltaChip("\(changeText) \(changeLabel)", positive: isPositive)
if sparkline.count > 1 {
ShareSparkline(values: sparkline)
.frame(height: storyFormat ? 130 : 74)
}
VStack(spacing: 8) {
if let yearChange {
metricRow("YoY", yearChange)
}
if let sinceInceptionChange {
metricRow("Since inception", sinceInceptionChange)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.white.opacity(0.06))
)
Spacer(minLength: 0)
shareFooter(appName: appName, qrCodeImage: qrCodeImage)
}
.padding(storyFormat ? 26 : 20)
.frame(width: storyFormat ? 270 : 340, height: storyFormat ? 480 : 340)
.background(ShareCardBackground())
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(Color.white.opacity(0.12), lineWidth: 1)
)
}
}
// MARK: - Monthly check-in card
struct MonthlyCheckInShareCardView: View {
let summary: MonthlySummary
let appName: String
var qrCodeImage: UIImage? = nil
var body: some View {
VStack(alignment: .leading, spacing: 12) {
eyebrow(summary.formattedMonthYear)
Text("Monthly Check-in")
.font(.title2.weight(.bold))
.foregroundColor(.white)
VStack(spacing: 8) {
metricRow("Starting", summary.formattedStartingValue)
metricRow("Ending", summary.formattedEndingValue)
if summary.contributions != 0 {
metricRow("Contributions", summary.formattedContributions)
}
metricRow("Net performance", "\(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))")
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.white.opacity(0.06))
)
Spacer(minLength: 0)
shareFooter(appName: appName, qrCodeImage: qrCodeImage)
}
.padding(20)
.frame(width: 340, height: 330)
.background(ShareCardBackground())
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(Color.white.opacity(0.12), lineWidth: 1)
)
}
}
// MARK: - Pre-share options (privacy + format) with live preview
/// Sheet shown before sharing the portfolio card. The privacy toggle ("only
/// percentages") is the referral unlock: nobody shares absolute net worth,
/// everybody shares +42%.
struct PortfolioShareOptionsView: View {
let totalValue: String
let changeText: String
let changeLabel: String
let yearChange: String?
let sinceInceptionChange: String?
let sparkline: [Double]
let isPositive: Bool
@State private var percentOnly = false
@State private var storyFormat = false
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
VStack(spacing: 16) {
Spacer(minLength: 0)
// Live preview scaled to fit
previewCard
.scaleEffect(storyFormat ? 0.62 : 0.78)
.frame(height: storyFormat ? 310 : 275)
.animation(.snappy, value: percentOnly)
.animation(.snappy, value: storyFormat)
Spacer(minLength: 0)
VStack(spacing: 12) {
Toggle(isOn: $percentOnly.animation(.snappy)) {
Label(String(localized: "share_percent_only"), systemImage: "eye.slash")
.font(.subheadline)
}
.tint(.appPrimary)
Picker("Format", selection: $storyFormat.animation(.snappy)) {
Text(String(localized: "share_format_post")).tag(false)
Text(String(localized: "share_format_story")).tag(true)
}
.pickerStyle(.segmented)
}
.padding(.horizontal)
Button {
ShareService.shared.sharePortfolioValue(
totalValue: totalValue,
changeText: changeText,
changeLabel: changeLabel,
yearChange: yearChange,
sinceInceptionChange: sinceInceptionChange,
sparkline: sparkline,
isPositive: isPositive,
percentOnly: percentOnly,
storyFormat: storyFormat
)
dismiss()
} label: {
Text(String(localized: "share_card_cta"))
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(14)
}
.padding(.horizontal)
.padding(.bottom, 8)
}
.navigationTitle(String(localized: "share_options_title"))
.navigationBarTitleDisplayMode(.inline)
}
.presentationDetents([.large])
}
private var previewCard: some View {
let display = ShareService.portfolioCardStrings(
totalValue: totalValue,
changeText: changeText,
yearChange: yearChange,
sinceInceptionChange: sinceInceptionChange,
percentOnly: percentOnly
)
return PortfolioValueShareCardView(
totalValue: display.totalValue,
changeText: display.changeText,
changeLabel: changeLabel,
yearChange: display.yearChange,
sinceInceptionChange: display.sinceInceptionChange,
appName: "Portfolio Journal",
qrCodeImage: GoalShareService.generateQRCode(for: ShareService.snapshotShareURL, size: 200),
sparkline: sparkline,
isPositive: isPositive,
storyFormat: storyFormat
)
}
}