import SwiftUI struct MonthlyCheckInView: View { @Environment(\.openURL) private var openURL @EnvironmentObject var accountStore: AccountStore @StateObject private var viewModel = MonthlyCheckInViewModel() @State private var referenceDate: Date let duplicatePrevious: Bool @State private var monthlyNote: String @State private var starRating: Int @State private var selectedMood: MonthlyCheckInMood? @FocusState private var noteFocused: Bool @State private var editingSnapshot: Snapshot? @State private var addingSource: InvestmentSource? @State private var didApplyDuplicate = false @State private var showBatchUpdate = false @State private var showAchievementSatisfactionDialog = false @State private var showAppStoreReviewAlert = false init(referenceDate: Date = Date(), duplicatePrevious: Bool = false) { _referenceDate = State(initialValue: referenceDate) self.duplicatePrevious = duplicatePrevious _monthlyNote = State(initialValue: MonthlyCheckInStore.note(for: referenceDate)) _starRating = State(initialValue: MonthlyCheckInStore.rating(for: referenceDate) ?? 0) _selectedMood = State(initialValue: MonthlyCheckInStore.mood(for: referenceDate)) } private var lastCompletionDate: Date? { MonthlyCheckInStore.latestCompletionDate() } private var checkInProgress: Double { guard let nextDate = nextCheckInDate else { return 1 } // Period starts at the first day of the month that opens the interval. // e.g. monthly → Feb 1; quarterly → Jan 1 (3 months ending Mar 31) let periodStart = nextDate.adding(months: -(checkInIntervalMonths - 1)).startOfMonth let totalDays = Double(max(1, periodStart.startOfDay.daysBetween(nextDate.startOfDay))) let elapsedDays = Double(max(0, periodStart.startOfDay.daysBetween(Date().startOfDay))) return min(elapsedDays / totalDays, 1) } private var checkInIntervalMonths: Int { if accountStore.showAllAccounts || accountStore.selectedAccount == nil { return NotificationFrequency.monthly.months } let account = accountStore.selectedAccount let frequency = account?.frequency ?? .monthly if frequency == .custom { return max(1, Int(account?.customFrequencyMonths ?? 1)) } if frequency == .never { return NotificationFrequency.monthly.months } return frequency.months } private var nextCheckInDate: Date? { guard let last = lastCompletionDate else { return nil } let effective = MonthlyCheckInStore.effectiveMonth(for: last, relativeTo: last) return effective.adding(months: checkInIntervalMonths).endOfMonth } private var isOverdue: Bool { guard let next = nextCheckInDate else { return false } return Date() > next } private var daysUntilDeadline: Int? { guard let next = nextCheckInDate else { return nil } return Calendar.current.dateComponents([.day], from: Date().startOfDay, to: next.startOfDay).day } private var progressBarTint: Color { if isOverdue { return .red } if let days = daysUntilDeadline, days <= 3 { return .orange } return .appSecondary } private var canGoToNextMonth: Bool { guard let nextMonth = Calendar.current.date(byAdding: .month, value: 1, to: referenceDate) else { return false } return nextMonth <= Date() } private func navigateMonth(offset: Int) { guard let newDate = Calendar.current.date(byAdding: .month, value: offset, to: referenceDate) else { return } referenceDate = newDate monthlyNote = MonthlyCheckInStore.note(for: newDate) starRating = MonthlyCheckInStore.rating(for: newDate) ?? 0 selectedMood = MonthlyCheckInStore.mood(for: newDate) viewModel.selectedRange = DateRange.month(containing: newDate) viewModel.refresh() } private var canAddNewCheckIn: Bool { true } var body: some View { ScrollView { VStack(spacing: 20) { headerCard summaryCard monthlyHighlightsCard reflectionCard sourcesCard notesCard journalCard } .padding() } .navigationTitle("Monthly Check-in") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { shareMonthlyCheckIn() } label: { Image(systemName: "square.and.arrow.up") } } } .onAppear { viewModel.selectedAccount = accountStore.selectedAccount viewModel.showAllAccounts = accountStore.showAllAccounts viewModel.selectedRange = DateRange.month(containing: referenceDate) if duplicatePrevious, !didApplyDuplicate { viewModel.duplicatePreviousMonthSnapshots(referenceDate: referenceDate) didApplyDuplicate = true } viewModel.refresh() monthlyNote = MonthlyCheckInStore.note(for: referenceDate) starRating = MonthlyCheckInStore.rating(for: referenceDate) ?? 0 selectedMood = MonthlyCheckInStore.mood(for: referenceDate) } .onReceive(accountStore.$selectedAccount) { account in viewModel.selectedAccount = account viewModel.selectedRange = DateRange.month(containing: referenceDate) viewModel.refresh() } .onReceive(accountStore.$showAllAccounts) { showAll in viewModel.showAllAccounts = showAll viewModel.selectedRange = DateRange.month(containing: referenceDate) viewModel.refresh() } .sheet(item: $editingSnapshot) { snapshot in if let source = snapshot.source { AddSnapshotView(source: source, snapshot: snapshot) } } .sheet(item: $addingSource) { source in AddSnapshotView(source: source) } .sheet(isPresented: $showBatchUpdate) { viewModel.refresh() } content: { BatchUpdateView(sources: viewModel.sources) } .onChange(of: starRating) { _, newValue in MonthlyCheckInStore.setRating(newValue == 0 ? nil : newValue, for: referenceDate) } .onChange(of: selectedMood) { _, newValue in MonthlyCheckInStore.setMood(newValue, for: referenceDate) } .confirmationDialog( "How much are you enjoying Portfolio Journal?", isPresented: $showAchievementSatisfactionDialog, titleVisibility: .visible ) { ForEach(1...5, id: \.self) { value in Button("\(value) Star\(value > 1 ? "s" : "")") { if value == 5 { showAppStoreReviewAlert = true } } } Button("Not Now", role: .cancel) {} } message: { Text("Congrats on your new achievement! Your feedback helps us improve.") } .alert("Would you like to leave an App Store review?", isPresented: $showAppStoreReviewAlert) { Button("Not Now", role: .cancel) {} Button("Write a Review") { ReviewPromptService.shared.markStoreReviewCompleted() openURL(ReviewPromptService.appStoreWriteReviewURL()) } } message: { Text("Thanks for the 5 stars. It really helps other investors discover the app.") } } private var headerCard: some View { VStack(alignment: .leading, spacing: 8) { HStack { Button { navigateMonth(offset: -1) } label: { Image(systemName: "chevron.left") .font(.headline) .foregroundColor(.appPrimary) } Spacer() Text(monthLabel) .font(.title3.weight(.bold)) Spacer() Button { navigateMonth(offset: 1) } label: { Image(systemName: "chevron.right") .font(.headline) .foregroundColor(canGoToNextMonth ? .appPrimary : .secondary.opacity(0.3)) } .disabled(!canGoToNextMonth) } if let date = lastCompletionDate { Text( String( format: NSLocalizedString("last_check_in", comment: ""), date.friendlyDescription ) ) .font(.subheadline) .foregroundColor(.secondary) } else { Text("No check-in yet this month") .font(.subheadline) .foregroundColor(.secondary) } VStack(alignment: .leading, spacing: 6) { ProgressView(value: checkInProgress) .tint(progressBarTint) if let nextDate = nextCheckInDate { Text( String( format: NSLocalizedString("next_check_in", comment: ""), nextDate.mediumDateString ) ) .font(.caption) .foregroundColor(.secondary) } else { Text("Start your first check-in anytime.") .font(.caption) .foregroundColor(.secondary) } } if let completed = MonthlyCheckInStore.completionDate(for: referenceDate) { HStack(spacing: 6) { Image(systemName: "checkmark.circle.fill") .foregroundColor(.positiveGreen) Text("Completed \(completed.friendlyDescription)") .font(.caption) .foregroundColor(.secondary) } } Button { let previousUnlockedAchievementKeys = unlockedAchievementKeys() let now = Date() let completionDate = referenceDate.isSameMonth(as: now) ? now : min(referenceDate.endOfMonth, now) MonthlyCheckInStore.setCompletionDate(completionDate, for: referenceDate) ReviewPromptService.shared.recordMonthlyCheckInCompleted() viewModel.refresh() let newlyUnlockedAchievementKeys = unlockedAchievementKeys().subtracting(previousUnlockedAchievementKeys) if ReviewPromptService.shared.shouldAskForAchievementSatisfaction( newlyUnlockedAchievementKeys: newlyUnlockedAchievementKeys ) { showAchievementSatisfactionDialog = true } } label: { let isCompleted = MonthlyCheckInStore.completionDate(for: referenceDate) != nil Text(isCompleted ? "Update Check-in" : "Mark Check-in Complete") .font(.subheadline.weight(.semibold)) .frame(maxWidth: .infinity) .padding(.vertical, 10) .background(isCompleted ? Color.appSecondary.opacity(0.1) : Color.appPrimary.opacity(0.1)) .cornerRadius(AppConstants.UI.cornerRadius) } } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } private var monthLabel: String { Self.monthLabel(for: referenceDate, relativeTo: Date(), locale: .current) } private func unlockedAchievementKeys() -> Set { Set( MonthlyCheckInStore .achievementStatuses(referenceDate: referenceDate) .filter(\.isUnlocked) .map(\.id) ) } static func monthLabel(for date: Date, relativeTo referenceDate: Date, locale: Locale) -> String { let formatter = DateFormatter() formatter.dateFormat = "LLLL yyyy" formatter.locale = locale let effectiveMonth = MonthlyCheckInStore.effectiveMonth(for: date, relativeTo: referenceDate) return formatter.string(from: effectiveMonth) } private var reflectionCard: some View { VStack(alignment: .leading, spacing: 12) { HStack { Text("Monthly Pulse") .font(.headline) Spacer() Text("Optional") .font(.caption) .foregroundColor(.secondary) } VStack(alignment: .leading, spacing: 8) { Text("Rate this month") .font(.subheadline.weight(.semibold)) HStack(spacing: 8) { ForEach(1...5, id: \.self) { value in Button { withAnimation(.easeInOut(duration: AppConstants.Animation.shortDuration)) { starRating = value == starRating ? 0 : value } } label: { Image(systemName: value <= starRating ? "star.fill" : "star") .font(.title3) .foregroundColor(value <= starRating ? .appSecondary : .secondary) .padding(8) .background( Circle() .fill(value <= starRating ? Color.appSecondary.opacity(0.12) : Color.gray.opacity(0.08)) ) } .buttonStyle(.plain) } Button { withAnimation(.easeInOut(duration: AppConstants.Animation.shortDuration)) { starRating = 0 } } label: { Text("Skip") .font(.caption.weight(.semibold)) .padding(.horizontal, 10) .padding(.vertical, 6) .background(Color.gray.opacity(0.12)) .cornerRadius(AppConstants.UI.smallCornerRadius) } } } VStack(alignment: .leading, spacing: 8) { Text("How did it feel?") .font(.subheadline.weight(.semibold)) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(MonthlyCheckInMood.allCases) { mood in Button { withAnimation(.easeInOut(duration: AppConstants.Animation.shortDuration)) { selectedMood = selectedMood == mood ? nil : mood } } label: { moodPill(for: mood) } .buttonStyle(.plain) } } } } } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } private var summaryCard: some View { VStack(alignment: .leading, spacing: 12) { Text("Monthly Summary") .font(.headline) HStack { VStack(alignment: .leading, spacing: 4) { Text("Starting") .font(.caption) .foregroundColor(.secondary) Text(viewModel.monthlySummary.formattedStartingValue) .font(.subheadline.weight(.semibold)) } Spacer() VStack(alignment: .trailing, spacing: 4) { Text("Ending") .font(.caption) .foregroundColor(.secondary) Text(viewModel.monthlySummary.formattedEndingValue) .font(.subheadline.weight(.semibold)) } } HStack { VStack(alignment: .leading, spacing: 4) { Text("Contributions") .font(.caption) .foregroundColor(.secondary) Text(viewModel.monthlySummary.formattedContributions) .font(.subheadline.weight(.semibold)) } Spacer() VStack(alignment: .trailing, spacing: 4) { Text("Net Performance") .font(.caption) .foregroundColor(.secondary) Text("\(viewModel.monthlySummary.formattedNetPerformance) (\(viewModel.monthlySummary.formattedNetPerformancePercentage))") .font(.subheadline.weight(.semibold)) .foregroundColor(viewModel.monthlySummary.netPerformance >= 0 ? .positiveGreen : .negativeRed) } } } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } private var sourcePerformances: [(name: String, diff: Decimal, percentage: Double)] { viewModel.sources.compactMap { source -> (name: String, diff: Decimal, percentage: Double)? in let snapshots = source.sortedSnapshotsByDateAscending guard snapshots.count >= 2 else { return nil } let latest = snapshots[snapshots.count - 1] let previous = snapshots[snapshots.count - 2] let diff = latest.decimalValue - previous.decimalValue let pct = previous.decimalValue > 0 ? NSDecimalNumber(decimal: diff / previous.decimalValue * 100).doubleValue : 0 return (name: source.name, diff: diff, percentage: pct) } } @ViewBuilder private var monthlyHighlightsCard: some View { let perfs = sourcePerformances if perfs.count >= 2 { let best = perfs.max(by: { $0.percentage < $1.percentage }) let worst = perfs.min(by: { $0.percentage < $1.percentage }) let bestContributor = perfs.max(by: { abs($0.diff) < abs($1.diff) }) VStack(alignment: .leading, spacing: 12) { Text("Monthly Highlights") .font(.headline) if let best { highlightRow( icon: "arrow.up.circle.fill", iconColor: .positiveGreen, label: "Best Performer", name: best.name, percentage: best.percentage, diff: best.diff, valueColor: .positiveGreen ) } if let worst, worst.name != best?.name { highlightRow( icon: "arrow.down.circle.fill", iconColor: .negativeRed, label: "Worst Performer", name: worst.name, percentage: worst.percentage, diff: worst.diff, valueColor: .negativeRed ) } if let contributor = bestContributor, contributor.name != best?.name { highlightRow( icon: "star.circle.fill", iconColor: .appAccent, label: "Best Contributor", name: contributor.name, percentage: contributor.percentage, diff: contributor.diff, valueColor: .financialColor(for: contributor.diff) ) } } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } } private func highlightRow(icon: String, iconColor: Color, label: String, name: String, percentage: Double, diff: Decimal, valueColor: Color) -> some View { HStack { Image(systemName: icon) .foregroundColor(iconColor) VStack(alignment: .leading, spacing: 2) { Text(label) .font(.caption) .foregroundColor(.secondary) Text(name) .font(.subheadline.weight(.semibold)) } Spacer() VStack(alignment: .trailing, spacing: 2) { Text(String(format: "%+.1f%%", percentage)) .font(.subheadline.weight(.bold)) .foregroundColor(valueColor) Text("(\(diff.compactCurrencyString))") .font(.caption) .foregroundColor(.secondary) } } } private var sourcesCard: some View { VStack(alignment: .leading, spacing: 12) { HStack { Text("Update Sources") .font(.headline) Spacer() if viewModel.sources.count > 1 { Button { showBatchUpdate = true } label: { Label("Batch Update", systemImage: "square.and.pencil") .font(.caption.weight(.semibold)) .foregroundColor(.appPrimary) } } Text("\(viewModel.sources.count)") .font(.subheadline) .foregroundColor(.secondary) } if viewModel.sources.isEmpty { Text("Add sources to start your monthly check-in.") .font(.subheadline) .foregroundColor(.secondary) } else { ForEach(viewModel.sources, id: \.objectID) { source in let latestSnapshot = source.latestSnapshot let updatedThisCycle = isSnapshotInCurrentCycle(latestSnapshot) let snapshots = source.sortedSnapshotsByDateAscending let previousSnapshot: Snapshot? = { guard snapshots.count >= 2 else { return nil } return snapshots[snapshots.count - 2] }() let valueDiff: Decimal? = { guard let latest = latestSnapshot, let previous = previousSnapshot else { return nil } return latest.decimalValue - previous.decimalValue }() Button { if updatedThisCycle, let snapshot = latestSnapshot { editingSnapshot = snapshot } else { addingSource = source } } label: { HStack { Circle() .fill(source.category?.color ?? .gray) .frame(width: 8, height: 8) VStack(alignment: .leading, spacing: 2) { Text(source.name) .font(.subheadline.weight(.medium)) Text(updatedThisCycle ? "Updated this cycle" : "Needs update") .font(.caption2) .foregroundColor(updatedThisCycle ? .positiveGreen : .secondary) } Spacer() if let diff = valueDiff, updatedThisCycle { Text(diff >= 0 ? "+\(diff.compactCurrencyString)" : diff.compactCurrencyString) .font(.caption.weight(.semibold)) .foregroundColor(diff >= 0 ? .positiveGreen : .negativeRed) } Text(latestSnapshot?.date.relativeDayDescription ?? String(localized: "date_never")) .font(.caption) .foregroundColor(.secondary) Image(systemName: "chevron.right") .font(.caption) .foregroundColor(.secondary) } } .buttonStyle(.plain) } } } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } private var notesCard: some View { VStack(alignment: .leading, spacing: 12) { Text("Monthly Note") .font(.headline) TextEditor(text: $monthlyNote) .frame(minHeight: 120) .padding(8) .background(Color.gray.opacity(0.08)) .cornerRadius(12) .focused($noteFocused) .onChange(of: monthlyNote) { _, newValue in MonthlyCheckInStore.setNote(newValue, for: referenceDate) } HStack(spacing: 12) { NavigationLink { MonthlyNoteEditorView(date: referenceDate, note: $monthlyNote) } label: { Text("Open Full Note") .font(.subheadline.weight(.semibold)) .frame(maxWidth: .infinity) .padding(.vertical, 8) .background(Color.appSecondary.opacity(0.12)) .cornerRadius(AppConstants.UI.cornerRadius) } .buttonStyle(.plain) Button { viewModel.duplicatePreviousMonthSnapshots(referenceDate: referenceDate) viewModel.refresh() } label: { Text("Duplicate Previous") .font(.subheadline.weight(.semibold)) .frame(maxWidth: .infinity) .padding(.vertical, 8) .background(Color.appPrimary.opacity(0.12)) .cornerRadius(AppConstants.UI.cornerRadius) } } } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } private var journalCard: some View { VStack(alignment: .leading, spacing: 12) { Text("Snapshot Notes") .font(.headline) if viewModel.recentNotes.isEmpty { Text("No snapshot notes for this month.") .font(.subheadline) .foregroundColor(.secondary) } else { ForEach(viewModel.recentNotes, id: \.objectID) { snapshot in VStack(alignment: .leading, spacing: 4) { Text(snapshot.source?.name ?? "Source") .font(.subheadline.weight(.semibold)) Text(snapshot.notes ?? "") .font(.subheadline) .foregroundColor(.secondary) Text(snapshot.date.friendlyDescription) .font(.caption) .foregroundColor(.secondary) } if snapshot.id != viewModel.recentNotes.last?.id { Divider() } } } } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } private func shareMonthlyCheckIn() { let summary = viewModel.monthlySummary ShareService.shared.shareMonthlyCheckIn(summary: summary, appName: appDisplayName) } private var appDisplayName: String { if let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String { return name } if let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String { return name } return "Portfolio Journal" } } struct AchievementsView: View { let referenceDate: Date private var achievementStatuses: [MonthlyCheckInAchievementStatus] { MonthlyCheckInStore.achievementStatuses(referenceDate: referenceDate) } private var unlockedAchievements: [MonthlyCheckInAchievementStatus] { achievementStatuses.filter { $0.isUnlocked } } private var lockedAchievements: [MonthlyCheckInAchievementStatus] { achievementStatuses.filter { !$0.isUnlocked } } var body: some View { ScrollView { VStack(spacing: 16) { headerCard achievementSection( title: String(localized: "achievements_unlocked_title"), subtitle: unlockedAchievements.isEmpty ? String(localized: "achievements_unlocked_empty") : nil, achievements: unlockedAchievements, isLocked: false ) achievementSection( title: String(localized: "achievements_locked_title"), subtitle: lockedAchievements.isEmpty ? String(localized: "achievements_locked_empty") : nil, achievements: lockedAchievements, isLocked: true ) } .padding() } .navigationTitle(String(localized: "achievements_nav_title")) .navigationBarTitleDisplayMode(.inline) } private var headerCard: some View { let total = max(achievementStatuses.count, 1) let unlockedCount = unlockedAchievements.count return VStack(alignment: .leading, spacing: 8) { Text(String(localized: "achievements_progress_title")) .font(.headline) AchievementMilestoneBar(statuses: achievementStatuses) Text( String( format: NSLocalizedString("achievements_unlocked_count", comment: ""), unlockedCount, total ) ) .font(.subheadline) .foregroundColor(.secondary) } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } private func achievementSection( title: String, subtitle: String?, achievements: [MonthlyCheckInAchievementStatus], isLocked: Bool ) -> some View { VStack(alignment: .leading, spacing: 12) { Text(title) .font(.headline) if let subtitle { Text(subtitle) .font(.subheadline) .foregroundColor(.secondary) } if achievements.isEmpty { EmptyView() } else { ForEach(achievements) { status in achievementRow(status, isLocked: isLocked) } } } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } private func achievementRow(_ status: MonthlyCheckInAchievementStatus, isLocked: Bool) -> some View { HStack(alignment: .center, spacing: 12) { ZStack { Circle() .fill(isLocked ? Color.gray.opacity(0.2) : Color.appSecondary.opacity(0.18)) .frame(width: 42, height: 42) Image(systemName: status.achievement.icon) .font(.headline) .foregroundColor(isLocked ? .secondary : .appSecondary) } VStack(alignment: .leading, spacing: 2) { Text(status.achievement.title) .font(.subheadline.weight(.semibold)) Text(status.achievement.detail) .font(.caption) .foregroundColor(.secondary) } Spacer() if isLocked { Image(systemName: "lock.fill") .font(.caption) .foregroundColor(.secondary) } } .padding(10) .background(isLocked ? Color.gray.opacity(0.08) : Color.appSecondary.opacity(0.12)) .cornerRadius(AppConstants.UI.smallCornerRadius) } } private extension MonthlyCheckInView { func isSnapshotInCurrentCycle(_ snapshot: Snapshot?) -> Bool { guard let snapshot, let last = lastCompletionDate else { return false } return snapshot.date >= Calendar.current.startOfDay(for: last) } @ViewBuilder func moodPill(for mood: MonthlyCheckInMood) -> some View { let isSelected = selectedMood == mood HStack(alignment: .center, spacing: 8) { Image(systemName: mood.iconName) .font(.body) .foregroundColor(isSelected ? moodColor(for: mood) : .secondary) VStack(alignment: .leading, spacing: 2) { Text(mood.title) .font(.subheadline.weight(.semibold)) Text(mood.detail) .font(.caption2) .foregroundColor(.secondary) } } .padding(10) .background(isSelected ? moodColor(for: mood).opacity(0.16) : Color.gray.opacity(0.08)) .overlay( RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius) .stroke(isSelected ? moodColor(for: mood) : Color.clear, lineWidth: 1) ) .cornerRadius(AppConstants.UI.smallCornerRadius) } func moodColor(for mood: MonthlyCheckInMood) -> Color { switch mood { case .energized: return .appSecondary case .confident: return .appPrimary case .balanced: return .teal case .cautious: return .orange case .stressed: return .red } } } // MARK: - Achievement Milestone Bar struct AchievementMilestoneBar: View { let statuses: [MonthlyCheckInAchievementStatus] var body: some View { let sorted = statuses.sorted { $0.isUnlocked && !$1.isUnlocked } let unlockedCount = sorted.filter(\.isUnlocked).count let total = max(sorted.count, 1) let progress = Double(unlockedCount) / Double(total) GeometryReader { geo in let barWidth = geo.size.width let circleSize: CGFloat = 18 let barY = geo.size.height / 2 // Background track Capsule() .fill(Color.gray.opacity(0.2)) .frame(width: barWidth, height: 6) .position(x: barWidth / 2, y: barY) // Filled track Capsule() .fill(Color.positiveGreen) .frame(width: barWidth * progress, height: 6) .position(x: barWidth * progress / 2, y: barY) // Milestone circles on the bar ForEach(Array(sorted.enumerated()), id: \.element.id) { index, status in let x = total == 1 ? barWidth / 2 : circleSize / 2 + (barWidth - circleSize) * Double(index) / Double(total - 1) ZStack { Circle() .fill(status.isUnlocked ? Color.positiveGreen : Color.gray.opacity(0.3)) .frame(width: circleSize, height: circleSize) Circle() .stroke(Color(.systemBackground), lineWidth: 2) .frame(width: circleSize, height: circleSize) if status.isUnlocked { Image(systemName: "checkmark") .font(.system(size: 8, weight: .bold)) .foregroundColor(.white) } } .position(x: x, y: barY) } } .frame(height: 24) } } // MARK: - Batch Update View struct BatchUpdateView: View { @Environment(\.dismiss) private var dismiss let sources: [InvestmentSource] @State private var values: [UUID: String] = [:] @State private var contributions: [UUID: String] = [:] @State private var savedCount = 0 var body: some View { NavigationStack { List { ForEach(sources, id: \.objectID) { source in sourceRow(source) } if filledCount > 0 { Section { Button { saveAll() } label: { Text("Save \(filledCount) Snapshot\(filledCount == 1 ? "" : "s")") .font(.headline) .frame(maxWidth: .infinity) } } } } .navigationTitle("Batch Update") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .navigationBarTrailing) { Button("Save") { saveAll() } .disabled(filledCount == 0) .fontWeight(.semibold) } } .onAppear { prefillCurrentValues() } } } private func sourceRow(_ source: InvestmentSource) -> some View { let currencyCode = source.account?.currency ?? AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency let symbol = CurrencyFormatter.symbol(for: currencyCode) let previousValue = source.latestSnapshot?.decimalValue let isDetailed = InputMode(rawValue: source.account?.inputMode ?? "") == .detailed let valueBinding = Binding( get: { values[source.id] ?? "" }, set: { values[source.id] = $0 } ) let contributionBinding = Binding( get: { contributions[source.id] ?? "" }, set: { contributions[source.id] = $0 } ) return VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { Circle() .fill(source.category?.color ?? .gray) .frame(width: 8, height: 8) Text(source.name) .font(.subheadline.weight(.medium)) Spacer() if let prev = previousValue { Text(prev.currencyString) .font(.caption) .foregroundColor(.secondary) } } HStack { Text(symbol) .foregroundColor(.secondary) TextField("Current value", text: valueBinding) .keyboardType(.decimalPad) } .padding(8) .background(Color.gray.opacity(0.08)) .cornerRadius(8) if isDetailed { HStack { Image(systemName: "plus.circle") .font(.caption) .foregroundColor(.secondary) TextField("Contribution this period (optional)", text: contributionBinding) .keyboardType(.decimalPad) .font(.subheadline) .foregroundColor(.secondary) } .padding(8) .background(Color.appSecondary.opacity(0.06)) .cornerRadius(8) } } .padding(.vertical, 2) } private var filledCount: Int { values.values.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }.count } private func prefillCurrentValues() { for source in sources { guard values[source.id] == nil, let latest = source.latestSnapshot else { continue } let currencyCode = source.account?.currency ?? AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency values[source.id] = CurrencyFormatter.formatForInput(latest.decimalValue, currencyCode: currencyCode) } } private func saveAll() { let repository = SnapshotRepository() var count = 0 for source in sources { guard let input = values[source.id], !input.trimmingCharacters(in: .whitespaces).isEmpty else { continue } let currencyCode = source.account?.currency ?? AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency let symbol = CurrencyFormatter.symbol(for: currencyCode) guard let parsed = CurrencyFormatter.parseUserInput(input, currencySymbol: symbol), parsed >= 0 else { continue } let contributionInput = contributions[source.id] ?? "" let parsedContribution: Decimal? = contributionInput.trimmingCharacters(in: .whitespaces).isEmpty ? nil : CurrencyFormatter.parseUserInput(contributionInput, currencySymbol: symbol) repository.createSnapshot( for: source, date: Date(), value: parsed, contribution: parsedContribution ) count += 1 } savedCount = count dismiss() } } #Preview { NavigationStack { MonthlyCheckInView() .environmentObject(AccountStore(iapService: IAPService())) } }