What's New versionado y acumulativo (build 54)

El modal estaba congelado en las features de 1.4.0 (hardcodeadas en la vista) y
el título decía 'What's New in 1.4' fijo. Rediseñado como catálogo por versión:

- WhatsNewCatalog: una entrada por release (1.4.0/1.4.1/1.4.2/1.5.0) con sus
  features; AÑADIR ENTRADA CON CADA RELEASE (comentario en el código).
- Diff ACUMULATIVO desde lastSeenWhatsNewVersion: saltar 1.3.x → 1.4.2 muestra
  1.4.0 + 1.4.1 + 1.4.2 agrupadas con badge de versión (headers solo si hay
  más de una release pendiente). Comparación numérica de versiones (1.10 > 1.9).
- lastSeen vacío + onboarding ya completado (usuarios pre-1.4.0) = mostrar todo
  el catálogo; instalación nueva = lastSeen sella a la versión actual al
  completar onboarding (un usuario recién onboardeado no ve 'novedades').
- Sin entrada en el catálogo para el salto → no se muestra sheet.
- Título dinámico (whats_new_title_format) y contenido scrollable.
- Strings de 4 releases + título ×7 idiomas.

Verificado en simulador con -lastSeenWhatsNewVersion 1.4.0: pending
[1.5.0, 1.4.2, 1.4.1] y sheet agrupada correcta.

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-10 08:06:49 +02:00
parent ed737bc290
commit 08094483ed
10 changed files with 347 additions and 65 deletions
@@ -7,75 +7,192 @@ struct WhatsNewFeature {
let description: String
}
struct WhatsNewRelease: Identifiable {
let version: String
let features: [WhatsNewFeature]
var id: String { version }
}
// MARK: - Versioned catalog
//
// One entry per released version, newest first. The sheet shows the CUMULATIVE
// diff between the version the user is coming from (lastSeenWhatsNewVersion)
// and the one they just installed jumping 1.3.x 1.4.2 shows 1.4.0 + 1.4.1
// + 1.4.2, not just the last step. ADD AN ENTRY HERE WITH EVERY RELEASE.
enum WhatsNewCatalog {
static var releases: [WhatsNewRelease] {
[
WhatsNewRelease(version: "1.5.0", features: [
WhatsNewFeature(
icon: "sparkles",
color: .appPrimary,
title: String(localized: "whats_new_150_redesign_title"),
description: String(localized: "whats_new_150_redesign_body")
),
WhatsNewFeature(
icon: "figure.mind.and.body",
color: .positiveGreen,
title: String(localized: "whats_new_150_guided_title"),
description: String(localized: "whats_new_150_guided_body")
),
WhatsNewFeature(
icon: "circle.dashed.inset.filled",
color: .orange,
title: String(localized: "whats_new_150_rings_title"),
description: String(localized: "whats_new_150_rings_body")
)
]),
WhatsNewRelease(version: "1.4.2", features: [
WhatsNewFeature(
icon: "text.viewfinder",
color: .appPrimary,
title: String(localized: "whats_new_142_ocr_title"),
description: String(localized: "whats_new_142_ocr_body")
),
WhatsNewFeature(
icon: "mic.fill",
color: .purple,
title: String(localized: "whats_new_142_siri_title"),
description: String(localized: "whats_new_142_siri_body")
),
WhatsNewFeature(
icon: "chart.xyaxis.line",
color: .appSecondary,
title: String(localized: "whats_new_142_charts_title"),
description: String(localized: "whats_new_142_charts_body")
)
]),
WhatsNewRelease(version: "1.4.1", features: [
WhatsNewFeature(
icon: "square.and.arrow.up.circle.fill",
color: .appPrimary,
title: String(localized: "whats_new_141_extension_title"),
description: String(localized: "whats_new_141_extension_body")
),
WhatsNewFeature(
icon: "ipad.landscape",
color: .appSecondary,
title: String(localized: "whats_new_141_ipad_title"),
description: String(localized: "whats_new_141_ipad_body")
),
WhatsNewFeature(
icon: "icloud.fill",
color: .cyan,
title: String(localized: "whats_new_141_icloud_title"),
description: String(localized: "whats_new_141_icloud_body")
)
]),
WhatsNewRelease(version: "1.4.0", features: [
WhatsNewFeature(
icon: "bolt.circle.fill",
color: .appPrimary,
title: String(localized: "whats_new_quick_update_title"),
description: String(localized: "whats_new_quick_update_body")
),
WhatsNewFeature(
icon: "flame.fill",
color: .orange,
title: String(localized: "whats_new_streak_title"),
description: String(localized: "whats_new_streak_body")
),
WhatsNewFeature(
icon: "target",
color: .appSecondary,
title: String(localized: "whats_new_goals_title"),
description: String(localized: "whats_new_goals_body")
)
])
]
}
/// Releases the user hasn't seen: newer than `since`, no newer than `current`.
/// Newest first. `since` empty (pre-1.4.0 installs, before the key existed)
/// is treated as "show everything in the catalog".
static func pendingReleases(since: String, current: String) -> [WhatsNewRelease] {
releases.filter { release in
(since.isEmpty || compare(release.version, isNewerThan: since))
&& !compare(release.version, isNewerThan: current)
}
}
/// Numeric semver-style comparison ("1.10.0" > "1.9.0").
static func compare(_ a: String, isNewerThan b: String) -> Bool {
let aParts = a.split(separator: ".").map { Int($0) ?? 0 }
let bParts = b.split(separator: ".").map { Int($0) ?? 0 }
for i in 0..<max(aParts.count, bParts.count) {
let x = i < aParts.count ? aParts[i] : 0
let y = i < bParts.count ? bParts[i] : 0
if x != y { return x > y }
}
return false
}
}
// MARK: - Sheet
struct WhatsNewView: View {
let releases: [WhatsNewRelease]
@Environment(\.dismiss) private var dismiss
private let features: [WhatsNewFeature] = [
WhatsNewFeature(
icon: "bolt.circle.fill",
color: .appPrimary,
title: String(localized: "whats_new_quick_update_title"),
description: String(localized: "whats_new_quick_update_body")
),
WhatsNewFeature(
icon: "flame.fill",
color: .orange,
title: String(localized: "whats_new_streak_title"),
description: String(localized: "whats_new_streak_body")
),
WhatsNewFeature(
icon: "target",
color: .appSecondary,
title: String(localized: "whats_new_goals_title"),
description: String(localized: "whats_new_goals_body")
)
]
/// Version headers only add value when the user skipped several releases.
private var showsVersionHeaders: Bool { releases.count > 1 }
var body: some View {
ZStack {
AppBackground()
VStack(spacing: 0) {
Spacer()
VStack(spacing: 8) {
Text("🎉")
.font(.system(size: 48))
Text(String(format: String(localized: "whats_new_title_format"), releases.first?.version ?? ""))
.font(.title.weight(.bold))
Text(String(localized: "whats_new_subtitle"))
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.padding(.top, 32)
.padding(.horizontal)
VStack(spacing: 32) {
VStack(spacing: 8) {
Text("🎉")
.font(.system(size: 56))
Text(String(localized: "whats_new_title"))
.font(.title.weight(.bold))
Text(String(localized: "whats_new_subtitle"))
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
VStack(spacing: 20) {
ForEach(features, id: \.title) { feature in
HStack(spacing: 16) {
Image(systemName: feature.icon)
.font(.title2)
.foregroundColor(feature.color)
.frame(width: 40)
VStack(alignment: .leading, spacing: 2) {
Text(feature.title)
.font(.subheadline.weight(.semibold))
Text(feature.description)
.font(.caption)
.foregroundColor(.secondary)
ScrollView {
VStack(spacing: 16) {
ForEach(releases) { release in
VStack(alignment: .leading, spacing: 14) {
if showsVersionHeaders {
Text(String(format: String(localized: "whats_new_version_header"), release.version))
.font(.caption.weight(.bold))
.foregroundColor(.appPrimary)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Color.appPrimary.opacity(0.12))
.clipShape(Capsule())
}
ForEach(release.features, id: \.title) { feature in
HStack(spacing: 16) {
Image(systemName: feature.icon)
.font(.title2)
.foregroundColor(feature.color)
.frame(width: 40)
VStack(alignment: .leading, spacing: 2) {
Text(feature.title)
.font(.subheadline.weight(.semibold))
Text(feature.description)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
}
Spacer()
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(16)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(16)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
.padding()
Spacer()
Button {
dismiss()