86c95a9e73
De 7 comentarios de TestFlight: - Labels de línea ilegibles (puntos 5+7): ChartLabels.showsLabel thin-out (máx 5 iPhone / 8 iPad, siempre primero y último). Charts multi-serie (Compare, Period, Year vs Year) etiquetan solo el ENDPOINT de cada serie. Prediction etiqueta solo el forecast final. Aplicado a Evolution, Rolling, Drawdown, Volatility, Compare, Period, YoY, Prediction. - 'Needs update' en meses pasados (punto 2): MonthlyCheckInView.snapshotForViewedMonth — el estado es por el MES QUE SE VE (effective month de referenceDate), no contra la última completion global. Diff usa el snapshot de ese mes. - Selector de periodo Performance (punto 6): slider 1..60 → picker segmentado consistente con el resto, opciones capadas a los meses de datos disponibles (availableHistoryMonths). Ya no deja pedir ventanas sin datos. - Home iPad/Mac (feedback previo): masonry con nº de columnas según ancho (2 iPad / 3 Mac ancho >=1250), reparto por peso, propio ScrollView. Pendiente de este lote: Goals no sincronizan (verificado modelo Goal CloudKit-OK → es el subset del partial-failure, no filtro ni esquema; necesita códigos internos del error), paste/OCR por campo (punto 3) y swipe entre charts (punto 4). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
1120 lines
44 KiB
Swift
1120 lines
44 KiB
Swift
import SwiftUI
|
|
import CoreData
|
|
|
|
struct MonthlyCheckInView: View {
|
|
@Environment(\.openURL) private var openURL
|
|
@EnvironmentObject var accountStore: AccountStore
|
|
@StateObject private var viewModel = MonthlyCheckInViewModel()
|
|
@State private var referenceDate: Date
|
|
|
|
@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 showBatchUpdate = false
|
|
@State private var showAchievementSatisfactionDialog = false
|
|
@State private var showAppStoreReviewAlert = false
|
|
|
|
init(referenceDate: Date = Date()) {
|
|
_referenceDate = State(initialValue: referenceDate)
|
|
_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()
|
|
}
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .principal) {
|
|
HStack(spacing: 12) {
|
|
Button {
|
|
navigateMonth(offset: -1)
|
|
} label: {
|
|
Image(systemName: "chevron.left")
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundColor(.appPrimary)
|
|
}
|
|
|
|
Text(monthLabel)
|
|
.font(.headline)
|
|
|
|
Button {
|
|
navigateMonth(offset: 1)
|
|
} label: {
|
|
Image(systemName: "chevron.right")
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundColor(canGoToNextMonth ? .appPrimary : .secondary.opacity(0.3))
|
|
}
|
|
.disabled(!canGoToNextMonth)
|
|
}
|
|
}
|
|
|
|
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)
|
|
viewModel.refresh()
|
|
monthlyNote = MonthlyCheckInStore.note(for: referenceDate)
|
|
starRating = MonthlyCheckInStore.rating(for: referenceDate) ?? 0
|
|
selectedMood = MonthlyCheckInStore.mood(for: referenceDate)
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)) { _ in
|
|
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: {
|
|
let batchSaveDate = referenceDate.isSameMonth(as: Date()) ? Date() : referenceDate.endOfMonth
|
|
BatchUpdateView(sources: viewModel.sources, saveDate: batchSaveDate)
|
|
}
|
|
.onChange(of: starRating) { _, newValue in
|
|
MonthlyCheckInStore.setRating(newValue == 0 ? nil : newValue, for: referenceDate)
|
|
}
|
|
.onChange(of: selectedMood) { _, newValue in
|
|
MonthlyCheckInStore.setMood(newValue, for: referenceDate)
|
|
}
|
|
.confirmationDialog(
|
|
"checkin_enjoying_dialog_title",
|
|
isPresented: $showAchievementSatisfactionDialog,
|
|
titleVisibility: .visible
|
|
) {
|
|
ForEach(1...5, id: \.self) { value in
|
|
Button(value == 1
|
|
? String(localized: "rating_1_star")
|
|
: String(format: NSLocalizedString("rating_n_stars", comment: ""), value)
|
|
) {
|
|
if value == 5 {
|
|
showAppStoreReviewAlert = true
|
|
}
|
|
}
|
|
}
|
|
Button(String(localized: "not_now"), role: .cancel) {}
|
|
} message: {
|
|
Text("checkin_enjoying_dialog_message")
|
|
}
|
|
.alert("app_store_review_title", isPresented: $showAppStoreReviewAlert) {
|
|
Button(String(localized: "not_now"), role: .cancel) {}
|
|
Button(String(localized: "write_review")) {
|
|
ReviewPromptService.shared.markStoreReviewCompleted()
|
|
openURL(ReviewPromptService.appStoreWriteReviewURL())
|
|
}
|
|
} message: {
|
|
Text("app_store_review_message")
|
|
}
|
|
}
|
|
|
|
private var headerCard: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
if referenceDate.isSameMonth(as: Date()) {
|
|
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()
|
|
NotificationService.shared.scheduleMonthlyCheckIn()
|
|
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
|
|
// Month-aware: "updated" means updated FOR THE VIEWED month.
|
|
let monthSnapshot = snapshotForViewedMonth(source)
|
|
let updatedThisCycle = monthSnapshot != nil
|
|
let latestSnapshot = monthSnapshot ?? source.latestSnapshot
|
|
let snapshots = source.sortedSnapshotsByDateAscending
|
|
let previousSnapshot: Snapshot? = {
|
|
guard let ref = latestSnapshot,
|
|
let idx = snapshots.firstIndex(where: { $0.objectID == ref.objectID }),
|
|
idx > 0 else { return nil }
|
|
return snapshots[idx - 1]
|
|
}()
|
|
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)
|
|
}
|
|
|
|
/// The effective (year, month) of the check-in month currently being viewed.
|
|
private var viewedMonthComponents: DateComponents {
|
|
let effective = MonthlyCheckInStore.effectiveMonth(for: referenceDate, relativeTo: referenceDate)
|
|
return Calendar.current.dateComponents([.year, .month], from: effective)
|
|
}
|
|
|
|
/// The source's snapshot that belongs to the VIEWED month (not the global
|
|
/// latest) — so a past month shows "Updated" for the sources it recorded,
|
|
/// instead of "Needs update" for everything.
|
|
func snapshotForViewedMonth(_ source: InvestmentSource) -> Snapshot? {
|
|
let target = viewedMonthComponents
|
|
return source.sortedSnapshotsByDateAscending.last { snapshot in
|
|
let eff = MonthlyCheckInStore.effectiveMonth(for: snapshot.date, relativeTo: snapshot.date)
|
|
let c = Calendar.current.dateComponents([.year, .month], from: eff)
|
|
return c.year == target.year && c.month == target.month
|
|
}
|
|
}
|
|
|
|
@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]
|
|
let saveDate: Date
|
|
@State private var values: [UUID: String] = [:]
|
|
@State private var contributions: [UUID: String] = [:]
|
|
@State private var savedCount = 0
|
|
|
|
init(sources: [InvestmentSource], saveDate: Date = Date()) {
|
|
self.sources = sources
|
|
self.saveDate = saveDate
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
ForEach(sources, id: \.objectID) { source in
|
|
sourceRow(source)
|
|
}
|
|
|
|
if filledCount > 0 {
|
|
Section {
|
|
Button {
|
|
saveAll()
|
|
} label: {
|
|
Text(filledCount == 1
|
|
? String(localized: "save_1_snapshot")
|
|
: String(format: NSLocalizedString("save_n_snapshots", comment: ""), filledCount))
|
|
.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: saveDate,
|
|
value: parsed,
|
|
contribution: parsedContribution
|
|
)
|
|
NotificationService.shared.scheduleReminder(for: source)
|
|
count += 1
|
|
}
|
|
|
|
savedCount = count
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
MonthlyCheckInView()
|
|
.environmentObject(AccountStore(iapService: IAPService()))
|
|
}
|
|
}
|