4c0d2af564
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
483 lines
19 KiB
Swift
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
|
|
}
|
|
}
|