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:
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user