Files
InvestmentTrackerApp/PortfolioJournal/Views/Dashboard/GuidedCheckInView.swift
T
alexandrev-tibco 4c0d2af564 Calm 2.0 Fase 4: TipKit para descubrimiento + aha post-onboarding (build 55)
TipKit (Tips.configure con frecuencia diaria):
- QuickUpdateTip: popover en el '+' del Home — descubre Quick Update
- ScreenshotOCRTip: inline en Quick Update — descubre el OCR por captura
- SiriTip: en el cierre del check-in guiado — descubre el intent de voz
- ChartShareTip: popover en el compartir de Charts
- Se invalidan al ejecutar la acción; máx 1-2 impresiones cada uno
- Strings ×7 idiomas

Onboarding:
- Al completar el onboarding sin datos, se abre Add Source directamente
  (notificación .openAddFirstSource) — el usuario nuevo no aterriza en un
  dashboard vacío

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-10 09:31:03 +02:00

483 lines
19 KiB
Swift

import SwiftUI
import CoreData
import TipKit
import UIKit
// MARK: - Guided monthly check-in (Calm 2.0, Fase 2)
//
// The monthly ritual as a full-screen, one-source-per-step flow instead of a
// form: previous value in sight, big keypad entry, live delta, clipboard
// hand-off then a short reflection (mood + one line) and a closing summary
// with the streak. Persistence reuses the exact same paths as Quick Update
// (Snapshots) and the journal (MonthlyCheckInStore JournalEntry), so data
// stays consistent with every other entry point.
struct GuidedCheckInView: View {
/// Frozen at presentation: the launching card recomputes its reference date
/// as soon as the check-in saves, and SwiftUI re-evaluates the cover content
/// without freezing, the month would shift mid-flow on the done screen.
@State private var referenceDate: Date
init(referenceDate: Date) {
_referenceDate = State(initialValue: referenceDate)
}
@Environment(\.dismiss) private var dismiss
@Environment(\.managedObjectContext) private var context
@Environment(\.scenePhase) private var scenePhase
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)],
predicate: NSPredicate(format: "isActive == YES"),
animation: .none
) private var sources: FetchedResults<InvestmentSource>
// Step 0..<count one source each; count reflection; count+1 done.
@State private var step = 0
@State private var values: [NSManagedObjectID: String] = [:]
@State private var mood: MonthlyCheckInMood?
@State private var note = ""
@State private var saveError: String?
@State private var savedTotals: (previous: Decimal, current: Decimal)?
@State private var clipboardAmount: Decimal?
@State private var lastSuggestedRaw: String?
@FocusState private var amountFocused: Bool
@FocusState private var noteFocused: Bool
private var reflectionStep: Int { sources.count }
private var doneStep: Int { sources.count + 1 }
private var currentSource: InvestmentSource? {
step < sources.count ? sources[step] : nil
}
private var monthLabel: String {
let formatter = DateFormatter()
formatter.dateFormat = "LLLL yyyy"
return formatter.string(from: MonthlyCheckInStore.effectiveMonth(for: referenceDate, relativeTo: referenceDate))
}
var body: some View {
NavigationStack {
ZStack {
AppBackground()
VStack(spacing: 0) {
if step < doneStep {
ProgressView(value: Double(step + 1), total: Double(doneStep))
.tint(.appPrimary)
.padding(.horizontal)
.padding(.top, 8)
}
Group {
if let source = currentSource {
sourceStep(source)
.id(source.objectID)
} else if step == reflectionStep {
reflectionView
} else {
doneView
}
}
.frame(maxWidth: 560)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.transition(.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .move(edge: .leading).combined(with: .opacity)
))
}
}
.navigationTitle(step == doneStep ? "" : monthLabel)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if step < doneStep {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "cancel")) { dismiss() }
}
}
}
.alert("Error", isPresented: Binding(
get: { saveError != nil },
set: { if !$0 { saveError = nil } }
)) {
Button("OK", role: .cancel) { saveError = nil }
} message: {
Text(saveError ?? "")
}
.onAppear {
amountFocused = true
checkClipboard()
}
.onChange(of: scenePhase) { _, phase in
if phase == .active { checkClipboard() }
}
}
.interactiveDismissDisabled(step > 0 && step < doneStep)
}
// MARK: - Source step
private func sourceStep(_ source: InvestmentSource) -> some View {
VStack(spacing: 24) {
Spacer()
VStack(spacing: 6) {
Text(source.name)
.font(.title2.weight(.bold))
if let category = source.category?.name {
Text(category)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
if source.latestValue != .zero {
VStack(spacing: 2) {
Text(String(localized: "guided_previous_value"))
.font(.caption)
.foregroundColor(.secondary)
Text(source.latestValue.currencyString)
.font(.title3.weight(.semibold))
.foregroundColor(.secondary)
}
}
TextField("0", text: valueBinding(for: source))
.keyboardType(.decimalPad)
.multilineTextAlignment(.center)
.font(.system(size: 40, weight: .bold, design: .rounded))
.focused($amountFocused)
.accessibilityIdentifier("guided_value_field")
.padding(.vertical, 12)
.padding(.horizontal, 24)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.padding(.horizontal, 32)
liveDelta(for: source)
if let amount = clipboardAmount {
Button {
values[source.objectID] = String(format: "%.2f", NSDecimalNumber(decimal: amount).doubleValue)
clipboardAmount = nil
} label: {
Label(
String(format: String(localized: "guided_paste_amount"), amount.currencyString),
systemImage: "doc.on.clipboard.fill"
)
.font(.subheadline.weight(.semibold))
}
.buttonStyle(.bordered)
.tint(.appPrimary)
}
Spacer()
VStack(spacing: 10) {
Button {
advance()
} label: {
Text(String(localized: "guided_next"))
.font(.headline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.tint(.appPrimary)
.disabled(parsedValue(for: source) == nil)
.accessibilityIdentifier("guided_next_btn")
HStack(spacing: 12) {
if source.latestValue != .zero {
Button {
values[source.objectID] = String(format: "%.2f", NSDecimalNumber(decimal: source.latestValue).doubleValue)
advance()
} label: {
Text(String(localized: "guided_same_as_last"))
.font(.subheadline.weight(.medium))
}
}
Button {
values[source.objectID] = ""
advance()
} label: {
Text(String(localized: "guided_skip"))
.font(.subheadline.weight(.medium))
.foregroundColor(.secondary)
}
.accessibilityIdentifier("guided_skip_btn")
}
}
.padding(.horizontal, 32)
.padding(.bottom, 24)
}
}
@ViewBuilder
private func liveDelta(for source: InvestmentSource) -> some View {
if let value = parsedValue(for: source), source.latestValue > 0 {
let delta = value - source.latestValue
let pct = NSDecimalNumber(decimal: delta / source.latestValue).doubleValue * 100
let positive = delta >= 0
HStack(spacing: 4) {
Image(systemName: positive ? "arrow.up.right" : "arrow.down.right")
.font(.caption.weight(.semibold))
Text("\(delta.currencyString) (\(String(format: "%+.1f%%", pct)))")
.font(.subheadline.weight(.semibold))
}
.foregroundColor(positive ? .positiveGreen : .negativeRed)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background((positive ? Color.positiveGreen : Color.negativeRed).opacity(0.12))
.clipShape(Capsule())
.transition(.opacity)
} else {
// Reserve the space so the layout doesn't jump while typing.
Color.clear.frame(height: 30)
}
}
// MARK: - Reflection step
private var reflectionView: some View {
VStack(spacing: 24) {
Spacer()
VStack(spacing: 6) {
Text(String(localized: "guided_reflection_title"))
.font(.title2.weight(.bold))
Text(String(localized: "guided_reflection_subtitle"))
.font(.subheadline)
.foregroundColor(.secondary)
}
HStack(spacing: 10) {
ForEach(MonthlyCheckInMood.allCases) { candidate in
Button {
withAnimation(.snappy) {
mood = (mood == candidate) ? nil : candidate
}
} label: {
VStack(spacing: 6) {
Image(systemName: candidate.iconName)
.font(.title3)
Text(candidate.title)
.font(.caption2)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(
mood == candidate
? Color.appPrimary.opacity(0.15)
: Color(.secondarySystemGroupedBackground)
)
.foregroundColor(mood == candidate ? .appPrimary : .primary)
.cornerRadius(12)
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(mood == candidate ? Color.appPrimary : .clear, lineWidth: 1.5)
)
}
.buttonStyle(.plain)
.accessibilityIdentifier("guided_mood_\(candidate.rawValue)")
}
}
.padding(.horizontal, 24)
TextField(String(localized: "guided_note_placeholder"), text: $note, axis: .vertical)
.lineLimit(2...4)
.focused($noteFocused)
.padding(14)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.padding(.horizontal, 24)
Spacer()
Button {
finish()
} label: {
Text(String(localized: "guided_finish"))
.font(.headline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.tint(.appPrimary)
.accessibilityIdentifier("guided_finish_btn")
.padding(.horizontal, 32)
.padding(.bottom, 24)
}
}
// MARK: - Done step
private var doneView: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 64))
.foregroundColor(.positiveGreen)
.symbolEffect(.bounce, value: step)
Text(String(format: String(localized: "guided_done_title"), monthLabel))
.font(.title2.weight(.bold))
.multilineTextAlignment(.center)
if let totals = savedTotals {
VStack(spacing: 4) {
Text(totals.current.currencyString)
.font(.system(size: 40, weight: .bold, design: .rounded))
if totals.previous > 0 {
let delta = totals.current - totals.previous
let pct = NSDecimalNumber(decimal: delta / totals.previous).doubleValue * 100
Text("\(delta.currencyString) (\(String(format: "%+.1f%%", pct)))")
.font(.subheadline.weight(.semibold))
.foregroundColor(delta >= 0 ? .positiveGreen : .negativeRed)
}
}
}
let streak = MonthlyCheckInStore.stats(referenceDate: referenceDate).currentStreak
if streak >= 2 {
HStack(spacing: 6) {
Image(systemName: "flame.fill")
Text(String(format: String(localized: "guided_streak"), streak))
}
.font(.subheadline.weight(.semibold))
.foregroundColor(.orange)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(Color.orange.opacity(0.12))
.clipShape(Capsule())
}
Spacer()
TipView(SiriTip())
.padding(.horizontal, 32)
VStack(spacing: 10) {
if let totals = savedTotals {
Button {
let delta = totals.current - totals.previous
let pct = totals.previous > 0
? NSDecimalNumber(decimal: delta / totals.previous).doubleValue * 100 : 0
ShareService.shared.sharePortfolioValue(
totalValue: totals.current.currencyString,
changeText: "\(delta.currencyString) (\(String(format: "%+.1f%%", pct)))",
changeLabel: monthLabel,
yearChange: nil,
sinceInceptionChange: nil
)
} label: {
Label(String(localized: "guided_share_month"), systemImage: "square.and.arrow.up")
.font(.subheadline.weight(.semibold))
}
.buttonStyle(.bordered)
.tint(.appPrimary)
}
Button {
dismiss()
} label: {
Text(String(localized: "guided_done_cta"))
.font(.headline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.tint(.appPrimary)
}
.padding(.horizontal, 32)
.padding(.bottom, 24)
}
}
// MARK: - Flow
private func advance() {
withAnimation(.snappy) {
step += 1
}
if step < sources.count {
amountFocused = true
checkClipboard()
} else if step == reflectionStep {
amountFocused = false
}
}
private func finish() {
let previousTotal = sources.reduce(Decimal.zero) { $0 + $1.latestValue }
let now = Date()
var updatedSources: [InvestmentSource] = []
for source in sources {
guard let value = parsedValue(for: source) else { continue }
let snapshot = Snapshot(context: context)
snapshot.id = UUID()
snapshot.value = NSDecimalNumber(decimal: value)
snapshot.date = now
snapshot.source = source
if let contribution = MonthlyContributionStore.contribution(for: source.id) {
snapshot.contribution = NSDecimalNumber(decimal: contribution)
}
updatedSources.append(source)
}
do {
try context.save()
} catch {
saveError = error.localizedDescription
return
}
// New total = entered values + untouched sources at their previous value.
let currentTotal = sources.reduce(Decimal.zero) { total, source in
total + (parsedValue(for: source) ?? source.latestValue)
}
savedTotals = (previous: previousTotal, current: currentTotal)
if let mood { MonthlyCheckInStore.setMood(mood, for: referenceDate) }
let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedNote.isEmpty { MonthlyCheckInStore.setNote(trimmedNote, for: referenceDate) }
MonthlyCheckInStore.setCompletionDate(now, for: referenceDate)
for source in updatedSources {
NotificationService.shared.scheduleReminder(for: source)
}
SharedQuickUpdateSync.refreshMirror()
UINotificationFeedbackGenerator().notificationOccurred(.success)
withAnimation(.snappy) {
step = doneStep
}
}
// MARK: - Helpers
private func valueBinding(for source: InvestmentSource) -> Binding<String> {
Binding(
get: { values[source.objectID] ?? "" },
set: { values[source.objectID] = $0 }
)
}
private func parsedValue(for source: InvestmentSource) -> Decimal? {
guard let raw = values[source.objectID],
!raw.trimmingCharacters(in: .whitespaces).isEmpty,
let value = CurrencyFormatter.parseUserInput(raw), value > 0 else { return nil }
return value
}
private func checkClipboard() {
guard let raw = UIPasteboard.general.string, raw != lastSuggestedRaw,
let parsed = CurrencyFormatter.parseUserInput(raw), parsed > 0 else { return }
lastSuggestedRaw = raw
clipboardAmount = parsed
}
}