import SwiftUI struct MonthlyCheckInView: View { @EnvironmentObject var accountStore: AccountStore @StateObject private var viewModel = MonthlyCheckInViewModel() let 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 init(referenceDate: Date = Date(), duplicatePrevious: Bool = false) { self.referenceDate = 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 last = lastCompletionDate, let nextDate = nextCheckInDate else { return 1 } let totalDays = Double(max(1, last.startOfDay.daysBetween(nextDate.startOfDay))) guard totalDays > 0 else { return 1 } let elapsedDays = Double(last.startOfDay.daysBetween(Date())) return min(max(elapsedDays / totalDays, 0), 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 } return last.adding(months: checkInIntervalMonths) } private var canAddNewCheckIn: Bool { lastCompletionDate == nil || checkInProgress >= 0.7 } var body: some View { ScrollView { VStack(spacing: 20) { headerCard summaryCard reflectionCard sourcesCard notesCard journalCard } .padding() } .navigationTitle("Monthly Check-in") .navigationBarTitleDisplayMode(.inline) .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) } .onChange(of: starRating) { _, newValue in MonthlyCheckInStore.setRating(newValue == 0 ? nil : newValue, for: referenceDate) } .onChange(of: selectedMood) { _, newValue in MonthlyCheckInStore.setMood(newValue, for: referenceDate) } } private var headerCard: some View { VStack(alignment: .leading, spacing: 8) { Text("This Month") .font(.headline) 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(.appSecondary) 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) } } Button { let now = Date() let completionDate = referenceDate.isSameMonth(as: now) ? now : min(referenceDate.endOfMonth, now) MonthlyCheckInStore.setCompletionDate(completionDate, for: referenceDate) viewModel.refresh() } label: { Text("Mark Check-in Complete") .font(.subheadline.weight(.semibold)) .frame(maxWidth: .infinity) .padding(.vertical, 10) .background(Color.appPrimary.opacity(0.1)) .cornerRadius(AppConstants.UI.cornerRadius) } .disabled(!canAddNewCheckIn) if !canAddNewCheckIn { Text("Editing stays open. New check-ins unlock after 70% of the month.") .font(.caption) .foregroundColor(.secondary) } } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } 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 sourcesCard: some View { VStack(alignment: .leading, spacing: 12) { HStack { Text("Update Sources") .font(.headline) Spacer() 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) { source in let latestSnapshot = source.latestSnapshot let updatedThisCycle = isSnapshotInCurrentCycle(latestSnapshot) 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() Text(latestSnapshot?.date.relativeDescription ?? 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) { 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) } } 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 let progress = Double(unlockedCount) / Double(total) return VStack(alignment: .leading, spacing: 8) { Text(String(localized: "achievements_progress_title")) .font(.headline) ProgressView(value: progress) .tint(.appSecondary) Text( String( format: NSLocalizedString("achievements_unlocked_count", comment: ""), unlockedCount, achievementStatuses.count ) ) .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 } } } #Preview { NavigationStack { MonthlyCheckInView() .environmentObject(AccountStore(iapService: IAPService())) } }