initial version
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CategoryBreakdownCard: View {
|
||||
@EnvironmentObject private var iapService: IAPService
|
||||
let categories: [CategoryMetrics]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("By Category")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
NavigationLink {
|
||||
ChartsContainerView(iapService: iapService)
|
||||
} label: {
|
||||
Text("See All")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(categories) { category in
|
||||
CategoryRowView(category: category)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
struct CategoryRowView: View {
|
||||
let category: CategoryMetrics
|
||||
private var targetPercentage: Double? {
|
||||
AllocationTargetStore.target(for: category.id)
|
||||
}
|
||||
|
||||
private var driftText: String? {
|
||||
guard let target = targetPercentage else { return nil }
|
||||
let drift = category.percentageOfPortfolio - target
|
||||
let prefix = drift >= 0 ? "+" : ""
|
||||
return "\(prefix)\(String(format: "%.1f%%", drift))"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill((Color(hex: category.colorHex) ?? .gray).opacity(0.2))
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
Image(systemName: category.icon)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(Color(hex: category.colorHex) ?? .gray)
|
||||
}
|
||||
|
||||
// Name and percentage
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(category.categoryName)
|
||||
.font(.subheadline.weight(.medium))
|
||||
|
||||
Text(category.formattedPercentage)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if let target = targetPercentage {
|
||||
Text("Target \(String(format: "%.0f%%", target)) | Drift \(driftText ?? "")")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Value and return
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(category.formattedTotalValue)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Text("CAGR")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Text(category.metrics.formattedCAGR)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(category.metrics.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Progress Bar
|
||||
|
||||
struct CategoryProgressBar: View {
|
||||
let category: CategoryMetrics
|
||||
let maxValue: Decimal
|
||||
|
||||
var progress: Double {
|
||||
guard maxValue > 0 else { return 0 }
|
||||
return NSDecimalNumber(decimal: category.totalValue / maxValue).doubleValue
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(Color(hex: category.colorHex) ?? .gray)
|
||||
.frame(width: 8, height: 8)
|
||||
|
||||
Text(category.categoryName)
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(category.formattedPercentage)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .leading) {
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.1))
|
||||
.frame(height: 6)
|
||||
.cornerRadius(3)
|
||||
|
||||
Rectangle()
|
||||
.fill(Color(hex: category.colorHex) ?? .gray)
|
||||
.frame(width: geometry.size.width * progress, height: 6)
|
||||
.cornerRadius(3)
|
||||
}
|
||||
}
|
||||
.frame(height: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Simple Category List
|
||||
|
||||
struct SimpleCategoryList: View {
|
||||
let categories: [CategoryMetrics]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(categories) { category in
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: category.colorHex) ?? .gray)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
Text(category.categoryName)
|
||||
.font(.subheadline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(category.formattedTotalValue)
|
||||
.font(.subheadline.weight(.medium))
|
||||
|
||||
Text("(\(category.formattedPercentage))")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let sampleCategories = [
|
||||
CategoryMetrics(
|
||||
id: UUID(),
|
||||
categoryName: "Stocks",
|
||||
colorHex: "#10B981",
|
||||
icon: "chart.line.uptrend.xyaxis",
|
||||
totalValue: 50000,
|
||||
percentageOfPortfolio: 50,
|
||||
metrics: .empty
|
||||
),
|
||||
CategoryMetrics(
|
||||
id: UUID(),
|
||||
categoryName: "Bonds",
|
||||
colorHex: "#3B82F6",
|
||||
icon: "building.columns.fill",
|
||||
totalValue: 30000,
|
||||
percentageOfPortfolio: 30,
|
||||
metrics: .empty
|
||||
),
|
||||
CategoryMetrics(
|
||||
id: UUID(),
|
||||
categoryName: "Real Estate",
|
||||
colorHex: "#F59E0B",
|
||||
icon: "house.fill",
|
||||
totalValue: 20000,
|
||||
percentageOfPortfolio: 20,
|
||||
metrics: .empty
|
||||
)
|
||||
]
|
||||
|
||||
return CategoryBreakdownCard(categories: sampleCategories)
|
||||
.padding()
|
||||
.environmentObject(IAPService())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,299 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct EvolutionChartCard: View {
|
||||
let data: [(date: Date, value: Decimal)]
|
||||
let categoryData: [CategoryEvolutionPoint]
|
||||
let goals: [Goal]
|
||||
|
||||
@State private var selectedDataPoint: (date: Date, value: Decimal)?
|
||||
@State private var chartMode: ChartMode = .total
|
||||
@State private var showGoalLines = true
|
||||
|
||||
enum ChartMode: String, CaseIterable, Identifiable {
|
||||
case total = "Total"
|
||||
case byCategory = "By Category"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
headerView
|
||||
modePicker
|
||||
chartSection
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var headerView: some View {
|
||||
HStack {
|
||||
Text("Portfolio Evolution")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
showGoalLines.toggle()
|
||||
} label: {
|
||||
Image(systemName: showGoalLines ? "target" : "slash.circle")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals")
|
||||
|
||||
if let selected = selectedDataPoint, chartMode == .total {
|
||||
VStack(alignment: .trailing) {
|
||||
Text(selected.value.compactCurrencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(selected.date.monthYearString)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var modePicker: some View {
|
||||
Picker("Evolution Mode", selection: $chartMode) {
|
||||
ForEach(ChartMode.allCases) { mode in
|
||||
Text(mode.rawValue).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var chartSection: some View {
|
||||
if data.count >= 2 {
|
||||
chartView
|
||||
} else {
|
||||
Text("Not enough data to display chart")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 200)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private var chartView: some View {
|
||||
Chart {
|
||||
chartMarks
|
||||
}
|
||||
.chartForegroundStyleScale(domain: chartCategoryNames, range: chartCategoryColors)
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: 3)) { value in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(Decimal(doubleValue).shortCurrencyString)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartOverlay { proxy in
|
||||
GeometryReader { geometry in
|
||||
Rectangle()
|
||||
.fill(.clear)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
guard let plotFrameAnchor = proxy.plotFrame else { return }
|
||||
let plotFrame = geometry[plotFrameAnchor]
|
||||
let x = value.location.x - plotFrame.origin.x
|
||||
guard let date: Date = proxy.value(atX: x) else { return }
|
||||
|
||||
if let closest = data.min(by: {
|
||||
abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date))
|
||||
}) {
|
||||
selectedDataPoint = closest
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
selectedDataPoint = nil
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(height: 200)
|
||||
// Performance: Use GPU rendering for smoother scrolling
|
||||
.drawingGroup()
|
||||
}
|
||||
|
||||
@ChartContentBuilder
|
||||
private var chartMarks: some ChartContent {
|
||||
switch chartMode {
|
||||
case .total:
|
||||
ForEach(data, id: \.date) { item in
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
PointMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.symbolSize(26)
|
||||
|
||||
AreaMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
}
|
||||
case .byCategory:
|
||||
ForEach(stackedCategoryData) { item in
|
||||
AreaMark(
|
||||
x: .value("Date", item.date),
|
||||
yStart: .value("Start", NSDecimalNumber(decimal: item.start).doubleValue),
|
||||
yEnd: .value("End", NSDecimalNumber(decimal: item.end).doubleValue)
|
||||
)
|
||||
.foregroundStyle(by: .value("Category", item.categoryName))
|
||||
.interpolationMethod(.catmullRom)
|
||||
}
|
||||
}
|
||||
|
||||
if showGoalLines {
|
||||
ForEach(goals) { goal in
|
||||
RuleMark(y: .value("Goal", NSDecimalNumber(decimal: goal.targetDecimal).doubleValue))
|
||||
.foregroundStyle(Color.appSecondary.opacity(0.5))
|
||||
.lineStyle(StrokeStyle(lineWidth: 1, dash: [6, 4]))
|
||||
.annotation(position: .topTrailing) {
|
||||
Text(goal.name)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let selected = selectedDataPoint, chartMode == .total {
|
||||
RuleMark(x: .value("Selected", selected.date))
|
||||
.foregroundStyle(Color.gray.opacity(0.3))
|
||||
.lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
|
||||
|
||||
PointMark(
|
||||
x: .value("Date", selected.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: selected.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.symbolSize(100)
|
||||
}
|
||||
}
|
||||
|
||||
private var chartCategoryNames: [String] {
|
||||
let names = Array(Set(categoryData.map { $0.categoryName })).sorted()
|
||||
return names
|
||||
}
|
||||
|
||||
private struct StackedCategoryPoint: Identifiable {
|
||||
let date: Date
|
||||
let categoryName: String
|
||||
let colorHex: String
|
||||
let start: Decimal
|
||||
let end: Decimal
|
||||
|
||||
var id: String {
|
||||
"\(categoryName)-\(date.timeIntervalSince1970)"
|
||||
}
|
||||
}
|
||||
|
||||
private var stackedCategoryData: [StackedCategoryPoint] {
|
||||
let grouped = Dictionary(grouping: categoryData) { $0.date }
|
||||
let dates = grouped.keys.sorted()
|
||||
let categories = chartCategoryNames
|
||||
var stacked: [StackedCategoryPoint] = []
|
||||
|
||||
for date in dates {
|
||||
let points = grouped[date] ?? []
|
||||
var running: Decimal = 0
|
||||
|
||||
for category in categories {
|
||||
let value = points.first(where: { $0.categoryName == category })?.value ?? 0
|
||||
let start = running
|
||||
let end = running + value
|
||||
running = end
|
||||
|
||||
if let colorHex = points.first(where: { $0.categoryName == category })?.colorHex {
|
||||
stacked.append(StackedCategoryPoint(
|
||||
date: date,
|
||||
categoryName: category,
|
||||
colorHex: colorHex,
|
||||
start: start,
|
||||
end: end
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stacked
|
||||
}
|
||||
|
||||
private var chartCategoryColors: [Color] {
|
||||
chartCategoryNames.map { name in
|
||||
if let hex = categoryData.first(where: { $0.categoryName == name })?.colorHex {
|
||||
return Color(hex: hex) ?? .gray
|
||||
}
|
||||
return .gray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mini Sparkline
|
||||
|
||||
struct SparklineView: View {
|
||||
let data: [(date: Date, value: Decimal)]
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
if data.count >= 2 {
|
||||
Chart(data, id: \.date) { item in
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(color)
|
||||
.interpolationMethod(.catmullRom)
|
||||
}
|
||||
.chartXAxis(.hidden)
|
||||
.chartYAxis(.hidden)
|
||||
.chartLegend(.hidden)
|
||||
} else {
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let sampleData: [(date: Date, value: Decimal)] = [
|
||||
(Date().adding(months: -6), 10000),
|
||||
(Date().adding(months: -5), 10500),
|
||||
(Date().adding(months: -4), 10200),
|
||||
(Date().adding(months: -3), 11000),
|
||||
(Date().adding(months: -2), 11500),
|
||||
(Date().adding(months: -1), 11200),
|
||||
(Date(), 12000)
|
||||
]
|
||||
|
||||
return EvolutionChartCard(data: sampleData, categoryData: [], goals: [])
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,629 @@
|
||||
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()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user