Calm 2.0 Fase 2: check-in guiado full-screen (build 52)
El ritual mensual como flujo paso a paso en vez de formulario: - Una fuente por pantalla: valor anterior a la vista, entrada grande (40pt rounded), delta en vivo (chip verde/rojo con importe y %), sugerencia de portapapeles, 'Igual que el mes pasado' y 'Omitir' - Barra de progreso del ritual; cancelable solo al inicio (interactiveDismiss bloqueado a mitad para no perder valores) - Reflexión: mood (5 estados existentes) + nota de una línea - Cierre: total nuevo, delta del mes, streak, compartir y haptic de éxito - Persistencia por los MISMOS caminos que Quick Update (Snapshots) y el journal (MonthlyCheckInStore → JournalEntry): coherente con el resto de entradas - Entrada: CTA del check-in card en Home (fullScreenCover); la fila de completado sigue navegando al journal - Fix: referenceDate congelada en @State al presentar — el card la recalcula al guardar y SwiftUI re-evaluaba el contenido del cover cambiando el mes a mitad de flujo - --pending-checkin en ScreenshotMode (revierte el seed a 'mes pendiente') + UITest testGuidedCheckInFlow end-to-end (valor → skips → mood → cierre) ✓ - Strings ×7 idiomas Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
This commit is contained in:
@@ -615,6 +615,7 @@ struct MonthlyCheckInCard: View {
|
||||
var totalSources: Int = 0
|
||||
var streak: Int = 0
|
||||
@State private var startDestinationActive = false
|
||||
@State private var showingGuidedFlow = false
|
||||
|
||||
@FetchRequest(
|
||||
sortDescriptors: [NSSortDescriptor(keyPath: \JournalEntry.completionTime, ascending: false)],
|
||||
@@ -740,6 +741,12 @@ struct MonthlyCheckInCard: View {
|
||||
}
|
||||
.opacity(0)
|
||||
)
|
||||
// Calm 2.0 Fase 2: the ritual runs as a guided full-screen flow — one
|
||||
// source per step, reflection, closing summary.
|
||||
.fullScreenCover(isPresented: $showingGuidedFlow) {
|
||||
GuidedCheckInView(referenceDate: navigationReferenceDate)
|
||||
.environment(\.managedObjectContext, CoreDataStack.shared.viewContext)
|
||||
}
|
||||
}
|
||||
|
||||
/// Done state: one quiet, reassuring line.
|
||||
@@ -849,7 +856,7 @@ struct MonthlyCheckInCard: View {
|
||||
}
|
||||
|
||||
Button {
|
||||
startDestinationActive = true
|
||||
showingGuidedFlow = true
|
||||
} label: {
|
||||
Text(buttonLabel)
|
||||
.font(.headline.weight(.semibold))
|
||||
@@ -859,6 +866,7 @@ struct MonthlyCheckInCard: View {
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
.accessibilityIdentifier("checkin_start_cta")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
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()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user