630 lines
24 KiB
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()))
|
|
}
|
|
}
|