08094483ed
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
214 lines
9.0 KiB
Swift
214 lines
9.0 KiB
Swift
import SwiftUI
|
|
|
|
struct WhatsNewFeature {
|
|
let icon: String
|
|
let color: Color
|
|
let title: String
|
|
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
|
|
|
|
/// 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) {
|
|
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)
|
|
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color(.secondarySystemGroupedBackground))
|
|
.cornerRadius(16)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
|
|
Button {
|
|
dismiss()
|
|
} label: {
|
|
Text(String(localized: "whats_new_continue"))
|
|
.font(.headline)
|
|
.foregroundColor(.white)
|
|
.frame(maxWidth: .infinity)
|
|
.padding()
|
|
.background(Color.appPrimary)
|
|
.cornerRadius(14)
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
.interactiveDismissDisabled()
|
|
}
|
|
}
|