Files
InvestmentTrackerApp/PortfolioJournal/Views/Dashboard/ShareCards.swift
T
alexandrev-tibco a8a9ad64f3 Xcode 27: compila sin warnings y migra API deprecadas de SwiftUI
- Imports de CoreData que faltaban (MemberImportVisibility) en AccountsView,
  ImportDataView, ChartsContainerView y AddSourceView.
- Aislamiento MainActor: IsolatedDefaultValues en el target de la app,
  LinearGradient @MainActor, WatchSyncMessage nonisolated, closures de
  ReviewPromptService/MonthlyCheckInStore/AppIntents.
- Warnings de valores sin usar (SnapshotGapDetector, DashboardLayoutStore,
  OnboardingView, GoalEditorView, CoreDataStack, SettingsViewModel).
- foregroundColor→foregroundStyle, cornerRadius→clipShape,
  navigationBarLeading/Trailing→topBarLeading/Trailing,
  Task.sleep(nanoseconds:)→sleep(for:), NavigationLink(isActive:)→
  navigationDestination(isPresented:).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-16 11:44:49 +02:00

512 lines
20 KiB
Swift

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)
.foregroundStyle(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))
}
.foregroundStyle(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))
.foregroundStyle(theme.mutedInk)
Spacer()
Text(value)
.font(.subheadline.weight(.semibold))
.foregroundStyle(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))
.foregroundStyle(theme.ink)
.minimumScaleFactor(0.7)
.lineLimit(1)
if let asOf {
Text(String(format: String(localized: "share_as_of"), asOf))
.font(.caption2.weight(.medium))
.foregroundStyle(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))
.foregroundStyle(theme.ink)
.fixedSize(horizontal: false, vertical: true)
Text(String(localized: "share_cta_sub"))
.font(.caption2.weight(.medium))
.foregroundStyle(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))
.foregroundStyle(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))
.foregroundStyle(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))
.foregroundStyle(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)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(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
)
}
}