Files
InvestmentTrackerApp/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift
T
alexandrev-tibco e3ec0ddb25 Fix progress bar period to start at first day of interval month
Period now starts at startOfMonth of the first month in the interval,
not 30 days before the deadline. For monthly: Feb 1 → Feb 28.
For quarterly: Jan 1 → Mar 31. Formula: startOfMonth(nextDate - (interval-1) months).

Feb 20 with next check-in Feb 28: 19/27 days ≈ 70% instead of 0%.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 22:45:59 +01:00

1101 lines
42 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 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<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 })
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<String>(
get: { values[source.id] ?? "" },
set: { values[source.id] = $0 }
)
let contributionBinding = Binding<String>(
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()))
}
}