1.5.0: aviso de huecos + resumen mensual compartible + import con vista previa

Feature 1 — Aviso de huecos: SnapshotGapDetector detecta huecos internos por
source (meses sin datos entre dos snapshots). Banner desechable en Dashboard +
DataGapsSheet para rellenar (abre AddSnapshotView con el mes que falta). Complementa
la interpolación de gráficas: la línea se ve suave pero el usuario sabe que faltan
datos reales.

Feature 2 — Resumen mensual compartible: MonthlySummaryShareView (tarjeta con marca:
valor, variación desde el check-in, racha, mood/rating) renderizada con ImageRenderer
y compartida vía ShareService. Botón en la fila "check-in done" del Dashboard.

Feature 3 — Import con vista previa: ImportPreview + previewImportAsync (dry-run
READ-ONLY, no muta nada) cuenta qué se creará/actualizará usando los mismos checks
de existencia que applyImport. ImportDataView muestra la confirmación antes de
importar. El path real de import (applyImport/importDataAsync) NO se toca.

Localización en los 7 idiomas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
This commit is contained in:
alexandrev-tibco
2026-07-24 09:36:50 +02:00
parent 8d7cfc098b
commit 84d9240ee6
17 changed files with 1096 additions and 13 deletions
@@ -15,6 +15,10 @@ struct DashboardView: View {
@State private var sectionConfigs = DashboardLayoutStore.load()
@AppStorage("showForecast") private var showForecast = true
@State private var showingQuickUpdate = false
@State private var showingDataGaps = false
/// Signature of the gap set last dismissed, so re-dismissal sticks until the
/// gaps actually change (e.g. the user filled some).
@AppStorage("dismissedDataGapsSignature") private var dismissedGapsSignature = ""
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
init() {
@@ -39,9 +43,11 @@ struct DashboardView: View {
VStack(spacing: 16) {
ForEach(visibleSections) { config in
sectionView(for: config)
if config.id == DashboardSection.monthlyCheckIn.id,
!viewModel.insights.isEmpty {
InsightsRow(insights: viewModel.insights)
if config.id == DashboardSection.monthlyCheckIn.id {
gapsBanner
if !viewModel.insights.isEmpty {
InsightsRow(insights: viewModel.insights)
}
}
}
}
@@ -131,6 +137,12 @@ struct DashboardView: View {
.sheet(isPresented: $viewModel.showingPaywall) {
PaywallView()
}
.sheet(isPresented: $showingDataGaps) {
DataGapsSheet(
gaps: viewModel.dataGaps,
sourceProvider: { viewModel.source(withId: $0) }
)
}
.onReceive(NotificationCenter.default.publisher(for: .openQuickUpdate)) { _ in
showingQuickUpdate = true
}
@@ -180,6 +192,29 @@ struct DashboardView: View {
sectionConfigs.filter { $0.isVisible }
}
// MARK: - Data Gaps
/// A stable fingerprint of the current gap set changes when gaps are
/// added or filled, which re-shows a previously dismissed banner.
private var currentGapsSignature: String {
viewModel.dataGaps.map { $0.id }.sorted().joined(separator: "|")
}
private var shouldShowGapsBanner: Bool {
!viewModel.dataGaps.isEmpty && dismissedGapsSignature != currentGapsSignature
}
@ViewBuilder
private var gapsBanner: some View {
if shouldShowGapsBanner {
DataGapsBanner(
totalMissingMonths: viewModel.totalMissingMonths,
onTap: { showingDataGaps = true },
onDismiss: { dismissedGapsSignature = currentGapsSignature }
)
}
}
// MARK: - iPad Layout Helpers
/// Full-width "hero" zones that lead the dashboard, in order.
@@ -224,8 +259,11 @@ struct DashboardView: View {
VStack(spacing: 16) {
ForEach(iPadLeadSections) { config in
sectionView(for: config)
if config.id == DashboardSection.monthlyCheckIn.id, !viewModel.insights.isEmpty {
InsightsRow(insights: viewModel.insights)
if config.id == DashboardSection.monthlyCheckIn.id {
gapsBanner
if !viewModel.insights.isEmpty {
InsightsRow(insights: viewModel.insights)
}
}
}
@@ -285,7 +323,10 @@ struct DashboardView: View {
lastUpdated: viewModel.formattedLastUpdate,
pendingCount: viewModel.sourcesNeedingUpdate.count,
totalSources: viewModel.totalSourceCount,
streak: viewModel.updateStreak
streak: viewModel.updateStreak,
onShareSummary: {
ShareService.shared.shareMonthlySummary(viewModel.monthlySummaryShareData())
}
)
}
case .momentumStreaks:
@@ -557,6 +598,7 @@ struct MonthlyCheckInCard: View {
var pendingCount: Int = 0
var totalSources: Int = 0
var streak: Int = 0
var onShareSummary: (() -> Void)? = nil
@State private var startDestinationActive = false
@State private var showingGuidedFlow = false
@@ -734,6 +776,15 @@ struct MonthlyCheckInCard: View {
.background(Color.orange.opacity(0.12))
.clipShape(Capsule())
}
if let onShareSummary {
Button(action: onShareSummary) {
Image(systemName: "square.and.arrow.up")
.font(.caption.weight(.semibold))
.foregroundColor(.appPrimary)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("Share monthly summary"))
}
Button {
startDestinationActive = true
} label: {