Files
InvestmentTrackerApp/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift
T
alexandrev-tibco 5dc9eb109f Add monthly highlights: best and worst performing sources
Shows a highlights card with the best and worst performing sources by
percentage change, displayed between the summary and reflection cards.

Fixes #22

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:49:00 +01:00

842 lines
33 KiB
Swift

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 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 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 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)
}
.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)
}
}
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: {
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)
}
.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<String> {
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 })
VStack(alignment: .leading, spacing: 12) {
Text("Monthly Highlights")
.font(.headline)
if let best {
HStack {
Image(systemName: "arrow.up.circle.fill")
.foregroundColor(.positiveGreen)
VStack(alignment: .leading, spacing: 2) {
Text("Best Performer")
.font(.caption)
.foregroundColor(.secondary)
Text("\(best.name)")
.font(.subheadline.weight(.semibold))
}
Spacer()
Text(String(format: "%+.1f%%", best.percentage))
.font(.subheadline.weight(.bold))
.foregroundColor(.positiveGreen)
}
}
if let worst, worst.name != best?.name {
HStack {
Image(systemName: "arrow.down.circle.fill")
.foregroundColor(.negativeRed)
VStack(alignment: .leading, spacing: 2) {
Text("Worst Performer")
.font(.caption)
.foregroundColor(.secondary)
Text("\(worst.name)")
.font(.subheadline.weight(.semibold))
}
Spacer()
Text(String(format: "%+.1f%%", worst.percentage))
.font(.subheadline.weight(.bold))
.foregroundColor(.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, 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.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, 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
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()))
}
}