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:
alexandrev-tibco
2026-07-08 23:58:06 +02:00
parent ff67ea1e71
commit be7119f4b9
12 changed files with 375 additions and 238 deletions
@@ -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)
)
}
}