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: {
@@ -0,0 +1,139 @@
import SwiftUI
/// Dismissible banner shown on the Dashboard when internal snapshot gaps exist.
/// The evolution chart smooths these over, so without this the user would never
/// know real data is missing. Tapping opens `DataGapsSheet` to fill them.
struct DataGapsBanner: View {
let totalMissingMonths: Int
let onTap: () -> Void
let onDismiss: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.title3)
.foregroundColor(.orange)
VStack(alignment: .leading, spacing: 2) {
Text(String(format: String(localized: "gaps_banner_title"), totalMissingMonths))
.font(.subheadline.weight(.semibold))
.foregroundColor(.primary)
.multilineTextAlignment(.leading)
Text("Fill in missing data")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
}
.padding(14)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.overlay(
RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius, style: .continuous)
.stroke(Color.orange.opacity(0.35), lineWidth: 1)
)
}
.buttonStyle(.plain)
.overlay(alignment: .topTrailing) {
Button(action: onDismiss) {
Image(systemName: "xmark.circle.fill")
.font(.body)
.foregroundColor(.secondary)
.padding(6)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("Dismiss"))
}
}
}
/// Lists each detected gap and offers to fill it. Filling opens the standard
/// add-snapshot flow for that source, prefilled to the missing month's date.
struct DataGapsSheet: View {
let gaps: [SnapshotGapDetector.Gap]
/// Resolves a source id to a live InvestmentSource for the add-snapshot flow.
let sourceProvider: (UUID) -> InvestmentSource?
@Environment(\.dismiss) private var dismiss
@State private var fillTarget: FillTarget?
private struct FillTarget: Identifiable {
let source: InvestmentSource
let date: Date
var id: String { "\(source.id.uuidString)-\(date.timeIntervalSince1970)" }
}
var body: some View {
NavigationStack {
List {
Section {
ForEach(gaps) { gap in
gapRow(gap)
}
} footer: {
Text("The chart estimates these months. Add real snapshots to keep your history accurate.")
}
}
.navigationTitle("Missing Data")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { dismiss() }
}
}
.sheet(item: $fillTarget) { target in
AddSnapshotView(source: target.source, initialDate: target.date)
.environment(\.managedObjectContext, CoreDataStack.shared.viewContext)
}
}
}
@ViewBuilder
private func gapRow(_ gap: SnapshotGapDetector.Gap) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(gap.sourceName)
.font(.headline)
Spacer()
Text(String(format: String(localized: "gaps_missing_count"), gap.missingMonths))
.font(.caption.weight(.semibold))
.foregroundColor(.orange)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.orange.opacity(0.12))
.clipShape(Capsule())
}
Text(rangeLabel(gap))
.font(.caption)
.foregroundColor(.secondary)
if let source = sourceProvider(gap.sourceId),
let firstMissing = gap.missingMonthDates.first {
Button {
fillTarget = FillTarget(source: source, date: firstMissing)
} label: {
Label("Fill Gap", systemImage: "plus.circle.fill")
.font(.subheadline.weight(.semibold))
}
.buttonStyle(.borderless)
.tint(.appPrimary)
}
}
.padding(.vertical, 4)
}
private func rangeLabel(_ gap: SnapshotGapDetector.Gap) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM yyyy"
let from = formatter.string(from: gap.fromMonth)
let to = formatter.string(from: gap.toMonth)
return String(format: String(localized: "gaps_range_between"), from, to)
}
}
@@ -0,0 +1,151 @@
import SwiftUI
/// A polished, branded card summarising the month designed to be rendered to
/// an image and shared (screenshot-worthy, marketing-facing). Pulls only from
/// data the Dashboard already computes.
struct MonthlySummaryShareView: View {
let monthLabel: String
let totalValue: String
let changeAmount: String
let changePercentage: String
let isPositive: Bool
let streak: Int
let mood: MonthlyCheckInMood?
let rating: Int?
let appName: String
var qrCodeImage: UIImage? = nil
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
HStack {
Text(monthLabel)
.font(.caption.weight(.semibold))
.foregroundColor(.white.opacity(0.85))
.textCase(.uppercase)
Spacer()
if streak >= 2 {
HStack(spacing: 4) {
Image(systemName: "flame.fill")
.font(.caption2)
Text(String(format: String(localized: "share_summary_streak_badge"), streak))
.font(.caption2.weight(.bold))
}
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Color.white.opacity(0.18))
.clipShape(Capsule())
}
}
Text("Monthly Summary")
.font(.title.weight(.bold))
.foregroundColor(.white)
.padding(.top, 6)
// Hero value
VStack(alignment: .leading, spacing: 6) {
Text("Portfolio Value")
.font(.subheadline.weight(.semibold))
.foregroundColor(.white.opacity(0.85))
Text(totalValue)
.font(.system(size: 40, weight: .bold, design: .rounded))
.foregroundColor(.white)
.minimumScaleFactor(0.6)
.lineLimit(1)
}
.padding(.top, 22)
// Change since last check-in
HStack(spacing: 8) {
Image(systemName: isPositive ? "arrow.up.right" : "arrow.down.right")
.font(.subheadline.weight(.bold))
Text("\(changeAmount) (\(changePercentage))")
.font(.headline.weight(.bold))
Spacer()
Text("since last check-in")
.font(.caption)
.foregroundColor(.white.opacity(0.75))
}
.foregroundColor(.white)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(Color.white.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.padding(.top, 18)
// Mood / rating
if mood != nil || rating != nil {
HStack(spacing: 14) {
if let mood {
HStack(spacing: 6) {
Image(systemName: mood.iconName)
.font(.subheadline)
Text(mood.title)
.font(.subheadline.weight(.semibold))
}
.foregroundColor(.white)
}
if let rating {
HStack(spacing: 2) {
ForEach(1...5, id: \.self) { i in
Image(systemName: i <= rating ? "star.fill" : "star")
.font(.caption)
.foregroundColor(.white.opacity(i <= rating ? 1 : 0.4))
}
}
}
Spacer()
}
.padding(.top, 16)
}
Spacer(minLength: 0)
// Footer / branding
HStack(spacing: 12) {
Image("BrandMark")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 40, height: 40)
.clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
VStack(alignment: .leading, spacing: 2) {
Text("Tracked with")
.font(.caption2.weight(.medium))
.foregroundColor(.white.opacity(0.7))
Text(appName)
.font(.subheadline.weight(.bold))
.foregroundColor(.white)
}
Spacer()
if let qrCodeImage {
Image(uiImage: qrCodeImage)
.interpolation(.none)
.resizable()
.frame(width: 52, height: 52)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
}
}
.padding(.top, 20)
}
.padding(24)
.frame(width: 340, height: 440)
.background(
LinearGradient(
colors: [Color.appSecondary, Color.appPrimary],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 28, style: .continuous)
.stroke(Color.white.opacity(0.2), lineWidth: 1)
)
}
}