151eb0e662
Replaces the simple progress bar with a custom milestone bar showing individual circles for each achievement, filled when unlocked with a checkmark indicator. Fixes #27 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
897 lines
35 KiB
Swift
897 lines
35 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
|
|
|
|
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 total = max(statuses.count, 1)
|
|
let unlockedCount = statuses.filter(\.isUnlocked).count
|
|
let progress = Double(unlockedCount) / Double(total)
|
|
|
|
GeometryReader { geo in
|
|
let barWidth = geo.size.width
|
|
let barHeight: CGFloat = 6
|
|
|
|
ZStack(alignment: .leading) {
|
|
// Background track
|
|
Capsule()
|
|
.fill(Color.gray.opacity(0.2))
|
|
.frame(height: barHeight)
|
|
|
|
// Filled track
|
|
Capsule()
|
|
.fill(Color.appSecondary)
|
|
.frame(width: barWidth * progress, height: barHeight)
|
|
|
|
// Milestone circles
|
|
ForEach(Array(statuses.enumerated()), id: \.element.id) { index, status in
|
|
let position = total == 1
|
|
? barWidth / 2
|
|
: barWidth * Double(index) / Double(total - 1)
|
|
|
|
Circle()
|
|
.fill(status.isUnlocked ? Color.appSecondary : Color.gray.opacity(0.3))
|
|
.frame(width: 14, height: 14)
|
|
.overlay(
|
|
Circle()
|
|
.stroke(Color(.systemBackground), lineWidth: 2)
|
|
)
|
|
.overlay(
|
|
Group {
|
|
if status.isUnlocked {
|
|
Image(systemName: "checkmark")
|
|
.font(.system(size: 7, weight: .bold))
|
|
.foregroundColor(.white)
|
|
}
|
|
}
|
|
)
|
|
.position(x: position, y: geo.size.height / 2)
|
|
}
|
|
}
|
|
}
|
|
.frame(height: 20)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
MonthlyCheckInView()
|
|
.environmentObject(AccountStore(iapService: IAPService()))
|
|
}
|
|
}
|