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