Calm 2.0 Fase 1: sistema de diseño limpio + Home en 3 zonas (1.5.0 build 51)
Sistema: - AppBackground: fuera gradiente + formas decorativas → systemGroupedBackground plano en toda la app. La profundidad la da la jerarquía, no la decoración. Home (3 zonas: estado / acción / contexto): - TotalValueCard: hero tipográfico — número 44pt rounded sobre card limpia, chip de delta semántico (verde/rojo), sparkline de evolución 56pt estilo Stocks, fila secundaria YoY / inception / forecast. Fuera el gradiente azul. - MonthlyCheckInCard state-driven: pendiente → protagonista con progreso real de fuentes (N de M) y un CTA; completado → fila discreta con checkmark, próximo check-in y streak integrado. En iPad va a ancho completo bajo el hero (zona de acción), fuera del grid. - InsightsRow: de carrusel de chips a un insight único rotatorio (tap para ciclar, dots de posición). - Eliminados del top: streak badge (vive en el check-in) y banner de pending updates (el progreso del check-in lo comunica). - Defaults nuevos: momentumStreaks/pendingUpdates/periodReturns/monthlySummary ocultos por defecto (siguen disponibles en Customize; configs guardadas de usuarios existentes se respetan). - Strings nuevas en 7 idiomas (checkin_done_*, checkin_sources_progress). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
This commit is contained in:
@@ -12,7 +12,6 @@ struct DashboardView: View {
|
||||
@State private var showingCustomize = false
|
||||
@State private var sectionConfigs = DashboardLayoutStore.load()
|
||||
@AppStorage("showForecast") private var showForecast = true
|
||||
@State private var pendingAlertDismissed = false
|
||||
@State private var fullScreenSection: DashboardSection? = nil
|
||||
@State private var dragTargetID: String? = nil
|
||||
@State private var showingQuickUpdate = false
|
||||
@@ -28,33 +27,20 @@ struct DashboardView: View {
|
||||
AppBackground()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
if viewModel.updateStreak >= 2 {
|
||||
streakBadge
|
||||
}
|
||||
|
||||
VStack(spacing: 16) {
|
||||
// Calm 2.0: three zones — state (hero), action (check-in),
|
||||
// context (everything else). The old top clutter (streak
|
||||
// badge, pending banner, insight chips) folded into them.
|
||||
if viewModel.hasData {
|
||||
if !pendingAlertDismissed && !viewModel.sourcesNeedingUpdate.isEmpty {
|
||||
PendingUpdatesAlertBanner(
|
||||
count: viewModel.sourcesNeedingUpdate.count,
|
||||
onDismiss: {
|
||||
withAnimation {
|
||||
pendingAlertDismissed = true
|
||||
}
|
||||
}
|
||||
)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
|
||||
if !viewModel.insights.isEmpty {
|
||||
InsightsRow(insights: viewModel.insights)
|
||||
}
|
||||
|
||||
if horizontalSizeClass == .regular {
|
||||
iPadDashboardLayout
|
||||
} else {
|
||||
ForEach(visibleSections) { config in
|
||||
sectionView(for: config)
|
||||
if config.id == DashboardSection.monthlyCheckIn.id,
|
||||
!viewModel.insights.isEmpty {
|
||||
InsightsRow(insights: viewModel.insights)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -146,21 +132,6 @@ struct DashboardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var streakBadge: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "flame.fill")
|
||||
.font(.caption.weight(.semibold))
|
||||
Text(String(format: String(localized: "streak_badge"), viewModel.updateStreak))
|
||||
.font(.caption.weight(.semibold))
|
||||
}
|
||||
.foregroundColor(.orange)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.orange.opacity(0.15))
|
||||
.cornerRadius(20)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var accountFilterMenu: some View {
|
||||
Menu {
|
||||
Button {
|
||||
@@ -203,8 +174,12 @@ struct DashboardView: View {
|
||||
|
||||
// MARK: - iPad Layout Helpers
|
||||
|
||||
/// Hero and check-in render full-width above the grid (state + action zones);
|
||||
/// only context cards flow into the 2-column grid.
|
||||
private var iPadGridSections: [DashboardSectionConfig] {
|
||||
visibleSections.filter { $0.id != DashboardSection.totalValue.id }
|
||||
visibleSections.filter {
|
||||
$0.id != DashboardSection.totalValue.id && $0.id != DashboardSection.monthlyCheckIn.id
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -214,6 +189,14 @@ struct DashboardView: View {
|
||||
?? DashboardSectionConfig(id: DashboardSection.totalValue.id, isVisible: true, isCollapsed: false)
|
||||
sectionView(for: DashboardSectionConfig(id: totalConfig.id, isVisible: true, isCollapsed: totalConfig.isCollapsed))
|
||||
|
||||
if let checkInConfig = visibleSections.first(where: { $0.id == DashboardSection.monthlyCheckIn.id }) {
|
||||
sectionView(for: checkInConfig)
|
||||
}
|
||||
|
||||
if !viewModel.insights.isEmpty {
|
||||
InsightsRow(insights: viewModel.insights)
|
||||
}
|
||||
|
||||
// Other cards — 2-column grid with drag-and-drop
|
||||
if !iPadGridSections.isEmpty {
|
||||
LazyVGrid(
|
||||
@@ -352,14 +335,20 @@ struct DashboardView: View {
|
||||
yearChange: viewModel.portfolioSummary.formattedYearChange,
|
||||
sinceInceptionChange: viewModel.portfolioSummary.formattedAllTimeReturn
|
||||
)
|
||||
}
|
||||
},
|
||||
sparklineData: viewModel.evolutionData
|
||||
)
|
||||
}
|
||||
case .monthlyCheckIn:
|
||||
if config.isCollapsed {
|
||||
CompactCard(title: "Monthly Check-in", subtitle: "Last update: \(viewModel.formattedLastUpdate)")
|
||||
} else {
|
||||
MonthlyCheckInCard(lastUpdated: viewModel.formattedLastUpdate)
|
||||
MonthlyCheckInCard(
|
||||
lastUpdated: viewModel.formattedLastUpdate,
|
||||
pendingCount: viewModel.sourcesNeedingUpdate.count,
|
||||
totalSources: viewModel.totalSourceCount,
|
||||
streak: viewModel.updateStreak
|
||||
)
|
||||
}
|
||||
case .momentumStreaks:
|
||||
if config.isCollapsed {
|
||||
@@ -455,6 +444,8 @@ struct DashboardView: View {
|
||||
|
||||
// MARK: - Total Value Card
|
||||
|
||||
// Calm 2.0 hero: typography-led — the number IS the brand. Clean card,
|
||||
// semantic delta chip, sparkline for trend at a glance (Stocks-style).
|
||||
struct TotalValueCard: View {
|
||||
let totalValue: String
|
||||
let changeText: String
|
||||
@@ -468,125 +459,161 @@ struct TotalValueCard: View {
|
||||
var isYearPositive: Bool = true
|
||||
var isSinceInceptionPositive: Bool = true
|
||||
var onShareTap: (() -> Void)?
|
||||
var sparklineData: [(date: Date, value: Decimal)] = []
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("Total Portfolio Value")
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.overlay(alignment: .trailing) {
|
||||
if let onShareTap {
|
||||
Button(action: onShareTap) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
}
|
||||
|
||||
Text(totalValue)
|
||||
.font(.system(size: 42, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 44, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.primary)
|
||||
.minimumScaleFactor(0.6)
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: isPositive ? "arrow.up.right" : "arrow.down.right")
|
||||
.font(.caption)
|
||||
Text(changeText)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text(changeLabel)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
|
||||
// YoY and Since Inception returns
|
||||
if yearChange != nil || sinceInceptionChange != nil {
|
||||
HStack(spacing: 16) {
|
||||
if let yearChange = yearChange {
|
||||
VStack(spacing: 2) {
|
||||
Text("YoY")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
Text(yearChange)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
|
||||
if let sinceInceptionChange = sinceInceptionChange {
|
||||
VStack(spacing: 2) {
|
||||
Text("Since inception")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
Text(sinceInceptionChange)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: isPositive ? "arrow.up.right" : "arrow.down.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
Text(changeText)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.foregroundColor(isPositive ? .positiveGreen : .negativeRed)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background((isPositive ? Color.positiveGreen : Color.negativeRed).opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
|
||||
Text(changeLabel)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
// Forecast section
|
||||
if let forecast = forecast {
|
||||
if sparklineData.count >= 2 {
|
||||
heroSparkline
|
||||
}
|
||||
|
||||
if yearChange != nil || sinceInceptionChange != nil || forecast != nil {
|
||||
Divider()
|
||||
.background(Color.white.opacity(0.3))
|
||||
.padding(.vertical, 8)
|
||||
|
||||
if isPremium {
|
||||
VStack(spacing: 4) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "wand.and.stars")
|
||||
.font(.caption)
|
||||
Text("12-month forecast")
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
|
||||
Text(forecast.formattedForecastValue)
|
||||
.font(.title2.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
|
||||
Text(forecast.formattedGrowthPercentage)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(forecast.isPositiveGrowth ? .white : .white.opacity(0.8))
|
||||
HStack(spacing: 0) {
|
||||
if let yearChange {
|
||||
heroMetric(label: "YoY", value: yearChange, positive: isYearPositive)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
onUnlockTap?()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.caption)
|
||||
Text("Unlock 12-month forecast")
|
||||
.font(.caption.weight(.semibold))
|
||||
if let sinceInceptionChange {
|
||||
heroMetric(label: "Since inception", value: sinceInceptionChange, positive: isSinceInceptionPositive)
|
||||
}
|
||||
if let forecast {
|
||||
if isPremium {
|
||||
heroMetric(
|
||||
label: "12m forecast",
|
||||
value: forecast.formattedForecastValue,
|
||||
positive: forecast.isPositiveGrowth
|
||||
)
|
||||
} else {
|
||||
Button {
|
||||
onUnlockTap?()
|
||||
} label: {
|
||||
VStack(spacing: 2) {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "lock.fill").font(.caption2)
|
||||
Text("12m forecast").font(.caption2)
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
Text("Unlock")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.white.opacity(0.2))
|
||||
.cornerRadius(16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 24)
|
||||
.background(LinearGradient.appPrimaryGradient)
|
||||
.padding(20)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private func heroMetric(label: String, value: String, positive: Bool) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Text(value)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(positive ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var heroSparkline: some View {
|
||||
Chart {
|
||||
ForEach(sparklineData, id: \.date) { item in
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
AreaMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary.opacity(0.18), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
}
|
||||
}
|
||||
.chartXAxis(.hidden)
|
||||
.chartYAxis(.hidden)
|
||||
.chartYScale(domain: sparklineDomain)
|
||||
.frame(height: 56)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
/// Tight domain so small monthly moves are still visible in a 56pt strip.
|
||||
private var sparklineDomain: ClosedRange<Double> {
|
||||
let values = sparklineData.map { NSDecimalNumber(decimal: $0.value).doubleValue }
|
||||
guard let min = values.min(), let max = values.max(), max > min else {
|
||||
let v = values.first ?? 0
|
||||
return (v * 0.95)...(v * 1.05 + 1)
|
||||
}
|
||||
let padding = (max - min) * 0.15
|
||||
return (min - padding)...(max + padding)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Monthly Check-in Card
|
||||
|
||||
// Calm 2.0: the check-in card is state-driven — the app's core ritual is THE
|
||||
// protagonist while pending (progress + one CTA) and collapses to a quiet
|
||||
// confirmation row once the month is done. Streak lives here, not as a
|
||||
// separate badge competing for attention.
|
||||
struct MonthlyCheckInCard: View {
|
||||
let lastUpdated: String
|
||||
var pendingCount: Int = 0
|
||||
var totalSources: Int = 0
|
||||
var streak: Int = 0
|
||||
@State private var startDestinationActive = false
|
||||
|
||||
@FetchRequest(
|
||||
@@ -685,22 +712,100 @@ struct MonthlyCheckInCard: View {
|
||||
return formatter.string(from: Date())
|
||||
}
|
||||
|
||||
/// All sources recorded this month — the ritual is done, be quiet about it.
|
||||
private var isMonthComplete: Bool {
|
||||
totalSources > 0 && pendingCount == 0
|
||||
}
|
||||
|
||||
private var updatedCount: Int {
|
||||
max(0, totalSources - pendingCount)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Monthly Check-in")
|
||||
.font(.headline)
|
||||
Group {
|
||||
if isMonthComplete {
|
||||
completedRow
|
||||
} else {
|
||||
pendingCard
|
||||
}
|
||||
}
|
||||
.padding(isMonthComplete ? 14 : 20)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.background(
|
||||
NavigationLink(isActive: $startDestinationActive) {
|
||||
MonthlyCheckInView(referenceDate: navigationReferenceDate)
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.opacity(0)
|
||||
)
|
||||
}
|
||||
|
||||
Text(currentMonthLabel)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
HStack {
|
||||
Text("Last update: \(lastUpdated)")
|
||||
.font(.caption)
|
||||
/// Done state: one quiet, reassuring line.
|
||||
private var completedRow: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.title3)
|
||||
.foregroundColor(.positiveGreen)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(String(format: String(localized: "checkin_done_title"), currentMonthLabel))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
if let nextDate = nextCheckInDate {
|
||||
Text(String(format: String(localized: "checkin_done_next"), nextDate.mediumDateString))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if streak >= 2 {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "flame.fill")
|
||||
.font(.caption)
|
||||
Text("\(streak)")
|
||||
.font(.caption.weight(.bold))
|
||||
}
|
||||
.foregroundColor(.orange)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Color.orange.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
Button {
|
||||
startDestinationActive = true
|
||||
} label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { startDestinationActive = true }
|
||||
}
|
||||
|
||||
/// Pending state: the ritual front and center — progress and one CTA.
|
||||
private var pendingCard: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Monthly Check-in")
|
||||
.font(.headline)
|
||||
Text(currentMonthLabel)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
|
||||
if streak >= 2 {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "flame.fill").font(.caption)
|
||||
Text("\(streak)").font(.caption.weight(.bold))
|
||||
}
|
||||
.foregroundColor(.orange)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Color.orange.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
if let reminderDate {
|
||||
Button {
|
||||
let title = String(
|
||||
@@ -718,13 +823,24 @@ struct MonthlyCheckInCard: View {
|
||||
)
|
||||
} label: {
|
||||
Image(systemName: "calendar.badge.plus")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
}
|
||||
|
||||
ProgressView(value: checkInProgress)
|
||||
.tint(progressBarTint)
|
||||
if totalSources > 0 {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ProgressView(value: Double(updatedCount), total: Double(max(1, totalSources)))
|
||||
.tint(progressBarTint)
|
||||
Text(String(format: String(localized: "checkin_sources_progress"), updatedCount, totalSources))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
ProgressView(value: checkInProgress)
|
||||
.tint(progressBarTint)
|
||||
}
|
||||
|
||||
if let nextDate = nextCheckInDate {
|
||||
Text("Next check-in: \(nextDate.mediumDateString)")
|
||||
@@ -743,19 +859,7 @@ struct MonthlyCheckInCard: View {
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
|
||||
NavigationLink(
|
||||
isActive: $startDestinationActive
|
||||
) {
|
||||
MonthlyCheckInView(referenceDate: navigationReferenceDate)
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var defaultReminderTime: Date {
|
||||
|
||||
@@ -1,49 +1,60 @@
|
||||
import SwiftUI
|
||||
|
||||
// Calm 2.0: one insight at a time instead of a chip carousel — a quiet,
|
||||
// glanceable line under the check-in zone. Tap to cycle through the rest.
|
||||
struct InsightsRow: View {
|
||||
let insights: [PortfolioInsight]
|
||||
@State private var index = 0
|
||||
|
||||
private var current: PortfolioInsight? {
|
||||
guard !insights.isEmpty else { return nil }
|
||||
return insights[index % insights.count]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(String(localized: "insights_section_title"))
|
||||
.font(.headline)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
if let insight = current {
|
||||
Button {
|
||||
withAnimation(.snappy) {
|
||||
index = (index + 1) % insights.count
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(insights) { insight in
|
||||
insightChip(insight)
|
||||
Image(systemName: insight.systemImage)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(insight.accentColor)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(insight.accentColor.opacity(0.12))
|
||||
.clipShape(Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(insight.title)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(insight.value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if insights.count > 1 {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(insights.indices, id: \.self) { i in
|
||||
Circle()
|
||||
.fill(i == index % insights.count ? Color.secondary : Color.secondary.opacity(0.25))
|
||||
.frame(width: 5, height: 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.id(insight.id)
|
||||
.transition(.opacity)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private func insightChip(_ insight: PortfolioInsight) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: insight.systemImage)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(insight.accentColor)
|
||||
Text(insight.title)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Text(insight.value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(insight.accentColor.opacity(0.08))
|
||||
.cornerRadius(12)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(insight.accentColor.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user