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 // Step 0.. 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 { 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 } }