import SwiftUI // 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) // 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: 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 * theme.glowOpacityMul)) .frame(width: 280, height: 280) .blur(radius: 70) .offset(x: -100, y: -120) Circle() .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(theme.gridOpacity), lineWidth: 1) } } } 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 { 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) ) // 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) ) // 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) } } } } } 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, 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, theme: ShareCardTheme) -> some View { HStack { Text(title) .font(.caption.weight(.semibold)) .foregroundColor(theme.mutedInk) Spacer() Text(value) .font(.subheadline.weight(.semibold)) .foregroundColor(theme.ink) .multilineTextAlignment(.trailing) .minimumScaleFactor(0.7) .lineLimit(1) } } /// 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(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 { 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 /// 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: 0) { brandHeader(theme: theme, asOf: asOf) Spacer(minLength: storyFormat ? 24 : 14) // 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 ? 150 : 96) .padding(.top, storyFormat ? 24 : 16) } VStack(spacing: 9) { if let yearChange { metricRow(String(localized: "share_metric_yoy"), yearChange, theme: theme) } if let sinceInceptionChange { metricRow(String(localized: "share_metric_since_inception"), sinceInceptionChange, theme: theme) } } .padding(13) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(theme.panelFill) ) .padding(.top, storyFormat ? 24 : 14) Spacer(minLength: 14) downloadFooter(qrCodeImage: qrCodeImage, theme: theme) } .padding(storyFormat ? 28 : 22) .frame(width: cardWidth, height: cardHeight) .background(ShareCardBackground(theme: theme)) .clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 26, style: .continuous) .stroke(theme.strokeColor, lineWidth: 1) ) } private var heroIsPercent: Bool { heroPercent != nil } } // MARK: - Monthly check-in card 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: 0) { brandHeader(theme: theme, asOf: summary.formattedMonthYear) Spacer(minLength: 16) eyebrow(summary.formattedMonthYear) Text("Monthly Check-in") .font(.title.weight(.bold)) .foregroundColor(theme.ink) .padding(.top, 4) VStack(spacing: 9) { metricRow("Starting", summary.formattedStartingValue, theme: theme) metricRow("Ending", summary.formattedEndingValue, theme: theme) if summary.contributions != 0 { metricRow("Contributions", summary.formattedContributions, theme: theme) } metricRow("Net performance", "\(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))", theme: theme) } .padding(13) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(theme.panelFill) ) .padding(.top, 16) Spacer(minLength: 14) downloadFooter(qrCodeImage: qrCodeImage, theme: theme) } .padding(22) .frame(width: 360, height: 420) .background(ShareCardBackground(theme: theme)) .clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 26, style: .continuous) .stroke(theme.strokeColor, 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 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 { 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) .animation(.snappy, value: theme) 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("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) } .pickerStyle(.segmented) } .padding(.horizontal) Button { ShareService.shared.sharePortfolioValue( totalValue: totalValue, changeText: changeText, changeLabel: changeLabel, yearChange: yearChange, sinceInceptionChange: sinceInceptionChange, sparkline: sparkline, isPositive: isPositive, asOf: asOf, theme: theme, 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, heroPercent: display.heroPercent, asOf: asOf, theme: theme, storyFormat: storyFormat ) } }