InvestmentTrackerApp/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift

630 lines
24 KiB
Swift

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()))
}
}