1.1.0 feature work: Monthly Check-in, Charts, Goals, Share, Reviews
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -40,7 +40,7 @@ extension Snapshot {
|
|||||||
}
|
}
|
||||||
let newId = UUID()
|
let newId = UUID()
|
||||||
setPrimitiveValue(newId, forKey: "id")
|
setPrimitiveValue(newId, forKey: "id")
|
||||||
return newId
|
return newId
|
||||||
}
|
}
|
||||||
|
|
||||||
var safeDate: Date {
|
var safeDate: Date {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ final class ReviewPromptService {
|
|||||||
|
|
||||||
private let lastPromptKey = "reviewPromptLastDate"
|
private let lastPromptKey = "reviewPromptLastDate"
|
||||||
private let checkInCountKey = "reviewPromptCheckinCount"
|
private let checkInCountKey = "reviewPromptCheckinCount"
|
||||||
|
private let hasCompletedStoreReviewKey = "reviewPromptHasCompletedStoreReview"
|
||||||
|
private let promptedAchievementKeysKey = "reviewPromptedAchievementKeys"
|
||||||
private let minCheckInsBetweenPrompts = 3
|
private let minCheckInsBetweenPrompts = 3
|
||||||
private let minDaysBetweenPrompts = 90
|
private let minDaysBetweenPrompts = 90
|
||||||
private let userDefaults: UserDefaults
|
private let userDefaults: UserDefaults
|
||||||
@@ -34,7 +36,40 @@ final class ReviewPromptService {
|
|||||||
requestReviewIfEligible()
|
requestReviewIfEligible()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shouldAskForAchievementSatisfaction(newlyUnlockedAchievementKeys: Set<String>) -> Bool {
|
||||||
|
guard !newlyUnlockedAchievementKeys.isEmpty else { return false }
|
||||||
|
guard !hasCompletedStoreReview else { return false }
|
||||||
|
|
||||||
|
let promptedKeys = Set(userDefaults.stringArray(forKey: promptedAchievementKeysKey) ?? [])
|
||||||
|
let unpromptedKeys = newlyUnlockedAchievementKeys.subtracting(promptedKeys)
|
||||||
|
guard !unpromptedKeys.isEmpty else { return false }
|
||||||
|
|
||||||
|
let mergedKeys = promptedKeys.union(unpromptedKeys).sorted()
|
||||||
|
userDefaults.set(mergedKeys, forKey: promptedAchievementKeysKey)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasCompletedStoreReview: Bool {
|
||||||
|
userDefaults.bool(forKey: hasCompletedStoreReviewKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markStoreReviewCompleted() {
|
||||||
|
userDefaults.set(true, forKey: hasCompletedStoreReviewKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func appStoreWriteReviewURL() -> URL {
|
||||||
|
guard var components = URLComponents(url: GoalShareService.appStoreURL, resolvingAgainstBaseURL: false) else {
|
||||||
|
return GoalShareService.appStoreURL
|
||||||
|
}
|
||||||
|
var queryItems = components.queryItems ?? []
|
||||||
|
queryItems.removeAll { $0.name == "action" }
|
||||||
|
queryItems.append(URLQueryItem(name: "action", value: "write-review"))
|
||||||
|
components.queryItems = queryItems
|
||||||
|
return components.url ?? GoalShareService.appStoreURL
|
||||||
|
}
|
||||||
|
|
||||||
private func requestReviewIfEligible() {
|
private func requestReviewIfEligible() {
|
||||||
|
guard !hasCompletedStoreReview else { return }
|
||||||
let now = dateProvider()
|
let now = dateProvider()
|
||||||
if let lastPrompt = userDefaults.object(forKey: lastPromptKey) as? Date {
|
if let lastPrompt = userDefaults.object(forKey: lastPromptKey) as? Date {
|
||||||
let daysSince = now.timeIntervalSince(lastPrompt) / 86_400
|
let daysSince = now.timeIntervalSince(lastPrompt) / 86_400
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import SwiftUI
|
||||||
|
import LinkPresentation
|
||||||
|
|
||||||
class ShareService {
|
class ShareService {
|
||||||
static let shared = ShareService()
|
static let shared = ShareService()
|
||||||
@@ -18,6 +20,81 @@ class ShareService {
|
|||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func buildPortfolioValueShareText(
|
||||||
|
totalValue: String,
|
||||||
|
changeText: String,
|
||||||
|
changeLabel: String,
|
||||||
|
yearChange: String?,
|
||||||
|
sinceInceptionChange: String?,
|
||||||
|
appName: String
|
||||||
|
) -> String {
|
||||||
|
var lines = [
|
||||||
|
"Total Portfolio Value",
|
||||||
|
totalValue,
|
||||||
|
"\(changeText) \(changeLabel)"
|
||||||
|
]
|
||||||
|
|
||||||
|
if let yearChange {
|
||||||
|
lines.append("YoY: \(yearChange)")
|
||||||
|
}
|
||||||
|
if let sinceInceptionChange {
|
||||||
|
lines.append("Since inception: \(sinceInceptionChange)")
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Shared from \(appName)")
|
||||||
|
return lines.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func shareMonthlyCheckIn(summary: MonthlySummary, appName: String) {
|
||||||
|
let text = Self.buildMonthlyCheckInShareText(summary: summary, appName: appName)
|
||||||
|
shareCard(
|
||||||
|
cardTitle: "\(summary.periodLabel) Check-in",
|
||||||
|
fallbackText: text
|
||||||
|
) {
|
||||||
|
MonthlyCheckInShareCardView(
|
||||||
|
summary: summary,
|
||||||
|
appName: appName,
|
||||||
|
qrCodeImage: GoalShareService.generateQRCode(for: GoalShareService.appStoreURL, size: 200)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func sharePortfolioValue(
|
||||||
|
totalValue: String,
|
||||||
|
changeText: String,
|
||||||
|
changeLabel: String,
|
||||||
|
yearChange: String?,
|
||||||
|
sinceInceptionChange: String?
|
||||||
|
) {
|
||||||
|
let appName = Self.appDisplayName
|
||||||
|
let text = Self.buildPortfolioValueShareText(
|
||||||
|
totalValue: totalValue,
|
||||||
|
changeText: changeText,
|
||||||
|
changeLabel: changeLabel,
|
||||||
|
yearChange: yearChange,
|
||||||
|
sinceInceptionChange: sinceInceptionChange,
|
||||||
|
appName: appName
|
||||||
|
)
|
||||||
|
|
||||||
|
shareCard(
|
||||||
|
cardTitle: "Portfolio Snapshot",
|
||||||
|
fallbackText: text
|
||||||
|
) {
|
||||||
|
PortfolioValueShareCardView(
|
||||||
|
totalValue: totalValue,
|
||||||
|
changeText: changeText,
|
||||||
|
changeLabel: changeLabel,
|
||||||
|
yearChange: yearChange,
|
||||||
|
sinceInceptionChange: sinceInceptionChange,
|
||||||
|
appName: appName,
|
||||||
|
qrCodeImage: GoalShareService.generateQRCode(for: GoalShareService.appStoreURL, size: 200)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func shareText(_ content: String) {
|
func shareText(_ content: String) {
|
||||||
guard let viewController = ShareService.topViewController() else { return }
|
guard let viewController = ShareService.topViewController() else { return }
|
||||||
|
|
||||||
@@ -41,6 +118,36 @@ class ShareService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func shareCard<Content: View>(
|
||||||
|
cardTitle: String,
|
||||||
|
fallbackText: String,
|
||||||
|
@ViewBuilder card: () -> Content
|
||||||
|
) {
|
||||||
|
guard let viewController = ShareService.topViewController() else { return }
|
||||||
|
let shareURL = GoalShareService.appStoreURL
|
||||||
|
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
let renderer = ImageRenderer(content: card())
|
||||||
|
let scale = viewController.view.window?.windowScene?.screen.scale
|
||||||
|
?? viewController.traitCollection.displayScale
|
||||||
|
renderer.scale = scale
|
||||||
|
|
||||||
|
if let image = renderer.uiImage {
|
||||||
|
let item = CardShareItem(
|
||||||
|
image: image,
|
||||||
|
title: cardTitle,
|
||||||
|
text: fallbackText,
|
||||||
|
url: shareURL
|
||||||
|
)
|
||||||
|
presentShareSheet(items: [item], from: viewController)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
presentShareSheet(items: [fallbackText], from: viewController)
|
||||||
|
}
|
||||||
|
|
||||||
func shareTextFile(content: String, fileName: String) {
|
func shareTextFile(content: String, fileName: String) {
|
||||||
guard let viewController = ShareService.topViewController() else { return }
|
guard let viewController = ShareService.topViewController() else { return }
|
||||||
|
|
||||||
@@ -144,4 +251,67 @@ class ShareService {
|
|||||||
.replacingOccurrences(of: ";", with: "\\;")
|
.replacingOccurrences(of: ";", with: "\\;")
|
||||||
.replacingOccurrences(of: ",", with: "\\,")
|
.replacingOccurrences(of: ",", with: "\\,")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func presentShareSheet(items: [Any], from viewController: UIViewController) {
|
||||||
|
let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||||
|
if let popover = activityVC.popoverPresentationController {
|
||||||
|
popover.sourceView = viewController.view
|
||||||
|
popover.sourceRect = CGRect(
|
||||||
|
x: viewController.view.bounds.midX,
|
||||||
|
y: viewController.view.bounds.midY,
|
||||||
|
width: 0,
|
||||||
|
height: 0
|
||||||
|
)
|
||||||
|
popover.permittedArrowDirections = []
|
||||||
|
}
|
||||||
|
viewController.present(activityVC, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CardShareItem: NSObject, UIActivityItemSource {
|
||||||
|
let image: UIImage
|
||||||
|
let title: String
|
||||||
|
let text: String
|
||||||
|
let url: URL
|
||||||
|
|
||||||
|
init(image: UIImage, title: String, text: String, url: URL) {
|
||||||
|
self.image = image
|
||||||
|
self.title = title
|
||||||
|
self.text = text
|
||||||
|
self.url = url
|
||||||
|
}
|
||||||
|
|
||||||
|
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
|
||||||
|
image
|
||||||
|
}
|
||||||
|
|
||||||
|
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
|
||||||
|
image
|
||||||
|
}
|
||||||
|
|
||||||
|
func activityViewController(_ activityViewController: UIActivityViewController, subjectForActivityType activityType: UIActivity.ActivityType?) -> String {
|
||||||
|
title
|
||||||
|
}
|
||||||
|
|
||||||
|
func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
|
||||||
|
let metadata = LPLinkMetadata()
|
||||||
|
metadata.title = title
|
||||||
|
metadata.originalURL = url
|
||||||
|
metadata.url = url
|
||||||
|
metadata.imageProvider = NSItemProvider(object: image)
|
||||||
|
if let appIcon = UIImage(named: "BrandMark") {
|
||||||
|
metadata.iconProvider = NSItemProvider(object: appIcon)
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ enum MonthlyCheckInStore {
|
|||||||
private static let completionsKey = "monthlyCheckInCompletions"
|
private static let completionsKey = "monthlyCheckInCompletions"
|
||||||
private static let legacyLastCheckInKey = "lastCheckInDate"
|
private static let legacyLastCheckInKey = "lastCheckInDate"
|
||||||
private static let entriesKey = "monthlyCheckInEntries"
|
private static let entriesKey = "monthlyCheckInEntries"
|
||||||
|
private static let graceDays = 20
|
||||||
|
|
||||||
// MARK: - Public Accessors
|
// MARK: - Public Accessors
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ enum MonthlyCheckInStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func monthKey(for date: Date) -> String {
|
static func monthKey(for date: Date) -> String {
|
||||||
Self.monthFormatter.string(from: date)
|
Self.monthFormatter.string(from: effectiveMonth(for: date))
|
||||||
}
|
}
|
||||||
|
|
||||||
static func allNotes() -> [(date: Date, note: String)] {
|
static func allNotes() -> [(date: Date, note: String)] {
|
||||||
@@ -74,11 +75,42 @@ enum MonthlyCheckInStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func setCompletionDate(_ completionDate: Date, for month: Date) {
|
static func setCompletionDate(_ completionDate: Date, for month: Date) {
|
||||||
let targetMonth = month.startOfMonth
|
let targetMonth = effectiveMonth(for: month, relativeTo: completionDate, graceDays: graceDays)
|
||||||
let targetKey = monthKey(for: targetMonth)
|
let targetKey = monthKey(for: targetMonth)
|
||||||
var entries = loadEntries()
|
var entries = loadEntries()
|
||||||
var didChange = false
|
var didChange = false
|
||||||
|
|
||||||
|
let calendar = Calendar.current
|
||||||
|
if calendar.isDate(month, inSameDayAs: completionDate),
|
||||||
|
calendar.component(.day, from: completionDate) > graceDays {
|
||||||
|
let completedEntries = entries.compactMap { key, entry -> (month: Date, entry: MonthlyCheckInEntry)? in
|
||||||
|
guard let monthDate = monthFormatter.date(from: key)?.startOfMonth else { return nil }
|
||||||
|
guard entry.completionTime != nil else { return nil }
|
||||||
|
guard monthDate < targetMonth else { return nil }
|
||||||
|
return (month: monthDate, entry: entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lastCompleted = completedEntries.max(by: { $0.month < $1.month }) {
|
||||||
|
var cursor = lastCompleted.month.adding(months: 1).startOfMonth
|
||||||
|
while cursor < targetMonth {
|
||||||
|
let key = monthFormatter.string(from: cursor)
|
||||||
|
if entries[key] == nil {
|
||||||
|
let fallbackCompletion = min(cursor.endOfMonth, completionDate)
|
||||||
|
let entry = MonthlyCheckInEntry(
|
||||||
|
note: lastCompleted.entry.note,
|
||||||
|
rating: lastCompleted.entry.rating,
|
||||||
|
mood: lastCompleted.entry.mood,
|
||||||
|
completionTime: fallbackCompletion.timeIntervalSince1970,
|
||||||
|
createdAt: Date().timeIntervalSince1970
|
||||||
|
)
|
||||||
|
entries[key] = entry
|
||||||
|
didChange = true
|
||||||
|
}
|
||||||
|
cursor = cursor.adding(months: 1).startOfMonth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure the target month entry exists.
|
// Ensure the target month entry exists.
|
||||||
var targetEntry = entries[targetKey] ?? MonthlyCheckInEntry(
|
var targetEntry = entries[targetKey] ?? MonthlyCheckInEntry(
|
||||||
note: nil,
|
note: nil,
|
||||||
@@ -123,6 +155,21 @@ enum MonthlyCheckInStore {
|
|||||||
return Date(timeIntervalSince1970: legacy)
|
return Date(timeIntervalSince1970: legacy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func effectiveMonth(
|
||||||
|
for date: Date,
|
||||||
|
relativeTo referenceDate: Date = Date(),
|
||||||
|
graceDays: Int = 20
|
||||||
|
) -> Date {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
if calendar.isDate(date, inSameDayAs: referenceDate) {
|
||||||
|
let day = calendar.component(.day, from: referenceDate)
|
||||||
|
if day <= graceDays {
|
||||||
|
return referenceDate.adding(months: -1).startOfMonth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return date.startOfMonth
|
||||||
|
}
|
||||||
|
|
||||||
static func stats(referenceDate: Date = Date()) -> MonthlyCheckInStats {
|
static func stats(referenceDate: Date = Date()) -> MonthlyCheckInStats {
|
||||||
let cutoff = referenceDate.endOfMonth
|
let cutoff = referenceDate.endOfMonth
|
||||||
let entries = allEntries().filter { $0.date <= cutoff }
|
let entries = allEntries().filter { $0.date <= cutoff }
|
||||||
|
|||||||
@@ -85,9 +85,11 @@ class ChartsViewModel: ObservableObject {
|
|||||||
|
|
||||||
@Published var selectedChartType: ChartType = .evolution
|
@Published var selectedChartType: ChartType = .evolution
|
||||||
@Published var selectedCategory: Category?
|
@Published var selectedCategory: Category?
|
||||||
|
@Published var selectedSource: InvestmentSource?
|
||||||
@Published var selectedTimeRange: TimeRange = .year
|
@Published var selectedTimeRange: TimeRange = .year
|
||||||
@Published var selectedAccount: Account?
|
@Published var selectedAccount: Account?
|
||||||
@Published var showAllAccounts = true
|
@Published var showAllAccounts = true
|
||||||
|
@Published var selectedBreakdown: BreakdownMode = .category
|
||||||
|
|
||||||
@Published var evolutionData: [(date: Date, value: Decimal)] = []
|
@Published var evolutionData: [(date: Date, value: Decimal)] = []
|
||||||
@Published var categoryEvolutionData: [CategoryEvolutionPoint] = []
|
@Published var categoryEvolutionData: [CategoryEvolutionPoint] = []
|
||||||
@@ -111,7 +113,8 @@ class ChartsViewModel: ObservableObject {
|
|||||||
case month = "1M"
|
case month = "1M"
|
||||||
case quarter = "3M"
|
case quarter = "3M"
|
||||||
case halfYear = "6M"
|
case halfYear = "6M"
|
||||||
case year = "1Y"
|
case year = "12M"
|
||||||
|
case yearToDate = "YTD"
|
||||||
case all = "All"
|
case all = "All"
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
@@ -122,9 +125,33 @@ class ChartsViewModel: ObservableObject {
|
|||||||
case .quarter: return 3
|
case .quarter: return 3
|
||||||
case .halfYear: return 6
|
case .halfYear: return 6
|
||||||
case .year: return 12
|
case .year: return 12
|
||||||
|
case .yearToDate: return nil
|
||||||
case .all: return nil
|
case .all: return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func startDate(referenceDate: Date = Date()) -> Date? {
|
||||||
|
switch self {
|
||||||
|
case .month, .quarter, .halfYear, .year:
|
||||||
|
guard let months else { return nil }
|
||||||
|
return referenceDate.adding(months: -months).startOfDay
|
||||||
|
case .yearToDate:
|
||||||
|
return referenceDate.startOfYear
|
||||||
|
case .all:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BreakdownMode: String, CaseIterable, Identifiable {
|
||||||
|
case category = "By Category"
|
||||||
|
case source = "By Source"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func supportsAllocationTargets(for breakdown: BreakdownMode) -> Bool {
|
||||||
|
breakdown == .category
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Dependencies
|
// MARK: - Dependencies
|
||||||
@@ -147,8 +174,10 @@ class ChartsViewModel: ObservableObject {
|
|||||||
private var lastChartType: ChartType?
|
private var lastChartType: ChartType?
|
||||||
private var lastTimeRange: TimeRange?
|
private var lastTimeRange: TimeRange?
|
||||||
private var lastCategoryId: UUID?
|
private var lastCategoryId: UUID?
|
||||||
|
private var lastSourceId: UUID?
|
||||||
private var lastAccountId: UUID?
|
private var lastAccountId: UUID?
|
||||||
private var lastShowAllAccounts: Bool = true
|
private var lastShowAllAccounts: Bool = true
|
||||||
|
private var lastBreakdown: BreakdownMode = .category
|
||||||
private var cachedSnapshots: [Snapshot]?
|
private var cachedSnapshots: [Snapshot]?
|
||||||
private var isUpdateInProgress = false
|
private var isUpdateInProgress = false
|
||||||
|
|
||||||
@@ -179,9 +208,9 @@ class ChartsViewModel: ObservableObject {
|
|||||||
// Performance: Combine all selection changes into a single debounced stream
|
// Performance: Combine all selection changes into a single debounced stream
|
||||||
// This prevents multiple rapid updates when switching between views
|
// This prevents multiple rapid updates when switching between views
|
||||||
Publishers.CombineLatest4($selectedChartType, $selectedCategory, $selectedTimeRange, $selectedAccount)
|
Publishers.CombineLatest4($selectedChartType, $selectedCategory, $selectedTimeRange, $selectedAccount)
|
||||||
.combineLatest($showAllAccounts)
|
.combineLatest($selectedSource, $selectedBreakdown, $showAllAccounts)
|
||||||
.debounce(for: .milliseconds(150), scheduler: DispatchQueue.main)
|
.debounce(for: .milliseconds(150), scheduler: DispatchQueue.main)
|
||||||
.sink { [weak self] combined, showAll in
|
.sink { [weak self] combined, selectedSource, selectedBreakdown, showAll in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
let (chartType, category, timeRange, _) = combined
|
let (chartType, category, timeRange, _) = combined
|
||||||
|
|
||||||
@@ -190,15 +219,19 @@ class ChartsViewModel: ObservableObject {
|
|||||||
let hasChanges = self.lastChartType != chartType ||
|
let hasChanges = self.lastChartType != chartType ||
|
||||||
self.lastTimeRange != timeRange ||
|
self.lastTimeRange != timeRange ||
|
||||||
self.lastCategoryId != category?.id ||
|
self.lastCategoryId != category?.id ||
|
||||||
|
self.lastSourceId != selectedSource?.id ||
|
||||||
self.lastAccountId != safeSelectedAccountId ||
|
self.lastAccountId != safeSelectedAccountId ||
|
||||||
self.lastShowAllAccounts != showAll
|
self.lastShowAllAccounts != showAll ||
|
||||||
|
self.lastBreakdown != selectedBreakdown
|
||||||
|
|
||||||
if hasChanges {
|
if hasChanges {
|
||||||
self.lastChartType = chartType
|
self.lastChartType = chartType
|
||||||
self.lastTimeRange = timeRange
|
self.lastTimeRange = timeRange
|
||||||
self.lastCategoryId = category?.id
|
self.lastCategoryId = category?.id
|
||||||
|
self.lastSourceId = selectedSource?.id
|
||||||
self.lastAccountId = safeSelectedAccountId
|
self.lastAccountId = safeSelectedAccountId
|
||||||
self.lastShowAllAccounts = showAll
|
self.lastShowAllAccounts = showAll
|
||||||
|
self.lastBreakdown = selectedBreakdown
|
||||||
self.cachedSnapshots = nil // Invalidate cache on meaningful changes
|
self.cachedSnapshots = nil // Invalidate cache on meaningful changes
|
||||||
self.updateChartData(chartType: chartType, category: category, timeRange: timeRange)
|
self.updateChartData(chartType: chartType, category: category, timeRange: timeRange)
|
||||||
}
|
}
|
||||||
@@ -226,12 +259,25 @@ class ChartsViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectedChartType = chartType
|
selectedChartType = chartType
|
||||||
|
let allowedRanges = availableTimeRanges(for: chartType)
|
||||||
|
if !allowedRanges.contains(selectedTimeRange) {
|
||||||
|
selectedTimeRange = allowedRanges.first ?? .year
|
||||||
|
}
|
||||||
FirebaseService.shared.logChartViewed(
|
FirebaseService.shared.logChartViewed(
|
||||||
chartType: chartType.rawValue,
|
chartType: chartType.rawValue,
|
||||||
isPremium: chartType.isPremium
|
isPremium: chartType.isPremium
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func availableTimeRanges(for chartType: ChartType) -> [TimeRange] {
|
||||||
|
switch chartType {
|
||||||
|
case .evolution, .performance:
|
||||||
|
return [.all, .yearToDate, .year, .quarter]
|
||||||
|
default:
|
||||||
|
return [.month, .quarter, .halfYear, .year, .all]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func updateChartData(chartType: ChartType, category: Category?, timeRange: TimeRange) {
|
private func updateChartData(chartType: ChartType, category: Category?, timeRange: TimeRange) {
|
||||||
// Performance: Prevent re-entrancy
|
// Performance: Prevent re-entrancy
|
||||||
guard !isUpdateInProgress else { return }
|
guard !isUpdateInProgress else { return }
|
||||||
@@ -244,7 +290,9 @@ class ChartsViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let sources: [InvestmentSource]
|
let sources: [InvestmentSource]
|
||||||
if let category = category {
|
if let selectedSource {
|
||||||
|
sources = sourceRepository.sources.filter { $0.id == selectedSource.id && shouldIncludeSource($0) }
|
||||||
|
} else if let category = category {
|
||||||
sources = sourceRepository.fetchSources(for: category).filter { shouldIncludeSource($0) }
|
sources = sourceRepository.fetchSources(for: category).filter { shouldIncludeSource($0) }
|
||||||
} else {
|
} else {
|
||||||
sources = sourceRepository.sources.filter { shouldIncludeSource($0) }
|
sources = sourceRepository.sources.filter { shouldIncludeSource($0) }
|
||||||
@@ -266,6 +314,10 @@ class ChartsViewModel: ObservableObject {
|
|||||||
cachedSnapshots = snapshots
|
cachedSnapshots = snapshots
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let cutoffDate = timeRange.startDate() {
|
||||||
|
snapshots = snapshots.filter { $0.date >= cutoffDate }
|
||||||
|
}
|
||||||
|
|
||||||
let completedSnapshots = filterSnapshotsForCharts(
|
let completedSnapshots = filterSnapshotsForCharts(
|
||||||
sources: sources,
|
sources: sources,
|
||||||
snapshots: snapshots
|
snapshots: snapshots
|
||||||
@@ -281,10 +333,14 @@ class ChartsViewModel: ObservableObject {
|
|||||||
)
|
)
|
||||||
calculateCategoryEvolutionData(from: completedSnapshots, categories: categoriesForChart)
|
calculateCategoryEvolutionData(from: completedSnapshots, categories: categoriesForChart)
|
||||||
case .allocation:
|
case .allocation:
|
||||||
calculateAllocationData(for: sources)
|
calculateAllocationData(for: sources, breakdown: selectedBreakdown)
|
||||||
case .performance:
|
case .performance:
|
||||||
let completedSnapshotsBySource = groupSnapshotsBySource(completedSnapshots)
|
let completedSnapshotsBySource = groupSnapshotsBySource(completedSnapshots)
|
||||||
calculatePerformanceData(for: sources, snapshotsBySource: completedSnapshotsBySource)
|
calculatePerformanceData(
|
||||||
|
for: sources,
|
||||||
|
snapshotsBySource: completedSnapshotsBySource,
|
||||||
|
breakdown: selectedBreakdown
|
||||||
|
)
|
||||||
case .contributions:
|
case .contributions:
|
||||||
calculateContributionsData(from: completedSnapshots)
|
calculateContributionsData(from: completedSnapshots)
|
||||||
case .rollingReturn:
|
case .rollingReturn:
|
||||||
@@ -306,6 +362,10 @@ class ChartsViewModel: ObservableObject {
|
|||||||
!availableCategories(for: chartType, sources: sources).contains(where: { $0.id == selected.id }) {
|
!availableCategories(for: chartType, sources: sources).contains(where: { $0.id == selected.id }) {
|
||||||
selectedCategory = nil
|
selectedCategory = nil
|
||||||
}
|
}
|
||||||
|
if let selected = selectedSource,
|
||||||
|
!availableSources(for: chartType, sources: sources).contains(where: { $0.id == selected.id }) {
|
||||||
|
selectedSource = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func availableCategories(
|
func availableCategories(
|
||||||
@@ -324,6 +384,19 @@ class ChartsViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func availableSources(
|
||||||
|
for chartType: ChartType,
|
||||||
|
sources: [InvestmentSource]? = nil
|
||||||
|
) -> [InvestmentSource] {
|
||||||
|
let relevantSources = sources ?? sourceRepository.sources.filter { shouldIncludeSource($0) }
|
||||||
|
switch chartType {
|
||||||
|
case .evolution, .allocation, .performance:
|
||||||
|
return relevantSources.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||||
|
default:
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func shouldIncludeSource(_ source: InvestmentSource) -> Bool {
|
private func shouldIncludeSource(_ source: InvestmentSource) -> Bool {
|
||||||
if showAllAccounts || selectedAccount == nil {
|
if showAllAccounts || selectedAccount == nil {
|
||||||
return true
|
return true
|
||||||
@@ -479,21 +552,33 @@ class ChartsViewModel: ObservableObject {
|
|||||||
categoryEvolutionData = points
|
categoryEvolutionData = points
|
||||||
}
|
}
|
||||||
|
|
||||||
private func calculateAllocationData(for sources: [InvestmentSource]) {
|
private func calculateAllocationData(for sources: [InvestmentSource], breakdown: BreakdownMode) {
|
||||||
let categories = categoryRepository.categories
|
switch breakdown {
|
||||||
let valuesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
|
case .category:
|
||||||
|
let categories = categoryRepository.categories
|
||||||
|
let valuesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
|
||||||
|
|
||||||
allocationData = categories.compactMap { category in
|
allocationData = categories.compactMap { category in
|
||||||
let categorySources = valuesByCategory[category.id] ?? []
|
let categorySources = valuesByCategory[category.id] ?? []
|
||||||
let categoryValue = categorySources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
let categoryValue = categorySources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||||
guard categoryValue > 0 else { return nil }
|
guard categoryValue > 0 else { return nil }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
category: category.name,
|
category: category.name,
|
||||||
value: categoryValue,
|
value: categoryValue,
|
||||||
color: category.colorHex
|
color: category.colorHex
|
||||||
)
|
)
|
||||||
}.sorted { $0.value > $1.value }
|
}.sorted { $0.value > $1.value }
|
||||||
|
case .source:
|
||||||
|
allocationData = sources.compactMap { source in
|
||||||
|
guard source.latestValue > 0 else { return nil }
|
||||||
|
return (
|
||||||
|
category: source.name,
|
||||||
|
value: source.latestValue,
|
||||||
|
color: source.category?.colorHex ?? "#6B7280"
|
||||||
|
)
|
||||||
|
}.sorted { $0.value > $1.value }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func completedMonthKeys(
|
private func completedMonthKeys(
|
||||||
@@ -565,36 +650,61 @@ class ChartsViewModel: ObservableObject {
|
|||||||
|
|
||||||
private func calculatePerformanceData(
|
private func calculatePerformanceData(
|
||||||
for sources: [InvestmentSource],
|
for sources: [InvestmentSource],
|
||||||
snapshotsBySource: [UUID: [Snapshot]]
|
snapshotsBySource: [UUID: [Snapshot]],
|
||||||
|
breakdown: BreakdownMode
|
||||||
) {
|
) {
|
||||||
let categories = categoryRepository.categories
|
switch breakdown {
|
||||||
let sourcesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
|
case .category:
|
||||||
|
let categories = categoryRepository.categories
|
||||||
|
let sourcesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
|
||||||
|
|
||||||
performanceData = categories.compactMap { category in
|
performanceData = categories.compactMap { category in
|
||||||
let categorySources = sourcesByCategory[category.id] ?? []
|
let categorySources = sourcesByCategory[category.id] ?? []
|
||||||
let snapshots = categorySources.compactMap { source -> [Snapshot]? in
|
let snapshots = categorySources.compactMap { source -> [Snapshot]? in
|
||||||
let id = source.id
|
let id = source.id
|
||||||
return snapshotsBySource[id]
|
return snapshotsBySource[id]
|
||||||
}.flatMap { $0 }
|
}.flatMap { $0 }
|
||||||
guard snapshots.count >= 2 else { return nil }
|
guard snapshots.count >= 2 else { return nil }
|
||||||
|
|
||||||
let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots)
|
let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots)
|
||||||
guard let first = monthlyTotals.first,
|
guard let first = monthlyTotals.first,
|
||||||
let last = monthlyTotals.last,
|
let last = monthlyTotals.last,
|
||||||
first.totalValue > 0 else { return nil }
|
first.totalValue > 0 else { return nil }
|
||||||
let cagr = calculationService.calculateCAGR(
|
let cagr = calculationService.calculateCAGR(
|
||||||
startValue: first.totalValue,
|
startValue: first.totalValue,
|
||||||
endValue: last.totalValue,
|
endValue: last.totalValue,
|
||||||
startDate: first.date,
|
startDate: first.date,
|
||||||
endDate: last.date
|
endDate: last.date
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
category: category.name,
|
category: category.name,
|
||||||
cagr: cagr,
|
cagr: cagr,
|
||||||
color: category.colorHex
|
color: category.colorHex
|
||||||
)
|
)
|
||||||
}.sorted { $0.cagr > $1.cagr }
|
}.sorted { $0.cagr > $1.cagr }
|
||||||
|
case .source:
|
||||||
|
performanceData = sources.compactMap { source in
|
||||||
|
guard let snapshots = snapshotsBySource[source.id], snapshots.count >= 2 else { return nil }
|
||||||
|
let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots)
|
||||||
|
guard let first = monthlyTotals.first,
|
||||||
|
let last = monthlyTotals.last,
|
||||||
|
first.totalValue > 0 else { return nil }
|
||||||
|
|
||||||
|
let cagr = calculationService.calculateCAGR(
|
||||||
|
startValue: first.totalValue,
|
||||||
|
endValue: last.totalValue,
|
||||||
|
startDate: first.date,
|
||||||
|
endDate: last.date
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
category: source.name,
|
||||||
|
cagr: cagr,
|
||||||
|
color: source.category?.colorHex ?? "#6B7280"
|
||||||
|
)
|
||||||
|
}.sorted { $0.cagr > $1.cagr }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func calculateContributionsData(from snapshots: [Snapshot]) {
|
private func calculateContributionsData(from snapshots: [Snapshot]) {
|
||||||
|
|||||||
@@ -68,6 +68,31 @@ class GoalsViewModel: ObservableObject {
|
|||||||
return NSDecimalNumber(decimal: current / goal.targetDecimal).doubleValue
|
return NSDecimalNumber(decimal: current / goal.targetDecimal).doubleValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isAchieved(_ goal: Goal) -> Bool {
|
||||||
|
Self.isAchieved(progress: progress(for: goal))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isAchieved(progress: Double) -> Bool {
|
||||||
|
progress >= 0.999
|
||||||
|
}
|
||||||
|
|
||||||
|
static func urgencyLevel(
|
||||||
|
targetDate: Date?,
|
||||||
|
isBehind: Bool,
|
||||||
|
isAchieved: Bool,
|
||||||
|
referenceDate: Date = Date()
|
||||||
|
) -> GoalUrgencyLevel {
|
||||||
|
guard let targetDate else { return .normal }
|
||||||
|
guard !isAchieved else { return .normal }
|
||||||
|
guard isBehind else { return .normal }
|
||||||
|
|
||||||
|
let daysUntilTarget = referenceDate.startOfDay.daysBetween(targetDate.startOfDay)
|
||||||
|
if daysUntilTarget < 0 {
|
||||||
|
return .critical
|
||||||
|
}
|
||||||
|
return .warning
|
||||||
|
}
|
||||||
|
|
||||||
func totalValue(for goal: Goal) -> Decimal {
|
func totalValue(for goal: Goal) -> Decimal {
|
||||||
if let accountId = goal.account?.safeId {
|
if let accountId = goal.account?.safeId {
|
||||||
return sourceRepository.sources
|
return sourceRepository.sources
|
||||||
@@ -288,3 +313,9 @@ struct GoalPaceStatus {
|
|||||||
let isBehind: Bool
|
let isBehind: Bool
|
||||||
let statusText: String
|
let statusText: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum GoalUrgencyLevel: Equatable {
|
||||||
|
case normal
|
||||||
|
case warning
|
||||||
|
case critical
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import Charts
|
|||||||
|
|
||||||
struct AllocationPieChart: View {
|
struct AllocationPieChart: View {
|
||||||
let data: [(category: String, value: Decimal, color: String)]
|
let data: [(category: String, value: Decimal, color: String)]
|
||||||
|
var title: String = "Asset Allocation"
|
||||||
|
var showsTargetsComparison: Bool = true
|
||||||
|
|
||||||
@State private var selectedSlice: String?
|
@State private var selectedSlice: String?
|
||||||
|
|
||||||
@@ -12,7 +14,7 @@ struct AllocationPieChart: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
Text("Asset Allocation")
|
Text(title)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
|
||||||
if !data.isEmpty {
|
if !data.isEmpty {
|
||||||
@@ -98,7 +100,9 @@ struct AllocationPieChart: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
AllocationTargetsComparisonChart(data: data)
|
if showsTargetsComparison {
|
||||||
|
AllocationTargetsComparisonChart(data: data)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("No allocation data available")
|
Text("No allocation data available")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ struct ChartsContainerView: View {
|
|||||||
|
|
||||||
// Time Range Selector
|
// Time Range Selector
|
||||||
if viewModel.selectedChartType != .allocation &&
|
if viewModel.selectedChartType != .allocation &&
|
||||||
viewModel.selectedChartType != .performance &&
|
|
||||||
viewModel.selectedChartType != .riskReturn {
|
viewModel.selectedChartType != .riskReturn {
|
||||||
timeRangeSelector
|
timeRangeSelector
|
||||||
}
|
}
|
||||||
@@ -34,6 +33,17 @@ struct ChartsContainerView: View {
|
|||||||
categoryFilter
|
categoryFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Source Filter
|
||||||
|
if viewModel.selectedChartType == .evolution {
|
||||||
|
sourceFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Breakdown Selector
|
||||||
|
if viewModel.selectedChartType == .allocation ||
|
||||||
|
viewModel.selectedChartType == .performance {
|
||||||
|
breakdownSelector
|
||||||
|
}
|
||||||
|
|
||||||
// Chart Content
|
// Chart Content
|
||||||
chartContent
|
chartContent
|
||||||
}
|
}
|
||||||
@@ -101,7 +111,7 @@ struct ChartsContainerView: View {
|
|||||||
|
|
||||||
private var timeRangeSelector: some View {
|
private var timeRangeSelector: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(ChartsViewModel.TimeRange.allCases) { range in
|
ForEach(viewModel.availableTimeRanges(for: viewModel.selectedChartType)) { range in
|
||||||
Button {
|
Button {
|
||||||
viewModel.selectedTimeRange = range
|
viewModel.selectedTimeRange = range
|
||||||
} label: {
|
} label: {
|
||||||
@@ -158,6 +168,7 @@ struct ChartsContainerView: View {
|
|||||||
ForEach(availableCategories) { category in
|
ForEach(availableCategories) { category in
|
||||||
Button {
|
Button {
|
||||||
viewModel.selectedCategory = category
|
viewModel.selectedCategory = category
|
||||||
|
viewModel.selectedSource = nil
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Circle()
|
Circle()
|
||||||
@@ -186,6 +197,91 @@ struct ChartsContainerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Filter
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var sourceFilter: some View {
|
||||||
|
let availableSources = viewModel.availableSources(for: viewModel.selectedChartType)
|
||||||
|
if !availableSources.isEmpty {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if availableSources.count > 1 {
|
||||||
|
Button {
|
||||||
|
viewModel.selectedSource = nil
|
||||||
|
} label: {
|
||||||
|
Text("All Sources")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(
|
||||||
|
viewModel.selectedSource == nil
|
||||||
|
? Color.appPrimary
|
||||||
|
: Color.gray.opacity(0.1)
|
||||||
|
)
|
||||||
|
.foregroundColor(
|
||||||
|
viewModel.selectedSource == nil
|
||||||
|
? .white
|
||||||
|
: .primary
|
||||||
|
)
|
||||||
|
.cornerRadius(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(availableSources, id: \.id) { source in
|
||||||
|
Button {
|
||||||
|
viewModel.selectedSource = source
|
||||||
|
viewModel.selectedCategory = nil
|
||||||
|
} label: {
|
||||||
|
Text(source.name)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(
|
||||||
|
viewModel.selectedSource?.id == source.id
|
||||||
|
? Color.appPrimary
|
||||||
|
: Color.gray.opacity(0.1)
|
||||||
|
)
|
||||||
|
.foregroundColor(
|
||||||
|
viewModel.selectedSource?.id == source.id
|
||||||
|
? .white
|
||||||
|
: .primary
|
||||||
|
)
|
||||||
|
.cornerRadius(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Breakdown Selector
|
||||||
|
|
||||||
|
private var breakdownSelector: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(ChartsViewModel.BreakdownMode.allCases) { mode in
|
||||||
|
Button {
|
||||||
|
viewModel.selectedBreakdown = mode
|
||||||
|
} label: {
|
||||||
|
Text(mode.rawValue)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
viewModel.selectedBreakdown == mode
|
||||||
|
? Color.appPrimary
|
||||||
|
: Color.gray.opacity(0.1)
|
||||||
|
)
|
||||||
|
.foregroundColor(
|
||||||
|
viewModel.selectedBreakdown == mode
|
||||||
|
? .white
|
||||||
|
: .primary
|
||||||
|
)
|
||||||
|
.cornerRadius(18)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Chart Content
|
// MARK: - Chart Content
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -204,9 +300,16 @@ struct ChartsContainerView: View {
|
|||||||
goals: goalsViewModel.goals
|
goals: goalsViewModel.goals
|
||||||
)
|
)
|
||||||
case .allocation:
|
case .allocation:
|
||||||
AllocationPieChart(data: viewModel.allocationData)
|
AllocationPieChart(
|
||||||
|
data: viewModel.allocationData,
|
||||||
|
title: viewModel.selectedBreakdown == .source ? "Allocation by Source" : "Asset Allocation",
|
||||||
|
showsTargetsComparison: ChartsViewModel.supportsAllocationTargets(for: viewModel.selectedBreakdown)
|
||||||
|
)
|
||||||
case .performance:
|
case .performance:
|
||||||
PerformanceBarChart(data: viewModel.performanceData)
|
PerformanceBarChart(
|
||||||
|
data: viewModel.performanceData,
|
||||||
|
title: viewModel.selectedBreakdown == .source ? "Performance by Source" : "Performance by Category"
|
||||||
|
)
|
||||||
case .contributions:
|
case .contributions:
|
||||||
ContributionsChartView(data: viewModel.contributionsData)
|
ContributionsChartView(data: viewModel.contributionsData)
|
||||||
case .rollingReturn:
|
case .rollingReturn:
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import Charts
|
|||||||
|
|
||||||
struct PerformanceBarChart: View {
|
struct PerformanceBarChart: View {
|
||||||
let data: [(category: String, cagr: Double, color: String)]
|
let data: [(category: String, cagr: Double, color: String)]
|
||||||
|
var title: String = "Performance by Category"
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
Text("Performance by Category")
|
Text(title)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
|
||||||
Text("Compound Annual Growth Rate (CAGR)")
|
Text("Compound Annual Growth Rate (CAGR)")
|
||||||
|
|||||||
@@ -160,7 +160,18 @@ struct DashboardView: View {
|
|||||||
yearChange: viewModel.portfolioSummary.formattedYearChange,
|
yearChange: viewModel.portfolioSummary.formattedYearChange,
|
||||||
sinceInceptionChange: viewModel.portfolioSummary.formattedAllTimeReturn,
|
sinceInceptionChange: viewModel.portfolioSummary.formattedAllTimeReturn,
|
||||||
isYearPositive: viewModel.isYearChangePositive,
|
isYearPositive: viewModel.isYearChangePositive,
|
||||||
isSinceInceptionPositive: viewModel.portfolioSummary.allTimeReturn >= 0
|
isSinceInceptionPositive: viewModel.portfolioSummary.allTimeReturn >= 0,
|
||||||
|
onShareTap: {
|
||||||
|
ShareService.shared.sharePortfolioValue(
|
||||||
|
totalValue: viewModel.portfolioSummary.formattedTotalValue,
|
||||||
|
changeText: calmModeEnabled
|
||||||
|
? "\(viewModel.latestPortfolioChange.formattedAbsolute) (\(viewModel.latestPortfolioChange.formattedPercentage))"
|
||||||
|
: viewModel.portfolioSummary.formattedDayChange,
|
||||||
|
changeLabel: calmModeEnabled ? "since last check-in" : "today",
|
||||||
|
yearChange: viewModel.portfolioSummary.formattedYearChange,
|
||||||
|
sinceInceptionChange: viewModel.portfolioSummary.formattedAllTimeReturn
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case .monthlyCheckIn:
|
case .monthlyCheckIn:
|
||||||
@@ -210,11 +221,12 @@ struct DashboardView: View {
|
|||||||
CategoryBreakdownCard(categories: viewModel.topCategories)
|
CategoryBreakdownCard(categories: viewModel.topCategories)
|
||||||
}
|
}
|
||||||
case .goals:
|
case .goals:
|
||||||
|
let homeGoals = goalsViewModel.goals.filter { !GoalsViewModel.isAchieved(progress: goalsViewModel.progress(for: $0)) }
|
||||||
if config.isCollapsed {
|
if config.isCollapsed {
|
||||||
CompactCard(title: "Goals", subtitle: "\(goalsViewModel.goals.count) active")
|
CompactCard(title: "Goals", subtitle: "\(homeGoals.count) active")
|
||||||
} else {
|
} else {
|
||||||
GoalsSummaryCard(
|
GoalsSummaryCard(
|
||||||
goals: goalsViewModel.goals,
|
goals: homeGoals,
|
||||||
progressProvider: goalsViewModel.progress(for:),
|
progressProvider: goalsViewModel.progress(for:),
|
||||||
currentValueProvider: goalsViewModel.totalValue(for:),
|
currentValueProvider: goalsViewModel.totalValue(for:),
|
||||||
paceStatusProvider: goalsViewModel.paceStatus(for:),
|
paceStatusProvider: goalsViewModel.paceStatus(for:),
|
||||||
@@ -266,12 +278,24 @@ struct TotalValueCard: View {
|
|||||||
var sinceInceptionChange: String?
|
var sinceInceptionChange: String?
|
||||||
var isYearPositive: Bool = true
|
var isYearPositive: Bool = true
|
||||||
var isSinceInceptionPositive: Bool = true
|
var isSinceInceptionPositive: Bool = true
|
||||||
|
var onShareTap: (() -> Void)?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
Text("Total Portfolio Value")
|
ZStack(alignment: .trailing) {
|
||||||
.font(.subheadline)
|
Text("Total Portfolio Value")
|
||||||
.foregroundColor(.white.opacity(0.85))
|
.font(.headline.weight(.semibold))
|
||||||
|
.foregroundColor(.white.opacity(0.85))
|
||||||
|
|
||||||
|
if let onShareTap {
|
||||||
|
Button(action: onShareTap) {
|
||||||
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundColor(.white.opacity(0.9))
|
||||||
|
}
|
||||||
|
.padding(.trailing, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Text(totalValue)
|
Text(totalValue)
|
||||||
.font(.system(size: 42, weight: .bold, design: .rounded))
|
.font(.system(size: 42, weight: .bold, design: .rounded))
|
||||||
@@ -423,13 +447,6 @@ struct MonthlyCheckInCard: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
NavigationLink {
|
|
||||||
AchievementsView(referenceDate: Date())
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "trophy.fill")
|
|
||||||
}
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
|
|
||||||
if let reminderDate {
|
if let reminderDate {
|
||||||
Button {
|
Button {
|
||||||
let title = String(
|
let title = String(
|
||||||
@@ -1053,6 +1070,12 @@ struct GoalsSummaryCard: View {
|
|||||||
ForEach(goals.prefix(2)) { goal in
|
ForEach(goals.prefix(2)) { goal in
|
||||||
let currentValue = currentValueProvider(goal)
|
let currentValue = currentValueProvider(goal)
|
||||||
let paceStatus = paceStatusProvider(goal)
|
let paceStatus = paceStatusProvider(goal)
|
||||||
|
let isAchieved = GoalsViewModel.isAchieved(progress: progressProvider(goal))
|
||||||
|
let targetUrgency = GoalsViewModel.urgencyLevel(
|
||||||
|
targetDate: goal.targetDate,
|
||||||
|
isBehind: paceStatus?.isBehind ?? false,
|
||||||
|
isAchieved: isAchieved
|
||||||
|
)
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(goal.name)
|
Text(goal.name)
|
||||||
@@ -1083,6 +1106,12 @@ struct GoalsSummaryCard: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let targetDate = goal.targetDate {
|
||||||
|
Text("Target: \(targetDate.mediumDateString)")
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.foregroundColor(targetUrgency == .critical ? .negativeRed : (targetUrgency == .warning ? .appWarning : .secondary))
|
||||||
|
}
|
||||||
|
|
||||||
if let etaText = etaProvider(goal) {
|
if let etaText = etaProvider(goal) {
|
||||||
Text(etaText)
|
Text(etaText)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct MonthlyCheckInView: View {
|
struct MonthlyCheckInView: View {
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
@EnvironmentObject var accountStore: AccountStore
|
@EnvironmentObject var accountStore: AccountStore
|
||||||
@StateObject private var viewModel = MonthlyCheckInViewModel()
|
@StateObject private var viewModel = MonthlyCheckInViewModel()
|
||||||
let referenceDate: Date
|
let referenceDate: Date
|
||||||
@@ -13,6 +14,8 @@ struct MonthlyCheckInView: View {
|
|||||||
@State private var editingSnapshot: Snapshot?
|
@State private var editingSnapshot: Snapshot?
|
||||||
@State private var addingSource: InvestmentSource?
|
@State private var addingSource: InvestmentSource?
|
||||||
@State private var didApplyDuplicate = false
|
@State private var didApplyDuplicate = false
|
||||||
|
@State private var showAchievementSatisfactionDialog = false
|
||||||
|
@State private var showAppStoreReviewAlert = false
|
||||||
|
|
||||||
init(referenceDate: Date = Date(), duplicatePrevious: Bool = false) {
|
init(referenceDate: Date = Date(), duplicatePrevious: Bool = false) {
|
||||||
self.referenceDate = referenceDate
|
self.referenceDate = referenceDate
|
||||||
@@ -56,7 +59,7 @@ struct MonthlyCheckInView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var canAddNewCheckIn: Bool {
|
private var canAddNewCheckIn: Bool {
|
||||||
lastCompletionDate == nil || checkInProgress >= 0.7
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -119,11 +122,36 @@ struct MonthlyCheckInView: View {
|
|||||||
.onChange(of: selectedMood) { _, newValue in
|
.onChange(of: selectedMood) { _, newValue in
|
||||||
MonthlyCheckInStore.setMood(newValue, for: referenceDate)
|
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 {
|
private var headerCard: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("This Month")
|
Text(monthLabel)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
|
||||||
if let date = lastCompletionDate {
|
if let date = lastCompletionDate {
|
||||||
@@ -162,6 +190,7 @@ struct MonthlyCheckInView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
|
let previousUnlockedAchievementKeys = unlockedAchievementKeys()
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let completionDate = referenceDate.isSameMonth(as: now)
|
let completionDate = referenceDate.isSameMonth(as: now)
|
||||||
? now
|
? now
|
||||||
@@ -169,6 +198,12 @@ struct MonthlyCheckInView: View {
|
|||||||
MonthlyCheckInStore.setCompletionDate(completionDate, for: referenceDate)
|
MonthlyCheckInStore.setCompletionDate(completionDate, for: referenceDate)
|
||||||
ReviewPromptService.shared.recordMonthlyCheckInCompleted()
|
ReviewPromptService.shared.recordMonthlyCheckInCompleted()
|
||||||
viewModel.refresh()
|
viewModel.refresh()
|
||||||
|
let newlyUnlockedAchievementKeys = unlockedAchievementKeys().subtracting(previousUnlockedAchievementKeys)
|
||||||
|
if ReviewPromptService.shared.shouldAskForAchievementSatisfaction(
|
||||||
|
newlyUnlockedAchievementKeys: newlyUnlockedAchievementKeys
|
||||||
|
) {
|
||||||
|
showAchievementSatisfactionDialog = true
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text("Mark Check-in Complete")
|
Text("Mark Check-in Complete")
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
@@ -178,12 +213,6 @@ struct MonthlyCheckInView: View {
|
|||||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||||
}
|
}
|
||||||
.disabled(!canAddNewCheckIn)
|
.disabled(!canAddNewCheckIn)
|
||||||
|
|
||||||
if !canAddNewCheckIn {
|
|
||||||
Text("Editing stays open. New check-ins unlock after 70% of the month.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color(.systemBackground))
|
.background(Color(.systemBackground))
|
||||||
@@ -191,6 +220,27 @@ struct MonthlyCheckInView: View {
|
|||||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
.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 {
|
private var reflectionCard: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -457,11 +507,7 @@ struct MonthlyCheckInView: View {
|
|||||||
|
|
||||||
private func shareMonthlyCheckIn() {
|
private func shareMonthlyCheckIn() {
|
||||||
let summary = viewModel.monthlySummary
|
let summary = viewModel.monthlySummary
|
||||||
let text = ShareService.buildMonthlyCheckInShareText(
|
ShareService.shared.shareMonthlyCheckIn(summary: summary, appName: appDisplayName)
|
||||||
summary: summary,
|
|
||||||
appName: appDisplayName
|
|
||||||
)
|
|
||||||
ShareService.shared.shareText(text)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var appDisplayName: String {
|
private var appDisplayName: String {
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MonthlyCheckInShareCardView: View {
|
||||||
|
let summary: MonthlySummary
|
||||||
|
let appName: String
|
||||||
|
var qrCodeImage: UIImage? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
Text(summary.periodLabel)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundColor(.white.opacity(0.85))
|
||||||
|
|
||||||
|
Text("Monthly Check-in")
|
||||||
|
.font(.title2.weight(.bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
|
||||||
|
metricRow("Starting", summary.formattedStartingValue)
|
||||||
|
metricRow("Ending", summary.formattedEndingValue)
|
||||||
|
metricRow("Contributions", summary.formattedContributions)
|
||||||
|
metricRow("Net performance", "\(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))")
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
shareFooter(appName: appName, qrCodeImage: qrCodeImage)
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(width: 320, height: 300)
|
||||||
|
.background(LinearGradient.appPrimaryGradient)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.stroke(Color.white.opacity(0.2), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PortfolioValueShareCardView: View {
|
||||||
|
let totalValue: String
|
||||||
|
let changeText: String
|
||||||
|
let changeLabel: String
|
||||||
|
let yearChange: String?
|
||||||
|
let sinceInceptionChange: String?
|
||||||
|
let appName: String
|
||||||
|
var qrCodeImage: UIImage? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
Text("Portfolio Snapshot")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundColor(.white.opacity(0.85))
|
||||||
|
|
||||||
|
Text("Total Portfolio Value")
|
||||||
|
.font(.headline.weight(.semibold))
|
||||||
|
.foregroundColor(.white.opacity(0.9))
|
||||||
|
|
||||||
|
Text(totalValue)
|
||||||
|
.font(.system(size: 34, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
|
||||||
|
Text("\(changeText) \(changeLabel)")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundColor(.white.opacity(0.9))
|
||||||
|
|
||||||
|
if let yearChange {
|
||||||
|
metricRow("YoY", yearChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let sinceInceptionChange {
|
||||||
|
metricRow("Since inception", sinceInceptionChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
shareFooter(appName: appName, qrCodeImage: qrCodeImage)
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(width: 320, height: 290)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.appSecondary, Color.appPrimary],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.stroke(Color.white.opacity(0.2), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func metricRow(_ title: String, _ value: String) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(title)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundColor(.white.opacity(0.8))
|
||||||
|
Spacer()
|
||||||
|
Text(value)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shareFooter(appName: String, qrCodeImage: UIImage?) -> some View {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.white.opacity(0.2))
|
||||||
|
.frame(height: 1)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image("BrandMark")
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Powered by")
|
||||||
|
.font(.caption2.weight(.medium))
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
|
Text(appName)
|
||||||
|
.font(.subheadline.weight(.bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let qrCodeImage {
|
||||||
|
Image(uiImage: qrCodeImage)
|
||||||
|
.interpolation(.none)
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
.background(Color.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||||
|
} else {
|
||||||
|
Image(systemName: "qrcode")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundColor(.white.opacity(0.9))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ struct GoalsView: View {
|
|||||||
@StateObject private var viewModel = GoalsViewModel()
|
@StateObject private var viewModel = GoalsViewModel()
|
||||||
@State private var showingAddGoal = false
|
@State private var showingAddGoal = false
|
||||||
@State private var editingGoal: Goal?
|
@State private var editingGoal: Goal?
|
||||||
|
@State private var showAchievedGoals = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -12,11 +13,15 @@ struct GoalsView: View {
|
|||||||
AppBackground()
|
AppBackground()
|
||||||
|
|
||||||
List {
|
List {
|
||||||
if viewModel.goals.isEmpty {
|
if filteredGoals.isEmpty {
|
||||||
emptyState
|
if viewModel.goals.isEmpty {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
hiddenAchievedState
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Section {
|
Section {
|
||||||
ForEach(viewModel.goals) { goal in
|
ForEach(filteredGoals) { goal in
|
||||||
GoalRowView(
|
GoalRowView(
|
||||||
goal: goal,
|
goal: goal,
|
||||||
progress: viewModel.progress(for: goal),
|
progress: viewModel.progress(for: goal),
|
||||||
@@ -48,6 +53,12 @@ struct GoalsView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("Goals")
|
.navigationTitle("Goals")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button(showAchievedGoals ? "Hide Achieved" : "Show Achieved") {
|
||||||
|
showAchievedGoals.toggle()
|
||||||
|
}
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
}
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
showingAddGoal = true
|
showingAddGoal = true
|
||||||
@@ -78,6 +89,11 @@ struct GoalsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var filteredGoals: [Goal] {
|
||||||
|
guard !showAchievedGoals else { return viewModel.goals }
|
||||||
|
return viewModel.goals.filter { !viewModel.isAchieved($0) }
|
||||||
|
}
|
||||||
|
|
||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Image(systemName: "target")
|
Image(systemName: "target")
|
||||||
@@ -93,6 +109,22 @@ struct GoalsView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 32)
|
.padding(.vertical, 32)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var hiddenAchievedState: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "party.popper")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundColor(.appSecondary)
|
||||||
|
Text("All visible goals are achieved")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Tap \"Show Achieved\" to review completed goals.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 32)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GoalRowView: View {
|
struct GoalRowView: View {
|
||||||
@@ -105,14 +137,53 @@ struct GoalRowView: View {
|
|||||||
|
|
||||||
@State private var showingShareOptions = false
|
@State private var showingShareOptions = false
|
||||||
|
|
||||||
|
private var isAchieved: Bool {
|
||||||
|
GoalsViewModel.isAchieved(progress: progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var targetUrgency: GoalUrgencyLevel {
|
||||||
|
GoalsViewModel.urgencyLevel(
|
||||||
|
targetDate: goal.targetDate,
|
||||||
|
isBehind: paceStatus?.isBehind ?? false,
|
||||||
|
isAchieved: isAchieved
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var targetDateColor: Color {
|
||||||
|
switch targetUrgency {
|
||||||
|
case .normal:
|
||||||
|
return .secondary
|
||||||
|
case .warning:
|
||||||
|
return .appWarning
|
||||||
|
case .critical:
|
||||||
|
return .negativeRed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .topTrailing) {
|
ZStack(alignment: .topTrailing) {
|
||||||
Button(action: onEdit) {
|
Button(action: onEdit) {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text(goal.name)
|
HStack {
|
||||||
.font(.headline)
|
Text(goal.name)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(isAchieved ? .appSecondary : .primary)
|
||||||
|
if isAchieved {
|
||||||
|
Text("Achieved")
|
||||||
|
.font(.caption2.weight(.bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.appSecondary)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
GoalProgressBar(progress: progress, tint: .appSecondary, iconColor: .appSecondary)
|
GoalProgressBar(
|
||||||
|
progress: progress,
|
||||||
|
tint: isAchieved ? .appSuccess : .appSecondary,
|
||||||
|
iconColor: isAchieved ? .appSuccess : .appSecondary
|
||||||
|
)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text(totalValue.currencyString)
|
Text(totalValue.currencyString)
|
||||||
@@ -126,16 +197,25 @@ struct GoalRowView: View {
|
|||||||
if let targetDate = goal.targetDate {
|
if let targetDate = goal.targetDate {
|
||||||
Text("Target date: \(targetDate.mediumDateString)")
|
Text("Target date: \(targetDate.mediumDateString)")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(targetDateColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let paceStatus {
|
if let paceStatus {
|
||||||
Text(paceStatus.statusText)
|
Text(paceStatus.statusText)
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
.foregroundColor(paceStatus.isBehind ? .negativeRed : .positiveGreen)
|
.foregroundColor(
|
||||||
|
isAchieved ? .appSuccess : (paceStatus.isBehind ? .negativeRed : .positiveGreen)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.background(isAchieved ? Color.appSuccess.opacity(0.10) : Color.clear)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(isAchieved ? Color.appSuccess.opacity(0.35) : Color.clear, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.cornerRadius(12)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
|||||||
@@ -51,4 +51,40 @@ final class ReviewPromptServiceTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertFalse(didRequest)
|
XCTAssertFalse(didRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAchievementPromptTriggersForNewUnlockWhenNotReviewed() {
|
||||||
|
let service = ReviewPromptService(
|
||||||
|
userDefaults: defaults,
|
||||||
|
dateProvider: Date.init,
|
||||||
|
reviewRequestHandler: {}
|
||||||
|
)
|
||||||
|
|
||||||
|
let shouldAsk = service.shouldAskForAchievementSatisfaction(newlyUnlockedAchievementKeys: ["streak_3"])
|
||||||
|
|
||||||
|
XCTAssertTrue(shouldAsk)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAchievementPromptDoesNotTriggerTwiceForSameAchievement() {
|
||||||
|
let service = ReviewPromptService(
|
||||||
|
userDefaults: defaults,
|
||||||
|
dateProvider: Date.init,
|
||||||
|
reviewRequestHandler: {}
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertTrue(service.shouldAskForAchievementSatisfaction(newlyUnlockedAchievementKeys: ["streak_3"]))
|
||||||
|
XCTAssertFalse(service.shouldAskForAchievementSatisfaction(newlyUnlockedAchievementKeys: ["streak_3"]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAchievementPromptDoesNotTriggerAfterStoreReviewCompleted() {
|
||||||
|
let service = ReviewPromptService(
|
||||||
|
userDefaults: defaults,
|
||||||
|
dateProvider: Date.init,
|
||||||
|
reviewRequestHandler: {}
|
||||||
|
)
|
||||||
|
service.markStoreReviewCompleted()
|
||||||
|
|
||||||
|
let shouldAsk = service.shouldAskForAchievementSatisfaction(newlyUnlockedAchievementKeys: ["streak_6"])
|
||||||
|
|
||||||
|
XCTAssertFalse(shouldAsk)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,4 +25,22 @@ final class ShareServiceTests: XCTestCase {
|
|||||||
XCTAssertTrue(text.contains("Net performance:"))
|
XCTAssertTrue(text.contains("Net performance:"))
|
||||||
XCTAssertTrue(text.contains("Shared from Portfolio Journal"))
|
XCTAssertTrue(text.contains("Shared from Portfolio Journal"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testBuildPortfolioValueShareText() {
|
||||||
|
let text = ShareService.buildPortfolioValueShareText(
|
||||||
|
totalValue: "€120,000",
|
||||||
|
changeText: "+€2,500 (+2.1%)",
|
||||||
|
changeLabel: "since last check-in",
|
||||||
|
yearChange: "+€8,000 (+7.1%)",
|
||||||
|
sinceInceptionChange: "+€24,000 (+25.0%)",
|
||||||
|
appName: "Portfolio Journal"
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertTrue(text.contains("Total Portfolio Value"))
|
||||||
|
XCTAssertTrue(text.contains("€120,000"))
|
||||||
|
XCTAssertTrue(text.contains("+€2,500 (+2.1%) since last check-in"))
|
||||||
|
XCTAssertTrue(text.contains("YoY: +€8,000 (+7.1%)"))
|
||||||
|
XCTAssertTrue(text.contains("Since inception: +€24,000 (+25.0%)"))
|
||||||
|
XCTAssertTrue(text.contains("Shared from Portfolio Journal"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import PortfolioJournal
|
||||||
|
|
||||||
|
final class MonthlyCheckInStoreTests: XCTestCase {
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
MonthlyCheckInStore.clearAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
MonthlyCheckInStore.clearAll()
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEffectiveMonthUsesPreviousMonthWithinGracePeriod() {
|
||||||
|
let calendar = Calendar(identifier: .gregorian)
|
||||||
|
let referenceDate = calendar.date(from: DateComponents(year: 2026, month: 2, day: 20))!
|
||||||
|
let effective = MonthlyCheckInStore.effectiveMonth(for: referenceDate, relativeTo: referenceDate, graceDays: 20)
|
||||||
|
|
||||||
|
let expected = calendar.date(from: DateComponents(year: 2026, month: 1, day: 1))!
|
||||||
|
XCTAssertEqual(effective, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEffectiveMonthUsesCurrentMonthAfterGracePeriod() {
|
||||||
|
let calendar = Calendar(identifier: .gregorian)
|
||||||
|
let referenceDate = calendar.date(from: DateComponents(year: 2026, month: 2, day: 21))!
|
||||||
|
let effective = MonthlyCheckInStore.effectiveMonth(for: referenceDate, relativeTo: referenceDate, graceDays: 20)
|
||||||
|
|
||||||
|
let expected = calendar.date(from: DateComponents(year: 2026, month: 2, day: 1))!
|
||||||
|
XCTAssertEqual(effective, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCompletionAfterGraceAutoFillsMissingMonths() {
|
||||||
|
let calendar = Calendar(identifier: .gregorian)
|
||||||
|
let decemberDate = calendar.date(from: DateComponents(year: 2025, month: 12, day: 15))!
|
||||||
|
let decemberCompletion = calendar.date(from: DateComponents(year: 2025, month: 12, day: 31))!
|
||||||
|
MonthlyCheckInStore.setNote("Carry", for: decemberDate)
|
||||||
|
MonthlyCheckInStore.setRating(4, for: decemberDate)
|
||||||
|
MonthlyCheckInStore.setMood(.balanced, for: decemberDate)
|
||||||
|
MonthlyCheckInStore.setCompletionDate(decemberCompletion, for: decemberDate)
|
||||||
|
|
||||||
|
let completionDate = calendar.date(from: DateComponents(year: 2026, month: 2, day: 21))!
|
||||||
|
MonthlyCheckInStore.setCompletionDate(completionDate, for: completionDate)
|
||||||
|
|
||||||
|
let januaryDate = calendar.date(from: DateComponents(year: 2026, month: 1, day: 5))!
|
||||||
|
let januaryEntry = MonthlyCheckInStore.entry(for: januaryDate)
|
||||||
|
XCTAssertEqual(januaryEntry?.note, "Carry")
|
||||||
|
XCTAssertEqual(januaryEntry?.rating, 4)
|
||||||
|
XCTAssertEqual(januaryEntry?.mood, .balanced)
|
||||||
|
XCTAssertNotNil(januaryEntry?.completionDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSetCompletionDateForVeryOldMonthStillCompletesEntry() {
|
||||||
|
let calendar = Calendar(identifier: .gregorian)
|
||||||
|
let oldMonthDate = calendar.date(from: DateComponents(year: 2024, month: 1, day: 10))!
|
||||||
|
let completionDate = calendar.date(from: DateComponents(year: 2026, month: 2, day: 22))!
|
||||||
|
|
||||||
|
MonthlyCheckInStore.setCompletionDate(completionDate, for: oldMonthDate)
|
||||||
|
|
||||||
|
let entry = MonthlyCheckInStore.entry(for: oldMonthDate)
|
||||||
|
XCTAssertNotNil(entry?.completionDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import PortfolioJournal
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ChartsViewModelDisplayRulesTests: XCTestCase {
|
||||||
|
func testAllocationTargetsSupportedOnlyForCategoryBreakdown() {
|
||||||
|
XCTAssertTrue(ChartsViewModel.supportsAllocationTargets(for: .category))
|
||||||
|
XCTAssertFalse(ChartsViewModel.supportsAllocationTargets(for: .source))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEvolutionTimeRangesIncludeRequestedOptions() {
|
||||||
|
let viewModel = ChartsViewModel(iapService: IAPService())
|
||||||
|
let ranges = viewModel.availableTimeRanges(for: .evolution)
|
||||||
|
|
||||||
|
XCTAssertEqual(ranges, [.all, .yearToDate, .year, .quarter])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testYearToDateRangeStartsAtBeginningOfYear() {
|
||||||
|
let calendar = Calendar(identifier: .gregorian)
|
||||||
|
let referenceDate = calendar.date(from: DateComponents(year: 2026, month: 8, day: 15))!
|
||||||
|
let startDate = ChartsViewModel.TimeRange.yearToDate.startDate(referenceDate: referenceDate)
|
||||||
|
let expected = calendar.date(from: DateComponents(year: 2026, month: 1, day: 1))!
|
||||||
|
|
||||||
|
XCTAssertEqual(startDate, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import PortfolioJournal
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class GoalsDisplayRulesTests: XCTestCase {
|
||||||
|
func testIsAchievedProgressThreshold() {
|
||||||
|
XCTAssertFalse(GoalsViewModel.isAchieved(progress: 0.998))
|
||||||
|
XCTAssertTrue(GoalsViewModel.isAchieved(progress: 0.999))
|
||||||
|
XCTAssertTrue(GoalsViewModel.isAchieved(progress: 1.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUrgencyLevelIsCriticalWhenBehindAndPastTargetDate() {
|
||||||
|
let targetDate = Date(timeIntervalSince1970: 1_000_000)
|
||||||
|
let referenceDate = Date(timeIntervalSince1970: 1_100_000)
|
||||||
|
|
||||||
|
let level = GoalsViewModel.urgencyLevel(
|
||||||
|
targetDate: targetDate,
|
||||||
|
isBehind: true,
|
||||||
|
isAchieved: false,
|
||||||
|
referenceDate: referenceDate
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(level, .critical)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUrgencyLevelIsWarningWhenBehindBeforeTargetDate() {
|
||||||
|
let targetDate = Date(timeIntervalSince1970: 1_100_000)
|
||||||
|
let referenceDate = Date(timeIntervalSince1970: 1_000_000)
|
||||||
|
|
||||||
|
let level = GoalsViewModel.urgencyLevel(
|
||||||
|
targetDate: targetDate,
|
||||||
|
isBehind: true,
|
||||||
|
isAchieved: false,
|
||||||
|
referenceDate: referenceDate
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(level, .warning)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUrgencyLevelIsNormalForAchievedGoals() {
|
||||||
|
let level = GoalsViewModel.urgencyLevel(
|
||||||
|
targetDate: Date(),
|
||||||
|
isBehind: true,
|
||||||
|
isAchieved: true,
|
||||||
|
referenceDate: Date().addingTimeInterval(86_400)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(level, .normal)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import PortfolioJournal
|
||||||
|
|
||||||
|
final class MonthlyCheckInViewTests: XCTestCase {
|
||||||
|
func testMonthLabelUsesMonthAndYear() {
|
||||||
|
var components = DateComponents()
|
||||||
|
components.year = 2026
|
||||||
|
components.month = 2
|
||||||
|
components.day = 1
|
||||||
|
let date = Calendar(identifier: .gregorian).date(from: components)!
|
||||||
|
|
||||||
|
let label = MonthlyCheckInView.monthLabel(
|
||||||
|
for: date,
|
||||||
|
relativeTo: date,
|
||||||
|
locale: Locale(identifier: "en_US_POSIX")
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(label, "February 2026")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMonthLabelUsesPreviousMonthDuringGracePeriod() {
|
||||||
|
var components = DateComponents()
|
||||||
|
components.year = 2026
|
||||||
|
components.month = 2
|
||||||
|
components.day = 20
|
||||||
|
let date = Calendar(identifier: .gregorian).date(from: components)!
|
||||||
|
|
||||||
|
let label = MonthlyCheckInView.monthLabel(
|
||||||
|
for: date,
|
||||||
|
relativeTo: date,
|
||||||
|
locale: Locale(identifier: "en_US_POSIX")
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(label, "January 2026")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user