initial version

This commit is contained in:
alexandrev-tibco
2026-01-15 09:24:06 +01:00
parent bab350dd22
commit 7988257399
139 changed files with 13149 additions and 3233 deletions
BIN
View File
Binary file not shown.
+52
View File
@@ -0,0 +1,52 @@
import UIKit
import UserNotifications
import FirebaseCore
import FirebaseAnalytics
import GoogleMobileAds
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
// Initialize Firebase (only if GoogleService-Info.plist exists)
if Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist") != nil {
FirebaseApp.configure()
} else {
print("Warning: GoogleService-Info.plist not found. Firebase disabled.")
}
// Initialize Google Mobile Ads
MobileAds.shared.start()
// Request notification permissions
requestNotificationPermissions()
return true
}
private func requestNotificationPermissions() {
let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in
if let error = error {
print("Notification permission error: \(error)")
}
print("Notification permission granted: \(granted)")
}
}
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
print("Device token: \(token)")
}
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
print("Failed to register for remote notifications: \(error)")
}
}
+112
View File
@@ -0,0 +1,112 @@
import SwiftUI
struct ContentView: View {
@EnvironmentObject var iapService: IAPService
@EnvironmentObject var adMobService: AdMobService
@EnvironmentObject var tabSelection: TabSelectionStore
@AppStorage("onboardingCompleted") private var onboardingCompleted = false
@AppStorage("faceIdEnabled") private var faceIdEnabled = false
@AppStorage("pinEnabled") private var pinEnabled = false
@AppStorage("lockOnLaunch") private var lockOnLaunch = true
@AppStorage("lockOnBackground") private var lockOnBackground = false
@Environment(\.scenePhase) private var scenePhase
@State private var isUnlocked = false
private var lockEnabled: Bool {
faceIdEnabled || pinEnabled
}
var body: some View {
ZStack {
Group {
if !onboardingCompleted {
OnboardingView(onboardingCompleted: $onboardingCompleted)
} else {
mainContent
}
}
if onboardingCompleted && lockEnabled && !isUnlocked {
AppLockView(isUnlocked: $isUnlocked)
}
}
.onAppear {
if !lockEnabled {
isUnlocked = true
} else {
isUnlocked = !lockOnLaunch
}
}
.onChange(of: lockEnabled) { _, enabled in
if !enabled {
isUnlocked = true
} else {
isUnlocked = !lockOnLaunch
}
}
.onChange(of: onboardingCompleted) { _, completed in
if completed && lockEnabled {
isUnlocked = !lockOnLaunch
}
}
.onChange(of: scenePhase) { _, phase in
guard onboardingCompleted, lockEnabled else { return }
if phase == .background && lockOnBackground {
isUnlocked = false
}
}
}
private var mainContent: some View {
ZStack {
TabView(selection: $tabSelection.selectedTab) {
DashboardView()
.tabItem {
Label("Home", systemImage: "house.fill")
}
.tag(0)
SourceListView(iapService: iapService)
.tabItem {
Label("Sources", systemImage: "list.bullet")
}
.tag(1)
ChartsContainerView(iapService: iapService)
.tabItem {
Label("Charts", systemImage: "chart.xyaxis.line")
}
.tag(2)
JournalView()
.tabItem {
Label("Journal", systemImage: "book.closed")
}
.tag(3)
SettingsView()
.tabItem {
Label("Settings", systemImage: "gearshape.fill")
}
.tag(4)
}
}
// Banner ad at bottom for free users
.safeAreaInset(edge: .bottom, spacing: 0) {
if !iapService.isPremium {
BannerAdView()
.frame(height: AppConstants.UI.bannerAdHeight)
.frame(maxWidth: .infinity)
.background(Color(.systemBackground))
}
}
}
}
#Preview {
ContentView()
.environmentObject(IAPService())
.environmentObject(AdMobService())
.environmentObject(AccountStore(iapService: IAPService()))
.environmentObject(TabSelectionStore())
}
@@ -0,0 +1,36 @@
import SwiftUI
@main
struct PortfolioJournalApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var iapService: IAPService
@StateObject private var adMobService: AdMobService
@StateObject private var accountStore: AccountStore
@StateObject private var tabSelection = TabSelectionStore()
@Environment(\.scenePhase) private var scenePhase
let coreDataStack = CoreDataStack.shared
init() {
let iap = IAPService()
_iapService = StateObject(wrappedValue: iap)
_adMobService = StateObject(wrappedValue: AdMobService())
_accountStore = StateObject(wrappedValue: AccountStore(iapService: iap))
}
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, coreDataStack.viewContext)
.environmentObject(iapService)
.environmentObject(adMobService)
.environmentObject(accountStore)
.environmentObject(tabSelection)
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
coreDataStack.refreshWidgetData()
}
}
}
}
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,38 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "AppIcon-dark.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"filename" : "AppIcon-tinted.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "BrandMark@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "BrandMark@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "BrandMark@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,12 @@
import Foundation
struct CategoryEvolutionPoint: Identifiable {
let date: Date
let categoryName: String
let colorHex: String
let value: Decimal
var id: String {
"\(categoryName)-\(date.timeIntervalSince1970)"
}
}
@@ -0,0 +1,54 @@
import Foundation
import CoreData
@objc(Account)
public class Account: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Account> {
return NSFetchRequest<Account>(entityName: "Account")
}
@NSManaged public var id: UUID
@NSManaged public var name: String
@NSManaged public var createdAt: Date
@NSManaged public var currency: String?
@NSManaged public var inputMode: String
@NSManaged public var notificationFrequency: String
@NSManaged public var customFrequencyMonths: Int16
@NSManaged public var sortOrder: Int16
@NSManaged public var sources: NSSet?
@NSManaged public var goals: NSSet?
public override func awakeFromInsert() {
super.awakeFromInsert()
id = UUID()
createdAt = Date()
name = "Account"
inputMode = InputMode.simple.rawValue
notificationFrequency = NotificationFrequency.monthly.rawValue
customFrequencyMonths = 1
sortOrder = 0
}
}
// MARK: - Computed Properties
extension Account {
var sourcesArray: [InvestmentSource] {
let set = sources as? Set<InvestmentSource> ?? []
return set.sorted { $0.name < $1.name }
}
var goalsArray: [Goal] {
let set = goals as? Set<Goal> ?? []
return set.sorted { $0.createdAt < $1.createdAt }
}
var frequency: NotificationFrequency {
NotificationFrequency(rawValue: notificationFrequency) ?? .monthly
}
var currencyCode: String? {
currency?.isEmpty == false ? currency : nil
}
}
@@ -0,0 +1,81 @@
import Foundation
import CoreData
@objc(AppSettings)
public class AppSettings: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<AppSettings> {
return NSFetchRequest<AppSettings>(entityName: "AppSettings")
}
@NSManaged public var id: UUID
@NSManaged public var currency: String
@NSManaged public var defaultNotificationTime: Date?
@NSManaged public var enableAnalytics: Bool
@NSManaged public var onboardingCompleted: Bool
@NSManaged public var lastSyncDate: Date?
@NSManaged public var createdAt: Date
@NSManaged public var inputMode: String
@NSManaged public var selectedAccountId: UUID?
@NSManaged public var showAllAccounts: Bool
public override func awakeFromInsert() {
super.awakeFromInsert()
id = UUID()
let localeCode = Locale.current.currency?.identifier
currency = CurrencyPicker.commonCodes.contains(localeCode ?? "")
? (localeCode ?? "EUR")
: "EUR"
enableAnalytics = true
onboardingCompleted = false
inputMode = InputMode.simple.rawValue
showAllAccounts = true
createdAt = Date()
// Default notification time: 9:00 AM
var components = DateComponents()
components.hour = 9
components.minute = 0
defaultNotificationTime = Calendar.current.date(from: components)
}
}
// MARK: - Computed Properties
extension AppSettings {
var currencySymbol: String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = currency
return formatter.currencySymbol ?? ""
}
var notificationTimeString: String {
guard let time = defaultNotificationTime else { return "9:00 AM" }
let formatter = DateFormatter()
formatter.timeStyle = .short
return formatter.string(from: time)
}
}
// MARK: - Static Helpers
extension AppSettings {
static func getOrCreate(in context: NSManagedObjectContext) -> AppSettings {
let request: NSFetchRequest<AppSettings> = AppSettings.fetchRequest()
request.fetchLimit = 1
if let existing = try? context.fetch(request).first {
return existing
}
let new = AppSettings(context: context)
try? context.save()
return new
}
static func markOnboardingComplete(in context: NSManagedObjectContext) {
let settings = getOrCreate(in: context)
settings.onboardingCompleted = true
try? context.save()
}
}
@@ -0,0 +1,50 @@
import Foundation
import CoreData
@objc(Asset)
public class Asset: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Asset> {
return NSFetchRequest<Asset>(entityName: "Asset")
}
@NSManaged public var id: UUID
@NSManaged public var name: String
@NSManaged public var symbol: String?
@NSManaged public var type: String
@NSManaged public var currency: String?
@NSManaged public var createdAt: Date
@NSManaged public var sources: NSSet?
public override func awakeFromInsert() {
super.awakeFromInsert()
id = UUID()
createdAt = Date()
name = ""
type = AssetType.stock.rawValue
}
}
enum AssetType: String, CaseIterable, Identifiable {
case stock
case etf
case crypto
case fund
case cash
case realEstate
case other
var id: String { rawValue }
var displayName: String {
switch self {
case .stock: return "Stock"
case .etf: return "ETF"
case .crypto: return "Crypto"
case .fund: return "Fund"
case .cash: return "Cash"
case .realEstate: return "Real Estate"
case .other: return "Other"
}
}
}
@@ -0,0 +1,92 @@
import Foundation
import CoreData
import SwiftUI
@objc(Category)
public class Category: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Category> {
return NSFetchRequest<Category>(entityName: "Category")
}
@NSManaged public var id: UUID
@NSManaged public var name: String
@NSManaged public var colorHex: String
@NSManaged public var icon: String
@NSManaged public var sortOrder: Int16
@NSManaged public var createdAt: Date
@NSManaged public var sources: NSSet?
public override func awakeFromInsert() {
super.awakeFromInsert()
id = UUID()
createdAt = Date()
sortOrder = 0
colorHex = "#3B82F6"
icon = "chart.pie.fill"
name = ""
}
}
// MARK: - Computed Properties
extension Category {
var color: Color {
Color(hex: colorHex) ?? .blue
}
var sourcesArray: [InvestmentSource] {
let set = sources as? Set<InvestmentSource> ?? []
return set.sorted { $0.name < $1.name }
}
var sourceCount: Int {
sources?.count ?? 0
}
var totalValue: Decimal {
sourcesArray.reduce(Decimal.zero) { $0 + $1.latestValue }
}
}
// MARK: - Generated accessors for sources
extension Category {
@objc(addSourcesObject:)
@NSManaged public func addToSources(_ value: InvestmentSource)
@objc(removeSourcesObject:)
@NSManaged public func removeFromSources(_ value: InvestmentSource)
@objc(addSources:)
@NSManaged public func addToSources(_ values: NSSet)
@objc(removeSources:)
@NSManaged public func removeFromSources(_ values: NSSet)
}
// MARK: - Default Categories
extension Category {
static let defaultCategories: [(name: String, colorHex: String, icon: String)] = [
("Stocks", "#10B981", "chart.line.uptrend.xyaxis"),
("Bonds", "#3B82F6", "building.columns.fill"),
("Real Estate", "#F59E0B", "house.fill"),
("Crypto", "#8B5CF6", "bitcoinsign.circle.fill"),
("Cash", "#6B7280", "banknote.fill"),
("ETFs", "#EC4899", "chart.bar.fill"),
("Retirement", "#14B8A6", "person.fill"),
("Other", "#64748B", "ellipsis.circle.fill")
]
static func createDefaultCategories(in context: NSManagedObjectContext) {
for (index, categoryData) in defaultCategories.enumerated() {
let category = Category(context: context)
category.name = categoryData.name
category.colorHex = categoryData.colorHex
category.icon = categoryData.icon
category.sortOrder = Int16(index)
}
try? context.save()
}
}
@@ -0,0 +1,34 @@
import Foundation
import CoreData
@objc(Goal)
public class Goal: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Goal> {
return NSFetchRequest<Goal>(entityName: "Goal")
}
@NSManaged public var id: UUID
@NSManaged public var name: String
@NSManaged public var targetAmount: NSDecimalNumber?
@NSManaged public var targetDate: Date?
@NSManaged public var isActive: Bool
@NSManaged public var createdAt: Date
@NSManaged public var account: Account?
public override func awakeFromInsert() {
super.awakeFromInsert()
id = UUID()
createdAt = Date()
name = ""
isActive = true
}
}
// MARK: - Computed Properties
extension Goal {
var targetDecimal: Decimal {
targetAmount?.decimalValue ?? Decimal.zero
}
}
@@ -0,0 +1,239 @@
import Foundation
import CoreData
@objc(InvestmentSource)
public class InvestmentSource: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<InvestmentSource> {
return NSFetchRequest<InvestmentSource>(entityName: "InvestmentSource")
}
@NSManaged public var id: UUID
@NSManaged public var name: String
@NSManaged public var notificationFrequency: String
@NSManaged public var customFrequencyMonths: Int16
@NSManaged public var isActive: Bool
@NSManaged public var createdAt: Date
@NSManaged public var category: Category?
@NSManaged public var account: Account?
@NSManaged public var snapshots: NSSet?
@NSManaged public var transactions: NSSet?
@NSManaged public var asset: Asset?
public override func awakeFromInsert() {
super.awakeFromInsert()
id = UUID()
createdAt = Date()
isActive = true
notificationFrequency = NotificationFrequency.monthly.rawValue
customFrequencyMonths = 1
name = ""
}
}
// MARK: - Notification Frequency
enum NotificationFrequency: String, CaseIterable, Identifiable {
case monthly = "monthly"
case quarterly = "quarterly"
case semiannual = "semiannual"
case annual = "annual"
case custom = "custom"
case never = "never"
var id: String { rawValue }
var displayName: String {
switch self {
case .monthly: return "Monthly"
case .quarterly: return "Quarterly"
case .semiannual: return "Semi-Annual"
case .annual: return "Annual"
case .custom: return "Custom"
case .never: return "Never"
}
}
var months: Int {
switch self {
case .monthly: return 1
case .quarterly: return 3
case .semiannual: return 6
case .annual: return 12
case .custom, .never: return 0
}
}
}
// MARK: - Computed Properties
extension InvestmentSource {
/// Returns snapshots sorted by date descending (most recent first)
/// Performance note: This sorts on every call. For repeated access, cache the result.
var snapshotsArray: [Snapshot] {
let set = snapshots as? Set<Snapshot> ?? []
return set.sorted { $0.date > $1.date }
}
/// Returns snapshots sorted by date ascending (oldest first)
/// Performance note: This sorts on every call. For repeated access, cache the result.
var sortedSnapshotsByDateAscending: [Snapshot] {
let set = snapshots as? Set<Snapshot> ?? []
return set.sorted { $0.date < $1.date }
}
/// Returns the most recent snapshot without sorting all snapshots
/// Performance: O(n) instead of O(n log n) for sorting
var latestSnapshot: Snapshot? {
guard let set = snapshots as? Set<Snapshot>, !set.isEmpty else { return nil }
return set.max(by: { $0.date < $1.date })
}
var latestValue: Decimal {
latestSnapshot?.value?.decimalValue ?? Decimal.zero
}
var snapshotCount: Int {
snapshots?.count ?? 0
}
/// Returns transactions sorted by date descending
/// Performance note: This sorts on every call. For repeated access, cache the result.
var transactionsArray: [Transaction] {
let set = transactions as? Set<Transaction> ?? []
return set.sorted { $0.date > $1.date }
}
var frequency: NotificationFrequency {
NotificationFrequency(rawValue: notificationFrequency) ?? .monthly
}
var nextReminderDate: Date? {
if let account = account {
return account.nextReminderDate
}
guard frequency != .never else { return nil }
let months = frequency == .custom ? Int(customFrequencyMonths) : frequency.months
guard let lastSnapshot = latestSnapshot else {
return Date() // Remind now if no snapshots
}
return Calendar.current.date(byAdding: .month, value: months, to: lastSnapshot.date)
}
var needsUpdate: Bool {
if let account = account {
guard let nextDate = account.nextReminderDate else { return false }
return Date() >= nextDate
}
guard let nextDate = nextReminderDate else { return false }
return Date() >= nextDate
}
// MARK: - Performance Metrics
/// Calculates total return percentage. Performance: Uses O(n) min/max instead of sorting.
var totalReturn: Decimal {
guard let set = snapshots as? Set<Snapshot>, set.count >= 2 else {
return Decimal.zero
}
// Performance: Find min and max by date in one pass instead of sorting twice
guard let first = set.min(by: { $0.date < $1.date }),
let last = set.max(by: { $0.date < $1.date }),
let firstValue = first.value?.decimalValue,
firstValue != Decimal.zero else {
return Decimal.zero
}
let lastValue = last.value?.decimalValue ?? Decimal.zero
return ((lastValue - firstValue) / firstValue) * 100
}
/// Performance: Iterates snapshots directly without sorting
var totalContributions: Decimal {
guard let set = snapshots as? Set<Snapshot> else { return Decimal.zero }
return set.reduce(Decimal.zero) { result, snapshot in
result + (snapshot.contribution?.decimalValue ?? Decimal.zero)
}
}
/// Performance: Iterates transactions directly without sorting
var totalInvested: Decimal {
guard let set = transactions as? Set<Transaction> else { return Decimal.zero }
return set.reduce(Decimal.zero) { result, transaction in
let amount = transaction.decimalAmount
switch transaction.transactionType {
case .buy:
return result + amount
case .sell:
return result - amount
default:
return result
}
}
}
/// Performance: Iterates transactions directly without sorting
var totalDividends: Decimal {
guard let set = transactions as? Set<Transaction> else { return Decimal.zero }
return set.reduce(Decimal.zero) { result, transaction in
transaction.transactionType == .dividend ? result + transaction.decimalAmount : result
}
}
/// Performance: Iterates transactions directly without sorting
var totalFees: Decimal {
guard let set = transactions as? Set<Transaction> else { return Decimal.zero }
return set.reduce(Decimal.zero) { result, transaction in
transaction.transactionType == .fee ? result + transaction.decimalAmount : result
}
}
}
// MARK: - Account Scheduling
extension Account {
var nextReminderDate: Date? {
guard frequency != .never else { return nil }
let months = frequency == .custom ? Int(customFrequencyMonths) : frequency.months
guard let latestSnapshotDate = sourcesArray
.compactMap({ $0.latestSnapshot?.date })
.max() else {
return Date()
}
return Calendar.current.date(byAdding: .month, value: months, to: latestSnapshotDate)
}
}
// MARK: - Generated accessors for snapshots
extension InvestmentSource {
@objc(addSnapshotsObject:)
@NSManaged public func addToSnapshots(_ value: Snapshot)
@objc(removeSnapshotsObject:)
@NSManaged public func removeFromSnapshots(_ value: Snapshot)
@objc(addSnapshots:)
@NSManaged public func addToSnapshots(_ values: NSSet)
@objc(removeSnapshots:)
@NSManaged public func removeFromSnapshots(_ values: NSSet)
@objc(addTransactionsObject:)
@NSManaged public func addToTransactions(_ value: Transaction)
@objc(removeTransactionsObject:)
@NSManaged public func removeFromTransactions(_ value: Transaction)
@objc(addTransactions:)
@NSManaged public func addToTransactions(_ values: NSSet)
@objc(removeTransactions:)
@NSManaged public func removeFromTransactions(_ values: NSSet)
}
@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier="">
<entity name="AppSettings" representedClassName="AppSettings" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="currency" attributeType="String" defaultValueString="EUR"/>
<attribute name="defaultNotificationTime" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="enableAnalytics" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="inputMode" attributeType="String" defaultValueString="simple"/>
<attribute name="lastSyncDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="onboardingCompleted" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="selectedAccountId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="showAllAccounts" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
</entity>
<entity name="Account" representedClassName="Account" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="currency" optional="YES" attributeType="String"/>
<attribute name="customFrequencyMonths" attributeType="Integer 16" defaultValueString="1" usesScalarValueType="YES"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="inputMode" attributeType="String" defaultValueString="simple"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="notificationFrequency" attributeType="String" defaultValueString="monthly"/>
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="goals" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Goal" inverseName="account" inverseEntity="Goal"/>
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="InvestmentSource" inverseName="account" inverseEntity="InvestmentSource"/>
</entity>
<entity name="Category" representedClassName="Category" syncable="YES">
<attribute name="colorHex" attributeType="String" defaultValueString="#3B82F6"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="icon" attributeType="String" defaultValueString="chart.pie.fill"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="InvestmentSource" inverseName="category" inverseEntity="InvestmentSource"/>
</entity>
<entity name="InvestmentSource" representedClassName="InvestmentSource" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="customFrequencyMonths" attributeType="Integer 16" defaultValueString="1" usesScalarValueType="YES"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="isActive" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="notificationFrequency" attributeType="String" defaultValueString="monthly"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="sources" inverseEntity="Account"/>
<relationship name="asset" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Asset" inverseName="sources" inverseEntity="Asset"/>
<relationship name="category" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Category" inverseName="sources" inverseEntity="Category"/>
<relationship name="snapshots" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Snapshot" inverseName="source" inverseEntity="Snapshot"/>
<relationship name="transactions" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Transaction" inverseName="source" inverseEntity="Transaction"/>
</entity>
<entity name="Asset" representedClassName="Asset" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="currency" optional="YES" attributeType="String"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="symbol" optional="YES" attributeType="String"/>
<attribute name="type" attributeType="String" defaultValueString="stock"/>
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="asset" inverseEntity="InvestmentSource"/>
</entity>
<entity name="Transaction" representedClassName="Transaction" syncable="YES">
<attribute name="amount" optional="YES" attributeType="Decimal"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="notes" optional="YES" attributeType="String"/>
<attribute name="price" optional="YES" attributeType="Decimal"/>
<attribute name="shares" optional="YES" attributeType="Decimal"/>
<attribute name="type" attributeType="String" defaultValueString="buy"/>
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="transactions" inverseEntity="InvestmentSource"/>
</entity>
<entity name="Goal" representedClassName="Goal" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="isActive" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="targetAmount" optional="YES" attributeType="Decimal"/>
<attribute name="targetDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="goals" inverseEntity="Account"/>
</entity>
<entity name="PredictionCache" representedClassName="PredictionCache" syncable="YES">
<attribute name="algorithm" attributeType="String" defaultValueString="linear"/>
<attribute name="calculatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="predictionData" optional="YES" attributeType="Binary"/>
<attribute name="sourceId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="validUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
</entity>
<entity name="PremiumStatus" representedClassName="PremiumStatus" syncable="YES">
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="isFamilyShared" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="isPremium" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="lastVerificationDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="originalPurchaseDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="productIdentifier" optional="YES" attributeType="String"/>
<attribute name="purchaseDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="transactionId" optional="YES" attributeType="String"/>
</entity>
<entity name="Snapshot" representedClassName="Snapshot" syncable="YES">
<attribute name="contribution" optional="YES" attributeType="Decimal"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="notes" optional="YES" attributeType="String"/>
<attribute name="value" optional="YES" attributeType="Decimal"/>
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="snapshots" inverseEntity="InvestmentSource"/>
</entity>
</model>
@@ -0,0 +1,151 @@
import Foundation
import CoreData
@objc(PredictionCache)
public class PredictionCache: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<PredictionCache> {
return NSFetchRequest<PredictionCache>(entityName: "PredictionCache")
}
@NSManaged public var id: UUID
@NSManaged public var sourceId: UUID?
@NSManaged public var algorithm: String
@NSManaged public var predictionData: Data?
@NSManaged public var calculatedAt: Date
@NSManaged public var validUntil: Date
public override func awakeFromInsert() {
super.awakeFromInsert()
id = UUID()
calculatedAt = Date()
// Cache valid for 24 hours
validUntil = Calendar.current.date(byAdding: .hour, value: 24, to: Date()) ?? Date()
algorithm = PredictionAlgorithm.linear.rawValue
}
}
// MARK: - Prediction Algorithm
enum PredictionAlgorithm: String, CaseIterable, Identifiable {
case linear = "linear"
case exponentialSmoothing = "exponential_smoothing"
case movingAverage = "moving_average"
case holtTrend = "holt_trend"
var id: String { rawValue }
var displayName: String {
switch self {
case .linear: return "Linear Regression"
case .exponentialSmoothing: return "Exponential Smoothing"
case .movingAverage: return "Moving Average"
case .holtTrend: return "Holt Trend"
}
}
var description: String {
switch self {
case .linear:
return "Projects future values based on historical trend line"
case .exponentialSmoothing:
return "Gives more weight to recent data points"
case .movingAverage:
return "Smooths out short-term fluctuations"
case .holtTrend:
return "Captures level and trend changes over time"
}
}
}
// MARK: - Computed Properties
extension PredictionCache {
var isValid: Bool {
Date() < validUntil
}
var algorithmType: PredictionAlgorithm {
PredictionAlgorithm(rawValue: algorithm) ?? .linear
}
var predictions: [Prediction]? {
guard let data = predictionData else { return nil }
return try? JSONDecoder().decode([Prediction].self, from: data)
}
}
// MARK: - Static Helpers
extension PredictionCache {
static func getCache(
for sourceId: UUID?,
algorithm: PredictionAlgorithm,
in context: NSManagedObjectContext
) -> PredictionCache? {
let request: NSFetchRequest<PredictionCache> = PredictionCache.fetchRequest()
if let sourceId = sourceId {
request.predicate = NSPredicate(
format: "sourceId == %@ AND algorithm == %@",
sourceId as CVarArg,
algorithm.rawValue
)
} else {
request.predicate = NSPredicate(
format: "sourceId == nil AND algorithm == %@",
algorithm.rawValue
)
}
request.fetchLimit = 1
guard let cache = try? context.fetch(request).first,
cache.isValid else {
return nil
}
return cache
}
static func saveCache(
for sourceId: UUID?,
algorithm: PredictionAlgorithm,
predictions: [Prediction],
in context: NSManagedObjectContext
) {
// Remove old cache
let request: NSFetchRequest<PredictionCache> = PredictionCache.fetchRequest()
if let sourceId = sourceId {
request.predicate = NSPredicate(
format: "sourceId == %@ AND algorithm == %@",
sourceId as CVarArg,
algorithm.rawValue
)
} else {
request.predicate = NSPredicate(
format: "sourceId == nil AND algorithm == %@",
algorithm.rawValue
)
}
if let oldCache = try? context.fetch(request) {
oldCache.forEach { context.delete($0) }
}
// Create new cache
let cache = PredictionCache(context: context)
cache.sourceId = sourceId
cache.algorithm = algorithm.rawValue
cache.predictionData = try? JSONEncoder().encode(predictions)
try? context.save()
}
static func invalidateAll(in context: NSManagedObjectContext) {
let request: NSFetchRequest<PredictionCache> = PredictionCache.fetchRequest()
if let caches = try? context.fetch(request) {
caches.forEach { context.delete($0) }
}
try? context.save()
}
}
@@ -0,0 +1,82 @@
import Foundation
import CoreData
@objc(PremiumStatus)
public class PremiumStatus: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<PremiumStatus> {
return NSFetchRequest<PremiumStatus>(entityName: "PremiumStatus")
}
@NSManaged public var id: UUID
@NSManaged public var isPremium: Bool
@NSManaged public var purchaseDate: Date?
@NSManaged public var productIdentifier: String?
@NSManaged public var transactionId: String?
@NSManaged public var isFamilyShared: Bool
@NSManaged public var lastVerificationDate: Date?
@NSManaged public var originalPurchaseDate: Date?
public override func awakeFromInsert() {
super.awakeFromInsert()
id = UUID()
isPremium = false
isFamilyShared = false
}
}
// MARK: - Computed Properties
extension PremiumStatus {
var daysSincePurchase: Int? {
guard let purchaseDate = purchaseDate else { return nil }
return Calendar.current.dateComponents([.day], from: purchaseDate, to: Date()).day
}
var needsVerification: Bool {
guard let lastVerification = lastVerificationDate else { return true }
let hoursSinceVerification = Calendar.current.dateComponents(
[.hour],
from: lastVerification,
to: Date()
).hour ?? 0
return hoursSinceVerification >= 24
}
}
// MARK: - Static Helpers
extension PremiumStatus {
static func getOrCreate(in context: NSManagedObjectContext) -> PremiumStatus {
let request: NSFetchRequest<PremiumStatus> = PremiumStatus.fetchRequest()
request.fetchLimit = 1
if let existing = try? context.fetch(request).first {
return existing
}
let new = PremiumStatus(context: context)
try? context.save()
return new
}
static func updateStatus(
isPremium: Bool,
productIdentifier: String?,
transactionId: String?,
isFamilyShared: Bool,
in context: NSManagedObjectContext
) {
let status = getOrCreate(in: context)
status.isPremium = isPremium
status.productIdentifier = productIdentifier
status.transactionId = transactionId
status.isFamilyShared = isFamilyShared
status.lastVerificationDate = Date()
if isPremium && status.purchaseDate == nil {
status.purchaseDate = Date()
}
try? context.save()
}
}
@@ -0,0 +1,79 @@
import Foundation
import CoreData
@objc(Snapshot)
public class Snapshot: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Snapshot> {
return NSFetchRequest<Snapshot>(entityName: "Snapshot")
}
@NSManaged public var id: UUID
@NSManaged public var date: Date
@NSManaged public var value: NSDecimalNumber?
@NSManaged public var contribution: NSDecimalNumber?
@NSManaged public var notes: String?
@NSManaged public var createdAt: Date
@NSManaged public var source: InvestmentSource?
public override func awakeFromInsert() {
super.awakeFromInsert()
id = UUID()
date = Date()
createdAt = Date()
}
}
// MARK: - Computed Properties
extension Snapshot {
var decimalValue: Decimal {
value?.decimalValue ?? Decimal.zero
}
var decimalContribution: Decimal {
contribution?.decimalValue ?? Decimal.zero
}
var formattedValue: String {
CurrencyFormatter.format(value?.decimalValue ?? Decimal.zero, style: .currency, maximumFractionDigits: 2)
}
var formattedDate: String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
return formatter.string(from: date)
}
var monthYearString: String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM yyyy"
return formatter.string(from: date)
}
}
// MARK: - Comparison with Previous
extension Snapshot {
func change(from previousSnapshot: Snapshot?) -> Decimal {
guard let previous = previousSnapshot,
let previousValue = previous.value?.decimalValue,
previousValue != Decimal.zero else {
return Decimal.zero
}
let currentValue = value?.decimalValue ?? Decimal.zero
return currentValue - previousValue
}
func percentageChange(from previousSnapshot: Snapshot?) -> Decimal {
guard let previous = previousSnapshot,
let previousValue = previous.value?.decimalValue,
previousValue != Decimal.zero else {
return Decimal.zero
}
let currentValue = value?.decimalValue ?? Decimal.zero
return ((currentValue - previousValue) / previousValue) * 100
}
}
@@ -0,0 +1,75 @@
import Foundation
import CoreData
@objc(Transaction)
public class Transaction: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Transaction> {
return NSFetchRequest<Transaction>(entityName: "Transaction")
}
@NSManaged public var id: UUID
@NSManaged public var date: Date
@NSManaged public var type: String
@NSManaged public var shares: NSDecimalNumber?
@NSManaged public var price: NSDecimalNumber?
@NSManaged public var amount: NSDecimalNumber?
@NSManaged public var notes: String?
@NSManaged public var createdAt: Date
@NSManaged public var source: InvestmentSource?
public override func awakeFromInsert() {
super.awakeFromInsert()
id = UUID()
createdAt = Date()
date = Date()
type = TransactionType.buy.rawValue
}
}
enum TransactionType: String, CaseIterable, Identifiable {
case buy
case sell
case dividend
case fee
case transfer
var id: String { rawValue }
var displayName: String {
switch self {
case .buy: return "Buy"
case .sell: return "Sell"
case .dividend: return "Dividend"
case .fee: return "Fee"
case .transfer: return "Transfer"
}
}
var isInvestmentFlow: Bool {
self == .buy || self == .sell
}
}
// MARK: - Computed Properties
extension Transaction {
var decimalShares: Decimal {
shares?.decimalValue ?? Decimal.zero
}
var decimalPrice: Decimal {
price?.decimalValue ?? Decimal.zero
}
var decimalAmount: Decimal {
if let amount = amount?.decimalValue, amount != 0 {
return amount
}
return decimalShares * decimalPrice
}
var transactionType: TransactionType {
TransactionType(rawValue: type) ?? .buy
}
}
+286
View File
@@ -0,0 +1,286 @@
import CoreData
import CloudKit
import Combine
import WidgetKit
class CoreDataStack: ObservableObject {
static let shared = CoreDataStack()
static let appGroupIdentifier = "group.com.alexandrevazquez.portfoliojournal"
static let cloudKitContainerIdentifier = "iCloud.com.alexandrevazquez.portfoliojournal"
private static var cloudKitEnabled: Bool {
UserDefaults.standard.bool(forKey: "cloudSyncEnabled")
}
private static var appGroupEnabled: Bool {
true
}
private static func migrateStoreIfNeeded(from sourceURL: URL, to destinationURL: URL) {
let fileManager = FileManager.default
guard fileManager.fileExists(atPath: sourceURL.path) else {
return
}
let sourceHasData = storeHasData(at: sourceURL)
let destinationHasData = storeHasData(at: destinationURL)
guard sourceHasData, !destinationHasData else { return }
removeStoreFilesIfNeeded(at: destinationURL)
let relatedSuffixes = ["", "-wal", "-shm"]
for suffix in relatedSuffixes {
let source = URL(fileURLWithPath: sourceURL.path + suffix)
let destination = URL(fileURLWithPath: destinationURL.path + suffix)
guard fileManager.fileExists(atPath: source.path),
!fileManager.fileExists(atPath: destination.path) else {
continue
}
do {
try fileManager.copyItem(at: source, to: destination)
} catch {
print("Core Data store migration failed for \(source.lastPathComponent): \(error)")
}
}
}
private static func removeStoreFilesIfNeeded(at url: URL) {
let fileManager = FileManager.default
let relatedSuffixes = ["", "-wal", "-shm"]
for suffix in relatedSuffixes {
let fileURL = URL(fileURLWithPath: url.path + suffix)
guard fileManager.fileExists(atPath: fileURL.path) else { continue }
do {
try fileManager.removeItem(at: fileURL)
} catch {
print("Failed to remove existing store file \(fileURL.lastPathComponent): \(error)")
}
}
}
private static func storeHasData(at url: URL) -> Bool {
let fileManager = FileManager.default
guard fileManager.fileExists(atPath: url.path),
let model = NSManagedObjectModel.mergedModel(from: [Bundle.main]) else {
return false
}
let coordinator = NSPersistentStoreCoordinator(managedObjectModel: model)
do {
try coordinator.addPersistentStore(
ofType: NSSQLiteStoreType,
configurationName: nil,
at: url,
options: [NSReadOnlyPersistentStoreOption: true]
)
} catch {
print("Failed to open store for data check: \(error)")
return false
}
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
context.persistentStoreCoordinator = coordinator
let snapshotRequest = NSFetchRequest<NSManagedObject>(entityName: "Snapshot")
snapshotRequest.resultType = .countResultType
let snapshotCount = (try? context.count(for: snapshotRequest)) ?? 0
if snapshotCount > 0 {
return true
}
let sourceRequest = NSFetchRequest<NSManagedObject>(entityName: "InvestmentSource")
sourceRequest.resultType = .countResultType
let sourceCount = (try? context.count(for: sourceRequest)) ?? 0
return sourceCount > 0
}
private static func resolveStoreURL() -> URL {
let defaultURL = NSPersistentContainer.defaultDirectoryURL()
.appendingPathComponent("PortfolioJournal.sqlite")
guard appGroupEnabled,
let appGroupURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)?
.appendingPathComponent("PortfolioJournal.sqlite") else {
if appGroupEnabled {
print("App Group unavailable; using default store URL: \(defaultURL)")
}
return defaultURL
}
migrateStoreIfNeeded(from: defaultURL, to: appGroupURL)
return appGroupURL
}
lazy var persistentContainer: NSPersistentContainer = {
let container: NSPersistentContainer
if Self.cloudKitEnabled {
container = NSPersistentCloudKitContainer(name: "PortfolioJournal")
} else {
container = NSPersistentContainer(name: "PortfolioJournal")
}
// App Group store URL for sharing with widgets. Fall back if not entitled.
let storeURL = Self.resolveStoreURL()
let description = NSPersistentStoreDescription(url: storeURL)
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true
if Self.cloudKitEnabled {
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: Self.cloudKitContainerIdentifier
)
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
}
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { description, error in
if let error = error as NSError? {
// In production, handle this error appropriately
print("Core Data failed to load: \(error), \(error.userInfo)")
} else {
print("Core Data loaded successfully at: \(description.url?.path ?? "unknown")")
}
}
// Merge policy - remote changes win
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
// Performance optimizations
container.viewContext.undoManager = nil
container.viewContext.shouldDeleteInaccessibleFaults = true
if Self.cloudKitEnabled {
NotificationCenter.default.addObserver(
self,
selector: #selector(processRemoteChanges),
name: .NSPersistentStoreRemoteChange,
object: container.persistentStoreCoordinator
)
}
return container
}()
var viewContext: NSManagedObjectContext {
return persistentContainer.viewContext
}
private init() {}
// MARK: - Save Context
func save() {
let context = viewContext
guard context.hasChanges else { return }
do {
try context.save()
} catch {
let nsError = error as NSError
print("Core Data save error: \(nsError), \(nsError.userInfo)")
}
}
// MARK: - Background Context
func newBackgroundContext() -> NSManagedObjectContext {
let context = persistentContainer.newBackgroundContext()
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
context.undoManager = nil
return context
}
func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void) {
persistentContainer.performBackgroundTask(block)
}
// MARK: - Remote Change Handling
@objc private func processRemoteChanges(_ notification: Notification) {
// Process remote changes on main context
DispatchQueue.main.async { [weak self] in
self?.objectWillChange.send()
// Ensure changes are persisted to disk before refreshing widget
self?.save()
self?.refreshWidgetData()
}
}
// MARK: - Widget Data Refresh
func refreshWidgetData() {
if #available(iOS 14.0, *) {
if Self.appGroupEnabled {
checkpointWAL()
WidgetCenter.shared.reloadAllTimelines()
}
}
}
/// Forces SQLite to checkpoint the WAL file, ensuring all changes are written to the main database file.
/// This is necessary for the widget to see changes, as it opens the database read-only.
private func checkpointWAL() {
if viewContext.hasChanges {
try? viewContext.save()
}
let coordinator = persistentContainer.persistentStoreCoordinator
coordinator.performAndWait {
viewContext.refreshAllObjects()
}
}
}
// MARK: - Shared Container for Widgets
extension CoreDataStack {
static var sharedStoreURL: URL? {
guard appGroupEnabled else {
let fallbackURL = NSPersistentContainer.defaultDirectoryURL()
.appendingPathComponent("PortfolioJournal.sqlite")
return fallbackURL
}
if let appGroupURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)?
.appendingPathComponent("PortfolioJournal.sqlite") {
return appGroupURL
}
let fallbackURL = NSPersistentContainer.defaultDirectoryURL()
.appendingPathComponent("PortfolioJournal.sqlite")
print("App Group unavailable for widgets; using default store URL: \(fallbackURL)")
return fallbackURL
}
/// Creates a lightweight Core Data stack for widgets (read-only)
static func createWidgetContainer() -> NSPersistentContainer {
let container = NSPersistentContainer(name: "PortfolioJournal")
guard let storeURL = sharedStoreURL else {
fatalError("Unable to get shared store URL")
}
let description = NSPersistentStoreDescription(url: storeURL)
description.isReadOnly = true
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { _, error in
if let error = error {
print("Widget Core Data failed: \(error)")
}
}
return container
}
}
+25
View File
@@ -0,0 +1,25 @@
import Foundation
enum InputMode: String, CaseIterable, Identifiable {
case simple
case detailed
var id: String { rawValue }
var title: String {
switch self {
case .simple: return "Simple"
case .detailed: return "Detailed"
}
}
var description: String {
switch self {
case .simple:
return "Enter a single total value per snapshot."
case .detailed:
return "Enter invested amount and gains to track additional metrics."
}
}
}
@@ -0,0 +1,203 @@
import Foundation
struct InvestmentMetrics {
// Basic Metrics
let totalValue: Decimal
let totalContributions: Decimal
let absoluteReturn: Decimal
let percentageReturn: Decimal
// Advanced Metrics
let cagr: Double // Compound Annual Growth Rate
let twr: Double // Time-Weighted Return
let volatility: Double // Standard deviation annualized
let maxDrawdown: Double // Maximum peak-to-trough decline
let sharpeRatio: Double // Risk-adjusted return
let bestMonth: MonthlyReturn?
let worstMonth: MonthlyReturn?
let winRate: Double // Percentage of positive months
let averageMonthlyReturn: Double
// Time period
let startDate: Date?
let endDate: Date?
let totalMonths: Int
struct MonthlyReturn: Identifiable {
let id = UUID()
let date: Date
let returnPercentage: Double
var formattedDate: String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM yyyy"
return formatter.string(from: date)
}
var formattedReturn: String {
String(format: "%.1f%%", returnPercentage)
}
}
}
// MARK: - Formatting Helpers
extension InvestmentMetrics {
var formattedTotalValue: String {
formatCurrency(totalValue)
}
var formattedAbsoluteReturn: String {
let prefix = absoluteReturn >= 0 ? "+" : ""
return prefix + formatCurrency(absoluteReturn)
}
var formattedPercentageReturn: String {
let prefix = percentageReturn >= 0 ? "+" : ""
return String(format: "\(prefix)%.2f%%", NSDecimalNumber(decimal: percentageReturn).doubleValue)
}
var formattedCAGR: String {
String(format: "%.2f%%", cagr)
}
var formattedTWR: String {
String(format: "%.2f%%", twr)
}
var formattedVolatility: String {
String(format: "%.2f%%", volatility)
}
var formattedMaxDrawdown: String {
String(format: "%.2f%%", maxDrawdown)
}
var formattedSharpeRatio: String {
String(format: "%.2f", sharpeRatio)
}
var formattedWinRate: String {
String(format: "%.0f%%", winRate)
}
var formattedAverageMonthlyReturn: String {
let prefix = averageMonthlyReturn >= 0 ? "+" : ""
return String(format: "\(prefix)%.2f%%", averageMonthlyReturn)
}
private func formatCurrency(_ value: Decimal) -> String {
CurrencyFormatter.format(value, style: .currency, maximumFractionDigits: 2)
}
}
// MARK: - Empty State
extension InvestmentMetrics {
static var empty: InvestmentMetrics {
InvestmentMetrics(
totalValue: 0,
totalContributions: 0,
absoluteReturn: 0,
percentageReturn: 0,
cagr: 0,
twr: 0,
volatility: 0,
maxDrawdown: 0,
sharpeRatio: 0,
bestMonth: nil,
worstMonth: nil,
winRate: 0,
averageMonthlyReturn: 0,
startDate: nil,
endDate: nil,
totalMonths: 0
)
}
}
// MARK: - Category Metrics
struct CategoryMetrics: Identifiable {
let id: UUID
let categoryName: String
let colorHex: String
let icon: String
let totalValue: Decimal
let percentageOfPortfolio: Double
let metrics: InvestmentMetrics
var formattedTotalValue: String {
CurrencyFormatter.format(totalValue, style: .currency, maximumFractionDigits: 0)
}
var formattedPercentage: String {
String(format: "%.1f%%", percentageOfPortfolio)
}
}
// MARK: - Portfolio Summary
struct PortfolioSummary {
let totalValue: Decimal
let totalContributions: Decimal
let dayChange: Decimal
let dayChangePercentage: Double
let weekChange: Decimal
let weekChangePercentage: Double
let monthChange: Decimal
let monthChangePercentage: Double
let yearChange: Decimal
let yearChangePercentage: Double
let allTimeReturn: Decimal
let allTimeReturnPercentage: Double
let sourceCount: Int
let lastUpdated: Date?
var formattedTotalValue: String {
CurrencyFormatter.format(totalValue, style: .currency, maximumFractionDigits: 0)
}
var formattedDayChange: String {
formatChange(dayChange, dayChangePercentage)
}
var formattedMonthChange: String {
formatChange(monthChange, monthChangePercentage)
}
var formattedYearChange: String {
formatChange(yearChange, yearChangePercentage)
}
var formattedAllTimeReturn: String {
formatChange(allTimeReturn, allTimeReturnPercentage)
}
private func formatChange(_ absolute: Decimal, _ percentage: Double) -> String {
let prefix = absolute >= 0 ? "+" : ""
let absString = CurrencyFormatter.format(absolute, style: .currency, maximumFractionDigits: 0)
let pctString = String(format: "%.2f%%", percentage)
return "\(prefix)\(absString) (\(prefix)\(pctString))"
}
static var empty: PortfolioSummary {
PortfolioSummary(
totalValue: 0,
totalContributions: 0,
dayChange: 0,
dayChangePercentage: 0,
weekChange: 0,
weekChangePercentage: 0,
monthChange: 0,
monthChangePercentage: 0,
yearChange: 0,
yearChangePercentage: 0,
allTimeReturn: 0,
allTimeReturnPercentage: 0,
sourceCount: 0,
lastUpdated: nil
)
}
}
@@ -0,0 +1,98 @@
import Foundation
struct MonthlyCheckInEntry: Codable, Equatable {
var note: String?
var rating: Int?
var mood: MonthlyCheckInMood?
var completionTime: Double?
var createdAt: Double
var completionDate: Date? {
completionTime.map { Date(timeIntervalSince1970: $0) }
}
}
enum MonthlyCheckInMood: String, Codable, CaseIterable, Identifiable {
case energized
case confident
case balanced
case cautious
case stressed
var id: String { rawValue }
var title: String {
switch self {
case .energized: return String(localized: "mood_energized_title")
case .confident: return String(localized: "mood_confident_title")
case .balanced: return String(localized: "mood_balanced_title")
case .cautious: return String(localized: "mood_cautious_title")
case .stressed: return String(localized: "mood_stressed_title")
}
}
var iconName: String {
switch self {
case .energized: return "flame.fill"
case .confident: return "hand.thumbsup.fill"
case .balanced: return "leaf.fill"
case .cautious: return "exclamationmark.triangle.fill"
case .stressed: return "exclamationmark.octagon.fill"
}
}
var detail: String {
switch self {
case .energized: return String(localized: "mood_energized_detail")
case .confident: return String(localized: "mood_confident_detail")
case .balanced: return String(localized: "mood_balanced_detail")
case .cautious: return String(localized: "mood_cautious_detail")
case .stressed: return String(localized: "mood_stressed_detail")
}
}
}
struct MonthlyCheckInAchievement: Identifiable, Equatable {
let key: String
let title: String
let detail: String
let icon: String
var id: String { key }
}
struct MonthlyCheckInAchievementStatus: Identifiable, Equatable {
let achievement: MonthlyCheckInAchievement
let isUnlocked: Bool
var id: String { achievement.key }
}
struct MonthlyCheckInStats: Equatable {
let currentStreak: Int
let bestStreak: Int
let onTimeCount: Int
let totalCheckIns: Int
let averageDaysBeforeDeadline: Double?
let closestCutoffDays: Double?
let recentMood: MonthlyCheckInMood?
let achievements: [MonthlyCheckInAchievement]
static var empty: MonthlyCheckInStats {
MonthlyCheckInStats(
currentStreak: 0,
bestStreak: 0,
onTimeCount: 0,
totalCheckIns: 0,
averageDaysBeforeDeadline: nil,
closestCutoffDays: nil,
recentMood: nil,
achievements: []
)
}
var onTimeRate: Double {
guard totalCheckIns > 0 else { return 0 }
return Double(onTimeCount) / Double(totalCheckIns)
}
}
@@ -0,0 +1,73 @@
import Foundation
struct MonthlySummary {
let periodLabel: String
let startDate: Date
let endDate: Date
let startingValue: Decimal
let endingValue: Decimal
let contributions: Decimal
let netPerformance: Decimal
var netPerformancePercentage: Double {
let base = startingValue + contributions
guard base > 0 else { return 0 }
return NSDecimalNumber(decimal: netPerformance / base).doubleValue * 100
}
var formattedStartingValue: String {
CurrencyFormatter.format(startingValue, style: .currency, maximumFractionDigits: 0)
}
var formattedEndingValue: String {
CurrencyFormatter.format(endingValue, style: .currency, maximumFractionDigits: 0)
}
var formattedContributions: String {
CurrencyFormatter.format(contributions, style: .currency, maximumFractionDigits: 0)
}
var formattedNetPerformance: String {
let prefix = netPerformance >= 0 ? "+" : ""
let value = CurrencyFormatter.format(netPerformance, style: .currency, maximumFractionDigits: 0)
return prefix + value
}
var formattedNetPerformancePercentage: String {
let prefix = netPerformance >= 0 ? "+" : ""
return String(format: "\(prefix)%.2f%%", netPerformancePercentage)
}
static var empty: MonthlySummary {
MonthlySummary(
periodLabel: "This Month",
startDate: Date(),
endDate: Date(),
startingValue: 0,
endingValue: 0,
contributions: 0,
netPerformance: 0
)
}
}
struct PortfolioChange {
let absolute: Decimal
let percentage: Double
let label: String
var formattedAbsolute: String {
let prefix = absolute >= 0 ? "+" : ""
let value = CurrencyFormatter.format(absolute, style: .currency, maximumFractionDigits: 0)
return prefix + value
}
var formattedPercentage: String {
let prefix = absolute >= 0 ? "+" : ""
return String(format: "\(prefix)%.2f%%", percentage)
}
static var empty: PortfolioChange {
PortfolioChange(absolute: 0, percentage: 0, label: "since last update")
}
}
+105
View File
@@ -0,0 +1,105 @@
import Foundation
struct Prediction: Codable, Identifiable {
let id: UUID
let date: Date
let predictedValue: Decimal
let algorithm: PredictionAlgorithm
let confidenceInterval: ConfidenceInterval
init(
id: UUID = UUID(),
date: Date,
predictedValue: Decimal,
algorithm: PredictionAlgorithm,
confidenceInterval: ConfidenceInterval
) {
self.id = id
self.date = date
self.predictedValue = predictedValue
self.algorithm = algorithm
self.confidenceInterval = confidenceInterval
}
struct ConfidenceInterval: Codable {
let lower: Decimal
let upper: Decimal
var range: Decimal {
upper - lower
}
var percentageWidth: Decimal {
guard upper != 0 else { return 0 }
return (range / upper) * 100
}
}
}
// MARK: - Prediction Algorithm Codable
extension PredictionAlgorithm: Codable {}
// MARK: - Prediction Helpers
extension Prediction {
var formattedValue: String {
CurrencyFormatter.format(predictedValue, style: .currency, maximumFractionDigits: 0)
}
var formattedDate: String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM yyyy"
return formatter.string(from: date)
}
var formattedConfidenceRange: String {
let lower = CurrencyFormatter.format(
confidenceInterval.lower,
style: .currency,
maximumFractionDigits: 0
)
let upper = CurrencyFormatter.format(
confidenceInterval.upper,
style: .currency,
maximumFractionDigits: 0
)
return "\(lower) - \(upper)"
}
}
// MARK: - Prediction Result
struct PredictionResult {
let predictions: [Prediction]
let algorithm: PredictionAlgorithm
let accuracy: Double // R-squared or similar metric
let volatility: Double
var isHighConfidence: Bool {
accuracy >= 0.7
}
var confidenceLevel: ConfidenceLevel {
switch accuracy {
case 0.8...: return .high
case 0.5..<0.8: return .medium
default: return .low
}
}
enum ConfidenceLevel: String {
case high = "High"
case medium = "Medium"
case low = "Low"
var color: String {
switch self {
case .high: return "#10B981"
case .medium: return "#F59E0B"
case .low: return "#EF4444"
}
}
}
}
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.alexandrevazquez.portfoliojournal</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)com.alexandrevazquez.portfoliojournal</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.alexandrevazquez.portfoliojournal</string>
</array>
</dict>
</plist>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.alexandrevazquez.portfoliojournal</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)com.alexandrevazquez.portfoliojournal</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.alexandrevazquez.portfoliojournal</string>
</array>
</dict>
</plist>
@@ -0,0 +1,153 @@
import Foundation
import CoreData
import Combine
class AccountRepository: ObservableObject {
private let context: NSManagedObjectContext
@Published private(set) var accounts: [Account] = []
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
self.context = context
fetchAccounts()
setupNotificationObserver()
}
private func setupNotificationObserver() {
NotificationCenter.default.addObserver(
self,
selector: #selector(contextDidChange),
name: .NSManagedObjectContextObjectsDidChange,
object: context
)
}
@objc private func contextDidChange(_ notification: Notification) {
fetchAccounts()
}
// MARK: - Fetch
func fetchAccounts() {
context.perform { [weak self] in
guard let self else { return }
let request: NSFetchRequest<Account> = Account.fetchRequest()
request.sortDescriptors = [
NSSortDescriptor(keyPath: \Account.sortOrder, ascending: true),
NSSortDescriptor(keyPath: \Account.createdAt, ascending: true)
]
do {
let fetched = try self.context.fetch(request)
DispatchQueue.main.async {
self.accounts = fetched
}
} catch {
print("Failed to fetch accounts: \(error)")
DispatchQueue.main.async {
self.accounts = []
}
}
}
}
func fetchAccount(by id: UUID) -> Account? {
let request: NSFetchRequest<Account> = Account.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
request.fetchLimit = 1
return try? context.fetch(request).first
}
// MARK: - Create
@discardableResult
func createAccount(
name: String,
currency: String?,
inputMode: InputMode,
notificationFrequency: NotificationFrequency,
customFrequencyMonths: Int = 1
) -> Account {
let account = Account(context: context)
account.name = name
account.currency = currency
account.inputMode = inputMode.rawValue
account.notificationFrequency = notificationFrequency.rawValue
account.customFrequencyMonths = Int16(customFrequencyMonths)
account.sortOrder = Int16(accounts.count)
save()
return account
}
func createDefaultAccountIfNeeded() -> Account {
if let existing = accounts.first {
return existing
}
let defaultCurrency = AppSettings.getOrCreate(in: context).currency
let account = createAccount(
name: "Personal",
currency: defaultCurrency,
inputMode: .simple,
notificationFrequency: .monthly
)
// Attach existing sources to the default account
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
if let sources = try? context.fetch(request) {
for source in sources where source.account == nil {
source.account = account
}
save()
}
return account
}
// MARK: - Update
func updateAccount(
_ account: Account,
name: String? = nil,
currency: String? = nil,
inputMode: InputMode? = nil,
notificationFrequency: NotificationFrequency? = nil,
customFrequencyMonths: Int? = nil
) {
if let name = name {
account.name = name
}
if let currency = currency {
account.currency = currency
}
if let inputMode = inputMode {
account.inputMode = inputMode.rawValue
}
if let notificationFrequency = notificationFrequency {
account.notificationFrequency = notificationFrequency.rawValue
}
if let customMonths = customFrequencyMonths {
account.customFrequencyMonths = Int16(customMonths)
}
save()
}
// MARK: - Delete
func deleteAccount(_ account: Account) {
context.delete(account)
save()
}
// MARK: - Save
private func save() {
guard context.hasChanges else { return }
do {
try context.save()
fetchAccounts()
} catch {
print("Failed to save accounts: \(error)")
}
}
}
@@ -0,0 +1,171 @@
import Foundation
import CoreData
import Combine
class CategoryRepository: ObservableObject {
private let context: NSManagedObjectContext
@Published private(set) var categories: [Category] = []
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
self.context = context
fetchCategories()
setupNotificationObserver()
}
private func setupNotificationObserver() {
NotificationCenter.default.addObserver(
self,
selector: #selector(contextDidChange),
name: .NSManagedObjectContextObjectsDidChange,
object: context
)
}
@objc private func contextDidChange(_ notification: Notification) {
guard isRelevantChange(notification) else { return }
fetchCategories()
}
// MARK: - Fetch
func fetchCategories() {
context.perform { [weak self] in
guard let self else { return }
let request: NSFetchRequest<Category> = Category.fetchRequest()
request.sortDescriptors = [
NSSortDescriptor(keyPath: \Category.sortOrder, ascending: true),
NSSortDescriptor(keyPath: \Category.name, ascending: true)
]
do {
self.categories = try self.context.fetch(request)
} catch {
print("Failed to fetch categories: \(error)")
self.categories = []
}
}
}
func fetchCategory(by id: UUID) -> Category? {
let request: NSFetchRequest<Category> = Category.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
request.fetchLimit = 1
return try? context.fetch(request).first
}
// MARK: - Create
@discardableResult
func createCategory(
name: String,
colorHex: String,
icon: String
) -> Category {
let category = Category(context: context)
category.name = name
category.colorHex = colorHex
category.icon = icon
category.sortOrder = Int16(categories.count)
save()
return category
}
func createDefaultCategoriesIfNeeded() {
guard categories.isEmpty else { return }
Category.createDefaultCategories(in: context)
fetchCategories()
}
// MARK: - Update
func updateCategory(
_ category: Category,
name: String? = nil,
colorHex: String? = nil,
icon: String? = nil
) {
if let name = name {
category.name = name
}
if let colorHex = colorHex {
category.colorHex = colorHex
}
if let icon = icon {
category.icon = icon
}
save()
}
func updateSortOrder(_ categories: [Category]) {
for (index, category) in categories.enumerated() {
category.sortOrder = Int16(index)
}
save()
}
// MARK: - Delete
func deleteCategory(_ category: Category) {
context.delete(category)
save()
}
func deleteCategory(at offsets: IndexSet) {
for index in offsets {
guard index < categories.count else { continue }
context.delete(categories[index])
}
save()
}
// MARK: - Statistics
var totalValue: Decimal {
categories.reduce(Decimal.zero) { $0 + $1.totalValue }
}
func categoryAllocation() -> [(category: Category, percentage: Double)] {
let total = totalValue
guard total > 0 else { return [] }
return categories.map { category in
let percentage = NSDecimalNumber(decimal: category.totalValue / total).doubleValue * 100
return (category: category, percentage: percentage)
}.sorted { $0.percentage > $1.percentage }
}
// MARK: - Save
private func save() {
guard context.hasChanges else { return }
do {
try context.save()
fetchCategories()
CoreDataStack.shared.refreshWidgetData()
} catch {
print("Failed to save context: \(error)")
}
}
private func isRelevantChange(_ notification: Notification) -> Bool {
guard let info = notification.userInfo else { return false }
let keys: [String] = [
NSInsertedObjectsKey,
NSUpdatedObjectsKey,
NSDeletedObjectsKey,
NSRefreshedObjectsKey
]
for key in keys {
if let objects = info[key] as? Set<NSManagedObject> {
if objects.contains(where: { $0 is Category }) {
return true
}
}
}
return false
}
}
@@ -0,0 +1,112 @@
import Foundation
import CoreData
import Combine
class GoalRepository: ObservableObject {
private let context: NSManagedObjectContext
@Published private(set) var goals: [Goal] = []
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
self.context = context
fetchGoals()
setupNotificationObserver()
}
private func setupNotificationObserver() {
NotificationCenter.default.addObserver(
self,
selector: #selector(contextDidChange),
name: .NSManagedObjectContextObjectsDidChange,
object: context
)
}
@objc private func contextDidChange(_ notification: Notification) {
fetchGoals()
}
// MARK: - Fetch
func fetchGoals(for account: Account? = nil) {
context.perform { [weak self] in
guard let self else { return }
let request: NSFetchRequest<Goal> = Goal.fetchRequest()
if let account = account {
request.predicate = NSPredicate(format: "account == %@", account)
}
request.sortDescriptors = [
NSSortDescriptor(keyPath: \Goal.createdAt, ascending: true)
]
do {
self.goals = try self.context.fetch(request)
} catch {
print("Failed to fetch goals: \(error)")
self.goals = []
}
}
}
// MARK: - Create
@discardableResult
func createGoal(
name: String,
targetAmount: Decimal,
targetDate: Date? = nil,
account: Account?
) -> Goal {
let goal = Goal(context: context)
goal.name = name
goal.targetAmount = NSDecimalNumber(decimal: targetAmount)
goal.targetDate = targetDate
goal.account = account
save()
return goal
}
// MARK: - Update
func updateGoal(
_ goal: Goal,
name: String? = nil,
targetAmount: Decimal? = nil,
targetDate: Date? = nil,
clearTargetDate: Bool = false,
isActive: Bool? = nil
) {
if let name = name {
goal.name = name
}
if let targetAmount = targetAmount {
goal.targetAmount = NSDecimalNumber(decimal: targetAmount)
}
if clearTargetDate {
goal.targetDate = nil
} else if let targetDate = targetDate {
goal.targetDate = targetDate
}
if let isActive = isActive {
goal.isActive = isActive
}
save()
}
func deleteGoal(_ goal: Goal) {
context.delete(goal)
save()
}
// MARK: - Save
private func save() {
guard context.hasChanges else { return }
do {
try context.save()
fetchGoals()
} catch {
print("Failed to save goals: \(error)")
}
}
}
@@ -0,0 +1,211 @@
import Foundation
import CoreData
import Combine
class InvestmentSourceRepository: ObservableObject {
private let context: NSManagedObjectContext
@Published private(set) var sources: [InvestmentSource] = []
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
self.context = context
fetchSources()
setupNotificationObserver()
}
private func setupNotificationObserver() {
NotificationCenter.default.addObserver(
self,
selector: #selector(contextDidChange),
name: .NSManagedObjectContextObjectsDidChange,
object: context
)
}
@objc private func contextDidChange(_ notification: Notification) {
guard isRelevantChange(notification) else { return }
fetchSources()
}
// MARK: - Fetch
func fetchSources(account: Account? = nil) {
context.perform { [weak self] in
guard let self else { return }
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
if let account = account {
request.predicate = NSPredicate(format: "account == %@", account)
}
request.sortDescriptors = [
NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)
]
do {
self.sources = try self.context.fetch(request)
} catch {
print("Failed to fetch sources: \(error)")
self.sources = []
}
}
}
func fetchSource(by id: UUID) -> InvestmentSource? {
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
request.fetchLimit = 1
return try? context.fetch(request).first
}
func fetchSources(for category: Category) -> [InvestmentSource] {
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
request.predicate = NSPredicate(format: "category == %@", category)
request.sortDescriptors = [
NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)
]
return (try? context.fetch(request)) ?? []
}
func fetchActiveSources() -> [InvestmentSource] {
sources.filter { $0.isActive }
}
func fetchSourcesNeedingUpdate(for account: Account? = nil) -> [InvestmentSource] {
let filtered = account == nil ? sources : sources.filter { $0.account?.id == account?.id }
return filtered.filter { $0.needsUpdate }
}
// MARK: - Create
@discardableResult
func createSource(
name: String,
category: Category,
notificationFrequency: NotificationFrequency = .monthly,
customFrequencyMonths: Int = 1,
account: Account? = nil
) -> InvestmentSource {
let source = InvestmentSource(context: context)
source.name = name
source.category = category
source.notificationFrequency = notificationFrequency.rawValue
source.customFrequencyMonths = Int16(customFrequencyMonths)
source.account = account
save()
return source
}
// MARK: - Update
func updateSource(
_ source: InvestmentSource,
name: String? = nil,
category: Category? = nil,
notificationFrequency: NotificationFrequency? = nil,
customFrequencyMonths: Int? = nil,
isActive: Bool? = nil,
account: Account? = nil
) {
if let name = name {
source.name = name
}
if let category = category {
source.category = category
}
if let frequency = notificationFrequency {
source.notificationFrequency = frequency.rawValue
}
if let customMonths = customFrequencyMonths {
source.customFrequencyMonths = Int16(customMonths)
}
if let isActive = isActive {
source.isActive = isActive
}
if let account = account {
source.account = account
}
save()
}
func toggleActive(_ source: InvestmentSource) {
source.isActive.toggle()
save()
}
// MARK: - Delete
func deleteSource(_ source: InvestmentSource) {
context.delete(source)
save()
}
func deleteSource(at offsets: IndexSet) {
for index in offsets {
guard index < sources.count else { continue }
context.delete(sources[index])
}
save()
}
// MARK: - Statistics
var sourceCount: Int {
sources.count
}
var activeSourceCount: Int {
sources.filter { $0.isActive }.count
}
var totalValue: Decimal {
sources.reduce(Decimal.zero) { $0 + $1.latestValue }
}
func topSources(limit: Int = 5) -> [InvestmentSource] {
sources
.sorted { $0.latestValue > $1.latestValue }
.prefix(limit)
.map { $0 }
}
// MARK: - Freemium Check
var canAddMoreSources: Bool {
// This will be checked against FreemiumValidator
true
}
// MARK: - Save
private func save() {
guard context.hasChanges else { return }
do {
try context.save()
fetchSources()
CoreDataStack.shared.refreshWidgetData()
} catch {
print("Failed to save context: \(error)")
}
}
private func isRelevantChange(_ notification: Notification) -> Bool {
guard let info = notification.userInfo else { return false }
let keys: [String] = [
NSInsertedObjectsKey,
NSUpdatedObjectsKey,
NSDeletedObjectsKey,
NSRefreshedObjectsKey
]
for key in keys {
if let objects = info[key] as? Set<NSManagedObject> {
if objects.contains(where: { $0 is InvestmentSource }) {
return true
}
}
}
return false
}
}
@@ -0,0 +1,316 @@
import Foundation
import CoreData
import Combine
class SnapshotRepository: ObservableObject {
private let context: NSManagedObjectContext
private let cache = NSCache<NSString, NSArray>()
@Published private(set) var cacheVersion: Int = 0
@Published private(set) var snapshots: [Snapshot] = []
// MARK: - Performance: Shared DateFormatter
private static let monthYearFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM"
return formatter
}()
// MARK: - Performance: Cached Calendar
private static let calendar = Calendar.current
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
self.context = context
// Performance: Increase cache limits for better hit rate
cache.countLimit = 12
cache.totalCostLimit = 4_000_000 // 4 MB
setupNotificationObserver()
}
// MARK: - Fetch
func fetchSnapshots(for source: InvestmentSource) -> [Snapshot] {
let request: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
request.predicate = NSPredicate(format: "source == %@", source)
request.sortDescriptors = [
NSSortDescriptor(keyPath: \Snapshot.date, ascending: false)
]
request.fetchBatchSize = 200
return (try? context.fetch(request)) ?? []
}
func fetchSnapshot(by id: UUID) -> Snapshot? {
let request: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
request.fetchLimit = 1
return try? context.fetch(request).first
}
func fetchAllSnapshots() -> [Snapshot] {
let request: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
request.sortDescriptors = [
NSSortDescriptor(keyPath: \Snapshot.date, ascending: false)
]
request.fetchBatchSize = 200
return (try? context.fetch(request)) ?? []
}
func fetchSnapshots(for account: Account) -> [Snapshot] {
let request: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
request.predicate = NSPredicate(format: "source.account == %@", account)
request.sortDescriptors = [
NSSortDescriptor(keyPath: \Snapshot.date, ascending: false)
]
request.fetchBatchSize = 200
return (try? context.fetch(request)) ?? []
}
func fetchSnapshots(from startDate: Date, to endDate: Date) -> [Snapshot] {
let request: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
request.predicate = NSPredicate(
format: "date >= %@ AND date <= %@",
startDate as NSDate,
endDate as NSDate
)
request.sortDescriptors = [
NSSortDescriptor(keyPath: \Snapshot.date, ascending: true)
]
request.fetchBatchSize = 200
return (try? context.fetch(request)) ?? []
}
func fetchLatestSnapshots() -> [Snapshot] {
// Get the latest snapshot for each source
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
let sources = (try? context.fetch(request)) ?? []
return sources.compactMap { $0.latestSnapshot }
}
// MARK: - Create
@discardableResult
func createSnapshot(
for source: InvestmentSource,
date: Date = Date(),
value: Decimal,
contribution: Decimal? = nil,
notes: String? = nil
) -> Snapshot {
let snapshot = Snapshot(context: context)
snapshot.source = source
snapshot.date = date
snapshot.value = NSDecimalNumber(decimal: value)
if let contribution = contribution {
snapshot.contribution = NSDecimalNumber(decimal: contribution)
}
snapshot.notes = notes
save()
return snapshot
}
// MARK: - Update
func updateSnapshot(
_ snapshot: Snapshot,
date: Date? = nil,
value: Decimal? = nil,
contribution: Decimal? = nil,
notes: String? = nil,
clearContribution: Bool = false,
clearNotes: Bool = false
) {
if let date = date {
snapshot.date = date
}
if let value = value {
snapshot.value = NSDecimalNumber(decimal: value)
}
if clearContribution {
snapshot.contribution = nil
} else if let contribution = contribution {
snapshot.contribution = NSDecimalNumber(decimal: contribution)
}
if clearNotes {
snapshot.notes = nil
} else if let notes = notes {
snapshot.notes = notes
}
save()
}
// MARK: - Delete
func deleteSnapshot(_ snapshot: Snapshot) {
context.delete(snapshot)
save()
}
func deleteSnapshots(_ snapshots: [Snapshot]) {
snapshots.forEach { context.delete($0) }
save()
}
func deleteSnapshot(at offsets: IndexSet, from snapshots: [Snapshot]) {
for index in offsets {
guard index < snapshots.count else { continue }
context.delete(snapshots[index])
}
save()
}
// MARK: - Filtered Fetch (Freemium)
func fetchSnapshots(
for source: InvestmentSource,
limitedToMonths months: Int?
) -> [Snapshot] {
var snapshots = fetchSnapshots(for: source)
if let months = months {
// Performance: Use cached calendar
let cutoffDate = Self.calendar.date(
byAdding: .month,
value: -months,
to: Date()
) ?? Date()
snapshots = snapshots.filter { $0.date >= cutoffDate }
}
return snapshots
}
func fetchSnapshots(
for sourceIds: [UUID],
months: Int? = nil
) -> [Snapshot] {
guard !sourceIds.isEmpty else { return [] }
// Performance: Use cached calendar
let cutoffDate: Date? = {
guard let months = months else { return nil }
return Self.calendar.date(byAdding: .month, value: -months, to: Date())
}()
let key = cacheKey(
sourceIds: sourceIds,
months: months
)
if let cached = cache.object(forKey: key) as? [Snapshot] {
return cached
}
let request: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
var predicates: [NSPredicate] = [
NSPredicate(format: "source.id IN %@", sourceIds)
]
if let cutoffDate {
predicates.append(NSPredicate(format: "date >= %@", cutoffDate as NSDate))
}
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
request.sortDescriptors = [
NSSortDescriptor(keyPath: \Snapshot.date, ascending: false)
]
request.fetchBatchSize = 300
let fetched = (try? context.fetch(request)) ?? []
cache.setObject(fetched as NSArray, forKey: key, cost: fetched.count)
return fetched
}
// MARK: - Historical Data
func getMonthlyValues(for source: InvestmentSource) -> [(date: Date, value: Decimal)] {
let snapshots = fetchSnapshots(for: source).reversed()
var monthlyData: [(date: Date, value: Decimal)] = []
monthlyData.reserveCapacity(min(snapshots.count, 60))
var processedMonths: Set<String> = []
processedMonths.reserveCapacity(60)
// Performance: Use shared formatter
let formatter = Self.monthYearFormatter
for snapshot in snapshots {
let monthKey = formatter.string(from: snapshot.date)
if !processedMonths.contains(monthKey) {
processedMonths.insert(monthKey)
monthlyData.append((date: snapshot.date, value: snapshot.decimalValue))
}
}
return monthlyData
}
func getPortfolioHistory() -> [(date: Date, totalValue: Decimal)] {
let allSnapshots = fetchAllSnapshots()
// Performance: Pre-allocate dictionary
var dateValues: [Date: Decimal] = [:]
dateValues.reserveCapacity(min(allSnapshots.count, 365))
// Performance: Use cached calendar
let calendar = Self.calendar
for snapshot in allSnapshots {
let startOfDay = calendar.startOfDay(for: snapshot.date)
dateValues[startOfDay, default: Decimal.zero] += snapshot.decimalValue
}
return dateValues
.map { (date: $0.key, totalValue: $0.value) }
.sorted { $0.date < $1.date }
}
// MARK: - Save
private func save() {
guard context.hasChanges else { return }
do {
try context.save()
invalidateCache()
CoreDataStack.shared.refreshWidgetData()
} catch {
print("Failed to save context: \(error)")
}
}
// MARK: - Cache
private func cacheKey(
sourceIds: [UUID],
months: Int?
) -> NSString {
let sortedIds = sourceIds.sorted().map { $0.uuidString }.joined(separator: ",")
let monthsPart = months.map(String.init) ?? "all"
return NSString(string: "\(cacheVersion)|\(monthsPart)|\(sortedIds)")
}
private func invalidateCache() {
cache.removeAllObjects()
cacheVersion &+= 1
}
private func setupNotificationObserver() {
NotificationCenter.default.addObserver(
self,
selector: #selector(contextDidChange),
name: .NSManagedObjectContextObjectsDidChange,
object: context
)
}
@objc private func contextDidChange(_ notification: Notification) {
invalidateCache()
}
}
@@ -0,0 +1,62 @@
import Foundation
import CoreData
class TransactionRepository {
private let context: NSManagedObjectContext
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
self.context = context
}
func fetchTransactions(for source: InvestmentSource) -> [Transaction] {
let request: NSFetchRequest<Transaction> = Transaction.fetchRequest()
request.predicate = NSPredicate(format: "source == %@", source)
request.sortDescriptors = [
NSSortDescriptor(keyPath: \Transaction.date, ascending: false)
]
return (try? context.fetch(request)) ?? []
}
@discardableResult
func createTransaction(
source: InvestmentSource,
type: TransactionType,
date: Date,
shares: Decimal?,
price: Decimal?,
amount: Decimal?,
notes: String?
) -> Transaction {
let transaction = Transaction(context: context)
transaction.source = source
transaction.type = type.rawValue
transaction.date = date
if let shares = shares {
transaction.shares = NSDecimalNumber(decimal: shares)
}
if let price = price {
transaction.price = NSDecimalNumber(decimal: price)
}
if let amount = amount {
transaction.amount = NSDecimalNumber(decimal: amount)
}
transaction.notes = notes
save()
return transaction
}
func deleteTransaction(_ transaction: Transaction) {
context.delete(transaction)
save()
}
private func save() {
guard context.hasChanges else { return }
do {
try context.save()
} catch {
print("Failed to save transaction: \(error)")
}
}
}
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>API_KEY</key>
<string>AIzaSyAregLaXQ-WqRQTltTRyjQx3-lfxLl4Cng</string>
<key>GCM_SENDER_ID</key>
<string>334225114072</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.alexandrevazquez.portfoliojournal</string>
<key>PROJECT_ID</key>
<string>portfoliojournal-ef2d7</string>
<key>STORAGE_BUCKET</key>
<string>portfoliojournal-ef2d7.firebasestorage.app</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:334225114072:ios:81bad412ffe1c6df3d28ad</string>
</dict>
</plist>
+92
View File
@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Portfolio Journal</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleURLSchemes</key>
<array>
<string>portfoliojournal</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>GADApplicationIdentifier</key>
<string>ca-app-pub-1549720748100858~9632507420</string>
<key>GADDelayAppMeasurementInit</key>
<true/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSAllowsArbitraryLoadsForMedia</key>
<false/>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<false/>
</dict>
<key>NSCalendarsUsageDescription</key>
<string>Used to set investment update reminders.</string>
<key>NSFaceIDUsageDescription</key>
<string>Use Face ID to unlock your portfolio data.</string>
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
<true/>
<key>NSUserTrackingUsageDescription</key>
<string>This app uses tracking to provide personalized ads and improve your experience. Your data is not sold to third parties.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportsDocumentBrowser</key>
<false/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,19 @@
{
"identifier" : "Default Configuration",
"provider" : "appstore",
"products" : [
{
"id" : "com.portfoliojournal.premium",
"type" : "Non-Consumable",
"referenceName" : "Premium Unlock",
"state" : "approved",
"price" : {
"currency" : "EUR",
"value" : 4.69
}
}
],
"subscriptionGroups" : [ ],
"localizations" : [ ],
"files" : [ ]
}
@@ -0,0 +1,236 @@
/*
Localizable.strings
PortfolioJournal
English (Base) localization
*/
// MARK: - General
"app_name" = "Portfolio Journal";
"ok" = "OK";
"cancel" = "Cancel";
"save" = "Save";
"delete" = "Delete";
"edit" = "Edit";
"add" = "Add";
"done" = "Done";
"close" = "Close";
"continue" = "Continue";
"skip" = "Skip";
"error" = "Error";
"success" = "Success";
"loading" = "Loading...";
// MARK: - Tab Bar
"tab_dashboard" = "Home";
"tab_sources" = "Sources";
"tab_charts" = "Charts";
"tab_settings" = "Settings";
// MARK: - Dashboard
"dashboard_title" = "Home";
"total_portfolio_value" = "Total Portfolio Value";
"today" = "today";
"returns" = "Returns";
"by_category" = "By Category";
"pending_updates" = "Pending Updates";
"see_all" = "See All";
// MARK: - Sources
"sources_title" = "Sources";
"add_source" = "Add Source";
"source_name" = "Source Name";
"select_category" = "Select Category";
"initial_value" = "Initial Value";
"initial_value_optional" = "Initial Value (Optional)";
"reminder_frequency" = "Reminder Frequency";
"source_limit_warning" = "Source limit reached. Upgrade to Premium for unlimited sources.";
"no_sources" = "No Investment Sources";
"no_sources_message" = "Add your first investment source to start tracking your portfolio.";
// MARK: - Snapshots
"add_snapshot" = "Add Snapshot";
"edit_snapshot" = "Edit Snapshot";
"snapshot_date" = "Date";
"snapshot_value" = "Value";
"snapshot_contribution" = "Contribution";
"contribution_optional" = "Contribution (Optional)";
"notes" = "Notes";
"notes_optional" = "Notes (Optional)";
"previous_value" = "Previous: %@";
"change_from_previous" = "Change from previous";
// MARK: - Charts
"charts_title" = "Charts";
"evolution" = "Evolution";
"allocation" = "Allocation";
"performance" = "Performance";
"drawdown" = "Drawdown";
"volatility" = "Volatility";
"prediction" = "Prediction";
"portfolio_evolution" = "Portfolio Evolution";
"asset_allocation" = "Asset Allocation";
"performance_by_category" = "Performance by Category";
"drawdown_analysis" = "Drawdown Analysis";
"prediction_12_month" = "12-Month Prediction";
"not_enough_data" = "Not enough data";
// MARK: - Metrics
"cagr" = "CAGR";
"twr" = "TWR";
"max_drawdown" = "Max Drawdown";
"sharpe_ratio" = "Sharpe Ratio";
"win_rate" = "Win Rate";
"avg_monthly" = "Avg Monthly";
"best_month" = "Best Month";
"worst_month" = "Worst Month";
// MARK: - Premium
"premium" = "Premium";
"upgrade_to_premium" = "Upgrade to Premium";
"unlock_full_potential" = "Unlock Full Potential";
"one_time_purchase" = "One-time purchase";
"includes_family_sharing" = "Includes Family Sharing";
"upgrade_now" = "Upgrade Now";
"restore_purchases" = "Restore Purchases";
"premium_active" = "Premium Active";
"premium_feature" = "Premium Feature";
"unlock" = "Unlock";
// Premium Features
"feature_unlimited_sources" = "Unlimited Sources";
"feature_unlimited_sources_desc" = "Track as many investments as you want";
"feature_full_history" = "Full History";
"feature_full_history_desc" = "Access your complete investment history";
"feature_advanced_charts" = "Advanced Charts";
"feature_advanced_charts_desc" = "5 types of detailed analytics charts";
"feature_predictions" = "Predictions";
"feature_predictions_desc" = "AI-powered 12-month forecasts";
"feature_export" = "Export Data";
"feature_export_desc" = "Export to CSV and JSON formats";
"feature_no_ads" = "No Ads";
"feature_no_ads_desc" = "Ad-free experience forever";
// MARK: - Settings
"settings_title" = "Settings";
"subscription" = "Subscription";
"notifications" = "Notifications";
"default_reminder_time" = "Default Reminder Time";
"data" = "Data";
"export_data" = "Export Data";
"total_sources" = "Total Sources";
"total_snapshots" = "Total Snapshots";
"storage_used" = "Storage Used";
"about" = "About";
"version" = "Version";
"privacy_policy" = "Privacy Policy";
"terms_of_service" = "Terms of Service";
"support" = "Support";
"rate_app" = "Rate App";
"danger_zone" = "Danger Zone";
"reset_all_data" = "Reset All Data";
"reset_confirmation" = "This will permanently delete all your investment data. This action cannot be undone.";
// MARK: - Notification Frequencies
"frequency_monthly" = "Monthly";
"frequency_quarterly" = "Quarterly";
"frequency_semiannual" = "Semi-Annual";
"frequency_annual" = "Annual";
"frequency_custom" = "Custom";
"frequency_never" = "Never";
"every_n_months" = "Every %d month(s)";
// MARK: - Categories
"category_stocks" = "Stocks";
"category_bonds" = "Bonds";
"category_real_estate" = "Real Estate";
"category_crypto" = "Crypto";
"category_cash" = "Cash";
"category_etfs" = "ETFs";
"category_retirement" = "Retirement";
"category_other" = "Other";
"uncategorized" = "Uncategorized";
// MARK: - Time Ranges
"time_1m" = "1M";
"time_3m" = "3M";
"time_6m" = "6M";
"time_1y" = "1Y";
"time_all" = "All";
// MARK: - Export
"export_format" = "Select Format";
"export_csv" = "CSV";
"export_csv_desc" = "Compatible with Excel, Google Sheets";
"export_json" = "JSON";
"export_json_desc" = "Full data structure for backup";
// MARK: - Onboarding
"onboarding_track_title" = "Track Your Investments";
"onboarding_track_desc" = "Monitor all your investment sources in one place. Stocks, bonds, real estate, crypto, and more.";
"onboarding_visualize_title" = "Visualize Your Growth";
"onboarding_visualize_desc" = "Beautiful charts show your portfolio evolution, allocation, and performance over time.";
"onboarding_reminders_title" = "Never Miss an Update";
"onboarding_reminders_desc" = "Set reminders to track your investments regularly. Monthly, quarterly, or custom schedules.";
"onboarding_sync_title" = "Sync Everywhere";
"onboarding_sync_desc" = "Your data syncs automatically via iCloud across all your Apple devices.";
"get_started" = "Get Started";
// MARK: - Errors
"error_generic" = "An error occurred. Please try again.";
"error_no_purchases" = "No purchases found to restore";
"error_purchase_failed" = "Purchase failed: %@";
"error_export_failed" = "Export failed. Please try again.";
// MARK: - Placeholders
"placeholder_source_name" = "e.g., Vanguard 401k";
"placeholder_value" = "0.00";
"placeholder_notes" = "Add notes...";
// MARK: - Monthly Check-in Moods
"mood_energized_title" = "On Fire";
"mood_confident_title" = "Confident";
"mood_balanced_title" = "Steady";
"mood_cautious_title" = "Cautious";
"mood_stressed_title" = "Stressed";
"mood_energized_detail" = "Feeling unbeatable";
"mood_confident_detail" = "On track and composed";
"mood_balanced_detail" = "Calm and patient";
"mood_cautious_detail" = "Watching the moves";
"mood_stressed_detail" = "Need a reset";
// MARK: - Monthly Check-in Achievements
"achievement_streak_3_title" = "3-Month Streak";
"achievement_streak_3_detail" = "Kept your check-ins on time for three months straight.";
"achievement_streak_6_title" = "Half-Year Hot Streak";
"achievement_streak_6_detail" = "Six consecutive on-time check-ins.";
"achievement_streak_12_title" = "Year of Momentum";
"achievement_streak_12_detail" = "A full year without missing the deadline.";
"achievement_perfect_on_time_title" = "Never Late";
"achievement_perfect_on_time_detail" = "Every check-in landed before the deadline.";
"achievement_clutch_finish_title" = "Clutch Finish";
"achievement_clutch_finish_detail" = "Submitted with hours to spare and still on time.";
"achievement_early_bird_title" = "Early Bird";
"achievement_early_bird_detail" = "On average you finish with plenty of time left.";
"achievements_title" = "Achievements";
"achievements_view_all" = "View all achievements";
"achievements_nav_title" = "Achievements";
"achievements_progress_title" = "Progress";
"achievements_unlocked_title" = "Unlocked";
"achievements_unlocked_empty" = "Complete check-ins to unlock achievements.";
"achievements_locked_title" = "Locked";
"achievements_locked_empty" = "All achievements unlocked. Nice work!";
// MARK: - Accessibility
"rating_accessibility" = "Rating %d of 5";
"achievements_unlocked_count" = "%d of %d unlocked";
"last_check_in" = "Last check-in: %@";
"next_check_in" = "Next check-in: %@";
"on_time_rate" = "%@ on-time";
"on_time_count" = "%d/%d on time";
"tightest_finish" = "Tightest finish: %@ before deadline.";
"date_today" = "Today";
"date_yesterday" = "Yesterday";
"date_never" = "Never";
"calendar_event_title" = "%@: Monthly Check-in";
"calendar_event_notes" = "Open %@ and complete your monthly check-in.";
@@ -0,0 +1,123 @@
/*
Localizable.strings
PortfolioJournal
Spanish (Spain) localization
*/
// MARK: - Journal
"Home" = "Inicio";
"Sources" = "Fuentes";
"Charts" = "Gráficos";
"Settings" = "Ajustes";
"Journal" = "Diario";
"Search monthly notes" = "Buscar notas mensuales";
"Monthly Check-ins" = "Chequeos mensuales";
"No monthly notes yet." = "Aún no hay notas mensuales.";
"No matching notes." = "No hay notas que coincidan.";
"Jump to month" = "Ir al mes";
"Today" = "Hoy";
"Mood not set" = "Estado de ánimo no establecido";
"No rating" = "Sin valoración";
"No note yet." = "Sin nota todavía.";
"Monthly Note" = "Nota mensual";
"Open Full Note" = "Ver nota completa";
"Duplicate Previous" = "Duplicar anterior";
"Save" = "Guardar";
// MARK: - Monthly Check-in
"Monthly Check-in" = "Chequeo mensual";
"This Month" = "Este mes";
"No check-in yet this month" = "Aún no hay chequeo este mes";
"Start your first check-in anytime." = "Empieza tu primer chequeo cuando quieras.";
"Mark Check-in Complete" = "Marcar chequeo completado";
"Editing stays open. New check-ins unlock after 70% of the month." = "La edición permanece abierta. Los nuevos chequeos se desbloquean tras el 70 % del mes.";
"Momentum & Streaks" = "Impulso y rachas";
"Log a check-in to start a streak" = "Registra un chequeo para iniciar una racha";
"Streak" = "Racha";
"On-time in a row" = "A tiempo seguidos";
"Best" = "Mejor";
"Personal best" = "Mejor personal";
"Avg early" = "Media de antelación";
"vs deadline" = "vs. fecha límite";
"On-time score" = "Puntuación a tiempo";
"Achievements" = "Logros";
"View all achievements" = "Ver todos los logros";
"Monthly Pulse" = "Pulso mensual";
"Optional" = "Opcional";
"Rate this month" = "Valora este mes";
"Skip" = "Omitir";
"How did it feel?" = "¿Cómo te sentiste?";
"Monthly Summary" = "Resumen mensual";
"Starting" = "Inicio";
"Ending" = "Final";
"Contributions" = "Aportaciones";
"Net Performance" = "Rendimiento neto";
"Update Sources" = "Actualizar fuentes";
"Add sources to start your monthly check-in." = "Añade fuentes para empezar tu chequeo mensual.";
"Updated this cycle" = "Actualizado en este ciclo";
"Needs update" = "Necesita actualización";
"Snapshot Notes" = "Notas de snapshots";
"No snapshot notes for this month." = "No hay notas de snapshots este mes.";
"Source" = "Fuente";
"last_check_in" = "Último chequeo: %@";
"next_check_in" = "Próximo chequeo: %@";
"on_time_rate" = "%@ a tiempo";
"on_time_count" = "%d/%d a tiempo";
"tightest_finish" = "Final más ajustado: %@ antes de la fecha límite.";
"date_today" = "Hoy";
"date_yesterday" = "Ayer";
"date_never" = "Nunca";
"calendar_event_title" = "%@: Chequeo mensual";
"calendar_event_notes" = "Abre %@ y completa tu chequeo mensual.";
// MARK: - Achievements View
"achievements_title" = "Logros";
"achievements_view_all" = "Ver todos los logros";
"achievements_nav_title" = "Logros";
"achievements_progress_title" = "Progreso";
"achievements_unlocked_title" = "Desbloqueados";
"achievements_unlocked_empty" = "Completa chequeos para desbloquear logros.";
"achievements_locked_title" = "Bloqueados";
"achievements_locked_empty" = "Todos los logros desbloqueados. ¡Buen trabajo!";
// MARK: - Monthly Check-in Moods
"mood_energized_title" = "En llamas";
"mood_confident_title" = "Confiado";
"mood_balanced_title" = "Estable";
"mood_cautious_title" = "Cauteloso";
"mood_stressed_title" = "Estresado";
"mood_energized_detail" = "Me siento imparable";
"mood_confident_detail" = "En camino y sereno";
"mood_balanced_detail" = "Calmado y paciente";
"mood_cautious_detail" = "Observando los movimientos";
"mood_stressed_detail" = "Necesito un respiro";
// MARK: - Categories
"category_stocks" = "Acciones";
"category_bonds" = "Bonos";
"category_real_estate" = "Inmobiliario";
"category_crypto" = "Cripto";
"category_cash" = "Efectivo";
"category_etfs" = "ETFs";
"category_retirement" = "Jubilación";
"category_other" = "Otros";
"uncategorized" = "Sin categoría";
// MARK: - Monthly Check-in Achievements
"achievement_streak_3_title" = "Racha de 3 meses";
"achievement_streak_3_detail" = "Has mantenido tus chequeos a tiempo durante tres meses seguidos.";
"achievement_streak_6_title" = "Racha de medio año";
"achievement_streak_6_detail" = "Seis chequeos a tiempo consecutivos.";
"achievement_streak_12_title" = "Un año de impulso";
"achievement_streak_12_detail" = "Un año completo sin perder la fecha límite.";
"achievement_perfect_on_time_title" = "Nunca tarde";
"achievement_perfect_on_time_detail" = "Todos los chequeos llegaron antes de la fecha límite.";
"achievement_clutch_finish_title" = "Final ajustado";
"achievement_clutch_finish_detail" = "Entregado con horas de margen y a tiempo.";
"achievement_early_bird_title" = "Madrugador";
"achievement_early_bird_detail" = "De media terminas con bastante margen.";
// MARK: - Accessibility
"rating_accessibility" = "Valoración %d de 5";
"achievements_unlocked_count" = "%d de %d desbloqueados";
@@ -0,0 +1,85 @@
import Foundation
import Combine
import CoreData
@MainActor
class AccountStore: ObservableObject {
@Published private(set) var accounts: [Account] = []
@Published var selectedAccount: Account?
@Published var showAllAccounts = true
private let accountRepository: AccountRepository
private let iapService: IAPService
private var cancellables = Set<AnyCancellable>()
init(
accountRepository: AccountRepository? = nil,
iapService: IAPService
) {
self.accountRepository = accountRepository ?? AccountRepository()
self.iapService = iapService
self.accountRepository.fetchAccounts()
let defaultAccount = self.accountRepository.createDefaultAccountIfNeeded()
accounts = self.accountRepository.accounts
selectedAccount = defaultAccount
loadSelection()
setupObservers()
}
private func setupObservers() {
accountRepository.$accounts
.receive(on: DispatchQueue.main)
.sink { [weak self] accounts in
self?.accounts = accounts
self?.syncSelectedAccount()
}
.store(in: &cancellables)
}
private func loadSelection() {
let context = CoreDataStack.shared.viewContext
let settings = AppSettings.getOrCreate(in: context)
showAllAccounts = settings.showAllAccounts
if let selectedId = settings.selectedAccountId,
let account = accountRepository.fetchAccount(by: selectedId) {
selectedAccount = account
} else {
selectedAccount = accounts.first
}
}
private func syncSelectedAccount() {
if showAllAccounts { return }
if let selected = selectedAccount,
accounts.contains(where: { $0.id == selected.id }) {
return
}
selectedAccount = accounts.first
}
func selectAllAccounts() {
showAllAccounts = true
persistSelection()
}
func selectAccount(_ account: Account) {
showAllAccounts = false
selectedAccount = account
persistSelection()
}
func persistSelection() {
let context = CoreDataStack.shared.viewContext
let settings = AppSettings.getOrCreate(in: context)
settings.showAllAccounts = showAllAccounts
settings.selectedAccountId = showAllAccounts ? nil : selectedAccount?.id
CoreDataStack.shared.save()
}
func canAddAccount() -> Bool {
iapService.isPremium || accounts.count < 1
}
}
@@ -0,0 +1,176 @@
import Foundation
import SwiftUI
import Combine
import GoogleMobileAds
import AppTrackingTransparency
import AdSupport
@MainActor
class AdMobService: ObservableObject {
// MARK: - Published Properties
@Published var isConsentObtained = false
@Published var canShowAds = false
@Published var isLoading = false
// MARK: - Ad Unit IDs
// Test Ad Unit IDs - Replace with production IDs before release
#if DEBUG
static let bannerAdUnitID = "ca-app-pub-3940256099942544/2934735716"
#else
static let bannerAdUnitID = "ca-app-pub-XXXXXXXXXXXXXXXX/YYYYYYYYYY" // Replace with your Ad Unit ID
#endif
// MARK: - Initialization
init() {
checkConsentStatus()
}
// MARK: - Consent Management
func checkConsentStatus() {
// Check if we already have consent
let consentStatus = UserDefaults.standard.bool(forKey: "adConsentObtained")
isConsentObtained = consentStatus
canShowAds = consentStatus
}
func requestConsent() async {
if #available(iOS 14.5, *) {
let status = await ATTrackingManager.requestTrackingAuthorization()
switch status {
case .authorized:
isConsentObtained = true
canShowAds = true
case .denied, .restricted:
// Can still show non-personalized ads
isConsentObtained = true
canShowAds = true
case .notDetermined:
// Will be asked again later
break
@unknown default:
break
}
} else {
// iOS 14.4 and earlier - consent assumed
isConsentObtained = true
canShowAds = true
}
UserDefaults.standard.set(isConsentObtained, forKey: "adConsentObtained")
}
// MARK: - GDPR Consent (UMP SDK)
func requestGDPRConsent() async {
// Implement UMP SDK consent flow if targeting EU users
// This is a simplified version - full implementation requires UMP SDK
let isEUUser = isUserInEU()
if isEUUser {
// Show GDPR consent dialog
// For now, assume consent if user continues
isConsentObtained = true
canShowAds = true
} else {
isConsentObtained = true
canShowAds = true
}
UserDefaults.standard.set(isConsentObtained, forKey: "adConsentObtained")
}
private func isUserInEU() -> Bool {
let euCountries = [
"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
"DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
"PL", "PT", "RO", "SK", "SI", "ES", "SE", "GB", "IS", "LI",
"NO", "CH"
]
let countryCode = Locale.current.region?.identifier ?? ""
return euCountries.contains(countryCode)
}
// MARK: - Analytics
func logAdImpression() {
FirebaseService.shared.logAdImpression(adType: "banner")
}
func logAdClick() {
FirebaseService.shared.logAdClick(adType: "banner")
}
}
// MARK: - Banner Ad Coordinator
class BannerAdCoordinator: NSObject, BannerViewDelegate {
weak var adMobService: AdMobService?
func bannerViewDidReceiveAd(_ bannerView: BannerView) {
print("Banner ad received")
adMobService?.logAdImpression()
}
func bannerView(_ bannerView: BannerView, didFailToReceiveAdWithError error: Error) {
print("Banner ad failed to load: \(error.localizedDescription)")
}
func bannerViewDidRecordImpression(_ bannerView: BannerView) {
// Impression recorded
}
func bannerViewDidRecordClick(_ bannerView: BannerView) {
adMobService?.logAdClick()
}
func bannerViewWillPresentScreen(_ bannerView: BannerView) {
// Ad will present full screen
}
func bannerViewWillDismissScreen(_ bannerView: BannerView) {
// Ad will dismiss
}
func bannerViewDidDismissScreen(_ bannerView: BannerView) {
// Ad dismissed
}
}
// MARK: - UIKit Banner View Wrapper
struct BannerAdView: UIViewRepresentable {
@EnvironmentObject var adMobService: AdMobService
func makeUIView(context: Context) -> BannerView {
let bannerView = BannerView(adSize: AdSizeBanner)
bannerView.adUnitID = AdMobService.bannerAdUnitID
// Get root view controller
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.windows.first?.rootViewController {
bannerView.rootViewController = rootViewController
}
bannerView.delegate = context.coordinator
bannerView.load(Request())
return bannerView
}
func updateUIView(_ uiView: BannerView, context: Context) {
// Banner updates automatically
}
func makeCoordinator() -> BannerAdCoordinator {
let coordinator = BannerAdCoordinator()
coordinator.adMobService = adMobService
return coordinator
}
}
@@ -0,0 +1,587 @@
import Foundation
class CalculationService {
static let shared = CalculationService()
// MARK: - Performance: Caching
private var cachedMonthlyReturns: [ObjectIdentifier: [InvestmentMetrics.MonthlyReturn]] = [:]
private var cacheVersion: Int = 0
private init() {}
/// Call this when underlying data changes to invalidate caches
func invalidateCache() {
cachedMonthlyReturns.removeAll()
cacheVersion += 1
}
// MARK: - Shared DateFormatter (avoid repeated allocations)
private static let monthYearFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM"
return formatter
}()
// MARK: - Portfolio Summary
func calculatePortfolioSummary(
from sources: [InvestmentSource],
snapshots: [Snapshot]
) -> PortfolioSummary {
let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue }
let totalContributions = sources.reduce(Decimal.zero) { $0 + $1.totalContributions }
// Calculate period changes
let now = Date()
let dayAgo = Calendar.current.date(byAdding: .day, value: -1, to: now) ?? now
let weekAgo = Calendar.current.date(byAdding: .weekOfYear, value: -1, to: now) ?? now
let monthAgo = Calendar.current.date(byAdding: .month, value: -1, to: now) ?? now
let yearAgo = Calendar.current.date(byAdding: .year, value: -1, to: now) ?? now
let dayChange = calculatePeriodChange(sources: sources, from: dayAgo)
let weekChange = calculatePeriodChange(sources: sources, from: weekAgo)
let monthChange = calculatePeriodChange(sources: sources, from: monthAgo)
let yearChange = calculatePeriodChange(sources: sources, from: yearAgo)
let allTimeReturn = totalValue - totalContributions
let allTimeReturnPercentage = totalContributions > 0
? NSDecimalNumber(decimal: allTimeReturn / totalContributions).doubleValue * 100
: 0
let lastUpdated = snapshots.map { $0.date }.max()
return PortfolioSummary(
totalValue: totalValue,
totalContributions: totalContributions,
dayChange: dayChange.absolute,
dayChangePercentage: dayChange.percentage,
weekChange: weekChange.absolute,
weekChangePercentage: weekChange.percentage,
monthChange: monthChange.absolute,
monthChangePercentage: monthChange.percentage,
yearChange: yearChange.absolute,
yearChangePercentage: yearChange.percentage,
allTimeReturn: allTimeReturn,
allTimeReturnPercentage: allTimeReturnPercentage,
sourceCount: sources.count,
lastUpdated: lastUpdated
)
}
private func calculatePeriodChange(
sources: [InvestmentSource],
from startDate: Date
) -> (absolute: Decimal, percentage: Double) {
var previousTotal: Decimal = 0
var currentTotal: Decimal = 0
for source in sources {
currentTotal += source.latestValue
// Find snapshot closest to start date
let snapshots = source.sortedSnapshotsByDateAscending
let previousSnapshot = snapshots.last { $0.date <= startDate } ?? snapshots.first
previousTotal += previousSnapshot?.decimalValue ?? 0
}
let absolute = currentTotal - previousTotal
let percentage = previousTotal > 0
? NSDecimalNumber(decimal: absolute / previousTotal).doubleValue * 100
: 0
return (absolute, percentage)
}
// MARK: - Investment Metrics
func calculateMetrics(for snapshots: [Snapshot]) -> InvestmentMetrics {
guard !snapshots.isEmpty else { return .empty }
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
let values = sortedSnapshots.map { $0.decimalValue }
guard let firstValue = values.first,
let lastValue = values.last,
firstValue != 0 else {
return .empty
}
let totalValue = lastValue
let totalContributions = sortedSnapshots.reduce(Decimal.zero) { $0 + $1.decimalContribution }
let absoluteReturn = lastValue - firstValue
let percentageReturn = (absoluteReturn / firstValue) * 100
// Calculate monthly returns for advanced metrics
let monthlyReturns = calculateMonthlyReturns(from: sortedSnapshots)
let cagr = calculateCAGR(
startValue: firstValue,
endValue: lastValue,
startDate: sortedSnapshots.first?.date ?? Date(),
endDate: sortedSnapshots.last?.date ?? Date()
)
let twr = calculateTWR(snapshots: sortedSnapshots)
let volatility = calculateVolatility(monthlyReturns: monthlyReturns)
let maxDrawdown = calculateMaxDrawdown(values: values)
let sharpeRatio = calculateSharpeRatio(
averageReturn: monthlyReturns.map { $0.returnPercentage }.average(),
volatility: volatility
)
let winRate = calculateWinRate(monthlyReturns: monthlyReturns)
let averageMonthlyReturn = monthlyReturns.map { $0.returnPercentage }.average()
return InvestmentMetrics(
totalValue: totalValue,
totalContributions: totalContributions,
absoluteReturn: absoluteReturn,
percentageReturn: percentageReturn,
cagr: cagr,
twr: twr,
volatility: volatility,
maxDrawdown: maxDrawdown,
sharpeRatio: sharpeRatio,
bestMonth: monthlyReturns.max(by: { $0.returnPercentage < $1.returnPercentage }),
worstMonth: monthlyReturns.min(by: { $0.returnPercentage < $1.returnPercentage }),
winRate: winRate,
averageMonthlyReturn: averageMonthlyReturn,
startDate: sortedSnapshots.first?.date,
endDate: sortedSnapshots.last?.date,
totalMonths: monthlyReturns.count
)
}
// MARK: - Monthly Summary
func calculateMonthlySummary(
sources: [InvestmentSource],
snapshots: [Snapshot],
range: DateRange = .thisMonth
) -> MonthlySummary {
let startDate = range.start
let endDate = range.end
var startingValue: Decimal = 0
var endingValue: Decimal = 0
var contributions: Decimal = 0
let snapshotsBySource = Dictionary(grouping: snapshots) { $0.source?.id }
for source in sources {
let sourceSnapshots = snapshotsBySource[source.id] ?? []
let sorted = sourceSnapshots.sorted { $0.date < $1.date }
let startSnapshot = sorted.last { $0.date <= startDate }
let endSnapshot = sorted.last { $0.date <= endDate }
startingValue += startSnapshot?.decimalValue ?? 0
endingValue += endSnapshot?.decimalValue ?? 0
let rangeContributions = sorted
.filter { $0.date >= startDate && $0.date <= endDate }
.reduce(Decimal.zero) { $0 + $1.decimalContribution }
contributions += rangeContributions
}
let netPerformance = endingValue - startingValue - contributions
return MonthlySummary(
periodLabel: "This Month",
startDate: startDate,
endDate: endDate,
startingValue: startingValue,
endingValue: endingValue,
contributions: contributions,
netPerformance: netPerformance
)
}
// MARK: - CAGR (Compound Annual Growth Rate)
func calculateCAGR(
startValue: Decimal,
endValue: Decimal,
startDate: Date,
endDate: Date
) -> Double {
guard startValue > 0 else { return 0 }
let years = Calendar.current.dateComponents(
[.day],
from: startDate,
to: endDate
).day.map { Double($0) / 365.25 } ?? 0
guard years > 0 else { return 0 }
let ratio = NSDecimalNumber(decimal: endValue / startValue).doubleValue
let cagr = pow(ratio, 1 / years) - 1
return cagr * 100
}
// MARK: - TWR (Time-Weighted Return)
func calculateTWR(snapshots: [Snapshot]) -> Double {
guard snapshots.count >= 2 else { return 0 }
var twr: Double = 1.0
for i in 1..<snapshots.count {
let previousValue = snapshots[i-1].decimalValue
let currentValue = snapshots[i].decimalValue
let contribution = snapshots[i].decimalContribution
guard previousValue > 0 else { continue }
// Adjust for contributions
let adjustedPreviousValue = previousValue + contribution
let periodReturn = NSDecimalNumber(
decimal: currentValue / adjustedPreviousValue
).doubleValue
twr *= periodReturn
}
return (twr - 1) * 100
}
// MARK: - Volatility (Annualized Standard Deviation)
func calculateVolatility(monthlyReturns: [InvestmentMetrics.MonthlyReturn]) -> Double {
let returns = monthlyReturns.map { $0.returnPercentage }
guard returns.count >= 2 else { return 0 }
let mean = returns.average()
let squaredDifferences = returns.map { pow($0 - mean, 2) }
let variance = squaredDifferences.reduce(0, +) / Double(returns.count - 1)
let stdDev = sqrt(variance)
// Annualize (multiply by sqrt(12) for monthly data)
return stdDev * sqrt(12)
}
// MARK: - Max Drawdown
func calculateMaxDrawdown(values: [Decimal]) -> Double {
guard !values.isEmpty else { return 0 }
var maxDrawdown: Double = 0
var peak = values[0]
for value in values {
if value > peak {
peak = value
}
guard peak > 0 else { continue }
let drawdown = NSDecimalNumber(
decimal: (peak - value) / peak
).doubleValue * 100
maxDrawdown = max(maxDrawdown, drawdown)
}
return maxDrawdown
}
// MARK: - Sharpe Ratio
func calculateSharpeRatio(
averageReturn: Double,
volatility: Double,
riskFreeRate: Double = 2.0 // Assume 2% annual risk-free rate
) -> Double {
guard volatility > 0 else { return 0 }
// Convert to monthly risk-free rate
let monthlyRiskFree = riskFreeRate / 12
return (averageReturn - monthlyRiskFree) / volatility * sqrt(12)
}
// MARK: - Win Rate
func calculateWinRate(monthlyReturns: [InvestmentMetrics.MonthlyReturn]) -> Double {
guard !monthlyReturns.isEmpty else { return 0 }
let positiveMonths = monthlyReturns.filter { $0.returnPercentage > 0 }.count
return Double(positiveMonths) / Double(monthlyReturns.count) * 100
}
// MARK: - Monthly Returns
func calculateMonthlyReturns(from snapshots: [Snapshot]) -> [InvestmentMetrics.MonthlyReturn] {
guard snapshots.count >= 2 else { return [] }
// Performance: Use shared formatter instead of creating new one each call
let formatter = Self.monthYearFormatter
// Group snapshots by month - pre-allocate capacity
var monthlySnapshots: [String: [Snapshot]] = [:]
monthlySnapshots.reserveCapacity(min(snapshots.count, 60)) // Reasonable max months
for snapshot in snapshots {
let key = formatter.string(from: snapshot.date)
monthlySnapshots[key, default: []].append(snapshot)
}
// Sort months
let sortedMonths = monthlySnapshots.keys.sorted()
guard sortedMonths.count >= 2 else { return [] }
// Pre-allocate result array
var monthlyReturns: [InvestmentMetrics.MonthlyReturn] = []
monthlyReturns.reserveCapacity(sortedMonths.count - 1)
for i in 1..<sortedMonths.count {
let previousMonth = sortedMonths[i-1]
let currentMonth = sortedMonths[i]
guard let previousSnapshots = monthlySnapshots[previousMonth],
let currentSnapshots = monthlySnapshots[currentMonth],
let previousValue = previousSnapshots.last?.decimalValue,
let currentValue = currentSnapshots.last?.decimalValue,
previousValue > 0 else {
continue
}
let returnPercentage = NSDecimalNumber(
decimal: (currentValue - previousValue) / previousValue
).doubleValue * 100
if let date = formatter.date(from: currentMonth) {
monthlyReturns.append(InvestmentMetrics.MonthlyReturn(
date: date,
returnPercentage: returnPercentage
))
}
}
return monthlyReturns
}
// MARK: - Category Metrics
func calculateCategoryMetrics(
for categories: [Category],
sources: [InvestmentSource],
totalPortfolioValue: Decimal
) -> [CategoryMetrics] {
categories.map { category in
let categorySources = sources.filter { $0.category?.id == category.id }
let allSnapshots = categorySources.flatMap { $0.snapshotsArray }
let metrics = calculateCategoryMetrics(from: allSnapshots)
let categoryValue = categorySources.reduce(Decimal.zero) { $0 + $1.latestValue }
let percentage = totalPortfolioValue > 0
? NSDecimalNumber(decimal: categoryValue / totalPortfolioValue).doubleValue * 100
: 0
return CategoryMetrics(
id: category.id,
categoryName: category.name,
colorHex: category.colorHex,
icon: category.icon,
totalValue: categoryValue,
percentageOfPortfolio: percentage,
metrics: metrics
)
}
}
private struct SeriesPoint {
let date: Date
let value: Decimal
let contribution: Decimal
}
private func calculateCategoryMetrics(from snapshots: [Snapshot]) -> InvestmentMetrics {
let series = buildCategorySeries(from: snapshots)
guard !series.isEmpty else { return .empty }
let sortedSeries = series.sorted { $0.date < $1.date }
let values = sortedSeries.map { $0.value }
guard let firstValue = values.first,
let lastValue = values.last,
firstValue != 0 else {
return .empty
}
let totalValue = lastValue
let totalContributions = sortedSeries.reduce(Decimal.zero) { $0 + $1.contribution }
let absoluteReturn = lastValue - firstValue
let percentageReturn = (absoluteReturn / firstValue) * 100
let monthlyReturns = calculateMonthlyReturns(from: sortedSeries)
let cagr = calculateCAGR(
startValue: firstValue,
endValue: lastValue,
startDate: sortedSeries.first?.date ?? Date(),
endDate: sortedSeries.last?.date ?? Date()
)
let twr = calculateTWR(series: sortedSeries)
let volatility = calculateVolatility(monthlyReturns: monthlyReturns)
let maxDrawdown = calculateMaxDrawdown(values: values)
let sharpeRatio = calculateSharpeRatio(
averageReturn: monthlyReturns.map { $0.returnPercentage }.average(),
volatility: volatility
)
let winRate = calculateWinRate(monthlyReturns: monthlyReturns)
let averageMonthlyReturn = monthlyReturns.map { $0.returnPercentage }.average()
return InvestmentMetrics(
totalValue: totalValue,
totalContributions: totalContributions,
absoluteReturn: absoluteReturn,
percentageReturn: percentageReturn,
cagr: cagr,
twr: twr,
volatility: volatility,
maxDrawdown: maxDrawdown,
sharpeRatio: sharpeRatio,
bestMonth: monthlyReturns.max(by: { $0.returnPercentage < $1.returnPercentage }),
worstMonth: monthlyReturns.min(by: { $0.returnPercentage < $1.returnPercentage }),
winRate: winRate,
averageMonthlyReturn: averageMonthlyReturn,
startDate: sortedSeries.first?.date,
endDate: sortedSeries.last?.date,
totalMonths: monthlyReturns.count
)
}
private func buildCategorySeries(from snapshots: [Snapshot]) -> [SeriesPoint] {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
let uniqueDates = Array(Set(sortedSnapshots.map { Calendar.current.startOfDay(for: $0.date) }))
.sorted()
guard !uniqueDates.isEmpty else { return [] }
var snapshotsBySource: [UUID: [(date: Date, value: Decimal)]] = [:]
var contributionsByDate: [Date: Decimal] = [:]
for snapshot in sortedSnapshots {
guard let sourceId = snapshot.source?.id else { continue }
snapshotsBySource[sourceId, default: []].append(
(date: snapshot.date, value: snapshot.decimalValue)
)
let day = Calendar.current.startOfDay(for: snapshot.date)
contributionsByDate[day, default: 0] += snapshot.decimalContribution
}
var indices: [UUID: Int] = [:]
var series: [SeriesPoint] = []
for (index, date) in uniqueDates.enumerated() {
let nextDate = index + 1 < uniqueDates.count
? uniqueDates[index + 1]
: Date.distantFuture
var total: Decimal = 0
for (sourceId, sourceSnapshots) in snapshotsBySource {
var currentIndex = indices[sourceId] ?? 0
var latest: (date: Date, value: Decimal)?
while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextDate {
latest = sourceSnapshots[currentIndex]
currentIndex += 1
}
indices[sourceId] = currentIndex
if let latest {
total += latest.value
}
}
series.append(
SeriesPoint(
date: date,
value: total,
contribution: contributionsByDate[date] ?? 0
)
)
}
return series
}
private func calculateMonthlyReturns(from series: [SeriesPoint]) -> [InvestmentMetrics.MonthlyReturn] {
guard series.count >= 2 else { return [] }
// Performance: Use shared formatter
let formatter = Self.monthYearFormatter
var monthlySeries: [String: [SeriesPoint]] = [:]
monthlySeries.reserveCapacity(min(series.count, 60))
for point in series {
let key = formatter.string(from: point.date)
monthlySeries[key, default: []].append(point)
}
let sortedMonths = monthlySeries.keys.sorted()
guard sortedMonths.count >= 2 else { return [] }
var monthlyReturns: [InvestmentMetrics.MonthlyReturn] = []
monthlyReturns.reserveCapacity(sortedMonths.count - 1)
for i in 1..<sortedMonths.count {
let previousMonth = sortedMonths[i - 1]
let currentMonth = sortedMonths[i]
guard let previousPoints = monthlySeries[previousMonth],
let currentPoints = monthlySeries[currentMonth],
let previousValue = previousPoints.last?.value,
let currentValue = currentPoints.last?.value,
previousValue > 0 else {
continue
}
let returnPercentage = NSDecimalNumber(
decimal: (currentValue - previousValue) / previousValue
).doubleValue * 100
if let date = formatter.date(from: currentMonth) {
monthlyReturns.append(InvestmentMetrics.MonthlyReturn(
date: date,
returnPercentage: returnPercentage
))
}
}
return monthlyReturns
}
private func calculateTWR(series: [SeriesPoint]) -> Double {
guard series.count >= 2 else { return 0 }
var twr: Double = 1.0
for i in 1..<series.count {
let previousValue = series[i - 1].value
let currentValue = series[i].value
let contribution = series[i].contribution
guard previousValue > 0 else { continue }
let periodReturn = (currentValue - contribution) / previousValue
twr *= NSDecimalNumber(decimal: periodReturn).doubleValue
}
return (twr - 1) * 100
}
}
// MARK: - Array Extension
extension Array where Element == Double {
func average() -> Double {
guard !isEmpty else { return 0 }
return reduce(0, +) / Double(count)
}
}
@@ -0,0 +1,258 @@
import Foundation
import UIKit
class ExportService {
static let shared = ExportService()
private init() {}
// MARK: - Export Formats
enum ExportFormat: String, CaseIterable, Identifiable {
case csv = "CSV"
case json = "JSON"
var id: String { rawValue }
var fileExtension: String {
switch self {
case .csv: return "csv"
case .json: return "json"
}
}
var mimeType: String {
switch self {
case .csv: return "text/csv"
case .json: return "application/json"
}
}
}
// MARK: - Export Data
func exportToCSV(
sources: [InvestmentSource],
categories: [Category]
) -> String {
let currencyCode = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
var csv = "Account,Category,Source,Date,Value (\(currencyCode)),Contribution (\(currencyCode)),Notes\n"
for source in sources.sorted(by: { $0.name < $1.name }) {
let accountName = source.account?.name ?? "Default"
let categoryName = source.category?.name ?? "Uncategorized"
for snapshot in source.snapshotsArray {
let date = formatDate(snapshot.date)
let value = formatDecimal(snapshot.decimalValue)
let contribution = snapshot.contribution != nil
? formatDecimal(snapshot.decimalContribution)
: ""
let notes = escapeCSV(snapshot.notes ?? "")
csv += "\(escapeCSV(accountName)),\(escapeCSV(categoryName)),\(escapeCSV(source.name)),\(date),\(value),\(contribution),\(notes)\n"
}
}
return csv
}
func exportToJSON(
sources: [InvestmentSource],
categories: [Category]
) -> String {
var exportData: [String: Any] = [:]
exportData["exportDate"] = ISO8601DateFormatter().string(from: Date())
exportData["version"] = 2
exportData["currency"] = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
let accounts = Dictionary(grouping: sources) { $0.account?.id.uuidString ?? "default" }
var accountsArray: [[String: Any]] = []
for (_, accountSources) in accounts {
let account = accountSources.first?.account
var accountDict: [String: Any] = [
"name": account?.name ?? "Default",
"currency": account?.currency ?? exportData["currency"] as? String ?? "EUR",
"inputMode": account?.inputMode ?? InputMode.simple.rawValue,
"notificationFrequency": account?.notificationFrequency ?? NotificationFrequency.monthly.rawValue,
"customFrequencyMonths": account?.customFrequencyMonths ?? 1
]
// Export categories for this account
let categoriesById = Dictionary(uniqueKeysWithValues: categories.map { ($0.id, $0) })
let sourcesByCategory = Dictionary(grouping: accountSources) { $0.category?.id ?? UUID() }
var categoriesArray: [[String: Any]] = []
for (categoryId, categorySources) in sourcesByCategory {
let category = categoriesById[categoryId]
var categoryDict: [String: Any] = [
"name": category?.name ?? "Uncategorized",
"color": category?.colorHex ?? "#3B82F6",
"icon": category?.icon ?? "chart.pie.fill"
]
var sourcesArray: [[String: Any]] = []
for source in categorySources {
var sourceDict: [String: Any] = [
"name": source.name,
"isActive": source.isActive,
"notificationFrequency": source.notificationFrequency
]
var snapshotsArray: [[String: Any]] = []
for snapshot in source.snapshotsArray {
var snapshotDict: [String: Any] = [
"date": ISO8601DateFormatter().string(from: snapshot.date),
"value": NSDecimalNumber(decimal: snapshot.decimalValue).doubleValue
]
if snapshot.contribution != nil {
snapshotDict["contribution"] = NSDecimalNumber(
decimal: snapshot.decimalContribution
).doubleValue
}
if let notes = snapshot.notes, !notes.isEmpty {
snapshotDict["notes"] = notes
}
snapshotsArray.append(snapshotDict)
}
sourceDict["snapshots"] = snapshotsArray
sourcesArray.append(sourceDict)
}
categoryDict["sources"] = sourcesArray
categoriesArray.append(categoryDict)
}
accountDict["categories"] = categoriesArray
accountsArray.append(accountDict)
}
exportData["accounts"] = accountsArray
// Add summary
let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue }
exportData["summary"] = [
"totalSources": sources.count,
"totalCategories": categories.count,
"totalValue": NSDecimalNumber(decimal: totalValue).doubleValue,
"totalSnapshots": sources.reduce(0) { $0 + $1.snapshotCount }
]
// Convert to JSON
do {
let jsonData = try JSONSerialization.data(
withJSONObject: exportData,
options: [.prettyPrinted, .sortedKeys]
)
return String(data: jsonData, encoding: .utf8) ?? "{}"
} catch {
print("JSON export error: \(error)")
return "{}"
}
}
// MARK: - Share
func share(
format: ExportFormat,
sources: [InvestmentSource],
categories: [Category],
from viewController: UIViewController
) {
let content: String
let fileName: String
switch format {
case .csv:
content = exportToCSV(sources: sources, categories: categories)
fileName = "investment_tracker_export.csv"
case .json:
content = exportToJSON(sources: sources, categories: categories)
fileName = "investment_tracker_export.json"
}
// Create temporary file
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(fileName)
do {
try content.write(to: tempURL, atomically: true, encoding: .utf8)
let activityVC = UIActivityViewController(
activityItems: [tempURL],
applicationActivities: nil
)
// iPad support
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
)
}
viewController.present(activityVC, animated: true) {
FirebaseService.shared.logExportAttempt(
format: format.rawValue,
success: true
)
}
} catch {
print("Export error: \(error)")
FirebaseService.shared.logExportAttempt(
format: format.rawValue,
success: false
)
}
}
// MARK: - Helpers
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
private func formatDecimal(_ decimal: Decimal) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = 2
formatter.maximumFractionDigits = 2
formatter.decimalSeparator = "."
formatter.groupingSeparator = ""
return formatter.string(from: decimal as NSDecimalNumber) ?? "0.00"
}
private func escapeCSV(_ value: String) -> String {
var escaped = value
if escaped.contains("\"") || escaped.contains(",") || escaped.contains("\n") {
escaped = escaped.replacingOccurrences(of: "\"", with: "\"\"")
escaped = "\"\(escaped)\""
}
return escaped
}
}
// MARK: - Import Service (Future)
extension ExportService {
func importFromCSV(_ content: String) -> (sources: [ImportedSource], errors: [String]) {
// Future implementation for importing data
return ([], ["Import not yet implemented"])
}
struct ImportedSource {
let name: String
let categoryName: String
let snapshots: [(date: Date, value: Decimal, contribution: Decimal?, notes: String?)]
}
}
@@ -0,0 +1,201 @@
import Foundation
import FirebaseAnalytics
import FirebaseCore
class FirebaseService {
static let shared = FirebaseService()
private var isConfigured: Bool {
FirebaseApp.app() != nil
}
private init() {}
// MARK: - User Properties
func setUserTier(_ tier: UserTier) {
guard isConfigured else { return }
Analytics.setUserProperty(tier.rawValue, forName: "user_tier")
}
enum UserTier: String {
case free = "free"
case premium = "premium"
}
// MARK: - Screen Tracking
func logScreenView(screenName: String, screenClass: String? = nil) {
guard isConfigured else { return }
Analytics.logEvent(AnalyticsEventScreenView, parameters: [
AnalyticsParameterScreenName: screenName,
AnalyticsParameterScreenClass: screenClass ?? screenName
])
}
// MARK: - Investment Events
func logSourceAdded(categoryName: String, sourceCount: Int) {
guard isConfigured else { return }
Analytics.logEvent("source_added", parameters: [
"category_name": categoryName,
"total_sources": sourceCount
])
}
func logSourceDeleted(categoryName: String) {
guard isConfigured else { return }
Analytics.logEvent("source_deleted", parameters: [
"category_name": categoryName
])
}
func logSnapshotAdded(sourceName: String, value: Decimal) {
guard isConfigured else { return }
Analytics.logEvent("snapshot_added", parameters: [
"source_name": sourceName,
"value": NSDecimalNumber(decimal: value).doubleValue
])
}
func logCategoryCreated(name: String) {
guard isConfigured else { return }
Analytics.logEvent("category_created", parameters: [
"category_name": name
])
}
// MARK: - Purchase Events
func logPaywallShown(trigger: String) {
guard isConfigured else { return }
Analytics.logEvent("paywall_shown", parameters: [
"trigger": trigger
])
}
func logPurchaseAttempt(productId: String) {
guard isConfigured else { return }
Analytics.logEvent("purchase_attempt", parameters: [
"product_id": productId
])
}
func logPurchaseSuccess(productId: String, price: Decimal, isFamilyShared: Bool) {
guard isConfigured else { return }
Analytics.logEvent(AnalyticsEventPurchase, parameters: [
AnalyticsParameterItemID: productId,
AnalyticsParameterPrice: NSDecimalNumber(decimal: price).doubleValue,
AnalyticsParameterCurrency: "EUR",
"is_family_shared": isFamilyShared
])
}
func logPurchaseFailure(productId: String, error: String) {
guard isConfigured else { return }
Analytics.logEvent("purchase_failed", parameters: [
"product_id": productId,
"error": error
])
}
func logRestorePurchases(success: Bool) {
guard isConfigured else { return }
Analytics.logEvent("restore_purchases", parameters: [
"success": success
])
}
// MARK: - Feature Usage Events
func logChartViewed(chartType: String, isPremium: Bool) {
guard isConfigured else { return }
Analytics.logEvent("chart_viewed", parameters: [
"chart_type": chartType,
"is_premium_chart": isPremium
])
}
func logPredictionViewed(algorithm: String) {
guard isConfigured else { return }
Analytics.logEvent("prediction_viewed", parameters: [
"algorithm": algorithm
])
}
func logExportAttempt(format: String, success: Bool) {
guard isConfigured else { return }
Analytics.logEvent("export_attempt", parameters: [
"format": format,
"success": success
])
}
func logNotificationScheduled(frequency: String) {
guard isConfigured else { return }
Analytics.logEvent("notification_scheduled", parameters: [
"frequency": frequency
])
}
// MARK: - Ad Events
func logAdImpression(adType: String) {
guard isConfigured else { return }
Analytics.logEvent("ad_impression", parameters: [
"ad_type": adType
])
}
func logAdClick(adType: String) {
guard isConfigured else { return }
Analytics.logEvent("ad_click", parameters: [
"ad_type": adType
])
}
// MARK: - Engagement Events
func logOnboardingCompleted(stepCount: Int) {
guard isConfigured else { return }
Analytics.logEvent("onboarding_completed", parameters: [
"steps_completed": stepCount
])
}
func logWidgetUsed(widgetType: String) {
guard isConfigured else { return }
Analytics.logEvent("widget_used", parameters: [
"widget_type": widgetType
])
}
func logAppOpened(fromWidget: Bool) {
guard isConfigured else { return }
Analytics.logEvent("app_opened", parameters: [
"from_widget": fromWidget
])
}
// MARK: - Portfolio Events
func logPortfolioMilestone(totalValue: Decimal, milestone: String) {
guard isConfigured else { return }
Analytics.logEvent("portfolio_milestone", parameters: [
"total_value": NSDecimalNumber(decimal: totalValue).doubleValue,
"milestone": milestone
])
}
// MARK: - Error Events
func logError(type: String, message: String, context: String? = nil) {
guard isConfigured else { return }
Analytics.logEvent("app_error", parameters: [
"error_type": type,
"error_message": message,
"context": context ?? ""
])
}
}
@@ -0,0 +1,44 @@
import Foundation
import SwiftUI
import UIKit
class GoalShareService {
static let shared = GoalShareService()
private init() {}
@MainActor
func shareGoal(
name: String,
progress: Double,
currentValue: Decimal,
targetValue: Decimal
) {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let viewController = windowScene.windows.first?.rootViewController else {
return
}
let card = GoalShareCardView(
name: name,
progress: progress,
currentValue: currentValue,
targetValue: targetValue
)
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 activityVC = UIActivityViewController(activityItems: [image], applicationActivities: nil)
viewController.present(activityVC, animated: true)
}
} else {
let text = "I am \(Int(progress * 100))% towards \(name) on Portfolio Journal!"
let activityVC = UIActivityViewController(activityItems: [text], applicationActivities: nil)
viewController.present(activityVC, animated: true)
}
}
}
+256
View File
@@ -0,0 +1,256 @@
import Foundation
import StoreKit
import Combine
@MainActor
class IAPService: ObservableObject {
// MARK: - Published Properties
@Published private(set) var isPremium = false
@Published private(set) var products: [Product] = []
@Published private(set) var purchaseState: PurchaseState = .idle
@Published private(set) var isFamilyShared = false
#if DEBUG
@Published var debugOverrideEnabled = false
#endif
// MARK: - Constants
static let premiumProductID = "com.portfoliojournal.premium"
static let premiumPrice = "€4.69"
// MARK: - Private Properties
private var updateListenerTask: Task<Void, Error>?
private var cancellables = Set<AnyCancellable>()
private let sharedDefaults = UserDefaults(suiteName: AppConstants.appGroupIdentifier)
// MARK: - Purchase State
enum PurchaseState: Equatable {
case idle
case purchasing
case purchased
case failed(String)
case restored
}
// MARK: - Initialization
init() {
#if DEBUG
debugOverrideEnabled = UserDefaults.standard.bool(forKey: "debugPremiumOverride")
#endif
updateListenerTask = listenForTransactions()
Task {
await loadProducts()
await updatePremiumStatus()
}
}
deinit {
updateListenerTask?.cancel()
}
// MARK: - Load Products
func loadProducts() async {
do {
products = try await Product.products(for: [Self.premiumProductID])
print("Loaded \(products.count) products")
} catch {
print("Failed to load products: \(error)")
}
}
// MARK: - Purchase
func purchase() async throws {
guard let product = products.first else {
throw IAPError.productNotFound
}
purchaseState = .purchasing
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
// Check if family shared
isFamilyShared = transaction.ownershipType == .familyShared
await transaction.finish()
await updatePremiumStatus()
purchaseState = .purchased
// Track analytics
FirebaseService.shared.logPurchaseSuccess(
productId: product.id,
price: product.price,
isFamilyShared: isFamilyShared
)
case .userCancelled:
purchaseState = .idle
case .pending:
purchaseState = .idle
@unknown default:
purchaseState = .idle
}
} catch {
purchaseState = .failed(error.localizedDescription)
FirebaseService.shared.logPurchaseFailure(
productId: product.id,
error: error.localizedDescription
)
throw error
}
}
// MARK: - Restore Purchases
func restorePurchases() async {
purchaseState = .purchasing
do {
try await AppStore.sync()
await updatePremiumStatus()
if isPremium {
purchaseState = .restored
} else {
purchaseState = .failed("No purchases to restore")
}
} catch {
purchaseState = .failed(error.localizedDescription)
}
}
// MARK: - Update Premium Status
func updatePremiumStatus() async {
var isEntitled = false
var familyShared = false
#if DEBUG
if debugOverrideEnabled {
isPremium = true
isFamilyShared = false
return
}
#endif
for await result in StoreKit.Transaction.currentEntitlements {
if case .verified(let transaction) = result {
if transaction.productID == Self.premiumProductID {
isEntitled = true
familyShared = transaction.ownershipType == .familyShared
break
}
}
}
isPremium = isEntitled
isFamilyShared = familyShared
sharedDefaults?.set(isEntitled, forKey: "premiumUnlocked")
// Update Core Data
let context = CoreDataStack.shared.viewContext
PremiumStatus.updateStatus(
isPremium: isEntitled,
productIdentifier: Self.premiumProductID,
transactionId: nil,
isFamilyShared: familyShared,
in: context
)
}
#if DEBUG
func setDebugPremiumOverride(_ enabled: Bool) {
debugOverrideEnabled = enabled
UserDefaults.standard.set(enabled, forKey: "debugPremiumOverride")
if enabled {
isPremium = true
isFamilyShared = false
sharedDefaults?.set(true, forKey: "premiumUnlocked")
} else {
Task { await updatePremiumStatus() }
}
}
#endif
// MARK: - Transaction Listener
private func listenForTransactions() -> Task<Void, Error> {
return Task.detached { [weak self] in
for await result in StoreKit.Transaction.updates {
if case .verified(let transaction) = result {
await transaction.finish()
await self?.updatePremiumStatus()
}
}
}
}
// MARK: - Verification
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified(_, let error):
throw IAPError.verificationFailed(error.localizedDescription)
case .verified(let safe):
return safe
}
}
// MARK: - Product Info
var premiumProduct: Product? {
products.first { $0.id == Self.premiumProductID }
}
var formattedPrice: String {
premiumProduct?.displayPrice ?? Self.premiumPrice
}
}
// MARK: - IAP Error
enum IAPError: LocalizedError {
case productNotFound
case verificationFailed(String)
case purchaseFailed(String)
var errorDescription: String? {
switch self {
case .productNotFound:
return "Product not found. Please try again later."
case .verificationFailed(let message):
return "Verification failed: \(message)"
case .purchaseFailed(let message):
return "Purchase failed: \(message)"
}
}
}
// MARK: - Premium Features
extension IAPService {
static let premiumFeatures: [(icon: String, title: String, description: String)] = [
("person.2", "Multiple Accounts", "Separate portfolios for business or family"),
("infinity", "Unlimited Sources", "Track as many investments as you want"),
("clock.arrow.circlepath", "Full History", "Access your complete investment history"),
("chart.bar.xaxis", "Advanced Charts", "5 types of detailed analytics charts"),
("wand.and.stars", "Predictions", "AI-powered 12-month forecasts"),
("square.and.arrow.up", "Export Data", "Export to CSV and JSON formats"),
("xmark.circle", "No Ads", "Ad-free experience forever"),
("person.2", "Family Sharing", "Share with up to 5 family members")
]
}
@@ -0,0 +1,678 @@
import Foundation
import CoreData
import Combine
class ImportService {
static let shared = ImportService()
private init() {}
struct ImportResult {
let accountsCreated: Int
let sourcesCreated: Int
let snapshotsCreated: Int
let errors: [String]
}
struct ImportProgress {
let completed: Int
let total: Int
let message: String
var fraction: Double {
guard total > 0 else { return 0 }
return min(max(Double(completed) / Double(total), 0), 1)
}
}
enum ImportFormat {
case csv
case json
}
struct ImportedAccount {
let name: String
let currency: String?
let inputMode: InputMode
let notificationFrequency: NotificationFrequency
let customFrequencyMonths: Int
let categories: [ImportedCategory]
}
struct ImportedCategory {
let name: String
let colorHex: String?
let icon: String?
let sources: [ImportedSource]
}
struct ImportedSource {
let name: String
let snapshots: [ImportedSnapshot]
}
struct ImportedSnapshot {
let date: Date
let value: Decimal
let contribution: Decimal?
let notes: String?
}
func importData(
content: String,
format: ImportFormat,
allowMultipleAccounts: Bool,
defaultAccountName: String? = nil
) -> ImportResult {
switch format {
case .csv:
let parsed = parseCSV(
content,
allowMultipleAccounts: allowMultipleAccounts,
defaultAccountName: defaultAccountName
)
return applyImport(parsed, context: CoreDataStack.shared.viewContext)
case .json:
let parsed = parseJSON(content, allowMultipleAccounts: allowMultipleAccounts)
return applyImport(parsed, context: CoreDataStack.shared.viewContext)
}
}
func importDataAsync(
content: String,
format: ImportFormat,
allowMultipleAccounts: Bool,
defaultAccountName: String? = nil,
progress: @escaping (ImportProgress) -> Void
) async -> ImportResult {
await withCheckedContinuation { continuation in
CoreDataStack.shared.performBackgroundTask { context in
let parsed: [ImportedAccount]
switch format {
case .csv:
parsed = self.parseCSV(
content,
allowMultipleAccounts: allowMultipleAccounts,
defaultAccountName: defaultAccountName
)
case .json:
parsed = self.parseJSON(content, allowMultipleAccounts: allowMultipleAccounts)
}
let totalSnapshots = parsed.reduce(0) { total, account in
total + account.categories.reduce(0) { subtotal, category in
subtotal + category.sources.reduce(0) { sourceTotal, source in
sourceTotal + source.snapshots.count
}
}
}
DispatchQueue.main.async {
progress(ImportProgress(completed: 0, total: totalSnapshots, message: "Importing data"))
}
let result = self.applyImport(parsed, context: context) { completed in
DispatchQueue.main.async {
progress(ImportProgress(
completed: completed,
total: totalSnapshots,
message: "Imported \(completed) of \(totalSnapshots) snapshots"
))
}
}
continuation.resume(returning: result)
}
}
}
static func sampleCSV() -> String {
return """
Account,Category,Source,Date,Value,Contribution,Notes
Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
Personal,Crypto,BTC,2024-01-01,3200,,Cold storage
Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
"""
}
static func sampleJSON() -> String {
return """
{
"version": 2,
"currency": "EUR",
"accounts": [{
"name": "Personal",
"inputMode": "simple",
"notificationFrequency": "monthly",
"categories": [{
"name": "Stocks",
"color": "#3B82F6",
"icon": "chart.line.uptrend.xyaxis",
"sources": [{
"name": "Index Fund",
"snapshots": [{
"date": "2024-01-01T00:00:00Z",
"value": 15000,
"contribution": 12000
}]
}]
}]
}]
}
"""
}
// MARK: - Parsing
private func parseCSV(
_ content: String,
allowMultipleAccounts: Bool,
defaultAccountName: String?
) -> [ImportedAccount] {
let rows = parseCSVRows(content)
guard rows.count > 1 else { return [] }
let headers = rows[0].map { $0.lowercased().trimmingCharacters(in: .whitespaces) }
let indexOfAccount = headers.firstIndex(of: "account")
let indexOfCategory = headers.firstIndex(of: "category")
let indexOfSource = headers.firstIndex(of: "source")
let indexOfDate = headers.firstIndex(of: "date")
let indexOfValue = headers.firstIndex(where: { $0.hasPrefix("value") })
let indexOfContribution = headers.firstIndex(where: { $0.hasPrefix("contribution") })
let indexOfNotes = headers.firstIndex(of: "notes")
var grouped: [String: [String: [String: [ImportedSnapshot]]]] = [:]
for row in rows.dropFirst() {
let providedAccount = indexOfAccount.flatMap { row.safeValue(at: $0) }
let fallbackAccount = defaultAccountName ?? "Personal"
let normalizedAccount = (providedAccount ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
let rawAccountName = normalizedAccount.isEmpty ? fallbackAccount : normalizedAccount
let accountName = allowMultipleAccounts ? rawAccountName : "Personal"
guard let categoryName = indexOfCategory.flatMap({ row.safeValue(at: $0) }), !categoryName.isEmpty,
let sourceName = indexOfSource.flatMap({ row.safeValue(at: $0) }), !sourceName.isEmpty,
let dateString = indexOfDate.flatMap({ row.safeValue(at: $0) }),
let valueString = indexOfValue.flatMap({ row.safeValue(at: $0) }) else {
continue
}
guard let date = parseDate(dateString),
let value = parseDecimal(valueString) else { continue }
let contribution = indexOfContribution
.flatMap { row.safeValue(at: $0) }
.flatMap(parseDecimal)
let notes = indexOfNotes
.flatMap { row.safeValue(at: $0) }
.flatMap { $0.isEmpty ? nil : $0 }
let snapshot = ImportedSnapshot(
date: date,
value: value,
contribution: contribution,
notes: notes
)
grouped[accountName, default: [:]][categoryName, default: [:]][sourceName, default: []].append(snapshot)
}
return grouped.map { accountName, categories in
let importedCategories = categories.map { categoryName, sources in
let importedSources = sources.map { sourceName, snapshots in
ImportedSource(name: sourceName, snapshots: snapshots)
}
return ImportedCategory(name: categoryName, colorHex: nil, icon: nil, sources: importedSources)
}
return ImportedAccount(
name: accountName,
currency: nil,
inputMode: .simple,
notificationFrequency: .monthly,
customFrequencyMonths: 1,
categories: importedCategories
)
}
}
private func parseJSON(_ content: String, allowMultipleAccounts: Bool) -> [ImportedAccount] {
guard let data = content.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return []
}
if let accountsArray = json["accounts"] as? [[String: Any]] {
return accountsArray.compactMap { accountDict in
let rawName = accountDict["name"] as? String ?? "Personal"
let name = allowMultipleAccounts ? rawName : "Personal"
let currency = accountDict["currency"] as? String
let inputMode = InputMode(rawValue: accountDict["inputMode"] as? String ?? "") ?? .simple
let notificationFrequency = NotificationFrequency(
rawValue: accountDict["notificationFrequency"] as? String ?? ""
) ?? .monthly
let customFrequencyMonths = accountDict["customFrequencyMonths"] as? Int ?? 1
let categoriesArray = accountDict["categories"] as? [[String: Any]] ?? []
let categories = categoriesArray.map { categoryDict in
let categoryName = categoryDict["name"] as? String ?? "Uncategorized"
let colorHex = categoryDict["color"] as? String
let icon = categoryDict["icon"] as? String
let sourcesArray = categoryDict["sources"] as? [[String: Any]] ?? []
let sources = sourcesArray.map { sourceDict in
let sourceName = sourceDict["name"] as? String ?? "Source"
let snapshotsArray = sourceDict["snapshots"] as? [[String: Any]] ?? []
let snapshots = snapshotsArray.compactMap { snapshotDict -> ImportedSnapshot? in
guard let dateString = snapshotDict["date"] as? String,
let date = ISO8601DateFormatter().date(from: dateString),
let value = snapshotDict["value"] as? Double else {
return nil
}
let contribution = (snapshotDict["contribution"] as? Double).map { Decimal($0) }
let notes = snapshotDict["notes"] as? String
return ImportedSnapshot(
date: date,
value: Decimal(value),
contribution: contribution,
notes: notes
)
}
return ImportedSource(name: sourceName, snapshots: snapshots)
}
return ImportedCategory(
name: categoryName,
colorHex: colorHex,
icon: icon,
sources: sources
)
}
return ImportedAccount(
name: name,
currency: currency,
inputMode: inputMode,
notificationFrequency: notificationFrequency,
customFrequencyMonths: customFrequencyMonths,
categories: categories
)
}
}
// Legacy JSON: categories only
if let categoriesArray = json["categories"] as? [[String: Any]] {
let categories = categoriesArray.map { categoryDict in
let categoryName = categoryDict["name"] as? String ?? "Uncategorized"
let colorHex = categoryDict["color"] as? String
let icon = categoryDict["icon"] as? String
let sourcesArray = categoryDict["sources"] as? [[String: Any]] ?? []
let sources = sourcesArray.map { sourceDict in
let sourceName = sourceDict["name"] as? String ?? "Source"
let snapshotsArray = sourceDict["snapshots"] as? [[String: Any]] ?? []
let snapshots = snapshotsArray.compactMap { snapshotDict -> ImportedSnapshot? in
guard let dateString = snapshotDict["date"] as? String,
let date = ISO8601DateFormatter().date(from: dateString),
let value = snapshotDict["value"] as? Double else {
return nil
}
let contribution = (snapshotDict["contribution"] as? Double).map { Decimal($0) }
let notes = snapshotDict["notes"] as? String
return ImportedSnapshot(
date: date,
value: Decimal(value),
contribution: contribution,
notes: notes
)
}
return ImportedSource(name: sourceName, snapshots: snapshots)
}
return ImportedCategory(
name: categoryName,
colorHex: colorHex,
icon: icon,
sources: sources
)
}
return [
ImportedAccount(
name: "Personal",
currency: json["currency"] as? String,
inputMode: .simple,
notificationFrequency: .monthly,
customFrequencyMonths: 1,
categories: categories
)
]
}
return []
}
// MARK: - Apply Import
private func applyImport(
_ accounts: [ImportedAccount],
context: NSManagedObjectContext,
snapshotProgress: ((Int) -> Void)? = nil
) -> ImportResult {
let accountRepository = AccountRepository(context: context)
let categoryRepository = CategoryRepository(context: context)
let sourceRepository = InvestmentSourceRepository(context: context)
let snapshotRepository = SnapshotRepository(context: context)
var accountsCreated = 0
var sourcesCreated = 0
var snapshotsCreated = 0
var errors: [String] = []
var categoryLookup = buildCategoryLookup(from: categoryRepository.categories)
let otherCategory = resolveExistingCategory(named: "Other", lookup: categoryLookup) ??
categoryRepository.createCategory(
name: "Other",
colorHex: "#64748B",
icon: "ellipsis.circle.fill"
)
categoryLookup[normalizedCategoryName(otherCategory.name)] = otherCategory
var completionDatesByMonth: [String: Date] = [:]
for importedAccount in accounts {
let existingAccount = accountRepository.accounts.first(where: { $0.name == importedAccount.name })
let account = existingAccount ?? accountRepository.createAccount(
name: importedAccount.name,
currency: importedAccount.currency,
inputMode: importedAccount.inputMode,
notificationFrequency: importedAccount.notificationFrequency,
customFrequencyMonths: importedAccount.customFrequencyMonths
)
if existingAccount == nil {
accountsCreated += 1
}
for importedCategory in importedAccount.categories {
let existingCategory = resolveExistingCategory(
named: importedCategory.name,
lookup: categoryLookup
)
let shouldUseOther = existingCategory == nil &&
importedCategory.colorHex == nil &&
importedCategory.icon == nil
let resolvedName = canonicalCategoryName(for: importedCategory.name) ?? importedCategory.name
let category = existingCategory ?? (shouldUseOther
? otherCategory
: categoryRepository.createCategory(
name: resolvedName,
colorHex: importedCategory.colorHex ?? "#3B82F6",
icon: importedCategory.icon ?? "chart.pie.fill"
))
categoryLookup[normalizedCategoryName(category.name)] = category
for importedSource in importedCategory.sources {
let existingSource = sourceRepository.sources.first(where: {
$0.name == importedSource.name && $0.account?.id == account.id
})
let source = existingSource ?? sourceRepository.createSource(
name: importedSource.name,
category: category,
notificationFrequency: importedAccount.notificationFrequency,
customFrequencyMonths: importedAccount.customFrequencyMonths
)
source.account = account
if existingSource == nil {
sourcesCreated += 1
}
for snapshot in importedSource.snapshots {
snapshotRepository.createSnapshot(
for: source,
date: snapshot.date,
value: snapshot.value,
contribution: snapshot.contribution,
notes: snapshot.notes
)
snapshotsCreated += 1
snapshotProgress?(snapshotsCreated)
let monthKey = MonthlyCheckInStore.monthKey(for: snapshot.date)
if let existingDate = completionDatesByMonth[monthKey] {
if snapshot.date > existingDate {
completionDatesByMonth[monthKey] = snapshot.date
}
} else {
completionDatesByMonth[monthKey] = snapshot.date
}
}
}
}
}
if context.hasChanges {
do {
try context.save()
} catch {
errors.append("Failed to save imported data.")
}
}
if !completionDatesByMonth.isEmpty {
for (monthKey, completionDate) in completionDatesByMonth {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM"
if let monthDate = formatter.date(from: monthKey) {
MonthlyCheckInStore.setCompletionDate(completionDate, for: monthDate)
}
}
}
return ImportResult(
accountsCreated: accountsCreated,
sourcesCreated: sourcesCreated,
snapshotsCreated: snapshotsCreated,
errors: errors
)
}
private func resolveExistingCategory(
named rawName: String,
lookup: [String: Category]
) -> Category? {
if let canonical = canonicalCategoryName(for: rawName) {
let canonicalKey = normalizedCategoryName(canonical)
if let match = lookup[canonicalKey] {
return match
}
}
return lookup[normalizedCategoryName(rawName)]
}
private func buildCategoryLookup(from categories: [Category]) -> [String: Category] {
var lookup: [String: Category] = [:]
for category in categories {
lookup[normalizedCategoryName(category.name)] = category
}
return lookup
}
private func canonicalCategoryName(for rawName: String) -> String? {
let normalized = normalizedCategoryName(rawName)
for mapping in categoryAliasMappings {
if mapping.aliases.contains(where: { normalizedCategoryName($0) == normalized }) {
return mapping.canonical
}
}
return nil
}
private func normalizedCategoryName(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = trimmed.folding(
options: [.diacriticInsensitive, .caseInsensitive],
locale: .current
)
return normalized.replacingOccurrences(
of: "\\s+",
with: " ",
options: .regularExpression
)
}
private var categoryAliasMappings: [(canonical: String, aliases: [String])] {
[
(
canonical: "Stocks",
aliases: ["Stocks", "category_stocks", String(localized: "category_stocks"), "Acciones"]
),
(
canonical: "Bonds",
aliases: ["Bonds", "category_bonds", String(localized: "category_bonds"), "Bonos"]
),
(
canonical: "Real Estate",
aliases: ["Real Estate", "category_real_estate", String(localized: "category_real_estate"), "Inmobiliario"]
),
(
canonical: "Crypto",
aliases: ["Crypto", "category_crypto", String(localized: "category_crypto"), "Cripto"]
),
(
canonical: "Cash",
aliases: ["Cash", "category_cash", String(localized: "category_cash"), "Efectivo"]
),
(
canonical: "ETFs",
aliases: ["ETFs", "category_etfs", String(localized: "category_etfs"), "ETF"]
),
(
canonical: "Retirement",
aliases: ["Retirement", "category_retirement", String(localized: "category_retirement"), "Jubilación"]
),
(
canonical: "Other",
aliases: [
"Other",
"category_other",
String(localized: "category_other"),
"Uncategorized",
"uncategorized",
String(localized: "uncategorized"),
"Otros",
"Sin categoría"
]
)
]
}
// MARK: - CSV Helpers
private func parseCSVRows(_ content: String) -> [[String]] {
var rows: [[String]] = []
var currentRow: [String] = []
var currentField = ""
var insideQuotes = false
for char in content {
if char == "\"" {
insideQuotes.toggle()
continue
}
if char == "," && !insideQuotes {
currentRow.append(currentField)
currentField = ""
continue
}
if char == "\n" && !insideQuotes {
currentRow.append(currentField)
rows.append(currentRow.map { $0.trimmingCharacters(in: .whitespaces) })
currentRow = []
currentField = ""
continue
}
currentField.append(char)
}
if !currentField.isEmpty || !currentRow.isEmpty {
currentRow.append(currentField)
rows.append(currentRow.map { $0.trimmingCharacters(in: .whitespaces) })
}
return rows
}
private func parseDate(_ value: String) -> Date? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
if let iso = ISO8601DateFormatter().date(from: trimmed) {
return iso
}
let formats = [
"yyyy-MM-dd",
"yyyy/MM/dd",
"dd/MM/yyyy",
"MM/dd/yyyy",
"dd-MM-yyyy",
"MM-dd-yyyy",
"yyyy-MM-dd HH:mm",
"yyyy-MM-dd HH:mm:ss",
"yyyy/MM/dd HH:mm",
"yyyy/MM/dd HH:mm:ss",
"dd/MM/yyyy HH:mm",
"dd/MM/yyyy HH:mm:ss",
"MM/dd/yyyy HH:mm",
"MM/dd/yyyy HH:mm:ss",
"dd-MM-yyyy HH:mm",
"dd-MM-yyyy HH:mm:ss",
"MM-dd-yyyy HH:mm",
"MM-dd-yyyy HH:mm:ss",
"dd/MM/yyyy h:mm a",
"dd/MM/yyyy h:mm:ss a",
"MM/dd/yyyy h:mm a",
"MM/dd/yyyy h:mm:ss a",
"dd-MM-yyyy h:mm a",
"dd-MM-yyyy h:mm:ss a",
"MM-dd-yyyy h:mm a",
"MM-dd-yyyy h:mm:ss a"
]
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = .current
for format in formats {
formatter.dateFormat = format
if let date = formatter.date(from: trimmed) {
return date
}
}
return nil
}
private func parseDecimal(_ value: String) -> Decimal? {
let cleaned = value
.replacingOccurrences(of: ",", with: ".")
.trimmingCharacters(in: .whitespaces)
guard !cleaned.isEmpty else { return nil }
return Decimal(string: cleaned)
}
}
private extension Array where Element == String {
func safeValue(at index: Int) -> String? {
guard index >= 0, index < count else { return nil }
return self[index]
}
}
@@ -0,0 +1,189 @@
import Foundation
import Combine
import UserNotifications
import UIKit
class NotificationService: ObservableObject {
static let shared = NotificationService()
@Published var isAuthorized = false
@Published var pendingCount = 0
private let center = UNUserNotificationCenter.current()
private init() {
checkAuthorizationStatus()
}
// MARK: - Authorization
func checkAuthorizationStatus() {
center.getNotificationSettings { [weak self] settings in
DispatchQueue.main.async {
self?.isAuthorized = settings.authorizationStatus == .authorized
}
}
}
func requestAuthorization() async -> Bool {
do {
let granted = try await center.requestAuthorization(options: [.alert, .badge, .sound])
await MainActor.run {
self.isAuthorized = granted
}
return granted
} catch {
print("Notification authorization error: \(error)")
return false
}
}
// MARK: - Schedule Notifications
func scheduleReminder(for source: InvestmentSource) {
guard let nextDate = source.nextReminderDate else { return }
guard source.frequency != .never else { return }
// Remove existing notification for this source
cancelReminder(for: source)
// Get notification time from settings
let settings = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext)
let notificationTime = settings.defaultNotificationTime ?? defaultNotificationTime()
// Combine date and time
let calendar = Calendar.current
var components = calendar.dateComponents([.year, .month, .day], from: nextDate)
let timeComponents = calendar.dateComponents([.hour, .minute], from: notificationTime)
components.hour = timeComponents.hour
components.minute = timeComponents.minute
guard let triggerDate = calendar.date(from: components) else { return }
// Create notification content
let content = UNMutableNotificationContent()
content.title = "Investment Update Reminder"
content.body = "Time to update \(source.name). Tap to add a new snapshot."
content.sound = .default
content.badge = NSNumber(value: pendingCount + 1)
content.userInfo = [
"sourceId": source.id.uuidString,
"sourceName": source.name
]
// Create trigger
let triggerComponents = calendar.dateComponents(
[.year, .month, .day, .hour, .minute],
from: triggerDate
)
let trigger = UNCalendarNotificationTrigger(dateMatching: triggerComponents, repeats: false)
// Create request
let request = UNNotificationRequest(
identifier: notificationIdentifier(for: source),
content: content,
trigger: trigger
)
// Schedule
center.add(request) { error in
if let error = error {
print("Failed to schedule notification: \(error)")
} else {
print("Scheduled reminder for \(source.name) on \(triggerDate)")
FirebaseService.shared.logNotificationScheduled(frequency: source.notificationFrequency)
}
}
}
func scheduleAllReminders(for sources: [InvestmentSource]) {
for source in sources {
scheduleReminder(for: source)
}
}
// MARK: - Cancel Notifications
func cancelReminder(for source: InvestmentSource) {
center.removePendingNotificationRequests(
withIdentifiers: [notificationIdentifier(for: source)]
)
}
func cancelAllReminders() {
center.removeAllPendingNotificationRequests()
}
// MARK: - Badge Management
func updateBadgeCount() {
let repository = InvestmentSourceRepository()
let needsUpdate = repository.fetchSourcesNeedingUpdate()
pendingCount = needsUpdate.count
center.setBadgeCount(pendingCount) { _ in }
}
func clearBadge() {
pendingCount = 0
center.setBadgeCount(0) { _ in }
}
// MARK: - Pending Notifications
func getPendingNotifications() async -> [UNNotificationRequest] {
await center.pendingNotificationRequests()
}
// MARK: - Helpers
private func notificationIdentifier(for source: InvestmentSource) -> String {
"investment_reminder_\(source.id.uuidString)"
}
private func defaultNotificationTime() -> Date {
var components = DateComponents()
components.hour = 9
components.minute = 0
return Calendar.current.date(from: components) ?? Date()
}
}
// MARK: - Deep Link Handler
extension NotificationService {
func handleNotificationResponse(_ response: UNNotificationResponse) {
let userInfo = response.notification.request.content.userInfo
guard let sourceIdString = userInfo["sourceId"] as? String,
let sourceId = UUID(uuidString: sourceIdString) else {
return
}
// Post notification for deep linking
NotificationCenter.default.post(
name: .openSourceDetail,
object: nil,
userInfo: ["sourceId": sourceId]
)
}
}
// MARK: - Notification Names
extension Notification.Name {
static let openSourceDetail = Notification.Name("openSourceDetail")
}
// MARK: - Background Refresh
extension NotificationService {
func performBackgroundRefresh() {
updateBadgeCount()
// Reschedule any missed notifications
let repository = InvestmentSourceRepository()
let sources = repository.fetchActiveSources()
scheduleAllReminders(for: sources)
}
}
@@ -0,0 +1,465 @@
import Foundation
class PredictionEngine {
static let shared = PredictionEngine()
private let context = CoreDataStack.shared.viewContext
// MARK: - Performance: Cached Calendar reference
private static let calendar = Calendar.current
private init() {}
// MARK: - Main Prediction Interface
func predict(
snapshots: [Snapshot],
monthsAhead: Int = 12,
algorithm: PredictionAlgorithm? = nil
) -> PredictionResult {
guard snapshots.count >= 3 else {
return PredictionResult(
predictions: [],
algorithm: .linear,
accuracy: 0,
volatility: 0
)
}
// Sort snapshots by date
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
// Calculate volatility for algorithm selection
let volatility = calculateVolatility(snapshots: sortedSnapshots)
// Select algorithm if not specified
let selectedAlgorithm = algorithm ?? selectBestAlgorithm(volatility: volatility)
// Generate predictions
let predictions: [Prediction]
let accuracy: Double
switch selectedAlgorithm {
case .linear:
predictions = predictLinear(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
accuracy = calculateLinearAccuracy(snapshots: sortedSnapshots)
case .exponentialSmoothing:
predictions = predictExponentialSmoothing(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
accuracy = calculateESAccuracy(snapshots: sortedSnapshots)
case .movingAverage:
predictions = predictMovingAverage(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
accuracy = calculateMAAccuracy(snapshots: sortedSnapshots)
case .holtTrend:
predictions = predictHoltTrend(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
accuracy = calculateHoltAccuracy(snapshots: sortedSnapshots)
}
return PredictionResult(
predictions: predictions,
algorithm: selectedAlgorithm,
accuracy: accuracy,
volatility: volatility
)
}
// MARK: - Algorithm Selection
private func selectBestAlgorithm(volatility: Double) -> PredictionAlgorithm {
switch volatility {
case 0..<8:
return .holtTrend
case 8..<20:
return .exponentialSmoothing
default:
return .movingAverage
}
}
// MARK: - Linear Regression
func predictLinear(snapshots: [Snapshot], monthsAhead: Int = 12) -> [Prediction] {
guard snapshots.count >= 3 else { return [] }
guard let firstDate = snapshots.first?.date else { return [] }
let dataPoints: [(x: Double, y: Double)] = snapshots.map { snapshot in
let daysSinceStart = snapshot.date.timeIntervalSince(firstDate) / 86400
return (x: daysSinceStart, y: snapshot.decimalValue.doubleValue)
}
let (slope, intercept) = calculateLinearRegression(dataPoints: dataPoints)
let residualStdDev = calculateResidualStdDev(dataPoints: dataPoints, slope: slope, intercept: intercept)
var predictions: [Prediction] = []
let lastDate = snapshots.last!.date
for month in 1...monthsAhead {
guard let futureDate = Self.calendar.date(
byAdding: .month,
value: month,
to: lastDate
) else { continue }
let daysFromStart = futureDate.timeIntervalSince(firstDate) / 86400
let predictedValue = max(0, slope * daysFromStart + intercept)
// Widen confidence interval for further predictions
let confidenceMultiplier = 1.0 + (Double(month) * 0.02)
let intervalWidth = residualStdDev * 1.96 * confidenceMultiplier
predictions.append(Prediction(
date: futureDate,
predictedValue: Decimal(predictedValue),
algorithm: .linear,
confidenceInterval: Prediction.ConfidenceInterval(
lower: Decimal(max(0, predictedValue - intervalWidth)),
upper: Decimal(predictedValue + intervalWidth)
)
))
}
return predictions
}
private func calculateLinearRegression(
dataPoints: [(x: Double, y: Double)]
) -> (slope: Double, intercept: Double) {
let n = Double(dataPoints.count)
let sumX = dataPoints.reduce(0) { $0 + $1.x }
let sumY = dataPoints.reduce(0) { $0 + $1.y }
let sumXY = dataPoints.reduce(0) { $0 + ($1.x * $1.y) }
let sumX2 = dataPoints.reduce(0) { $0 + ($1.x * $1.x) }
let denominator = n * sumX2 - sumX * sumX
guard denominator != 0 else { return (0, sumY / n) }
let slope = (n * sumXY - sumX * sumY) / denominator
let intercept = (sumY - slope * sumX) / n
return (slope, intercept)
}
private func calculateResidualStdDev(
dataPoints: [(x: Double, y: Double)],
slope: Double,
intercept: Double
) -> Double {
guard dataPoints.count > 2 else { return 0 }
let residuals = dataPoints.map { point in
let predicted = slope * point.x + intercept
return pow(point.y - predicted, 2)
}
let meanSquaredError = residuals.reduce(0, +) / Double(dataPoints.count - 2)
return sqrt(meanSquaredError)
}
private func calculateLinearAccuracy(snapshots: [Snapshot]) -> Double {
guard snapshots.count >= 5 else { return 0.5 }
// Use last 20% of data for validation
let splitIndex = Int(Double(snapshots.count) * 0.8)
let trainingData = Array(snapshots.prefix(splitIndex))
let validationData = Array(snapshots.suffix(from: splitIndex))
guard let firstDate = trainingData.first?.date else { return 0.5 }
let trainPoints = trainingData.map { snapshot in
(x: snapshot.date.timeIntervalSince(firstDate) / 86400, y: snapshot.decimalValue.doubleValue)
}
let (slope, intercept) = calculateLinearRegression(dataPoints: trainPoints)
// Calculate R-squared on validation data
let validationValues = validationData.map { $0.decimalValue.doubleValue }
let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count)
var ssRes: Double = 0
var ssTot: Double = 0
for snapshot in validationData {
let x = snapshot.date.timeIntervalSince(firstDate) / 86400
let actual = snapshot.decimalValue.doubleValue
let predicted = slope * x + intercept
ssRes += pow(actual - predicted, 2)
ssTot += pow(actual - meanValidation, 2)
}
guard ssTot != 0 else { return 0.5 }
let rSquared = max(0, 1 - (ssRes / ssTot))
return min(1.0, rSquared)
}
// MARK: - Exponential Smoothing
func predictExponentialSmoothing(
snapshots: [Snapshot],
monthsAhead: Int = 12,
alpha: Double = 0.3
) -> [Prediction] {
guard snapshots.count >= 3 else { return [] }
let values = snapshots.map { $0.decimalValue.doubleValue }
// Calculate smoothed values
var smoothed = values[0]
for i in 1..<values.count {
smoothed = alpha * values[i] + (1 - alpha) * smoothed
}
// Calculate trend
var trend: Double = 0
if values.count >= 2 {
let recentChange = values.suffix(3).reduce(0) { $0 + $1 } / 3.0 -
values.prefix(3).reduce(0) { $0 + $1 } / 3.0
trend = recentChange / Double(values.count)
}
// Calculate standard deviation for confidence interval
let stdDev = calculateStdDev(values: values)
var predictions: [Prediction] = []
let lastDate = snapshots.last!.date
for month in 1...monthsAhead {
guard let futureDate = Self.calendar.date(
byAdding: .month,
value: month,
to: lastDate
) else { continue }
let predictedValue = max(0, smoothed + trend * Double(month))
let intervalWidth = stdDev * 1.96 * (1.0 + Double(month) * 0.05)
predictions.append(Prediction(
date: futureDate,
predictedValue: Decimal(predictedValue),
algorithm: .exponentialSmoothing,
confidenceInterval: Prediction.ConfidenceInterval(
lower: Decimal(max(0, predictedValue - intervalWidth)),
upper: Decimal(predictedValue + intervalWidth)
)
))
}
return predictions
}
private func calculateESAccuracy(snapshots: [Snapshot]) -> Double {
guard snapshots.count >= 5 else { return 0.5 }
let values = snapshots.map { $0.decimalValue.doubleValue }
let splitIndex = Int(Double(values.count) * 0.8)
var smoothed = values[0]
for i in 1..<splitIndex {
smoothed = 0.3 * values[i] + 0.7 * smoothed
}
let validationValues = Array(values.suffix(from: splitIndex))
let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count)
var ssRes: Double = 0
var ssTot: Double = 0
for (i, actual) in validationValues.enumerated() {
let predicted = smoothed + (smoothed - values[splitIndex - 1]) * Double(i + 1) / Double(splitIndex)
ssRes += pow(actual - predicted, 2)
ssTot += pow(actual - meanValidation, 2)
}
guard ssTot != 0 else { return 0.5 }
return max(0, min(1.0, 1 - (ssRes / ssTot)))
}
// MARK: - Moving Average
func predictMovingAverage(
snapshots: [Snapshot],
monthsAhead: Int = 12,
windowSize: Int = 3
) -> [Prediction] {
guard snapshots.count >= windowSize else { return [] }
let values = snapshots.map { $0.decimalValue.doubleValue }
// Calculate moving average of last window
let recentValues = Array(values.suffix(windowSize))
let movingAverage = recentValues.reduce(0, +) / Double(windowSize)
// Calculate average monthly change
var changes: [Double] = []
for i in 1..<values.count {
changes.append(values[i] - values[i - 1])
}
let avgChange = changes.isEmpty ? 0 : changes.reduce(0, +) / Double(changes.count)
let stdDev = calculateStdDev(values: values)
var predictions: [Prediction] = []
let lastDate = snapshots.last!.date
for month in 1...monthsAhead {
guard let futureDate = Self.calendar.date(
byAdding: .month,
value: month,
to: lastDate
) else { continue }
let predictedValue = max(0, movingAverage + avgChange * Double(month))
let intervalWidth = stdDev * 1.96 * (1.0 + Double(month) * 0.03)
predictions.append(Prediction(
date: futureDate,
predictedValue: Decimal(predictedValue),
algorithm: .movingAverage,
confidenceInterval: Prediction.ConfidenceInterval(
lower: Decimal(max(0, predictedValue - intervalWidth)),
upper: Decimal(predictedValue + intervalWidth)
)
))
}
return predictions
}
private func calculateMAAccuracy(snapshots: [Snapshot]) -> Double {
guard snapshots.count >= 5 else { return 0.5 }
let values = snapshots.map { $0.decimalValue.doubleValue }
let windowSize = 3
let splitIndex = Int(Double(values.count) * 0.8)
guard splitIndex > windowSize else { return 0.5 }
let recentWindow = Array(values[(splitIndex - windowSize)..<splitIndex])
let movingAvg = recentWindow.reduce(0, +) / Double(windowSize)
let validationValues = Array(values.suffix(from: splitIndex))
let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count)
var ssRes: Double = 0
var ssTot: Double = 0
for actual in validationValues {
ssRes += pow(actual - movingAvg, 2)
ssTot += pow(actual - meanValidation, 2)
}
guard ssTot != 0 else { return 0.5 }
return max(0, min(1.0, 1 - (ssRes / ssTot)))
}
// MARK: - Holt Trend (Double Exponential Smoothing)
func predictHoltTrend(
snapshots: [Snapshot],
monthsAhead: Int = 12,
alpha: Double = 0.4,
beta: Double = 0.3
) -> [Prediction] {
guard snapshots.count >= 3 else { return [] }
let values = snapshots.map { $0.decimalValue.doubleValue }
var level = values[0]
var trend = values[1] - values[0]
var fitted: [Double] = []
for value in values {
let lastLevel = level
level = alpha * value + (1 - alpha) * (level + trend)
trend = beta * (level - lastLevel) + (1 - beta) * trend
fitted.append(level + trend)
}
let residuals = zip(values, fitted).map { $0 - $1 }
let stdDev = calculateStdDev(values: residuals)
var predictions: [Prediction] = []
let lastDate = snapshots.last!.date
for month in 1...monthsAhead {
guard let futureDate = Self.calendar.date(
byAdding: .month,
value: month,
to: lastDate
) else { continue }
let predictedValue = max(0, level + Double(month) * trend)
let intervalWidth = stdDev * 1.96 * (1.0 + Double(month) * 0.04)
predictions.append(Prediction(
date: futureDate,
predictedValue: Decimal(predictedValue),
algorithm: .holtTrend,
confidenceInterval: Prediction.ConfidenceInterval(
lower: Decimal(max(0, predictedValue - intervalWidth)),
upper: Decimal(predictedValue + intervalWidth)
)
))
}
return predictions
}
private func calculateHoltAccuracy(snapshots: [Snapshot]) -> Double {
guard snapshots.count >= 5 else { return 0.5 }
let values = snapshots.map { $0.decimalValue.doubleValue }
let splitIndex = Int(Double(values.count) * 0.8)
guard splitIndex >= 2 else { return 0.5 }
var level = values[0]
var trend = values[1] - values[0]
for value in values.prefix(splitIndex) {
let lastLevel = level
level = 0.4 * value + 0.6 * (level + trend)
trend = 0.3 * (level - lastLevel) + 0.7 * trend
}
let validationValues = Array(values.suffix(from: splitIndex))
let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count)
var ssRes: Double = 0
var ssTot: Double = 0
for (i, actual) in validationValues.enumerated() {
let predicted = level + Double(i + 1) * trend
ssRes += pow(actual - predicted, 2)
ssTot += pow(actual - meanValidation, 2)
}
guard ssTot != 0 else { return 0.5 }
return max(0, min(1.0, 1 - (ssRes / ssTot)))
}
// MARK: - Helpers
private func calculateVolatility(snapshots: [Snapshot]) -> Double {
let values = snapshots.map { $0.decimalValue.doubleValue }
guard values.count >= 2 else { return 0 }
var returns: [Double] = []
for i in 1..<values.count {
guard values[i - 1] != 0 else { continue }
let periodReturn = (values[i] - values[i - 1]) / values[i - 1] * 100
returns.append(periodReturn)
}
return calculateStdDev(values: returns)
}
private func calculateStdDev(values: [Double]) -> Double {
guard values.count >= 2 else { return 0 }
let mean = values.reduce(0, +) / Double(values.count)
let squaredDifferences = values.map { pow($0 - mean, 2) }
let variance = squaredDifferences.reduce(0, +) / Double(values.count - 1)
return sqrt(variance)
}
}
@@ -0,0 +1,101 @@
import Foundation
class SampleDataService {
static let shared = SampleDataService()
private init() {}
func seedSampleData() {
let context = CoreDataStack.shared.viewContext
let sourceRepository = InvestmentSourceRepository(context: context)
guard sourceRepository.sourceCount == 0 else { return }
let categoryRepository = CategoryRepository(context: context)
categoryRepository.createDefaultCategoriesIfNeeded()
let accountRepository = AccountRepository(context: context)
let account = accountRepository.createDefaultAccountIfNeeded()
let snapshotRepository = SnapshotRepository(context: context)
let goalRepository = GoalRepository(context: context)
let transactionRepository = TransactionRepository(context: context)
let categories = categoryRepository.categories
let stocksCategory = categories.first { $0.name == "Stocks" } ?? categories.first!
let cryptoCategory = categories.first { $0.name == "Crypto" } ?? categories.first!
let realEstateCategory = categories.first { $0.name == "Real Estate" } ?? categories.first!
let stocks = sourceRepository.createSource(
name: "Index Fund",
category: stocksCategory,
account: account
)
let crypto = sourceRepository.createSource(
name: "BTC",
category: cryptoCategory,
account: account
)
let realEstate = sourceRepository.createSource(
name: "Rental Property",
category: realEstateCategory,
account: account
)
seedSnapshots(for: stocks, baseValue: 12000, monthlyIncrease: 450, repository: snapshotRepository)
seedSnapshots(for: crypto, baseValue: 3000, monthlyIncrease: 250, repository: snapshotRepository)
seedSnapshots(for: realEstate, baseValue: 80000, monthlyIncrease: 600, repository: snapshotRepository)
seedMonthlyNotes()
transactionRepository.createTransaction(
source: stocks,
type: .buy,
date: Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date(),
shares: 10,
price: 400,
amount: nil,
notes: "Sample buy"
)
_ = goalRepository.createGoal(
name: "1M Goal",
targetAmount: 1_000_000,
targetDate: nil,
account: account
)
}
private func seedSnapshots(
for source: InvestmentSource,
baseValue: Decimal,
monthlyIncrease: Decimal,
repository: SnapshotRepository
) {
let calendar = Calendar.current
for monthOffset in (0..<6).reversed() {
let date = calendar.date(byAdding: .month, value: -monthOffset, to: Date()) ?? Date()
let value = baseValue + Decimal(monthOffset) * monthlyIncrease
repository.createSnapshot(
for: source,
date: date,
value: value,
contribution: monthOffset == 0 ? monthlyIncrease : nil,
notes: nil
)
}
}
private func seedMonthlyNotes() {
let calendar = Calendar.current
let notes = [
"Rebalanced slightly toward equities. Stayed calm despite noise.",
"Focused on contributions. No major changes.",
"Reviewed allocation drift and decided to hold positions."
]
for (index, note) in notes.enumerated() {
let date = calendar.date(byAdding: .month, value: -index, to: Date()) ?? Date()
MonthlyCheckInStore.setNote(note, for: date)
}
}
}
@@ -0,0 +1,112 @@
import Foundation
import UIKit
class ShareService {
static let shared = ShareService()
private init() {}
func shareTextFile(content: String, fileName: String) {
guard let viewController = ShareService.topViewController() else { return }
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(fileName)
do {
try content.write(to: tempURL, atomically: true, encoding: .utf8)
let activityVC = UIActivityViewController(
activityItems: [tempURL],
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
)
}
DispatchQueue.main.async {
viewController.present(activityVC, animated: true)
}
} catch {
print("Share file error: \(error)")
}
}
func shareCalendarEvent(
title: String,
notes: String,
startDate: Date,
durationMinutes: Int = 60
) {
let endDate = startDate.addingTimeInterval(TimeInterval(durationMinutes * 60))
let icsContent = calendarICS(
title: title,
notes: notes,
startDate: startDate,
endDate: endDate
)
shareTextFile(content: icsContent, fileName: "PortfolioJournal-CheckIn.ics")
}
private static func topViewController(
base: UIViewController? = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.first(where: { $0.isKeyWindow })?.rootViewController
) -> UIViewController? {
if let nav = base as? UINavigationController {
return topViewController(base: nav.visibleViewController)
}
if let tab = base as? UITabBarController {
return topViewController(base: tab.selectedViewController)
}
if let presented = base?.presentedViewController {
return topViewController(base: presented)
}
return base
}
private func calendarICS(
title: String,
notes: String,
startDate: Date,
endDate: Date
) -> String {
let uid = UUID().uuidString
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
formatter.timeZone = TimeZone(secondsFromGMT: 0)
let stamp = formatter.string(from: Date())
let start = formatter.string(from: startDate)
let end = formatter.string(from: endDate)
return """
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//PortfolioJournal//MonthlyCheckIn//EN
BEGIN:VEVENT
UID:\(uid)
DTSTAMP:\(stamp)
DTSTART:\(start)
DTEND:\(end)
SUMMARY:\(escapeICS(title))
DESCRIPTION:\(escapeICS(notes))
END:VEVENT
END:VCALENDAR
"""
}
private func escapeICS(_ value: String) -> String {
value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: ";", with: "\\;")
.replacingOccurrences(of: ",", with: "\\,")
}
}
@@ -0,0 +1,41 @@
import Foundation
enum AllocationTargetStore {
private static let targetsKey = "allocationTargets"
static func target(for categoryId: UUID) -> Double? {
loadTargets()[categoryId.uuidString]
}
static func setTarget(_ value: Double?, for categoryId: UUID) {
var targets = loadTargets()
let key = categoryId.uuidString
if let value, value > 0 {
targets[key] = value
} else {
targets.removeValue(forKey: key)
}
saveTargets(targets)
}
static func totalTargetPercentage(for categoryIds: [UUID]) -> Double {
let targets = loadTargets()
return categoryIds.reduce(0) { total, id in
total + (targets[id.uuidString] ?? 0)
}
}
private static func loadTargets() -> [String: Double] {
guard let data = UserDefaults.standard.data(forKey: targetsKey),
let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else {
return [:]
}
return decoded
}
private static func saveTargets(_ targets: [String: Double]) {
if let data = try? JSONEncoder().encode(targets) {
UserDefaults.standard.set(data, forKey: targetsKey)
}
}
}
@@ -0,0 +1,19 @@
import Foundation
import LocalAuthentication
enum AppLockService {
static func canUseBiometrics() -> Bool {
let context = LAContext()
var error: NSError?
return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
}
static func authenticate(reason: String, completion: @escaping (Bool) -> Void) {
let context = LAContext()
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in
DispatchQueue.main.async {
completion(success)
}
}
}
}
@@ -0,0 +1,175 @@
import Foundation
enum AppConstants {
// MARK: - App Info
static let appName = "Portfolio Journal"
static let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
static let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
// MARK: - Bundle Identifiers
static let bundleIdentifier = "com.alexandrevazquez.portfoliojournal"
static let appGroupIdentifier = "group.com.alexandrevazquez.portfoliojournal"
static let cloudKitContainerIdentifier = "iCloud.com.alexandrevazquez.portfoliojournal"
// MARK: - StoreKit
static let premiumProductID = "com.portfoliojournal.premium"
static let premiumPrice = "€4.69"
// MARK: - AdMob
#if DEBUG
static let adMobAppID = "ca-app-pub-3940256099942544~1458002511" // Test App ID
static let bannerAdUnitID = "ca-app-pub-3940256099942544/2934735716" // Test Banner
#else
static let adMobAppID = "ca-app-pub-XXXXXXXXXXXXXXXX~YYYYYYYYYY" // Replace with real App ID
static let bannerAdUnitID = "ca-app-pub-XXXXXXXXXXXXXXXX/YYYYYYYYYY" // Replace with real Ad Unit ID
#endif
// MARK: - Currency
static let defaultCurrency = "EUR"
static let currencySymbol = ""
// MARK: - Freemium Limits
static let maxFreeSources = 5
static let maxFreeHistoricalMonths = 12
// MARK: - UI Constants
enum UI {
static let cornerRadius: CGFloat = 12
static let smallCornerRadius: CGFloat = 8
static let largeCornerRadius: CGFloat = 16
static let padding: CGFloat = 16
static let smallPadding: CGFloat = 8
static let largePadding: CGFloat = 24
static let iconSize: CGFloat = 24
static let smallIconSize: CGFloat = 16
static let largeIconSize: CGFloat = 32
static let bannerAdHeight: CGFloat = 50
static let tabBarHeight: CGFloat = 49
static let cardShadowRadius: CGFloat = 4
static let cardShadowOpacity: CGFloat = 0.1
}
// MARK: - Animation
enum Animation {
static let defaultDuration: Double = 0.3
static let shortDuration: Double = 0.15
static let longDuration: Double = 0.5
}
// MARK: - Charts
enum Charts {
static let defaultMonthsToShow = 12
static let predictionMonths = 12
static let minDataPointsForPrediction = 3
static let confidenceIntervalPercentage = 0.15
}
// MARK: - Notifications
enum Notifications {
static let defaultHour = 9
static let defaultMinute = 0
static let categoryIdentifier = "INVESTMENT_REMINDER"
}
// MARK: - Storage Keys
enum StorageKeys {
static let onboardingCompleted = "onboardingCompleted"
static let adConsentObtained = "adConsentObtained"
static let lastSyncDate = "lastSyncDate"
static let selectedCategoryFilter = "selectedCategoryFilter"
static let preferredChartType = "preferredChartType"
}
// MARK: - Deep Links
enum DeepLinks {
static let scheme = "portfoliojournal"
static let sourceDetail = "source"
static let addSnapshot = "addSnapshot"
static let premium = "premium"
}
// MARK: - URLs
enum URLs {
static let privacyPolicy = "https://portfoliojournal.app/privacy.html"
static let termsOfService = "https://portfoliojournal.app/terms.html"
static let support = "https://portfoliojournal.app/support.html"
static let appStore = "https://apps.apple.com/app/idXXXXXXXXXX"
}
// MARK: - Feature Flags
enum Features {
static let enablePredictions = true
static let enableExport = true
static let enableWidgets = true
static let enableNotifications = true
static let enableAnalytics = true
}
}
// MARK: - SF Symbols
enum SFSymbol {
// Navigation
static let dashboard = "chart.pie.fill"
static let sources = "list.bullet"
static let charts = "chart.xyaxis.line"
static let settings = "gearshape.fill"
// Actions
static let add = "plus"
static let edit = "pencil"
static let delete = "trash"
static let share = "square.and.arrow.up"
static let export = "arrow.up.doc"
// Status
static let checkmark = "checkmark.circle.fill"
static let warning = "exclamationmark.triangle.fill"
static let error = "xmark.circle.fill"
static let info = "info.circle.fill"
// Financial
static let trendUp = "arrow.up.right"
static let trendDown = "arrow.down.right"
static let money = "eurosign.circle.fill"
static let chart = "chart.line.uptrend.xyaxis"
// Categories
static let stocks = "chart.line.uptrend.xyaxis"
static let bonds = "building.columns.fill"
static let realEstate = "house.fill"
static let crypto = "bitcoinsign.circle.fill"
static let cash = "banknote.fill"
static let etf = "chart.bar.fill"
static let retirement = "person.fill"
static let other = "ellipsis.circle.fill"
// Premium
static let premium = "crown.fill"
static let lock = "lock.fill"
static let unlock = "lock.open.fill"
// Misc
static let calendar = "calendar"
static let notification = "bell.fill"
static let refresh = "arrow.clockwise"
static let close = "xmark"
}
@@ -0,0 +1,23 @@
import Foundation
enum CurrencyFormatter {
static func currentCurrencyCode() -> String {
let context = CoreDataStack.shared.viewContext
return AppSettings.getOrCreate(in: context).currency
}
static func format(_ decimal: Decimal, style: NumberFormatter.Style = .currency, maximumFractionDigits: Int = 2) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = style
formatter.currencyCode = currentCurrencyCode()
formatter.maximumFractionDigits = maximumFractionDigits
return formatter.string(from: decimal as NSDecimalNumber) ?? "\(decimal)"
}
static func symbol(for code: String) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = code
return formatter.currencySymbol ?? code
}
}
@@ -0,0 +1,9 @@
import Foundation
enum CurrencyPicker {
static let commonCodes: [String] = [
"EUR", "USD", "GBP", "CHF", "JPY",
"CAD", "AUD", "SEK", "NOK", "DKK"
]
}
@@ -0,0 +1,96 @@
import Foundation
struct DashboardSectionConfig: Identifiable, Codable, Hashable {
let id: String
var isVisible: Bool
var isCollapsed: Bool
}
enum DashboardSection: String, CaseIterable, Identifiable {
case totalValue
case monthlyCheckIn
case momentumStreaks
case monthlySummary
case evolution
case categoryBreakdown
case goals
case pendingUpdates
case periodReturns
var id: String { rawValue }
var title: String {
switch self {
case .totalValue:
return "Total Portfolio Value"
case .monthlyCheckIn:
return "Monthly Check-in"
case .momentumStreaks:
return "Momentum & Streaks"
case .monthlySummary:
return "Cashflow vs Growth"
case .evolution:
return "Portfolio Evolution"
case .categoryBreakdown:
return "By Category"
case .goals:
return "Goals"
case .pendingUpdates:
return "Pending Updates"
case .periodReturns:
return "Returns"
}
}
}
enum DashboardLayoutStore {
private static let storageKey = "dashboardLayoutConfig"
static func load() -> [DashboardSectionConfig] {
let defaults = defaultConfigs()
guard let data = UserDefaults.standard.data(forKey: storageKey),
let decoded = try? JSONDecoder().decode([DashboardSectionConfig].self, from: data) else {
return defaults
}
var merged: [DashboardSectionConfig] = []
for config in decoded {
if let section = DashboardSection(rawValue: config.id) {
merged.append(config)
} else {
continue
}
}
for section in DashboardSection.allCases {
if !merged.contains(where: { $0.id == section.id }) {
merged.append(defaults.first(where: { $0.id == section.id }) ?? DashboardSectionConfig(
id: section.id,
isVisible: true,
isCollapsed: false
))
}
}
return merged
}
static func save(_ configs: [DashboardSectionConfig]) {
guard let data = try? JSONEncoder().encode(configs) else { return }
UserDefaults.standard.set(data, forKey: storageKey)
}
static func reset() {
UserDefaults.standard.removeObject(forKey: storageKey)
}
private static func defaultConfigs() -> [DashboardSectionConfig] {
DashboardSection.allCases.map { section in
DashboardSectionConfig(
id: section.id,
isVisible: true,
isCollapsed: false
)
}
}
}
@@ -0,0 +1,166 @@
import SwiftUI
extension Color {
// MARK: - Hex Initialization
init?(hex: String) {
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
var rgb: UInt64 = 0
guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else {
return nil
}
let length = hexSanitized.count
switch length {
case 6:
self.init(
red: Double((rgb & 0xFF0000) >> 16) / 255.0,
green: Double((rgb & 0x00FF00) >> 8) / 255.0,
blue: Double(rgb & 0x0000FF) / 255.0
)
case 8:
self.init(
red: Double((rgb & 0xFF000000) >> 24) / 255.0,
green: Double((rgb & 0x00FF0000) >> 16) / 255.0,
blue: Double((rgb & 0x0000FF00) >> 8) / 255.0,
opacity: Double(rgb & 0x000000FF) / 255.0
)
default:
return nil
}
}
// MARK: - Hex String Output
var hexString: String {
guard let components = UIColor(self).cgColor.components else {
return "#000000"
}
let r = Int(components[0] * 255.0)
let g = Int(components[1] * 255.0)
let b = Int(components[2] * 255.0)
return String(format: "#%02X%02X%02X", r, g, b)
}
// MARK: - App Colors
static let appPrimary = Color(hex: "#3B82F6") ?? .blue
static let appSecondary = Color(hex: "#10B981") ?? .green
static let appAccent = Color(hex: "#F59E0B") ?? .orange
static let appError = Color(hex: "#EF4444") ?? .red
static let appSuccess = Color(hex: "#10B981") ?? .green
static let appWarning = Color(hex: "#F59E0B") ?? .orange
// MARK: - Financial Colors
static let positiveGreen = Color(hex: "#10B981") ?? .green
static let negativeRed = Color(hex: "#EF4444") ?? .red
static let neutralGray = Color(hex: "#6B7280") ?? .gray
static func financialColor(for value: Decimal) -> Color {
if value > 0 {
return .positiveGreen
} else if value < 0 {
return .negativeRed
} else {
return .neutralGray
}
}
static func financialColor(for value: Double) -> Color {
if value > 0 {
return .positiveGreen
} else if value < 0 {
return .negativeRed
} else {
return .neutralGray
}
}
// MARK: - Category Colors
static let categoryColors: [String] = [
"#3B82F6", // Blue
"#10B981", // Green
"#F59E0B", // Amber
"#EF4444", // Red
"#8B5CF6", // Purple
"#EC4899", // Pink
"#14B8A6", // Teal
"#F97316", // Orange
"#6366F1", // Indigo
"#84CC16", // Lime
"#06B6D4", // Cyan
"#A855F7" // Violet
]
static func categoryColor(at index: Int) -> Color {
let hex = categoryColors[index % categoryColors.count]
return Color(hex: hex) ?? .blue
}
// MARK: - Chart Colors
static let chartColors: [Color] = categoryColors.compactMap { Color(hex: $0) }
// MARK: - Adjustments
func lighter(by percentage: Double = 0.2) -> Color {
adjustBrightness(by: abs(percentage))
}
func darker(by percentage: Double = 0.2) -> Color {
adjustBrightness(by: -abs(percentage))
}
private func adjustBrightness(by percentage: Double) -> Color {
var hue: CGFloat = 0
var saturation: CGFloat = 0
var brightness: CGFloat = 0
var alpha: CGFloat = 0
UIColor(self).getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
let newBrightness = max(0, min(1, brightness + CGFloat(percentage)))
return Color(UIColor(hue: hue, saturation: saturation, brightness: newBrightness, alpha: alpha))
}
// MARK: - Opacity Variants
var soft: Color {
self.opacity(0.1)
}
var medium: Color {
self.opacity(0.5)
}
}
// MARK: - Gradient Extensions
extension LinearGradient {
static let appPrimaryGradient = LinearGradient(
colors: [Color.appPrimary, Color.appPrimary.lighter()],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
static let positiveGradient = LinearGradient(
colors: [Color.positiveGreen.lighter(), Color.positiveGreen],
startPoint: .top,
endPoint: .bottom
)
static let negativeGradient = LinearGradient(
colors: [Color.negativeRed.lighter(), Color.negativeRed],
startPoint: .top,
endPoint: .bottom
)
}
@@ -0,0 +1,199 @@
import Foundation
extension Date {
// MARK: - Formatting
var shortDateString: String {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .none
return formatter.string(from: self)
}
var mediumDateString: String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
return formatter.string(from: self)
}
var longDateString: String {
let formatter = DateFormatter()
formatter.dateStyle = .long
formatter.timeStyle = .none
return formatter.string(from: self)
}
var monthYearString: String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM yyyy"
return formatter.string(from: self)
}
var yearString: String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy"
return formatter.string(from: self)
}
var iso8601String: String {
ISO8601DateFormatter().string(from: self)
}
// MARK: - Components
var startOfDay: Date {
Calendar.current.startOfDay(for: self)
}
var startOfMonth: Date {
let components = Calendar.current.dateComponents([.year, .month], from: self)
return Calendar.current.date(from: components) ?? self
}
var startOfYear: Date {
let components = Calendar.current.dateComponents([.year], from: self)
return Calendar.current.date(from: components) ?? self
}
var endOfMonth: Date {
let startOfNextMonth = Calendar.current.date(
byAdding: .month,
value: 1,
to: startOfMonth
) ?? self
return Calendar.current.date(byAdding: .day, value: -1, to: startOfNextMonth) ?? self
}
// MARK: - Comparisons
func isSameDay(as other: Date) -> Bool {
Calendar.current.isDate(self, inSameDayAs: other)
}
func isSameMonth(as other: Date) -> Bool {
let selfComponents = Calendar.current.dateComponents([.year, .month], from: self)
let otherComponents = Calendar.current.dateComponents([.year, .month], from: other)
return selfComponents.year == otherComponents.year &&
selfComponents.month == otherComponents.month
}
func isSameYear(as other: Date) -> Bool {
Calendar.current.component(.year, from: self) ==
Calendar.current.component(.year, from: other)
}
var isToday: Bool {
Calendar.current.isDateInToday(self)
}
var isYesterday: Bool {
Calendar.current.isDateInYesterday(self)
}
var isThisMonth: Bool {
isSameMonth(as: Date())
}
var isThisYear: Bool {
isSameYear(as: Date())
}
// MARK: - Calculations
func adding(days: Int) -> Date {
Calendar.current.date(byAdding: .day, value: days, to: self) ?? self
}
func adding(months: Int) -> Date {
Calendar.current.date(byAdding: .month, value: months, to: self) ?? self
}
func adding(years: Int) -> Date {
Calendar.current.date(byAdding: .year, value: years, to: self) ?? self
}
func monthsBetween(_ other: Date) -> Int {
let components = Calendar.current.dateComponents([.month], from: self, to: other)
return components.month ?? 0
}
func daysBetween(_ other: Date) -> Int {
let components = Calendar.current.dateComponents([.day], from: self, to: other)
return components.day ?? 0
}
func yearsBetween(_ other: Date) -> Double {
let days = Double(daysBetween(other))
return days / 365.25
}
// MARK: - Relative Description
var relativeDescription: String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter.localizedString(for: self, relativeTo: Date())
}
var friendlyDescription: String {
if isToday {
return String(localized: "date_today")
} else if isYesterday {
return String(localized: "date_yesterday")
} else if isThisMonth {
let formatter = DateFormatter()
formatter.dateFormat = "EEEE, d"
return formatter.string(from: self)
} else if isThisYear {
let formatter = DateFormatter()
formatter.dateFormat = "MMM d"
return formatter.string(from: self)
} else {
return mediumDateString
}
}
}
// MARK: - Date Range
struct DateRange {
let start: Date
let end: Date
static var thisMonth: DateRange {
let now = Date()
return DateRange(start: now.startOfMonth, end: now)
}
static var lastMonth: DateRange {
let now = Date()
let lastMonth = now.adding(months: -1)
return DateRange(start: lastMonth.startOfMonth, end: lastMonth.endOfMonth)
}
static var thisYear: DateRange {
let now = Date()
return DateRange(start: now.startOfYear, end: now)
}
static var lastYear: DateRange {
let now = Date()
let lastYear = now.adding(years: -1)
return DateRange(start: lastYear.startOfYear, end: now.startOfYear.adding(days: -1))
}
static func month(containing date: Date) -> DateRange {
DateRange(start: date.startOfMonth, end: date.endOfMonth)
}
static func last(months: Int) -> DateRange {
let now = Date()
let start = now.adding(months: -months)
return DateRange(start: start, end: now)
}
func contains(_ date: Date) -> Bool {
date >= start && date <= end
}
}
@@ -0,0 +1,153 @@
import Foundation
extension Decimal {
// MARK: - Performance: Shared formatters (avoid creating on every call)
private static let percentFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .percent
formatter.maximumFractionDigits = 2
formatter.multiplier = 1
return formatter
}()
private static let decimalFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = 2
return formatter
}()
// MARK: - Performance: Cached currency symbol
private static var _cachedCurrencySymbol: String?
private static var currencySymbolCacheTime: Date?
private static var cachedCurrencySymbol: String {
// Refresh cache every 60 seconds to pick up settings changes
let now = Date()
if let cached = _cachedCurrencySymbol,
let cacheTime = currencySymbolCacheTime,
now.timeIntervalSince(cacheTime) < 60 {
return cached
}
let symbol = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol
_cachedCurrencySymbol = symbol
currencySymbolCacheTime = now
return symbol
}
/// Call this when currency settings change to invalidate the cache
static func invalidateCurrencyCache() {
_cachedCurrencySymbol = nil
currencySymbolCacheTime = nil
}
// MARK: - Formatting
var currencyString: String {
CurrencyFormatter.format(self, style: .currency, maximumFractionDigits: 2)
}
var compactCurrencyString: String {
CurrencyFormatter.format(self, style: .currency, maximumFractionDigits: 0)
}
var shortCurrencyString: String {
let value = NSDecimalNumber(decimal: self).doubleValue
let symbol = Self.cachedCurrencySymbol
switch Swift.abs(value) {
case 1_000_000...:
return String(format: "%@%.1fM", symbol, value / 1_000_000)
case 1_000...:
return String(format: "%@%.1fK", symbol, value / 1_000)
default:
return compactCurrencyString
}
}
var percentageString: String {
Self.percentFormatter.string(from: self as NSDecimalNumber) ?? "0%"
}
var signedPercentageString: String {
let prefix = self >= 0 ? "+" : ""
return prefix + percentageString
}
var decimalString: String {
Self.decimalFormatter.string(from: self as NSDecimalNumber) ?? "0"
}
// MARK: - Conversions
var doubleValue: Double {
NSDecimalNumber(decimal: self).doubleValue
}
var intValue: Int {
NSDecimalNumber(decimal: self).intValue
}
// MARK: - Math Operations
var abs: Decimal {
self < 0 ? -self : self
}
func rounded(scale: Int = 2) -> Decimal {
var result = Decimal()
var mutableSelf = self
NSDecimalRound(&result, &mutableSelf, scale, .plain)
return result
}
// MARK: - Comparisons
var isPositive: Bool {
self > 0
}
var isNegative: Bool {
self < 0
}
var isZero: Bool {
self == 0
}
// MARK: - Static Helpers
static func from(_ double: Double) -> Decimal {
Decimal(double)
}
static func from(_ string: String) -> Decimal? {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
return formatter.number(from: string)?.decimalValue
}
}
// MARK: - NSDecimalNumber Extension
extension NSDecimalNumber {
var currencyString: String {
decimalValue.currencyString
}
var compactCurrencyString: String {
decimalValue.compactCurrencyString
}
}
// MARK: - Optional Decimal
extension Optional where Wrapped == Decimal {
var orZero: Decimal {
self ?? Decimal.zero
}
var currencyString: String {
(self ?? Decimal.zero).currencyString
}
}
@@ -0,0 +1,194 @@
import Foundation
import Combine
enum FreemiumLimits {
static let maxSources = 5
static let maxHistoricalMonths = 12
}
class FreemiumValidator: ObservableObject {
private let iapService: IAPService
init(iapService: IAPService) {
self.iapService = iapService
}
// MARK: - Source Limits
var isPremium: Bool {
iapService.isPremium
}
func canAddSource(currentCount: Int) -> Bool {
if iapService.isPremium { return true }
return currentCount < FreemiumLimits.maxSources
}
func remainingSources(currentCount: Int) -> Int {
if iapService.isPremium { return Int.max }
return max(0, FreemiumLimits.maxSources - currentCount)
}
var sourceLimit: Int {
iapService.isPremium ? Int.max : FreemiumLimits.maxSources
}
var sourceLimitDescription: String {
if iapService.isPremium {
return "Unlimited"
}
return "\(FreemiumLimits.maxSources) sources"
}
// MARK: - Historical Data Limits
func filterSnapshots(_ snapshots: [Snapshot]) -> [Snapshot] {
if iapService.isPremium { return snapshots }
let cutoffDate = Calendar.current.date(
byAdding: .month,
value: -FreemiumLimits.maxHistoricalMonths,
to: Date()
) ?? Date()
return snapshots.filter { $0.date >= cutoffDate }
}
func isSnapshotAccessible(_ snapshot: Snapshot) -> Bool {
if iapService.isPremium { return true }
let cutoffDate = Calendar.current.date(
byAdding: .month,
value: -FreemiumLimits.maxHistoricalMonths,
to: Date()
) ?? Date()
return snapshot.date >= cutoffDate
}
var historicalLimit: Int {
iapService.isPremium ? Int.max : FreemiumLimits.maxHistoricalMonths
}
var historicalLimitDescription: String {
if iapService.isPremium {
return "Full history"
}
return "Last \(FreemiumLimits.maxHistoricalMonths) months"
}
// MARK: - Feature Access
func canExport() -> Bool {
return iapService.isPremium
}
func canViewPredictions() -> Bool {
return iapService.isPremium
}
func canViewAdvancedCharts() -> Bool {
return iapService.isPremium
}
func canAccessFeature(_ feature: PremiumFeature) -> Bool {
if iapService.isPremium { return true }
switch feature {
case .multipleAccounts:
return false
case .unlimitedSources:
return false
case .fullHistory:
return false
case .advancedCharts:
return false
case .predictions:
return false
case .export:
return false
case .noAds:
return false
}
}
// MARK: - Premium Features Enum
enum PremiumFeature: String, CaseIterable, Identifiable {
case multipleAccounts = "multiple_accounts"
case unlimitedSources = "unlimited_sources"
case fullHistory = "full_history"
case advancedCharts = "advanced_charts"
case predictions = "predictions"
case export = "export"
case noAds = "no_ads"
var id: String { rawValue }
var title: String {
switch self {
case .multipleAccounts: return "Multiple Accounts"
case .unlimitedSources: return "Unlimited Sources"
case .fullHistory: return "Full History"
case .advancedCharts: return "Advanced Charts"
case .predictions: return "Predictions"
case .export: return "Export Data"
case .noAds: return "No Ads"
}
}
var icon: String {
switch self {
case .multipleAccounts: return "person.2"
case .unlimitedSources: return "infinity"
case .fullHistory: return "clock.arrow.circlepath"
case .advancedCharts: return "chart.bar.xaxis"
case .predictions: return "wand.and.stars"
case .export: return "square.and.arrow.up"
case .noAds: return "xmark.circle"
}
}
var description: String {
switch self {
case .multipleAccounts:
return "Track separate portfolios for family or business"
case .unlimitedSources:
return "Track as many investment sources as you want"
case .fullHistory:
return "Access your complete investment history"
case .advancedCharts:
return "5 types of detailed analytics charts"
case .predictions:
return "AI-powered 12-month forecasts"
case .export:
return "Export to CSV and JSON formats"
case .noAds:
return "Ad-free experience forever"
}
}
}
// MARK: - Paywall Triggers
enum PaywallTrigger: String {
case sourceLimit = "source_limit"
case historyLimit = "history_limit"
case advancedCharts = "advanced_charts"
case predictions = "predictions"
case export = "export"
case settingsUpgrade = "settings_upgrade"
case manualTap = "manual_tap"
}
func shouldShowPaywall(for trigger: PaywallTrigger) -> Bool {
guard !iapService.isPremium else { return false }
switch trigger {
case .sourceLimit, .historyLimit, .advancedCharts, .predictions, .export:
return true
case .settingsUpgrade, .manualTap:
return true
}
}
}
@@ -0,0 +1,47 @@
import Foundation
import Security
enum KeychainService {
private static let service = "PortfolioJournal"
private static let pinKey = "appLockPin"
static func savePin(_ pin: String) -> Bool {
guard let data = pin.data(using: .utf8) else { return false }
deletePin()
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: pinKey,
kSecValueData: data
]
return SecItemAdd(query as CFDictionary, nil) == errSecSuccess
}
static func readPin() -> String? {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: pinKey,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let pin = String(data: data, encoding: .utf8) else {
return nil
}
return pin
}
static func deletePin() {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: pinKey
]
SecItemDelete(query as CFDictionary)
}
}
@@ -0,0 +1,455 @@
import Foundation
enum MonthlyCheckInStore {
private static let notesKey = "monthlyCheckInNotes"
private static let completionsKey = "monthlyCheckInCompletions"
private static let legacyLastCheckInKey = "lastCheckInDate"
private static let entriesKey = "monthlyCheckInEntries"
// MARK: - Public Accessors
static func note(for date: Date) -> String {
entry(for: date)?.note ?? ""
}
static func setNote(_ note: String, for date: Date) {
updateEntry(for: date) { entry in
let trimmed = note.trimmingCharacters(in: .whitespacesAndNewlines)
entry.note = trimmed.isEmpty ? nil : note
}
}
static func rating(for date: Date) -> Int? {
entry(for: date)?.rating
}
static func setRating(_ rating: Int?, for date: Date) {
updateEntry(for: date) { entry in
if let rating, rating > 0 {
entry.rating = min(max(1, rating), 5)
} else {
entry.rating = nil
}
}
}
static func mood(for date: Date) -> MonthlyCheckInMood? {
entry(for: date)?.mood
}
static func setMood(_ mood: MonthlyCheckInMood?, for date: Date) {
updateEntry(for: date) { entry in
entry.mood = mood
}
}
static func monthKey(for date: Date) -> String {
Self.monthFormatter.string(from: date)
}
static func allNotes() -> [(date: Date, note: String)] {
loadEntries()
.compactMap { key, entry in
guard let date = Self.monthFormatter.date(from: key) else { return nil }
return (date: date, note: entry.note ?? "")
}
.sorted { $0.date > $1.date }
}
static func entry(for date: Date) -> MonthlyCheckInEntry? {
loadEntries()[monthKey(for: date)]
}
static func allEntries() -> [(date: Date, entry: MonthlyCheckInEntry)] {
loadEntries()
.compactMap { key, entry in
guard let date = Self.monthFormatter.date(from: key) else { return nil }
return (date: date, entry: entry)
}
.sorted { $0.date > $1.date }
}
static func completionDate(for date: Date) -> Date? {
entry(for: date)?.completionDate
}
static func setCompletionDate(_ completionDate: Date, for month: Date) {
updateEntry(for: month) { entry in
entry.completionTime = completionDate.timeIntervalSince1970
}
}
static func latestCompletionDate() -> Date? {
let latestEntryDate = loadEntries().values
.compactMap { $0.completionDate }
.max()
if let latestEntryDate {
return latestEntryDate
}
let legacy = UserDefaults.standard.double(forKey: legacyLastCheckInKey)
guard legacy > 0 else { return nil }
return Date(timeIntervalSince1970: legacy)
}
static func stats(referenceDate: Date = Date()) -> MonthlyCheckInStats {
let cutoff = referenceDate.endOfMonth
let entries = allEntries().filter { $0.date <= cutoff }
let completions: [(month: Date, completion: Date, mood: MonthlyCheckInMood?)] = entries.compactMap { entry in
guard let completion = entry.entry.completionDate else { return nil }
return (month: entry.date.startOfMonth, completion: completion, mood: entry.entry.mood)
}
guard !completions.isEmpty else { return .empty }
let deadlineDiffs = completions.map { item -> Double in
let deadline = item.month.endOfMonth
return deadline.timeIntervalSince(item.completion) / 86_400
}
let onTimeCompletions = completions.filter { item in
item.completion <= item.month.endOfMonth
}
let onTimeMonths = Set(onTimeCompletions.map { $0.month })
let totalCheckIns = completions.count
let onTimeCount = onTimeMonths.count
// Current streak counts consecutive on-time months up to the reference month.
var currentStreak = 0
var cursor = referenceDate.startOfMonth
while onTimeMonths.contains(cursor) {
currentStreak += 1
cursor = cursor.adding(months: -1).startOfMonth
}
// Best streak across history.
let sortedMonths = onTimeMonths.sorted()
var bestStreak = 0
var running = 0
var previousMonth: Date?
for month in sortedMonths {
if let previousMonth, month == previousMonth.adding(months: 1).startOfMonth {
running += 1
} else {
running = 1
}
bestStreak = max(bestStreak, running)
previousMonth = month
}
let averageDaysBeforeDeadline = onTimeCount > 0
? deadlineDiffs
.filter { $0 >= 0 }
.average()
: nil
let closestCutoffDays = onTimeCount > 0
? deadlineDiffs.filter { $0 >= 0 }.min()
: nil
let recentMood = completions.sorted { $0.month > $1.month }.first?.mood
let achievements = buildAchievements(
currentStreak: currentStreak,
bestStreak: bestStreak,
onTimeCount: onTimeCount,
totalCheckIns: totalCheckIns,
closestCutoffDays: closestCutoffDays,
averageDaysBeforeDeadline: averageDaysBeforeDeadline
)
return MonthlyCheckInStats(
currentStreak: currentStreak,
bestStreak: bestStreak,
onTimeCount: onTimeCount,
totalCheckIns: totalCheckIns,
averageDaysBeforeDeadline: averageDaysBeforeDeadline,
closestCutoffDays: closestCutoffDays,
recentMood: recentMood,
achievements: achievements
)
}
static func achievementStatuses(referenceDate: Date = Date()) -> [MonthlyCheckInAchievementStatus] {
let stats = stats(referenceDate: referenceDate)
return achievementStatuses(for: stats)
}
// MARK: - Private Helpers
private static func updateEntry(for date: Date, mutate: (inout MonthlyCheckInEntry) -> Void) {
let key = monthKey(for: date)
var entries = loadEntries()
var entry = entries[key] ?? MonthlyCheckInEntry(
note: nil,
rating: nil,
mood: nil,
completionTime: legacyCompletion(for: key),
createdAt: Date().timeIntervalSince1970
)
mutate(&entry)
if entry.note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true {
entry.note = nil
}
let isEmpty = entry.note == nil && entry.rating == nil && entry.mood == nil && entry.completionTime == nil
if isEmpty {
entries.removeValue(forKey: key)
} else {
entries[key] = entry
}
saveEntries(entries)
persistLegacyMirrors(entries)
}
private static func loadEntries() -> [String: MonthlyCheckInEntry] {
guard let data = UserDefaults.standard.data(forKey: entriesKey),
let decoded = try? JSONDecoder().decode([String: MonthlyCheckInEntry].self, from: data) else {
return migrateLegacyData()
}
// Ensure legacy data is merged if it existed before this release.
return mergeLegacy(into: decoded)
}
private static func saveEntries(_ entries: [String: MonthlyCheckInEntry]) {
guard let data = try? JSONEncoder().encode(entries) else { return }
UserDefaults.standard.set(data, forKey: entriesKey)
}
private static func migrateLegacyData() -> [String: MonthlyCheckInEntry] {
let notes = loadNotes()
let completions = loadCompletions()
guard !notes.isEmpty || !completions.isEmpty else { return [:] }
var entries: [String: MonthlyCheckInEntry] = [:]
let now = Date().timeIntervalSince1970
for (key, note) in notes {
entries[key] = MonthlyCheckInEntry(
note: note,
rating: nil,
mood: nil,
completionTime: completions[key],
createdAt: now
)
}
for (key, completion) in completions where entries[key] == nil {
entries[key] = MonthlyCheckInEntry(
note: nil,
rating: nil,
mood: nil,
completionTime: completion,
createdAt: completion
)
}
saveEntries(entries)
return entries
}
private static func mergeLegacy(into entries: [String: MonthlyCheckInEntry]) -> [String: MonthlyCheckInEntry] {
var merged = entries
let notes = loadNotes()
let completions = loadCompletions()
var shouldSave = false
for (key, note) in notes where merged[key]?.note == nil {
var entry = merged[key] ?? MonthlyCheckInEntry(
note: nil,
rating: nil,
mood: nil,
completionTime: completions[key],
createdAt: Date().timeIntervalSince1970
)
entry.note = note
merged[key] = entry
shouldSave = true
}
for (key, completion) in completions where merged[key]?.completionTime == nil {
var entry = merged[key] ?? MonthlyCheckInEntry(
note: nil,
rating: nil,
mood: nil,
completionTime: nil,
createdAt: completion
)
entry.completionTime = completion
merged[key] = entry
shouldSave = true
}
if shouldSave {
saveEntries(merged)
}
return merged
}
private static func persistLegacyMirrors(_ entries: [String: MonthlyCheckInEntry]) {
var notes: [String: String] = [:]
var completions: [String: Double] = [:]
for (key, entry) in entries {
if let note = entry.note {
notes[key] = note
}
if let completion = entry.completionTime {
completions[key] = completion
}
}
saveNotes(notes)
saveCompletions(completions)
}
private static func loadNotes() -> [String: String] {
guard let data = UserDefaults.standard.data(forKey: notesKey),
let decoded = try? JSONDecoder().decode([String: String].self, from: data) else {
return [:]
}
return decoded
}
private static func saveNotes(_ notes: [String: String]) {
guard let data = try? JSONEncoder().encode(notes) else { return }
UserDefaults.standard.set(data, forKey: notesKey)
}
private static func loadCompletions() -> [String: Double] {
guard let data = UserDefaults.standard.data(forKey: completionsKey),
let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else {
return [:]
}
return decoded
}
private static func saveCompletions(_ completions: [String: Double]) {
guard let data = try? JSONEncoder().encode(completions) else { return }
UserDefaults.standard.set(data, forKey: completionsKey)
}
private static func legacyCompletion(for key: String) -> Double? {
loadCompletions()[key]
}
private struct MonthlyCheckInAchievementRule {
let achievement: MonthlyCheckInAchievement
let isUnlocked: (Int, Int, Int, Int, Double?, Double?) -> Bool
}
private static let achievementRules: [MonthlyCheckInAchievementRule] = [
MonthlyCheckInAchievementRule(
achievement: MonthlyCheckInAchievement(
key: "streak_3",
title: String(localized: "achievement_streak_3_title"),
detail: String(localized: "achievement_streak_3_detail"),
icon: "flame.fill"
),
isUnlocked: { currentStreak, _, _, _, _, _ in currentStreak >= 3 }
),
MonthlyCheckInAchievementRule(
achievement: MonthlyCheckInAchievement(
key: "streak_6",
title: String(localized: "achievement_streak_6_title"),
detail: String(localized: "achievement_streak_6_detail"),
icon: "bolt.heart.fill"
),
isUnlocked: { currentStreak, _, _, _, _, _ in currentStreak >= 6 }
),
MonthlyCheckInAchievementRule(
achievement: MonthlyCheckInAchievement(
key: "streak_12",
title: String(localized: "achievement_streak_12_title"),
detail: String(localized: "achievement_streak_12_detail"),
icon: "calendar.circle.fill"
),
isUnlocked: { _, bestStreak, _, _, _, _ in bestStreak >= 12 }
),
MonthlyCheckInAchievementRule(
achievement: MonthlyCheckInAchievement(
key: "perfect_on_time",
title: String(localized: "achievement_perfect_on_time_title"),
detail: String(localized: "achievement_perfect_on_time_detail"),
icon: "checkmark.seal.fill"
),
isUnlocked: { _, _, onTimeCount, totalCheckIns, _, _ in
onTimeCount == totalCheckIns && totalCheckIns >= 3
}
),
MonthlyCheckInAchievementRule(
achievement: MonthlyCheckInAchievement(
key: "clutch_finish",
title: String(localized: "achievement_clutch_finish_title"),
detail: String(localized: "achievement_clutch_finish_detail"),
icon: "hourglass"
),
isUnlocked: { _, _, _, _, closestCutoffDays, _ in
if let closestCutoffDays {
return closestCutoffDays <= 2
}
return false
}
),
MonthlyCheckInAchievementRule(
achievement: MonthlyCheckInAchievement(
key: "early_bird",
title: String(localized: "achievement_early_bird_title"),
detail: String(localized: "achievement_early_bird_detail"),
icon: "sun.max.fill"
),
isUnlocked: { _, _, _, totalCheckIns, _, averageDaysBeforeDeadline in
if let averageDaysBeforeDeadline {
return averageDaysBeforeDeadline >= 10 && totalCheckIns >= 3
}
return false
}
)
]
private static func achievementStatuses(for stats: MonthlyCheckInStats) -> [MonthlyCheckInAchievementStatus] {
achievementRules.map { rule in
MonthlyCheckInAchievementStatus(
achievement: rule.achievement,
isUnlocked: rule.isUnlocked(
stats.currentStreak,
stats.bestStreak,
stats.onTimeCount,
stats.totalCheckIns,
stats.closestCutoffDays,
stats.averageDaysBeforeDeadline
)
)
}
}
private static func buildAchievements(
currentStreak: Int,
bestStreak: Int,
onTimeCount: Int,
totalCheckIns: Int,
closestCutoffDays: Double?,
averageDaysBeforeDeadline: Double?
) -> [MonthlyCheckInAchievement] {
achievementRules.compactMap { rule in
rule.isUnlocked(
currentStreak,
bestStreak,
onTimeCount,
totalCheckIns,
closestCutoffDays,
averageDaysBeforeDeadline
) ? rule.achievement : nil
}
}
private static var monthFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM"
return formatter
}
}
@@ -0,0 +1,7 @@
import Foundation
import Combine
@MainActor
final class TabSelectionStore: ObservableObject {
@Published var selectedTab = 0
}
@@ -0,0 +1,705 @@
import Foundation
import Combine
@MainActor
class ChartsViewModel: ObservableObject {
// MARK: - Chart Types
enum ChartType: String, CaseIterable, Identifiable {
case evolution = "Evolution"
case allocation = "Allocation"
case performance = "Performance"
case contributions = "Contributions"
case rollingReturn = "Rolling 12M"
case riskReturn = "Risk vs Return"
case cashflow = "Net vs Contributions"
case drawdown = "Drawdown"
case volatility = "Volatility"
case prediction = "Prediction"
var id: String { rawValue }
var icon: String {
switch self {
case .evolution: return "chart.line.uptrend.xyaxis"
case .allocation: return "chart.pie.fill"
case .performance: return "chart.bar.fill"
case .contributions: return "tray.and.arrow.down.fill"
case .rollingReturn: return "arrow.triangle.2.circlepath"
case .riskReturn: return "dot.square"
case .cashflow: return "chart.bar.xaxis"
case .drawdown: return "arrow.down.right.circle"
case .volatility: return "waveform.path.ecg"
case .prediction: return "wand.and.stars"
}
}
var isPremium: Bool {
switch self {
case .evolution:
return false
case .allocation, .performance, .contributions, .rollingReturn, .riskReturn, .cashflow,
.drawdown, .volatility, .prediction:
return true
}
}
var description: String {
switch self {
case .evolution:
return "Track your portfolio value over time"
case .allocation:
return "See how your investments are distributed"
case .performance:
return "Compare returns across categories"
case .contributions:
return "Review monthly inflows over time"
case .rollingReturn:
return "See rolling 12-month performance"
case .riskReturn:
return "Compare volatility vs return"
case .cashflow:
return "Compare growth vs contributions"
case .drawdown:
return "Analyze declines from peak values"
case .volatility:
return "Understand investment risk levels"
case .prediction:
return "View 12-month forecasts"
}
}
}
func availableChartTypes(calmModeEnabled: Bool) -> [ChartType] {
let types: [ChartType] = calmModeEnabled
? [.evolution, .allocation, .performance, .contributions]
: ChartType.allCases
if !types.contains(selectedChartType) {
selectedChartType = .evolution
}
return types
}
// MARK: - Published Properties
@Published var selectedChartType: ChartType = .evolution
@Published var selectedCategory: Category?
@Published var selectedTimeRange: TimeRange = .year
@Published var selectedAccount: Account?
@Published var showAllAccounts = true
@Published var evolutionData: [(date: Date, value: Decimal)] = []
@Published var categoryEvolutionData: [CategoryEvolutionPoint] = []
@Published var allocationData: [(category: String, value: Decimal, color: String)] = []
@Published var performanceData: [(category: String, cagr: Double, color: String)] = []
@Published var contributionsData: [(date: Date, amount: Decimal)] = []
@Published var rollingReturnData: [(date: Date, value: Double)] = []
@Published var riskReturnData: [(category: String, cagr: Double, volatility: Double, color: String)] = []
@Published var cashflowData: [(date: Date, contributions: Decimal, netPerformance: Decimal)] = []
@Published var drawdownData: [(date: Date, drawdown: Double)] = []
@Published var volatilityData: [(date: Date, volatility: Double)] = []
@Published var predictionData: [Prediction] = []
@Published var isLoading = false
@Published var showingPaywall = false
@Published private var predictionMonthsAhead = 12
// MARK: - Time Range
enum TimeRange: String, CaseIterable, Identifiable {
case month = "1M"
case quarter = "3M"
case halfYear = "6M"
case year = "1Y"
case all = "All"
var id: String { rawValue }
var months: Int? {
switch self {
case .month: return 1
case .quarter: return 3
case .halfYear: return 6
case .year: return 12
case .all: return nil
}
}
}
// MARK: - Dependencies
private let sourceRepository: InvestmentSourceRepository
private let categoryRepository: CategoryRepository
private let snapshotRepository: SnapshotRepository
private let calculationService: CalculationService
private let predictionEngine: PredictionEngine
private let freemiumValidator: FreemiumValidator
private let maxHistoryMonths = 60
private let maxStackedCategories = 6
private let maxChartPoints = 500
private var cancellables = Set<AnyCancellable>()
private var allCategories: [Category] {
categoryRepository.categories
}
// MARK: - Performance: Caching and State
private var lastChartType: ChartType?
private var lastTimeRange: TimeRange?
private var lastCategoryId: UUID?
private var lastAccountId: UUID?
private var lastShowAllAccounts: Bool = true
private var cachedSnapshots: [Snapshot]?
private var cachedSnapshotsBySource: [UUID: [Snapshot]]?
private var isUpdateInProgress = false
// MARK: - Initialization
init(
sourceRepository: InvestmentSourceRepository? = nil,
categoryRepository: CategoryRepository? = nil,
snapshotRepository: SnapshotRepository? = nil,
calculationService: CalculationService? = nil,
predictionEngine: PredictionEngine? = nil,
iapService: IAPService
) {
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
self.categoryRepository = categoryRepository ?? CategoryRepository()
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
self.calculationService = calculationService ?? .shared
self.predictionEngine = predictionEngine ?? .shared
self.freemiumValidator = FreemiumValidator(iapService: iapService)
setupObservers()
loadData()
}
// MARK: - Setup
private func setupObservers() {
// Performance: Combine all selection changes into a single debounced stream
// This prevents multiple rapid updates when switching between views
Publishers.CombineLatest4($selectedChartType, $selectedCategory, $selectedTimeRange, $selectedAccount)
.combineLatest($showAllAccounts)
.debounce(for: .milliseconds(150), scheduler: DispatchQueue.main)
.sink { [weak self] combined, showAll in
guard let self else { return }
let (chartType, category, timeRange, _) = combined
// Performance: Skip update if nothing meaningful changed
let hasChanges = self.lastChartType != chartType ||
self.lastTimeRange != timeRange ||
self.lastCategoryId != category?.id ||
self.lastAccountId != self.selectedAccount?.id ||
self.lastShowAllAccounts != showAll
if hasChanges {
self.lastChartType = chartType
self.lastTimeRange = timeRange
self.lastCategoryId = category?.id
self.lastAccountId = self.selectedAccount?.id
self.lastShowAllAccounts = showAll
self.cachedSnapshots = nil // Invalidate cache on meaningful changes
self.cachedSnapshotsBySource = nil
self.updateChartData(chartType: chartType, category: category, timeRange: timeRange)
}
}
.store(in: &cancellables)
}
// MARK: - Data Loading
func loadData() {
updateChartData(
chartType: selectedChartType,
category: selectedCategory,
timeRange: selectedTimeRange
)
FirebaseService.shared.logScreenView(screenName: "Charts")
}
func selectChart(_ chartType: ChartType) {
if chartType.isPremium && !freemiumValidator.isPremium {
showingPaywall = true
FirebaseService.shared.logPaywallShown(trigger: "advanced_charts")
return
}
selectedChartType = chartType
FirebaseService.shared.logChartViewed(
chartType: chartType.rawValue,
isPremium: chartType.isPremium
)
}
private func updateChartData(chartType: ChartType, category: Category?, timeRange: TimeRange) {
// Performance: Prevent re-entrancy
guard !isUpdateInProgress else { return }
isUpdateInProgress = true
isLoading = true
defer {
isLoading = false
isUpdateInProgress = false
}
let sources: [InvestmentSource]
if let category = category {
sources = sourceRepository.fetchSources(for: category).filter { shouldIncludeSource($0) }
} else {
sources = sourceRepository.sources.filter { shouldIncludeSource($0) }
}
let monthsLimit = timeRange.months ?? maxHistoryMonths
let sourceIds = sources.compactMap { $0.id }
// Performance: Reuse cached snapshots when possible
var snapshots: [Snapshot]
if let cached = cachedSnapshots {
snapshots = cached
} else {
snapshots = snapshotRepository.fetchSnapshots(
for: sourceIds,
months: monthsLimit
)
snapshots = freemiumValidator.filterSnapshots(snapshots)
cachedSnapshots = snapshots
}
// Performance: Cache snapshotsBySource
let snapshotsBySource: [UUID: [Snapshot]]
if let cached = cachedSnapshotsBySource {
snapshotsBySource = cached
} else {
var grouped: [UUID: [Snapshot]] = [:]
grouped.reserveCapacity(sources.count)
for snapshot in snapshots {
guard let id = snapshot.source?.id else { continue }
grouped[id, default: []].append(snapshot)
}
snapshotsBySource = grouped
cachedSnapshotsBySource = grouped
}
// Performance: Only calculate data for the selected chart type
switch chartType {
case .evolution:
calculateEvolutionData(from: snapshots)
let categoriesForChart = categoriesForStackedChart(
sources: sources,
selectedCategory: selectedCategory
)
calculateCategoryEvolutionData(from: snapshots, categories: categoriesForChart)
case .allocation:
calculateAllocationData(for: sources)
case .performance:
calculatePerformanceData(for: sources, snapshotsBySource: snapshotsBySource)
case .contributions:
calculateContributionsData(from: snapshots)
case .rollingReturn:
calculateRollingReturnData(from: snapshots)
case .riskReturn:
calculateRiskReturnData(for: sources, snapshotsBySource: snapshotsBySource)
case .cashflow:
calculateCashflowData(from: snapshots)
case .drawdown:
calculateDrawdownData(from: snapshots)
case .volatility:
calculateVolatilityData(from: snapshots)
case .prediction:
calculatePredictionData(from: snapshots)
}
if let selected = selectedCategory,
!availableCategories(for: chartType, sources: sources).contains(where: { $0.id == selected.id }) {
selectedCategory = nil
}
}
func availableCategories(
for chartType: ChartType,
sources: [InvestmentSource]? = nil
) -> [Category] {
let relevantSources = sources ?? sourceRepository.sources.filter { shouldIncludeSource($0) }
let categoriesWithData = Set(relevantSources.compactMap { $0.category?.id })
let filtered = allCategories.filter { categoriesWithData.contains($0.id) }
switch chartType {
case .evolution, .prediction:
return filtered
default:
return []
}
}
private func shouldIncludeSource(_ source: InvestmentSource) -> Bool {
if showAllAccounts || selectedAccount == nil {
return true
}
return source.account?.id == selectedAccount?.id
}
private func categoriesForStackedChart(
sources: [InvestmentSource],
selectedCategory: Category?
) -> [Category] {
var totals: [UUID: Decimal] = [:]
for source in sources {
guard let categoryId = source.category?.id else { continue }
totals[categoryId, default: 0] += source.latestValue
}
var topCategoryIds = Set(
totals.sorted { $0.value > $1.value }
.prefix(maxStackedCategories)
.map { $0.key }
)
if let selectedCategory {
topCategoryIds.insert(selectedCategory.id)
}
return categoryRepository.categories.filter { topCategoryIds.contains($0.id) }
}
private func downsampleSeries(
_ data: [(date: Date, value: Decimal)],
maxPoints: Int
) -> [(date: Date, value: Decimal)] {
guard data.count > maxPoints, maxPoints > 0 else { return data }
let bucketSize = max(1, Int(ceil(Double(data.count) / Double(maxPoints))))
var sampled: [(date: Date, value: Decimal)] = []
sampled.reserveCapacity(maxPoints)
var index = 0
while index < data.count {
let end = min(index + bucketSize, data.count)
let bucket = data[index..<end]
if let last = bucket.last {
sampled.append(last)
}
index += bucketSize
}
return sampled
}
private func downsampleDates(_ dates: [Date], maxPoints: Int) -> [Date] {
guard dates.count > maxPoints, maxPoints > 0 else { return dates }
let bucketSize = max(1, Int(ceil(Double(dates.count) / Double(maxPoints))))
var sampled: [Date] = []
sampled.reserveCapacity(maxPoints)
var index = 0
while index < dates.count {
let end = min(index + bucketSize, dates.count)
let bucket = dates[index..<end]
if let last = bucket.last {
sampled.append(last)
}
index += bucketSize
}
return sampled
}
// MARK: - Chart Calculations
private func calculateEvolutionData(from snapshots: [Snapshot]) {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
var dateValues: [Date: Decimal] = [:]
for snapshot in sortedSnapshots {
let day = Calendar.current.startOfDay(for: snapshot.date)
dateValues[day, default: 0] += snapshot.decimalValue
}
let series = dateValues
.map { (date: $0.key, value: $0.value) }
.sorted { $0.date < $1.date }
evolutionData = downsampleSeries(series, maxPoints: maxChartPoints)
}
private func calculateCategoryEvolutionData(from snapshots: [Snapshot], categories: [Category]) {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
let categoriesWithData = Set(sortedSnapshots.compactMap { $0.source?.category?.id })
let filteredCategories = categories.filter { categoriesWithData.contains($0.id) }
let snapshotsByDay = Dictionary(grouping: sortedSnapshots) {
Calendar.current.startOfDay(for: $0.date)
}
let uniqueDates = downsampleDates(snapshotsByDay.keys.sorted(), maxPoints: maxChartPoints)
var latestBySource: [UUID: Snapshot] = [:]
var points: [CategoryEvolutionPoint] = []
for date in uniqueDates {
if let daySnapshots = snapshotsByDay[date] {
for snapshot in daySnapshots {
guard let sourceId = snapshot.source?.id else { continue }
latestBySource[sourceId] = snapshot
}
}
var valuesByCategory: [UUID: Decimal] = [:]
for snapshot in latestBySource.values {
guard let category = snapshot.source?.category else { continue }
valuesByCategory[category.id, default: 0] += snapshot.decimalValue
}
for category in filteredCategories {
let value = valuesByCategory[category.id] ?? 0
points.append(CategoryEvolutionPoint(
date: date,
categoryName: category.name,
colorHex: category.colorHex,
value: value
))
}
}
categoryEvolutionData = points
}
private func calculateAllocationData(for sources: [InvestmentSource]) {
let categories = categoryRepository.categories
let valuesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
allocationData = categories.compactMap { category in
let categorySources = valuesByCategory[category.id] ?? []
let categoryValue = categorySources.reduce(Decimal.zero) { $0 + $1.latestValue }
guard categoryValue > 0 else { return nil }
return (
category: category.name,
value: categoryValue,
color: category.colorHex
)
}.sorted { $0.value > $1.value }
}
private func calculatePerformanceData(
for sources: [InvestmentSource],
snapshotsBySource: [UUID: [Snapshot]]
) {
let categories = categoryRepository.categories
let sourcesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
performanceData = categories.compactMap { category in
let categorySources = sourcesByCategory[category.id] ?? []
let snapshots = categorySources.compactMap { source -> [Snapshot]? in
let id = source.id
return snapshotsBySource[id]
}.flatMap { $0 }
guard snapshots.count >= 2 else { return nil }
let metrics = calculationService.calculateMetrics(for: snapshots)
return (
category: category.name,
cagr: metrics.cagr,
color: category.colorHex
)
}.sorted { $0.cagr > $1.cagr }
}
private func calculateContributionsData(from snapshots: [Snapshot]) {
let grouped = Dictionary(grouping: snapshots) { $0.date.startOfMonth }
contributionsData = grouped.map { date, items in
let total = items.reduce(Decimal.zero) { $0 + $1.decimalContribution }
return (date: date, amount: total)
}
.sorted { $0.date < $1.date }
}
private func calculateRollingReturnData(from snapshots: [Snapshot]) {
let monthlyTotals = monthlyTotals(from: snapshots)
guard monthlyTotals.count >= 13 else {
rollingReturnData = []
return
}
var returns: [(date: Date, value: Double)] = []
for index in 12..<monthlyTotals.count {
let current = monthlyTotals[index]
let base = monthlyTotals[index - 12]
guard base.totalValue > 0 else { continue }
let change = current.totalValue - base.totalValue
let percent = NSDecimalNumber(decimal: change / base.totalValue).doubleValue * 100
returns.append((date: current.date, value: percent))
}
rollingReturnData = returns
}
private func calculateRiskReturnData(
for sources: [InvestmentSource],
snapshotsBySource: [UUID: [Snapshot]]
) {
let categories = categoryRepository.categories
let sourcesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
riskReturnData = categories.compactMap { category in
let categorySources = sourcesByCategory[category.id] ?? []
let snapshots = categorySources.compactMap { source -> [Snapshot]? in
let id = source.id
return snapshotsBySource[id]
}.flatMap { $0 }
guard snapshots.count >= 3 else { return nil }
let metrics = calculationService.calculateMetrics(for: snapshots)
return (
category: category.name,
cagr: metrics.cagr,
volatility: metrics.volatility,
color: category.colorHex
)
}.sorted { $0.cagr > $1.cagr }
}
private func calculateCashflowData(from snapshots: [Snapshot]) {
let monthlyTotals = monthlyTotals(from: snapshots)
let contributionsByMonth = Dictionary(grouping: snapshots) { $0.date.startOfMonth }
.mapValues { items in
items.reduce(Decimal.zero) { $0 + $1.decimalContribution }
}
var data: [(date: Date, contributions: Decimal, netPerformance: Decimal)] = []
for index in 0..<monthlyTotals.count {
let current = monthlyTotals[index]
let previousTotal = index > 0 ? monthlyTotals[index - 1].totalValue : 0
let contributions = contributionsByMonth[current.date] ?? 0
let netPerformance = current.totalValue - previousTotal - contributions
data.append((date: current.date, contributions: contributions, netPerformance: netPerformance))
}
cashflowData = data
}
private func calculateDrawdownData(from snapshots: [Snapshot]) {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
guard !sortedSnapshots.isEmpty else {
drawdownData = []
return
}
var peak = sortedSnapshots.first!.decimalValue
var data: [(date: Date, drawdown: Double)] = []
for snapshot in sortedSnapshots {
let value = snapshot.decimalValue
if value > peak {
peak = value
}
let drawdown = peak > 0
? NSDecimalNumber(decimal: (peak - value) / peak).doubleValue * 100
: 0
data.append((date: snapshot.date, drawdown: -drawdown))
}
drawdownData = data
}
private func calculateVolatilityData(from snapshots: [Snapshot]) {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
guard sortedSnapshots.count >= 3 else {
volatilityData = []
return
}
var data: [(date: Date, volatility: Double)] = []
let windowSize = 3
for i in windowSize..<sortedSnapshots.count {
let window = Array(sortedSnapshots[(i - windowSize)..<i])
let values = window.map { NSDecimalNumber(decimal: $0.decimalValue).doubleValue }
let mean = values.reduce(0, +) / Double(values.count)
let variance = values.map { pow($0 - mean, 2) }.reduce(0, +) / Double(values.count)
let stdDev = sqrt(variance)
let volatility = mean > 0 ? (stdDev / mean) * 100 : 0
data.append((date: sortedSnapshots[i].date, volatility: volatility))
}
volatilityData = data
}
private func calculatePredictionData(from snapshots: [Snapshot]) {
guard freemiumValidator.canViewPredictions() else {
predictionData = []
return
}
let result = predictionEngine.predict(snapshots: snapshots, monthsAhead: predictionMonthsAhead)
predictionData = result.predictions
}
private func monthlyTotals(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
let months = Array(Set(sortedSnapshots.map { $0.date.startOfMonth })).sorted()
guard !months.isEmpty else { return [] }
var snapshotsBySource: [UUID: [Snapshot]] = [:]
for snapshot in sortedSnapshots {
guard let sourceId = snapshot.source?.id else { continue }
snapshotsBySource[sourceId, default: []].append(snapshot)
}
var indices: [UUID: Int] = [:]
var totals: [(date: Date, totalValue: Decimal)] = []
for (index, month) in months.enumerated() {
let nextMonth = index + 1 < months.count ? months[index + 1] : Date.distantFuture
var total: Decimal = 0
for (sourceId, sourceSnapshots) in snapshotsBySource {
var currentIndex = indices[sourceId] ?? 0
var latest: Snapshot?
while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextMonth {
latest = sourceSnapshots[currentIndex]
currentIndex += 1
}
indices[sourceId] = currentIndex
total += latest?.decimalValue ?? 0
}
totals.append((date: month, totalValue: total))
}
return totals
}
func updatePredictionTargetDate(_ goals: [Goal]) {
let futureGoalDates = goals.compactMap { $0.targetDate }.filter { $0 > Date() }
guard let latestGoalDate = futureGoalDates.max(),
let lastSnapshotDate = evolutionData.last?.date else {
predictionMonthsAhead = 12
return
}
let months = max(1, lastSnapshotDate.startOfMonth.monthsBetween(latestGoalDate.startOfMonth))
predictionMonthsAhead = max(12, months)
if selectedChartType == .prediction {
updateChartData(chartType: selectedChartType, category: selectedCategory, timeRange: selectedTimeRange)
}
}
// MARK: - Computed Properties
var categories: [Category] {
categoryRepository.categories
}
var availableChartTypes: [ChartType] {
ChartType.allCases
}
var isPremium: Bool {
freemiumValidator.isPremium
}
var hasData: Bool {
!sourceRepository.sources.filter { shouldIncludeSource($0) }.isEmpty
}
}
@@ -0,0 +1,443 @@
import Foundation
import Combine
import CoreData
@MainActor
class DashboardViewModel: ObservableObject {
// MARK: - Published Properties
@Published var portfolioSummary: PortfolioSummary = .empty
@Published var monthlySummary: MonthlySummary = .empty
@Published var categoryMetrics: [CategoryMetrics] = []
@Published var categoryEvolutionData: [CategoryEvolutionPoint] = []
@Published var recentSnapshots: [Snapshot] = []
@Published var sourcesNeedingUpdate: [InvestmentSource] = []
@Published var latestPortfolioChange: PortfolioChange = .empty
@Published var isLoading = false
@Published var errorMessage: String?
@Published var selectedAccount: Account?
@Published var showAllAccounts = true
// MARK: - Chart Data
@Published var evolutionData: [(date: Date, value: Decimal)] = []
// MARK: - Dependencies
private let categoryRepository: CategoryRepository
private let sourceRepository: InvestmentSourceRepository
private let snapshotRepository: SnapshotRepository
private let calculationService: CalculationService
private var cancellables = Set<AnyCancellable>()
private var isRefreshing = false
private var refreshQueued = false
private let maxHistoryMonths = 60
// MARK: - Performance: Caching
private var cachedFilteredSources: [InvestmentSource]?
private var cachedSourcesHash: Int = 0
private var lastAccountId: UUID?
private var lastShowAllAccounts: Bool = true
// MARK: - Initialization
init(
categoryRepository: CategoryRepository? = nil,
sourceRepository: InvestmentSourceRepository? = nil,
snapshotRepository: SnapshotRepository? = nil,
calculationService: CalculationService? = nil
) {
self.categoryRepository = categoryRepository ?? CategoryRepository()
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
self.calculationService = calculationService ?? .shared
setupObservers()
loadData()
}
// MARK: - Setup
private func setupObservers() {
// Performance: Combine multiple publishers to reduce redundant refresh calls
// Use dropFirst to avoid initial trigger, and debounce to coalesce rapid changes
Publishers.Merge(
categoryRepository.$categories.map { _ in () },
sourceRepository.$sources.map { _ in () }
)
.dropFirst(2) // Skip initial values from both publishers
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
self?.invalidateCache()
self?.refreshData()
}
.store(in: &cancellables)
// Observe Core Data changes with coalescing
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.compactMap { [weak self] notification -> Void? in
guard let self, self.isRelevantChange(notification) else { return nil }
return ()
}
.sink { [weak self] _ in
self?.invalidateCache()
self?.refreshData()
}
.store(in: &cancellables)
}
private func isRelevantChange(_ notification: Notification) -> Bool {
guard let info = notification.userInfo else { return false }
let keys: [String] = [
NSInsertedObjectsKey,
NSUpdatedObjectsKey,
NSDeletedObjectsKey,
NSRefreshedObjectsKey
]
for key in keys {
if let objects = info[key] as? Set<NSManagedObject> {
if objects.contains(where: { $0 is Snapshot || $0 is InvestmentSource || $0 is Category }) {
return true
}
}
}
return false
}
// MARK: - Data Loading
func loadData() {
isLoading = true
errorMessage = nil
queueRefresh(updateLoadingFlag: true)
}
func refreshData() {
queueRefresh(updateLoadingFlag: false)
}
private func queueRefresh(updateLoadingFlag: Bool) {
refreshQueued = true
if updateLoadingFlag {
isLoading = true
}
guard !isRefreshing else { return }
isRefreshing = true
Task { [weak self] in
guard let self else { return }
while self.refreshQueued {
self.refreshQueued = false
await self.refreshAllData()
}
self.isRefreshing = false
if updateLoadingFlag {
self.isLoading = false
}
}
}
private func refreshAllData() async {
let categories = categoryRepository.categories
let sources = filteredSources()
let allSnapshots = filteredSnapshots(for: sources)
// Calculate portfolio summary
portfolioSummary = calculationService.calculatePortfolioSummary(
from: sources,
snapshots: allSnapshots
)
monthlySummary = calculationService.calculateMonthlySummary(
sources: sources,
snapshots: allSnapshots
)
// Calculate category metrics
categoryMetrics = calculationService.calculateCategoryMetrics(
for: categories,
sources: sources,
totalPortfolioValue: portfolioSummary.totalValue
).sorted { $0.totalValue > $1.totalValue }
// Get recent snapshots
recentSnapshots = Array(allSnapshots.prefix(10))
// Get sources needing update
let accountFilter = showAllAccounts ? nil : selectedAccount
sourcesNeedingUpdate = sourceRepository.fetchSourcesNeedingUpdate(for: accountFilter)
// Calculate evolution data for chart
updateEvolutionData(from: allSnapshots, categories: categories)
latestPortfolioChange = calculateLatestChange(from: evolutionData)
// Log screen view
FirebaseService.shared.logScreenView(screenName: "Dashboard")
}
private func filteredSources() -> [InvestmentSource] {
// Performance: Cache filtered sources to avoid repeated filtering
let currentHash = sourceRepository.sources.count
let accountChanged = lastAccountId != selectedAccount?.id || lastShowAllAccounts != showAllAccounts
if !accountChanged && cachedFilteredSources != nil && cachedSourcesHash == currentHash {
return cachedFilteredSources!
}
lastAccountId = selectedAccount?.id
lastShowAllAccounts = showAllAccounts
cachedSourcesHash = currentHash
if showAllAccounts || selectedAccount == nil {
cachedFilteredSources = sourceRepository.sources
} else {
cachedFilteredSources = sourceRepository.sources.filter { $0.account?.id == selectedAccount?.id }
}
return cachedFilteredSources!
}
/// Invalidates cached data when underlying data changes
private func invalidateCache() {
cachedFilteredSources = nil
cachedSourcesHash = 0
}
private func filteredSnapshots(for sources: [InvestmentSource]) -> [Snapshot] {
let sourceIds = sources.compactMap { $0.id }
return snapshotRepository.fetchSnapshots(
for: sourceIds,
months: maxHistoryMonths
)
}
private func updateEvolutionData(from snapshots: [Snapshot], categories: [Category]) {
let summary = calculateEvolutionSummary(from: snapshots)
let categoriesWithData = Set(summary.categoryTotals.keys)
let categoryLookup = Dictionary(uniqueKeysWithValues: categories.map { ($0.id, $0) })
evolutionData = summary.evolutionData
categoryEvolutionData = summary.categorySeries.flatMap { entry in
entry.valuesByCategory.compactMap { categoryId, value in
guard categoriesWithData.contains(categoryId),
let category = categoryLookup[categoryId] else {
return nil
}
return CategoryEvolutionPoint(
date: entry.date,
categoryName: category.name,
colorHex: category.colorHex,
value: value
)
}
}
}
private struct EvolutionSummary {
let evolutionData: [(date: Date, value: Decimal)]
let categorySeries: [(date: Date, valuesByCategory: [UUID: Decimal])]
let categoryTotals: [UUID: Decimal]
}
private func calculateEvolutionSummary(from snapshots: [Snapshot]) -> EvolutionSummary {
guard !snapshots.isEmpty else {
return EvolutionSummary(evolutionData: [], categorySeries: [], categoryTotals: [:])
}
// Performance: Pre-allocate capacity and use more efficient data structures
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
// Use dictionary to deduplicate dates more efficiently
var uniqueDateSet = Set<Date>()
uniqueDateSet.reserveCapacity(sortedSnapshots.count)
for snapshot in sortedSnapshots {
uniqueDateSet.insert(Calendar.current.startOfDay(for: snapshot.date))
}
let uniqueDates = uniqueDateSet.sorted()
guard !uniqueDates.isEmpty else {
return EvolutionSummary(evolutionData: [], categorySeries: [], categoryTotals: [:])
}
// Pre-allocate dictionaries with estimated capacity
var snapshotsBySource: [UUID: [(date: Date, value: Decimal, categoryId: UUID?)]] = [:]
snapshotsBySource.reserveCapacity(Set(sortedSnapshots.compactMap { $0.source?.id }).count)
var categoryTotals: [UUID: Decimal] = [:]
for snapshot in sortedSnapshots {
guard let sourceId = snapshot.source?.id else { continue }
let categoryId = snapshot.source?.category?.id
snapshotsBySource[sourceId, default: []].append(
(date: snapshot.date, value: snapshot.decimalValue, categoryId: categoryId)
)
if let categoryId {
categoryTotals[categoryId, default: 0] += snapshot.decimalValue
}
}
// Pre-allocate result arrays
var indices: [UUID: Int] = [:]
indices.reserveCapacity(snapshotsBySource.count)
var evolution: [(date: Date, value: Decimal)] = []
evolution.reserveCapacity(uniqueDates.count)
var series: [(date: Date, valuesByCategory: [UUID: Decimal])] = []
series.reserveCapacity(uniqueDates.count)
// Track last known value per source for carry-forward optimization
var lastValues: [UUID: (value: Decimal, categoryId: UUID?)] = [:]
lastValues.reserveCapacity(snapshotsBySource.count)
for (index, date) in uniqueDates.enumerated() {
let nextDate = index + 1 < uniqueDates.count
? uniqueDates[index + 1]
: Date.distantFuture
var total: Decimal = 0
var valuesByCategory: [UUID: Decimal] = [:]
for (sourceId, sourceSnapshots) in snapshotsBySource {
var currentIndex = indices[sourceId] ?? 0
while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextDate {
let snap = sourceSnapshots[currentIndex]
lastValues[sourceId] = (value: snap.value, categoryId: snap.categoryId)
currentIndex += 1
}
indices[sourceId] = currentIndex
// Use last known value (carry-forward)
if let lastValue = lastValues[sourceId] {
total += lastValue.value
if let categoryId = lastValue.categoryId {
valuesByCategory[categoryId, default: 0] += lastValue.value
}
}
}
evolution.append((date: date, value: total))
series.append((date: date, valuesByCategory: valuesByCategory))
}
return EvolutionSummary(
evolutionData: evolution,
categorySeries: series,
categoryTotals: categoryTotals
)
}
private func calculateLatestChange(from data: [(date: Date, value: Decimal)]) -> PortfolioChange {
guard data.count >= 2 else {
return PortfolioChange(absolute: 0, percentage: 0, label: "since last update")
}
let last = data[data.count - 1]
let previous = data[data.count - 2]
let absolute = last.value - previous.value
let percentage = previous.value > 0
? NSDecimalNumber(decimal: absolute / previous.value).doubleValue * 100
: 0
return PortfolioChange(absolute: absolute, percentage: percentage, label: "since last update")
}
func goalEtaText(for goal: Goal, currentValue: Decimal) -> String? {
let target = goal.targetDecimal
let lastValue = evolutionData.last?.value ?? currentValue
if currentValue >= target {
return "Goal reached. Keep it steady."
}
guard let months = estimateMonthsToGoal(target: target, lastValue: lastValue) else {
return "Keep going. Consistency pays off."
}
if months == 0 {
return "Almost there. One more check-in."
}
let baseDate = evolutionData.last?.date ?? Date()
let estimatedDate = Calendar.current.date(byAdding: .month, value: months, to: baseDate)
if months >= 72 {
return "Long journey, strong discipline. You're building momentum."
}
if let estimatedDate = estimatedDate {
return "Estimated: \(estimatedDate.monthYearString)"
}
return "Estimated: \(months) months"
}
private func estimateMonthsToGoal(target: Decimal, lastValue: Decimal) -> Int? {
guard evolutionData.count >= 3 else { return nil }
let recent = Array(evolutionData.suffix(6))
guard let first = recent.first, let last = recent.last else { return nil }
let monthsBetween = max(1, first.date.monthsBetween(last.date))
let delta = last.value - first.value
guard delta > 0 else { return nil }
let monthlyGain = delta / Decimal(monthsBetween)
guard monthlyGain > 0 else { return nil }
let remaining = target - lastValue
guard remaining > 0 else { return 0 }
let months = NSDecimalNumber(decimal: remaining / monthlyGain).doubleValue
return Int(ceil(months))
}
// MARK: - Computed Properties
var hasData: Bool {
!filteredSources().isEmpty
}
var totalSourceCount: Int {
sourceRepository.sourceCount
}
var totalCategoryCount: Int {
categoryRepository.categories.count
}
var pendingUpdatesCount: Int {
sourcesNeedingUpdate.count
}
var topCategories: [CategoryMetrics] {
Array(categoryMetrics.prefix(5))
}
// MARK: - Formatting
var formattedTotalValue: String {
portfolioSummary.formattedTotalValue
}
var formattedDayChange: String {
portfolioSummary.formattedDayChange
}
var formattedMonthChange: String {
portfolioSummary.formattedMonthChange
}
var formattedYearChange: String {
portfolioSummary.formattedYearChange
}
var isDayChangePositive: Bool {
portfolioSummary.dayChange >= 0
}
var isMonthChangePositive: Bool {
portfolioSummary.monthChange >= 0
}
var isYearChangePositive: Bool {
portfolioSummary.yearChange >= 0
}
var formattedLastUpdate: String {
portfolioSummary.lastUpdated?.friendlyDescription ?? "Not yet"
}
}
@@ -0,0 +1,291 @@
import Foundation
import Combine
import CoreData
@MainActor
class GoalsViewModel: ObservableObject {
@Published var goals: [Goal] = []
@Published var totalValue: Decimal = Decimal.zero
@Published var selectedAccount: Account?
@Published var showAllAccounts = true
private let goalRepository: GoalRepository
private let sourceRepository: InvestmentSourceRepository
private let snapshotRepository: SnapshotRepository
private let maxHistoryMonths = 60
private var cancellables = Set<AnyCancellable>()
// MARK: - Performance: Caching
private var cachedEvolutionData: [UUID: [(date: Date, value: Decimal)]] = [:]
private var cachedCompletionDates: [UUID: Date?] = [:]
private var lastSourcesHash: Int = 0
init(
goalRepository: GoalRepository = GoalRepository(),
sourceRepository: InvestmentSourceRepository = InvestmentSourceRepository(),
snapshotRepository: SnapshotRepository = SnapshotRepository()
) {
self.goalRepository = goalRepository
self.sourceRepository = sourceRepository
self.snapshotRepository = snapshotRepository
setupObservers()
refresh()
}
private func setupObservers() {
goalRepository.$goals
.receive(on: DispatchQueue.main)
.sink { [weak self] goals in
self?.updateGoals(using: goals)
}
.store(in: &cancellables)
sourceRepository.$sources
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.updateTotalValue()
}
.store(in: &cancellables)
}
func refresh() {
// Performance: Invalidate caches when refreshing
let currentHash = sourceRepository.sources.count
if currentHash != lastSourcesHash {
cachedEvolutionData.removeAll()
cachedCompletionDates.removeAll()
lastSourcesHash = currentHash
}
loadGoals()
updateGoals(using: goalRepository.goals)
}
func progress(for goal: Goal) -> Double {
let currentTotal = totalValue(for: goal)
guard goal.targetDecimal > 0 else { return 0 }
let current = min(currentTotal, goal.targetDecimal)
return NSDecimalNumber(decimal: current / goal.targetDecimal).doubleValue
}
func totalValue(for goal: Goal) -> Decimal {
if let account = goal.account {
return sourceRepository.sources
.filter { $0.account?.id == account.id }
.reduce(Decimal.zero) { $0 + $1.latestValue }
}
return totalValue
}
func paceStatus(for goal: Goal) -> GoalPaceStatus? {
guard let targetDate = goal.targetDate else { return nil }
let targetDay = targetDate.startOfDay
let actualProgress = progress(for: goal)
let startDate = goal.createdAt.startOfDay
let totalDays = max(1, startDate.daysBetween(targetDay))
if actualProgress >= 1 {
return GoalPaceStatus(
expectedProgress: 1,
delta: 0,
isBehind: false,
statusText: "Goal reached"
)
}
if let estimatedCompletionDate = estimateCompletionDate(for: goal) {
let estimatedDay = estimatedCompletionDate.startOfDay
let deltaDays = targetDay.daysBetween(estimatedDay)
let deltaPercent = min(abs(Double(deltaDays)) / Double(totalDays) * 100, 999)
let isBehind = estimatedDay > targetDay
let statusText = abs(deltaPercent) < 1
? "On track"
: isBehind
? String(format: "Behind by %.1f%%", deltaPercent)
: String(format: "Ahead by %.1f%%", deltaPercent)
return GoalPaceStatus(
expectedProgress: actualProgress,
delta: isBehind ? -deltaPercent / 100 : deltaPercent / 100,
isBehind: isBehind,
statusText: statusText
)
}
let elapsedDays = max(0, startDate.daysBetween(Date()))
let expectedProgress = min(Double(elapsedDays) / Double(totalDays), 1)
let delta = actualProgress - expectedProgress
let isOverdue = Date() > targetDay && actualProgress < 1
let isBehind = isOverdue || delta < -0.03
let deltaPercent = abs(delta) * 100
let statusText = isOverdue
? "Behind schedule • target passed"
: delta >= 0
? String(format: "Ahead by %.1f%%", deltaPercent)
: String(format: "Behind by %.1f%%", deltaPercent)
return GoalPaceStatus(
expectedProgress: expectedProgress,
delta: delta,
isBehind: isBehind,
statusText: statusText
)
}
func deleteGoal(_ goal: Goal) {
goalRepository.deleteGoal(goal)
}
private func estimateCompletionDate(for goal: Goal) -> Date? {
// Performance: Use cached completion date if available
if let cached = cachedCompletionDates[goal.id] {
return cached
}
let sources: [InvestmentSource]
if let account = goal.account {
sources = sourceRepository.sources.filter { $0.account?.id == account.id }
} else {
sources = sourceRepository.sources
}
let sourceIds = sources.compactMap { $0.id }
guard !sourceIds.isEmpty else {
cachedCompletionDates[goal.id] = nil
return nil
}
// Performance: Use cached evolution data if available
let cacheKey = goal.account?.id ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
let evolutionData: [(date: Date, value: Decimal)]
if let cached = cachedEvolutionData[cacheKey] {
evolutionData = cached
} else {
let snapshots = snapshotRepository.fetchSnapshots(
for: sourceIds,
months: maxHistoryMonths
)
evolutionData = calculateEvolutionData(from: snapshots)
cachedEvolutionData[cacheKey] = evolutionData
}
guard evolutionData.count >= 3,
let first = evolutionData.suffix(6).first,
let last = evolutionData.suffix(6).last else {
cachedCompletionDates[goal.id] = nil
return nil
}
let monthsBetween = max(1, first.date.monthsBetween(last.date))
let delta = last.value - first.value
guard delta > 0 else {
cachedCompletionDates[goal.id] = nil
return nil
}
let monthlyGain = delta / Decimal(monthsBetween)
guard monthlyGain > 0 else {
cachedCompletionDates[goal.id] = nil
return nil
}
let currentValue = totalValue(for: goal)
let remaining = goal.targetDecimal - currentValue
guard remaining > 0 else {
let result = Date()
cachedCompletionDates[goal.id] = result
return result
}
let months = NSDecimalNumber(decimal: remaining / monthlyGain).doubleValue
let monthsRounded = Int(ceil(months))
let result = Calendar.current.date(byAdding: .month, value: monthsRounded, to: last.date)
cachedCompletionDates[goal.id] = result
return result
}
private func calculateEvolutionData(from snapshots: [Snapshot]) -> [(date: Date, value: Decimal)] {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
let uniqueDates = Array(Set(sortedSnapshots.map { Calendar.current.startOfDay(for: $0.date) }))
.sorted()
guard !uniqueDates.isEmpty else { return [] }
var snapshotsBySource: [UUID: [(date: Date, value: Decimal)]] = [:]
for snapshot in sortedSnapshots {
guard let sourceId = snapshot.source?.id else { continue }
snapshotsBySource[sourceId, default: []].append(
(date: snapshot.date, value: snapshot.decimalValue)
)
}
var indices: [UUID: Int] = [:]
var evolution: [(date: Date, value: Decimal)] = []
for (index, date) in uniqueDates.enumerated() {
let nextDate = index + 1 < uniqueDates.count
? uniqueDates[index + 1]
: Date.distantFuture
var total: Decimal = 0
for (sourceId, sourceSnapshots) in snapshotsBySource {
var currentIndex = indices[sourceId] ?? 0
var latest: (date: Date, value: Decimal)?
while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextDate {
latest = sourceSnapshots[currentIndex]
currentIndex += 1
}
indices[sourceId] = currentIndex
if let latest {
total += latest.value
}
}
evolution.append((date: date, value: total))
}
return evolution
}
// MARK: - Private helpers
private func loadGoals() {
if showAllAccounts || selectedAccount == nil {
goalRepository.fetchGoals()
} else if let account = selectedAccount {
goalRepository.fetchGoals(for: account)
}
}
private func updateGoals(using repositoryGoals: [Goal]) {
if showAllAccounts || selectedAccount == nil {
goals = repositoryGoals
} else if let account = selectedAccount {
goals = repositoryGoals.filter { $0.account?.id == account.id }
} else {
goals = repositoryGoals
}
updateTotalValue()
}
private func updateTotalValue() {
if showAllAccounts || selectedAccount == nil {
totalValue = sourceRepository.sources.reduce(Decimal.zero) { $0 + $1.latestValue }
return
}
if let account = selectedAccount {
totalValue = sourceRepository.sources
.filter { $0.account?.id == account.id }
.reduce(Decimal.zero) { $0 + $1.latestValue }
}
}
}
struct GoalPaceStatus {
let expectedProgress: Double
let delta: Double
let isBehind: Bool
let statusText: String
}
@@ -0,0 +1,55 @@
import Foundation
import Combine
import CoreData
@MainActor
class JournalViewModel: ObservableObject {
@Published var snapshotNotes: [Snapshot] = []
@Published var monthlyNotes: [MonthlyNoteItem] = []
private let snapshotRepository: SnapshotRepository
private var cancellables = Set<AnyCancellable>()
init(snapshotRepository: SnapshotRepository? = nil) {
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
setupObservers()
refresh()
}
private func setupObservers() {
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
}
func refresh() {
snapshotNotes = snapshotRepository.fetchAllSnapshots()
.sorted { $0.date > $1.date }
let snapshotMonths = Set(snapshotNotes.map { $0.date.startOfMonth })
let noteMonths = Set(MonthlyCheckInStore.allNotes().map { $0.date.startOfMonth })
let allMonths = snapshotMonths.union(noteMonths)
monthlyNotes = allMonths
.sorted(by: >)
.map { date -> MonthlyNoteItem in
let entry = MonthlyCheckInStore.entry(for: date)
return MonthlyNoteItem(
date: date,
note: entry?.note ?? "",
rating: entry?.rating,
mood: entry?.mood
)
}
}
}
struct MonthlyNoteItem: Identifiable, Equatable {
var id: Date { date }
let date: Date
let note: String
let rating: Int?
let mood: MonthlyCheckInMood?
}
@@ -0,0 +1,128 @@
import Foundation
import Combine
import CoreData
@MainActor
class MonthlyCheckInViewModel: ObservableObject {
@Published var sourcesNeedingUpdate: [InvestmentSource] = []
@Published var sources: [InvestmentSource] = []
@Published var monthlySummary: MonthlySummary = .empty
@Published var recentNotes: [Snapshot] = []
@Published var isLoading = false
@Published var selectedAccount: Account?
@Published var showAllAccounts = true
@Published var selectedRange: DateRange = .thisMonth
@Published var checkInStats: MonthlyCheckInStats = .empty
private let sourceRepository: InvestmentSourceRepository
private let snapshotRepository: SnapshotRepository
private let calculationService: CalculationService
private var cancellables = Set<AnyCancellable>()
@MainActor
init(
sourceRepository: InvestmentSourceRepository? = nil,
snapshotRepository: SnapshotRepository? = nil,
calculationService: CalculationService? = nil
) {
let context = CoreDataStack.shared.viewContext
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository(context: context)
self.snapshotRepository = snapshotRepository ?? SnapshotRepository(context: context)
self.calculationService = calculationService ?? .shared
setupObservers()
refresh()
}
private func setupObservers() {
sourceRepository.$sources
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
}
func refresh() {
isLoading = true
let filtered = filteredSources()
let snapshots = filteredSnapshots(for: filtered)
let accountFilter = showAllAccounts ? nil : selectedAccount
sourcesNeedingUpdate = sourceRepository.fetchSourcesNeedingUpdate(for: accountFilter)
sources = filtered
monthlySummary = calculationService.calculateMonthlySummary(
sources: filtered,
snapshots: snapshots,
range: selectedRange
)
checkInStats = MonthlyCheckInStore.stats(referenceDate: selectedRange.end)
recentNotes = snapshots
.filter { ($0.notes?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
&& selectedRange.contains($0.date) }
.sorted { $0.date > $1.date }
.prefix(5)
.map { $0 }
isLoading = false
}
func duplicatePreviousMonthSnapshots(referenceDate: Date) {
let targetRange = DateRange.month(containing: referenceDate)
let previousRange = DateRange.month(containing: referenceDate.adding(months: -1))
let sources = filteredSources()
guard !sources.isEmpty else { return }
let targetSnapshots = snapshotRepository.fetchSnapshots(
from: targetRange.start,
to: targetRange.end
)
let targetSourceIds = Set(targetSnapshots.compactMap { $0.source?.id })
let now = Date()
let targetDate = targetRange.contains(now) ? now : targetRange.end
for source in sources {
let sourceId = source.id
if targetSourceIds.contains(sourceId) { continue }
let previousSnapshots = snapshotRepository.fetchSnapshots(for: source)
guard let previousSnapshot = previousSnapshots.first(where: { previousRange.contains($0.date) }) else {
continue
}
snapshotRepository.createSnapshot(
for: source,
date: targetDate,
value: previousSnapshot.decimalValue,
contribution: previousSnapshot.contribution?.decimalValue,
notes: previousSnapshot.notes
)
}
}
private func filteredSources() -> [InvestmentSource] {
if showAllAccounts || selectedAccount == nil {
return sourceRepository.sources
}
return sourceRepository.sources.filter { $0.account?.id == selectedAccount?.id }
}
private func filteredSnapshots(for sources: [InvestmentSource]) -> [Snapshot] {
let sourceIds = Set(sources.compactMap { $0.id })
return snapshotRepository.fetchAllSnapshots().filter { snapshot in
let sourceId = snapshot.source?.id
return sourceId.map(sourceIds.contains) ?? false
}
}
}
@@ -0,0 +1,286 @@
import Foundation
import Combine
import CoreData
import UIKit
@MainActor
class SettingsViewModel: ObservableObject {
// MARK: - Published Properties
@Published var isPremium = false
@Published var isFamilyShared = false
@Published var notificationsEnabled = false
@Published var defaultNotificationTime = Date()
@Published var analyticsEnabled = true
@Published var currencyCode = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
@Published var inputMode: InputMode = .simple
@Published var isLoading = false
@Published var showingPaywall = false
@Published var showingExportOptions = false
@Published var showingImportSheet = false
@Published var showingResetConfirmation = false
@Published var errorMessage: String?
@Published var successMessage: String?
// MARK: - Statistics
@Published var totalSources = 0
@Published var totalSnapshots = 0
@Published var totalCategories = 0
// MARK: - Dependencies
private let iapService: IAPService
private let notificationService: NotificationService
private let sourceRepository: InvestmentSourceRepository
private let categoryRepository: CategoryRepository
private let freemiumValidator: FreemiumValidator
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init(
iapService: IAPService,
notificationService: NotificationService? = nil,
sourceRepository: InvestmentSourceRepository? = nil,
categoryRepository: CategoryRepository? = nil
) {
self.iapService = iapService
self.notificationService = notificationService ?? .shared
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
self.categoryRepository = categoryRepository ?? CategoryRepository()
self.freemiumValidator = FreemiumValidator(iapService: iapService)
setupObservers()
loadSettings()
}
// MARK: - Setup
private func setupObservers() {
iapService.$isPremium
.receive(on: DispatchQueue.main)
.assign(to: &$isPremium)
iapService.$isFamilyShared
.receive(on: DispatchQueue.main)
.assign(to: &$isFamilyShared)
notificationService.$isAuthorized
.receive(on: DispatchQueue.main)
.assign(to: &$notificationsEnabled)
}
// MARK: - Data Loading
func loadSettings() {
let context = CoreDataStack.shared.viewContext
let settings = AppSettings.getOrCreate(in: context)
defaultNotificationTime = settings.defaultNotificationTime ?? Date()
analyticsEnabled = settings.enableAnalytics
currencyCode = settings.currency
inputMode = InputMode(rawValue: settings.inputMode) ?? .simple
// Load statistics
totalSources = sourceRepository.sourceCount
totalCategories = categoryRepository.categories.count
totalSnapshots = sourceRepository.sources.reduce(0) { $0 + $1.snapshotCount }
FirebaseService.shared.logScreenView(screenName: "Settings")
}
// MARK: - Premium Actions
func upgradeToPremium() {
showingPaywall = true
FirebaseService.shared.logPaywallShown(trigger: "settings_upgrade")
}
func restorePurchases() async {
isLoading = true
await iapService.restorePurchases()
isLoading = false
if isPremium {
successMessage = "Purchases restored successfully!"
FirebaseService.shared.logRestorePurchases(success: true)
} else {
errorMessage = "No purchases to restore"
FirebaseService.shared.logRestorePurchases(success: false)
}
}
// MARK: - Notification Settings
func requestNotificationPermission() async {
let granted = await notificationService.requestAuthorization()
if !granted {
errorMessage = "Please enable notifications in Settings"
}
}
func updateNotificationTime(_ time: Date) {
defaultNotificationTime = time
let context = CoreDataStack.shared.viewContext
let settings = AppSettings.getOrCreate(in: context)
settings.defaultNotificationTime = time
CoreDataStack.shared.save()
// Reschedule all notifications with new time
let sources = sourceRepository.fetchActiveSources()
notificationService.scheduleAllReminders(for: sources)
}
func openSystemSettings() {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
// MARK: - Export
func exportData(format: ExportService.ExportFormat) {
guard freemiumValidator.canExport() else {
showingPaywall = true
FirebaseService.shared.logPaywallShown(trigger: "export")
return
}
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let viewController = windowScene.windows.first?.rootViewController else {
return
}
ExportService.shared.share(
format: format,
sources: sourceRepository.sources,
categories: categoryRepository.categories,
from: viewController
)
}
var canExport: Bool {
freemiumValidator.canExport()
}
// MARK: - Analytics
func toggleAnalytics(_ enabled: Bool) {
analyticsEnabled = enabled
let context = CoreDataStack.shared.viewContext
let settings = AppSettings.getOrCreate(in: context)
settings.enableAnalytics = enabled
CoreDataStack.shared.save()
// Note: In production, you'd also update Firebase Analytics consent
}
// MARK: - Currency
func updateCurrency(_ code: String) {
currencyCode = code
let context = CoreDataStack.shared.viewContext
let settings = AppSettings.getOrCreate(in: context)
settings.currency = code
CoreDataStack.shared.save()
}
// MARK: - Input Mode
func updateInputMode(_ mode: InputMode) {
inputMode = mode
let context = CoreDataStack.shared.viewContext
let settings = AppSettings.getOrCreate(in: context)
settings.inputMode = mode.rawValue
CoreDataStack.shared.save()
}
// MARK: - Data Management
func resetAllData() {
let context = CoreDataStack.shared.viewContext
// Delete all snapshots, sources, and categories
for source in sourceRepository.sources {
context.delete(source)
}
for category in categoryRepository.categories {
context.delete(category)
}
let assetRequest: NSFetchRequest<Asset> = Asset.fetchRequest()
let accountRequest: NSFetchRequest<Account> = Account.fetchRequest()
let goalRequest: NSFetchRequest<Goal> = Goal.fetchRequest()
if let assets = try? context.fetch(assetRequest) {
assets.forEach { context.delete($0) }
}
if let accounts = try? context.fetch(accountRequest) {
accounts.forEach { context.delete($0) }
}
if let goals = try? context.fetch(goalRequest) {
goals.forEach { context.delete($0) }
}
CoreDataStack.shared.save()
// Clear notifications
notificationService.cancelAllReminders()
// Recreate default categories
categoryRepository.createDefaultCategoriesIfNeeded()
_ = AccountRepository().createDefaultAccountIfNeeded()
// Reload data
loadSettings()
successMessage = "All data has been reset"
}
// MARK: - Computed Properties
var appVersion: String {
"\(AppConstants.appVersion) (\(AppConstants.buildNumber))"
}
var premiumStatusText: String {
if isPremium {
return isFamilyShared ? "Premium (Family)" : "Premium"
}
return "Free"
}
var sourceLimitText: String {
if isPremium {
return "Unlimited"
}
return "\(totalSources)/\(FreemiumLimits.maxSources)"
}
var historyLimitText: String {
if isPremium {
return "Full history"
}
return "Last \(FreemiumLimits.maxHistoricalMonths) months"
}
var storageUsedText: String {
let bytes = calculateStorageUsed()
let formatter = ByteCountFormatter()
formatter.countStyle = .file
return formatter.string(fromByteCount: Int64(bytes))
}
private func calculateStorageUsed() -> Int {
guard let storeURL = CoreDataStack.sharedStoreURL else { return 0 }
do {
let attributes = try FileManager.default.attributesOfItem(atPath: storeURL.path)
return attributes[.size] as? Int ?? 0
} catch {
return 0
}
}
}
@@ -0,0 +1,222 @@
import Foundation
import Combine
@MainActor
class SnapshotFormViewModel: ObservableObject {
// MARK: - Published Properties
@Published var date = Date()
@Published var valueString = ""
@Published var contributionString = ""
@Published var notes = ""
@Published var includeContribution = false
@Published var inputMode: InputMode = .simple
@Published var currencySymbol = ""
@Published var isValid = false
@Published var errorMessage: String?
// MARK: - Mode
enum Mode {
case add
case edit(Snapshot)
}
let mode: Mode
let source: InvestmentSource
// MARK: - Dependencies
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init(source: InvestmentSource, mode: Mode = .add) {
self.source = source
self.mode = mode
let settings = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext)
if let accountCurrency = source.account?.currency, !accountCurrency.isEmpty {
currencySymbol = CurrencyFormatter.symbol(for: accountCurrency)
} else {
currencySymbol = settings.currencySymbol
}
if let accountMode = InputMode(rawValue: source.account?.inputMode ?? "") {
inputMode = accountMode
} else {
inputMode = InputMode(rawValue: settings.inputMode) ?? .simple
}
includeContribution = inputMode == .detailed
setupValidation()
// Pre-fill for edit mode
if case .edit(let snapshot) = mode {
date = snapshot.date
valueString = formatDecimalForInput(snapshot.decimalValue)
if let contribution = snapshot.contribution {
includeContribution = true
contributionString = formatDecimalForInput(contribution.decimalValue)
}
notes = snapshot.notes ?? ""
}
}
// MARK: - Validation
private func setupValidation() {
Publishers.CombineLatest3($valueString, $contributionString, $includeContribution)
.map { [weak self] valueStr, contribStr, includeContrib in
self?.validateInputs(
valueString: valueStr,
contributionString: contribStr,
includeContribution: includeContrib
) ?? false
}
.assign(to: &$isValid)
}
private func validateInputs(
valueString: String,
contributionString: String,
includeContribution: Bool
) -> Bool {
// Value is required
guard let value = parseDecimal(valueString), value >= 0 else {
return false
}
// Contribution is optional but must be valid if included
if includeContribution {
guard let contribution = parseDecimal(contributionString), contribution >= 0 else {
return false
}
}
return true
}
// MARK: - Parsing
private func parseDecimal(_ string: String) -> Decimal? {
let cleaned = string
.replacingOccurrences(of: currencySymbol, with: "")
.replacingOccurrences(of: ",", with: ".")
.trimmingCharacters(in: .whitespaces)
guard !cleaned.isEmpty else { return nil }
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = Locale(identifier: "en_US")
return formatter.number(from: cleaned)?.decimalValue
}
private func formatDecimalForInput(_ decimal: Decimal) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = 2
formatter.maximumFractionDigits = 2
formatter.groupingSeparator = ""
return formatter.string(from: decimal as NSDecimalNumber) ?? ""
}
// MARK: - Computed Properties
var value: Decimal? {
parseDecimal(valueString)
}
var contribution: Decimal? {
guard includeContribution else { return nil }
return parseDecimal(contributionString)
}
var formattedValue: String {
guard let value = value else { return CurrencyFormatter.format(Decimal.zero) }
return value.currencyString
}
var title: String {
switch mode {
case .add:
return "Add Snapshot"
case .edit:
return "Edit Snapshot"
}
}
var buttonTitle: String {
switch mode {
case .add:
return "Add Snapshot"
case .edit:
return "Save Changes"
}
}
// MARK: - Previous Value Reference
var previousValue: Decimal? {
source.latestSnapshot?.decimalValue
}
var previousValueString: String {
guard let previous = previousValue else { return "No previous value" }
return "Previous: \(previous.currencyString)"
}
var changeFromPrevious: Decimal? {
guard let current = value, let previous = previousValue else { return nil }
return current - previous
}
var changePercentageFromPrevious: Double? {
guard let current = value,
let previous = previousValue,
previous != 0 else { return nil }
return NSDecimalNumber(decimal: (current - previous) / previous).doubleValue * 100
}
var formattedChange: String? {
guard let change = changeFromPrevious else { return nil }
let prefix = change >= 0 ? "+" : ""
return "\(prefix)\(change.currencyString)"
}
var formattedChangePercentage: String? {
guard let percentage = changePercentageFromPrevious else { return nil }
let prefix = percentage >= 0 ? "+" : ""
return String(format: "\(prefix)%.2f%%", percentage)
}
// MARK: - Duplicate Previous
func prefillFromPreviousSnapshot() {
guard case .add = mode,
let previous = source.latestSnapshot else { return }
valueString = formatDecimalForInput(previous.decimalValue)
if let contribution = previous.contribution {
includeContribution = true
contributionString = formatDecimalForInput(contribution.decimalValue)
}
notes = previous.notes ?? ""
date = Date()
}
// MARK: - Date Validation
var isDateInFuture: Bool {
date > Date()
}
var dateWarning: String? {
if isDateInFuture {
return "Date is in the future"
}
return nil
}
}
@@ -0,0 +1,324 @@
import Foundation
import Combine
import CoreData
@MainActor
class SourceDetailViewModel: ObservableObject {
// MARK: - Published Properties
@Published var source: InvestmentSource
@Published var snapshots: [Snapshot] = []
@Published var metrics: InvestmentMetrics = .empty
@Published var predictions: [Prediction] = []
@Published var predictionResult: PredictionResult?
@Published var transactions: [Transaction] = []
@Published var isLoading = false
@Published var showingAddSnapshot = false
@Published var showingEditSource = false
@Published var showingPaywall = false
@Published var showingAddTransaction = false
@Published var errorMessage: String?
// MARK: - Chart Data
@Published var chartData: [(date: Date, value: Decimal)] = []
// MARK: - Dependencies
private let snapshotRepository: SnapshotRepository
private let sourceRepository: InvestmentSourceRepository
private let transactionRepository: TransactionRepository
private let calculationService: CalculationService
private let predictionEngine: PredictionEngine
private let freemiumValidator: FreemiumValidator
private var cancellables = Set<AnyCancellable>()
private var isRefreshing = false
private var refreshQueued = false
// MARK: - Initialization
init(
source: InvestmentSource,
snapshotRepository: SnapshotRepository? = nil,
sourceRepository: InvestmentSourceRepository? = nil,
transactionRepository: TransactionRepository? = nil,
calculationService: CalculationService? = nil,
predictionEngine: PredictionEngine? = nil,
iapService: IAPService
) {
self.source = source
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
self.transactionRepository = transactionRepository ?? TransactionRepository()
self.calculationService = calculationService ?? .shared
self.predictionEngine = predictionEngine ?? .shared
self.freemiumValidator = FreemiumValidator(iapService: iapService)
loadData()
setupObservers()
}
// MARK: - Setup
private func setupObservers() {
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.sink { [weak self] notification in
guard let self,
self.isRelevantChange(notification) else { return }
self.refreshData()
}
.store(in: &cancellables)
}
// MARK: - Data Loading
func loadData() {
isLoading = true
refreshData()
isLoading = false
FirebaseService.shared.logScreenView(screenName: "SourceDetail")
}
func refreshData() {
refreshQueued = true
guard !isRefreshing else { return }
isRefreshing = true
Task { [weak self] in
guard let self else { return }
while self.refreshQueued {
self.refreshQueued = false
// Fetch snapshots (filtered by freemium limits)
let allSnapshots = snapshotRepository.fetchSnapshots(for: source)
let filteredSnapshots = freemiumValidator.filterSnapshots(allSnapshots)
// Performance: Only update if data actually changed
let snapshotsChanged = filteredSnapshots.count != self.snapshots.count ||
filteredSnapshots.first?.date != self.snapshots.first?.date
if snapshotsChanged {
self.snapshots = filteredSnapshots
// Calculate metrics
self.metrics = calculationService.calculateMetrics(for: filteredSnapshots)
// Performance: Pre-sort once and reuse
let sortedSnapshots = filteredSnapshots.sorted { $0.date < $1.date }
// Prepare chart data - avoid creating new array if possible
self.chartData = sortedSnapshots.map { (date: $0.date, value: $0.decimalValue) }
// Calculate predictions if premium - only if we have enough data
if freemiumValidator.canViewPredictions() && sortedSnapshots.count >= 3 {
self.predictionResult = predictionEngine.predict(snapshots: sortedSnapshots)
self.predictions = self.predictionResult?.predictions ?? []
} else {
self.predictions = []
self.predictionResult = nil
}
}
// Transactions update independently
self.transactions = transactionRepository.fetchTransactions(for: source)
}
self.isRefreshing = false
}
}
// MARK: - Snapshot Actions
func addSnapshot(date: Date, value: Decimal, contribution: Decimal?, notes: String?) {
snapshotRepository.createSnapshot(
for: source,
date: date,
value: value,
contribution: contribution,
notes: notes
)
// Reschedule notification
NotificationService.shared.scheduleReminder(for: source)
// Log analytics
FirebaseService.shared.logSnapshotAdded(sourceName: source.name, value: value)
showingAddSnapshot = false
refreshData()
}
func deleteSnapshot(_ snapshot: Snapshot) {
snapshotRepository.deleteSnapshot(snapshot)
refreshData()
}
func deleteSnapshot(at offsets: IndexSet) {
snapshotRepository.deleteSnapshot(at: offsets, from: snapshots)
refreshData()
}
// MARK: - Transaction Actions
func addTransaction(
type: TransactionType,
date: Date,
shares: Decimal?,
price: Decimal?,
amount: Decimal?,
notes: String?
) {
transactionRepository.createTransaction(
source: source,
type: type,
date: date,
shares: shares,
price: price,
amount: amount,
notes: notes
)
refreshData()
}
func deleteTransaction(_ transaction: Transaction) {
transactionRepository.deleteTransaction(transaction)
refreshData()
}
// MARK: - Source Actions
func updateSource(
name: String,
category: Category,
frequency: NotificationFrequency,
customMonths: Int
) {
sourceRepository.updateSource(
source,
name: name,
category: category,
notificationFrequency: frequency,
customFrequencyMonths: customMonths
)
// Update notification
NotificationService.shared.scheduleReminder(for: source)
showingEditSource = false
}
// MARK: - Predictions
func showPredictions() {
if freemiumValidator.canViewPredictions() {
// Already loaded, just navigate
FirebaseService.shared.logPredictionViewed(
algorithm: predictionResult?.algorithm.rawValue ?? "unknown"
)
} else {
showingPaywall = true
FirebaseService.shared.logPaywallShown(trigger: "predictions")
}
}
// MARK: - Computed Properties
var currentValue: Decimal {
source.latestValue
}
var formattedCurrentValue: String {
currentValue.currencyString
}
var totalReturn: Decimal {
metrics.absoluteReturn
}
var formattedTotalReturn: String {
metrics.formattedAbsoluteReturn
}
var percentageReturn: Decimal {
metrics.percentageReturn
}
var formattedPercentageReturn: String {
metrics.formattedPercentageReturn
}
var isPositiveReturn: Bool {
totalReturn >= 0
}
var categoryName: String {
source.category?.name ?? "Uncategorized"
}
var categoryColor: String {
source.category?.colorHex ?? "#3B82F6"
}
var lastUpdated: String {
source.latestSnapshot?.date.friendlyDescription ?? "Never"
}
var snapshotCount: Int {
snapshots.count
}
var hasEnoughDataForPredictions: Bool {
snapshots.count >= 3
}
var canViewPredictions: Bool {
freemiumValidator.canViewPredictions()
}
var isHistoryLimited: Bool {
!freemiumValidator.isPremium && hiddenSnapshotCount > 0
}
var hiddenSnapshotCount: Int {
guard let limit = snapshotDisplayLimit else { return 0 }
return max(0, snapshots.count - min(limit, snapshots.count))
}
var snapshotDisplayLimit: Int? {
freemiumValidator.isPremium ? nil : 10
}
var visibleSnapshots: [Snapshot] {
guard let limit = snapshotDisplayLimit else { return snapshots }
return Array(snapshots.prefix(limit))
}
private func isRelevantChange(_ notification: Notification) -> Bool {
guard let info = notification.userInfo else { return false }
let keys: [String] = [
NSInsertedObjectsKey,
NSUpdatedObjectsKey,
NSDeletedObjectsKey,
NSRefreshedObjectsKey
]
for key in keys {
if let objects = info[key] as? Set<NSManagedObject> {
if objects.contains(where: { obj in
if let snapshot = obj as? Snapshot {
return snapshot.source?.id == source.id
}
if let investmentSource = obj as? InvestmentSource {
return investmentSource.id == source.id
}
return false
}) {
return true
}
}
}
return false
}
}
@@ -0,0 +1,219 @@
import Foundation
import Combine
import CoreData
@MainActor
class SourceListViewModel: ObservableObject {
// MARK: - Published Properties
@Published var sources: [InvestmentSource] = []
@Published var categories: [Category] = []
@Published var selectedCategory: Category?
@Published var searchText = ""
@Published var selectedAccount: Account?
@Published var showAllAccounts = true
@Published var isLoading = false
@Published var showingAddSource = false
@Published var showingPaywall = false
@Published var errorMessage: String?
// MARK: - Dependencies
private let sourceRepository: InvestmentSourceRepository
private let categoryRepository: CategoryRepository
private let freemiumValidator: FreemiumValidator
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init(
sourceRepository: InvestmentSourceRepository? = nil,
categoryRepository: CategoryRepository? = nil,
iapService: IAPService
) {
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
self.categoryRepository = categoryRepository ?? CategoryRepository()
self.freemiumValidator = FreemiumValidator(iapService: iapService)
setupObservers()
loadData()
}
// MARK: - Setup
private func setupObservers() {
// Performance: Update categories separately (less frequent)
categoryRepository.$categories
.receive(on: DispatchQueue.main)
.sink { [weak self] categories in
self?.categories = categories
}
.store(in: &cancellables)
// Performance: Combine all filter-triggering publishers into one stream
// This prevents multiple rapid filter operations when state changes
Publishers.CombineLatest4(
sourceRepository.$sources,
$searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main),
$selectedCategory,
$selectedAccount
)
.combineLatest($showAllAccounts)
.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)
.receive(on: DispatchQueue.main)
.sink { [weak self] combined, _ in
let (sources, _, _, _) = combined
self?.filterAndSortSources(sources)
}
.store(in: &cancellables)
}
// MARK: - Data Loading
func loadData() {
isLoading = true
categoryRepository.createDefaultCategoriesIfNeeded()
sourceRepository.fetchSources()
categoryRepository.fetchCategories()
isLoading = false
FirebaseService.shared.logScreenView(screenName: "SourceList")
}
private func filterAndSortSources(_ allSources: [InvestmentSource]) {
var filtered = allSources
if !showAllAccounts, let account = selectedAccount {
filtered = filtered.filter { $0.account?.id == account.id }
}
// Filter by category
if let category = selectedCategory {
filtered = filtered.filter { $0.category?.id == category.id }
}
// Filter by search text
if !searchText.isEmpty {
filtered = filtered.filter {
$0.name.localizedCaseInsensitiveContains(searchText) ||
($0.category?.name.localizedCaseInsensitiveContains(searchText) ?? false)
}
}
// Sort by value descending
sources = filtered.sorted { $0.latestValue > $1.latestValue }
}
// MARK: - Actions
func addSourceTapped() {
if freemiumValidator.canAddSource(currentCount: sourceRepository.sourceCount) {
showingAddSource = true
} else {
showingPaywall = true
FirebaseService.shared.logPaywallShown(trigger: "source_limit")
}
}
func createSource(
name: String,
category: Category,
frequency: NotificationFrequency,
customMonths: Int = 1,
account: Account? = nil
) {
let source = sourceRepository.createSource(
name: name,
category: category,
notificationFrequency: frequency,
customFrequencyMonths: customMonths,
account: account
)
// Schedule notification
NotificationService.shared.scheduleReminder(for: source)
// Log analytics
FirebaseService.shared.logSourceAdded(
categoryName: category.name,
sourceCount: sourceRepository.sourceCount
)
showingAddSource = false
}
func deleteSource(_ source: InvestmentSource) {
let categoryName = source.category?.name ?? "Unknown"
// Cancel notifications
NotificationService.shared.cancelReminder(for: source)
// Delete source
sourceRepository.deleteSource(source)
// Log analytics
FirebaseService.shared.logSourceDeleted(categoryName: categoryName)
}
func deleteSource(at offsets: IndexSet) {
for index in offsets {
guard index < sources.count else { continue }
deleteSource(sources[index])
}
}
func toggleSourceActive(_ source: InvestmentSource) {
sourceRepository.toggleActive(source)
if source.isActive {
NotificationService.shared.scheduleReminder(for: source)
} else {
NotificationService.shared.cancelReminder(for: source)
}
}
// MARK: - Computed Properties
var canAddSource: Bool {
freemiumValidator.canAddSource(currentCount: sourceRepository.sourceCount)
}
var remainingSources: Int {
freemiumValidator.remainingSources(currentCount: sourceRepository.sourceCount)
}
var sourceLimitReached: Bool {
!canAddSource
}
var totalValue: Decimal {
sources.reduce(Decimal.zero) { $0 + $1.latestValue }
}
var formattedTotalValue: String {
totalValue.currencyString
}
var sourcesByCategory: [Category: [InvestmentSource]] {
Dictionary(grouping: sources) { $0.category ?? categories.first! }
}
var isEmpty: Bool {
sources.isEmpty && searchText.isEmpty && selectedCategory == nil
}
var isFiltered: Bool {
!searchText.isEmpty || selectedCategory != nil
}
// MARK: - Category Filter
func selectCategory(_ category: Category?) {
selectedCategory = category
}
func clearFilters() {
searchText = ""
selectedCategory = nil
}
}
@@ -0,0 +1,121 @@
import SwiftUI
struct AccountEditorView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var accountStore: AccountStore
let account: Account?
@State private var name = ""
@State private var currencyCode = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
@State private var inputMode: InputMode = .simple
@State private var notificationFrequency: NotificationFrequency = .monthly
@State private var customFrequencyMonths = 1
private let accountRepository = AccountRepository()
var body: some View {
NavigationStack {
Form {
Section {
TextField("Account name", text: $name)
Picker("Currency", selection: $currencyCode) {
ForEach(CurrencyPicker.commonCodes, id: \.self) { code in
Text(code).tag(code)
}
}
} header: {
Text("Account")
}
Section {
Picker("Input Mode", selection: $inputMode) {
ForEach(InputMode.allCases) { mode in
Text(mode.title).tag(mode)
}
}
} header: {
Text("Input")
} footer: {
Text(inputMode.description)
}
Section {
Picker("Reminder Frequency", selection: $notificationFrequency) {
ForEach(NotificationFrequency.allCases) { frequency in
Text(frequency.displayName).tag(frequency)
}
}
if notificationFrequency == .custom {
Stepper(
"Every \(customFrequencyMonths) month\(customFrequencyMonths > 1 ? "s" : "")",
value: $customFrequencyMonths,
in: 1...24
)
}
} header: {
Text("Account Reminders")
} footer: {
Text("Reminders apply to the whole account.")
}
}
.navigationTitle(account == nil ? "New Account" : "Edit Account")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") { saveAccount() }
.disabled(!isValid)
}
}
.onAppear {
loadAccount()
}
}
.presentationDetents([.large])
}
private var isValid: Bool {
!name.trimmingCharacters(in: .whitespaces).isEmpty
}
private func loadAccount() {
guard let account else { return }
name = account.name
currencyCode = account.currencyCode ?? currencyCode
inputMode = InputMode(rawValue: account.inputMode) ?? .simple
notificationFrequency = account.frequency
customFrequencyMonths = Int(account.customFrequencyMonths)
}
private func saveAccount() {
if let account {
accountRepository.updateAccount(
account,
name: name.trimmingCharacters(in: .whitespaces),
currency: currencyCode,
inputMode: inputMode,
notificationFrequency: notificationFrequency,
customFrequencyMonths: customFrequencyMonths
)
} else {
_ = accountRepository.createAccount(
name: name.trimmingCharacters(in: .whitespaces),
currency: currencyCode,
inputMode: inputMode,
notificationFrequency: notificationFrequency,
customFrequencyMonths: customFrequencyMonths
)
}
accountStore.persistSelection()
dismiss()
}
}
#Preview {
AccountEditorView(account: nil)
.environmentObject(AccountStore(iapService: IAPService()))
}
@@ -0,0 +1,78 @@
import SwiftUI
struct AccountsView: View {
@EnvironmentObject private var iapService: IAPService
@EnvironmentObject private var accountStore: AccountStore
@StateObject private var accountRepository = AccountRepository()
@State private var showingAddAccount = false
@State private var selectedAccount: Account?
@State private var showingPaywall = false
var body: some View {
ZStack {
AppBackground()
List {
Section {
ForEach(accountRepository.accounts) { account in
Button {
selectedAccount = account
} label: {
HStack {
VStack(alignment: .leading) {
Text(account.name)
.font(.headline)
Text(account.currencyCode ?? AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
if accountStore.selectedAccount?.id == account.id && !accountStore.showAllAccounts {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.appPrimary)
}
}
}
}
} header: {
Text("Accounts")
} footer: {
Text(iapService.isPremium ? "Create multiple accounts for business, family, or goals." : "Free users can create one account.")
}
}
.scrollContentBackground(.hidden)
}
.navigationTitle("Accounts")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
if accountStore.canAddAccount() {
showingAddAccount = true
} else {
showingPaywall = true
}
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAddAccount) {
AccountEditorView(account: nil)
}
.sheet(isPresented: $showingPaywall) {
PaywallView()
}
.sheet(item: $selectedAccount) { account in
AccountEditorView(account: account)
}
.onAppear {
accountRepository.fetchAccounts()
}
}
}
#Preview {
AccountsView()
.environmentObject(IAPService())
.environmentObject(AccountStore(iapService: IAPService()))
}
@@ -0,0 +1,299 @@
import SwiftUI
import Charts
struct AllocationPieChart: View {
let data: [(category: String, value: Decimal, color: String)]
@State private var selectedSlice: String?
var total: Decimal {
data.reduce(Decimal.zero) { $0 + $1.value }
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Asset Allocation")
.font(.headline)
if !data.isEmpty {
HStack(alignment: .top, spacing: 20) {
// Pie Chart
Chart(data, id: \.category) { item in
SectorMark(
angle: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue),
innerRadius: .ratio(0.6),
angularInset: 1.5
)
.foregroundStyle(Color(hex: item.color) ?? .gray)
.cornerRadius(4)
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
}
.chartLegend(.hidden)
.frame(width: 180, height: 180)
.overlay {
// Center content
VStack(spacing: 2) {
if let selected = selectedSlice,
let item = data.first(where: { $0.category == selected }) {
Text(selected)
.font(.caption)
.foregroundColor(.secondary)
Text(item.value.compactCurrencyString)
.font(.headline)
let percentage = total > 0
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
: 0
Text(String(format: "%.1f%%", percentage))
.font(.caption)
.foregroundColor(.secondary)
} else {
Text("Total")
.font(.caption)
.foregroundColor(.secondary)
Text(total.compactCurrencyString)
.font(.headline)
}
}
}
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
// Simple tap detection
}
)
// Legend
VStack(alignment: .leading, spacing: 8) {
ForEach(data, id: \.category) { item in
Button {
if selectedSlice == item.category {
selectedSlice = nil
} else {
selectedSlice = item.category
}
} label: {
HStack(spacing: 8) {
Circle()
.fill(Color(hex: item.color) ?? .gray)
.frame(width: 10, height: 10)
VStack(alignment: .leading, spacing: 0) {
Text(item.category)
.font(.caption)
.foregroundColor(.primary)
let percentage = total > 0
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
: 0
Text(String(format: "%.1f%%", percentage))
.font(.caption2)
.foregroundColor(.secondary)
}
}
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
}
.buttonStyle(.plain)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
AllocationTargetsComparisonChart(data: data)
} else {
Text("No allocation data available")
.foregroundColor(.secondary)
.frame(height: 200)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
// MARK: - Allocation Targets Comparison
struct AllocationTargetsComparisonChart: View {
let data: [(category: String, value: Decimal, color: String)]
@StateObject private var categoryRepository = CategoryRepository()
private var total: Decimal {
data.reduce(Decimal.zero) { $0 + $1.value }
}
private var targetData: [(category: String, actual: Double, target: Double, color: Color)] {
data.map { item in
let actual = total > 0
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
: 0
let target = AllocationTargetStore.target(for: categoryId(for: item.category)) ?? 0
let color = Color(hex: item.color) ?? .gray
return (item.category, actual, target, color)
}
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Targets vs Actual")
.font(.headline)
if targetData.allSatisfy({ $0.target == 0 }) {
Text("Set allocation targets to compare your portfolio against your plan.")
.font(.subheadline)
.foregroundColor(.secondary)
} else {
Chart {
ForEach(targetData, id: \.category) { item in
BarMark(
x: .value("Category", item.category),
y: .value("Actual", item.actual)
)
.foregroundStyle(item.color)
BarMark(
x: .value("Category", item.category),
y: .value("Target", item.target)
)
.foregroundStyle(Color.gray.opacity(0.35))
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.0f%%", doubleValue))
.font(.caption)
}
}
}
}
.chartXAxis {
AxisMarks { value in
AxisValueLabel()
}
}
.frame(height: 220)
ForEach(targetData, id: \.category) { item in
let drift = item.actual - item.target
let prefix = drift >= 0 ? "+" : ""
HStack {
Circle()
.fill(item.color)
.frame(width: 8, height: 8)
Text(item.category)
.font(.caption)
Spacer()
Text("Actual \(String(format: "%.1f%%", item.actual))")
.font(.caption2)
.foregroundColor(.secondary)
Text("Target \(String(format: "%.0f%%", item.target))")
.font(.caption2)
.foregroundColor(.secondary)
Text("\(prefix)\(String(format: "%.1f%%", drift))")
.font(.caption2.weight(.semibold))
.foregroundColor(drift >= 0 ? .positiveGreen : .negativeRed)
}
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private func categoryId(for name: String) -> UUID {
if let category = categoryRepository.categories.first(where: { $0.name == name }) {
return category.id
}
return UUID()
}
}
// MARK: - Allocation List View (Alternative)
struct AllocationListView: View {
let data: [(category: String, value: Decimal, color: String)]
var total: Decimal {
data.reduce(Decimal.zero) { $0 + $1.value }
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Asset Allocation")
.font(.headline)
ForEach(data, id: \.category) { item in
VStack(spacing: 4) {
HStack {
HStack(spacing: 8) {
Circle()
.fill(Color(hex: item.color) ?? .gray)
.frame(width: 10, height: 10)
Text(item.category)
.font(.subheadline)
}
Spacer()
let percentage = total > 0
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
: 0
Text(String(format: "%.1f%%", percentage))
.font(.subheadline.weight(.medium))
Text(item.value.compactCurrencyString)
.font(.caption)
.foregroundColor(.secondary)
.frame(width: 70, alignment: .trailing)
}
// Progress bar
GeometryReader { geometry in
let percentage = total > 0
? NSDecimalNumber(decimal: item.value / total).doubleValue
: 0
ZStack(alignment: .leading) {
Rectangle()
.fill(Color.gray.opacity(0.1))
.frame(height: 6)
.cornerRadius(3)
Rectangle()
.fill(Color(hex: item.color) ?? .gray)
.frame(width: geometry.size.width * percentage, height: 6)
.cornerRadius(3)
}
}
.frame(height: 6)
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
#Preview {
let sampleData: [(category: String, value: Decimal, color: String)] = [
("Stocks", 50000, "#10B981"),
("Bonds", 25000, "#3B82F6"),
("Real Estate", 15000, "#F59E0B"),
("Crypto", 10000, "#8B5CF6")
]
return VStack(spacing: 20) {
AllocationPieChart(data: sampleData)
AllocationListView(data: sampleData)
}
.padding()
}
@@ -0,0 +1,811 @@
import SwiftUI
import Charts
struct ChartsContainerView: View {
@EnvironmentObject var accountStore: AccountStore
@StateObject private var viewModel: ChartsViewModel
@StateObject private var goalsViewModel = GoalsViewModel()
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
init(iapService: IAPService) {
_viewModel = StateObject(wrappedValue: ChartsViewModel(iapService: iapService))
}
var body: some View {
NavigationStack {
ZStack {
AppBackground()
ScrollView {
VStack(spacing: 20) {
// Chart Type Selector
chartTypeSelector
// Time Range Selector
if viewModel.selectedChartType != .allocation &&
viewModel.selectedChartType != .performance &&
viewModel.selectedChartType != .riskReturn {
timeRangeSelector
}
// Category Filter
if viewModel.selectedChartType == .evolution ||
viewModel.selectedChartType == .prediction {
categoryFilter
}
// Chart Content
chartContent
}
.padding()
}
}
.navigationTitle("Charts")
.sheet(isPresented: $viewModel.showingPaywall) {
PaywallView()
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
accountFilterMenu
}
}
.onAppear {
viewModel.selectedAccount = accountStore.selectedAccount
viewModel.showAllAccounts = accountStore.showAllAccounts
viewModel.loadData()
goalsViewModel.selectedAccount = accountStore.selectedAccount
goalsViewModel.showAllAccounts = accountStore.showAllAccounts
goalsViewModel.refresh()
viewModel.updatePredictionTargetDate(goalsViewModel.goals)
}
// Performance: Use onChange instead of onReceive for cleaner state updates
.onChange(of: accountStore.selectedAccount) { _, newAccount in
viewModel.selectedAccount = newAccount
goalsViewModel.selectedAccount = newAccount
goalsViewModel.refresh()
viewModel.updatePredictionTargetDate(goalsViewModel.goals)
}
.onChange(of: accountStore.showAllAccounts) { _, showAll in
viewModel.showAllAccounts = showAll
goalsViewModel.showAllAccounts = showAll
goalsViewModel.refresh()
viewModel.updatePredictionTargetDate(goalsViewModel.goals)
}
.onChange(of: goalsViewModel.goals) { _, goals in
viewModel.updatePredictionTargetDate(goals)
}
}
}
// MARK: - Chart Type Selector
private var chartTypeSelector: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(viewModel.availableChartTypes(calmModeEnabled: calmModeEnabled)) { chartType in
ChartTypeButton(
chartType: chartType,
isSelected: viewModel.selectedChartType == chartType,
isPremium: chartType.isPremium,
userIsPremium: viewModel.isPremium
) {
viewModel.selectChart(chartType)
}
}
}
.padding(.horizontal, 4)
}
}
// MARK: - Time Range Selector
private var timeRangeSelector: some View {
HStack(spacing: 8) {
ForEach(ChartsViewModel.TimeRange.allCases) { range in
Button {
viewModel.selectedTimeRange = range
} label: {
Text(range.rawValue)
.font(.subheadline.weight(.medium))
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(
viewModel.selectedTimeRange == range
? Color.appPrimary
: Color.gray.opacity(0.1)
)
.foregroundColor(
viewModel.selectedTimeRange == range
? .white
: .primary
)
.cornerRadius(20)
}
}
}
}
// MARK: - Category Filter
@ViewBuilder
private var categoryFilter: some View {
let availableCategories = viewModel.availableCategories(for: viewModel.selectedChartType)
if !availableCategories.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
if availableCategories.count > 1 {
Button {
viewModel.selectedCategory = nil
} label: {
Text("All")
.font(.caption.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
viewModel.selectedCategory == nil
? Color.appPrimary
: Color.gray.opacity(0.1)
)
.foregroundColor(
viewModel.selectedCategory == nil
? .white
: .primary
)
.cornerRadius(16)
}
}
ForEach(availableCategories) { category in
Button {
viewModel.selectedCategory = category
} label: {
HStack(spacing: 4) {
Circle()
.fill(category.color)
.frame(width: 8, height: 8)
Text(category.name)
}
.font(.caption.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
viewModel.selectedCategory?.id == category.id
? category.color
: Color.gray.opacity(0.1)
)
.foregroundColor(
viewModel.selectedCategory?.id == category.id
? .white
: .primary
)
.cornerRadius(16)
}
}
}
}
}
}
// MARK: - Chart Content
@ViewBuilder
private var chartContent: some View {
if viewModel.isLoading {
ProgressView()
.frame(height: 300)
} else if !viewModel.hasData {
emptyStateView
} else {
switch viewModel.selectedChartType {
case .evolution:
EvolutionChartView(
data: viewModel.evolutionData,
categoryData: viewModel.categoryEvolutionData,
goals: goalsViewModel.goals
)
case .allocation:
AllocationPieChart(data: viewModel.allocationData)
case .performance:
PerformanceBarChart(data: viewModel.performanceData)
case .contributions:
ContributionsChartView(data: viewModel.contributionsData)
case .rollingReturn:
RollingReturnChartView(data: viewModel.rollingReturnData)
case .riskReturn:
RiskReturnChartView(data: viewModel.riskReturnData)
case .cashflow:
CashflowStackedChartView(data: viewModel.cashflowData)
case .drawdown:
DrawdownChart(data: viewModel.drawdownData)
case .volatility:
VolatilityChartView(data: viewModel.volatilityData)
case .prediction:
PredictionChartView(
predictions: viewModel.predictionData,
historicalData: viewModel.evolutionData
)
}
}
}
private var emptyStateView: some View {
VStack(spacing: 16) {
Image(systemName: "chart.bar.xaxis")
.font(.system(size: 48))
.foregroundColor(.secondary)
Text("No Data Available")
.font(.headline)
Text("Add some investment sources and snapshots to see charts.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(height: 300)
}
private var accountFilterMenu: some View {
Menu {
Button {
accountStore.selectAllAccounts()
} label: {
HStack {
Text("All Accounts")
if accountStore.showAllAccounts {
Image(systemName: "checkmark")
}
}
}
Divider()
ForEach(accountStore.accounts) { account in
Button {
accountStore.selectAccount(account)
} label: {
HStack {
Text(account.name)
if accountStore.selectedAccount?.id == account.id && !accountStore.showAllAccounts {
Image(systemName: "checkmark")
}
}
}
}
} label: {
Image(systemName: "person.2.circle")
}
}
}
// MARK: - Chart Type Button
struct ChartTypeButton: View {
let chartType: ChartsViewModel.ChartType
let isSelected: Bool
let isPremium: Bool
let userIsPremium: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: 8) {
ZStack(alignment: .topTrailing) {
Image(systemName: chartType.icon)
.font(.title2)
if isPremium && !userIsPremium {
Image(systemName: "lock.fill")
.font(.caption2)
.foregroundColor(.appWarning)
.offset(x: 8, y: -4)
}
}
Text(chartType.rawValue)
.font(.caption)
}
.frame(width: 80, height: 70)
.background(
isSelected
? LinearGradient.appPrimaryGradient
: LinearGradient(
colors: [
Color(.systemBackground).opacity(0.85),
Color(.systemBackground).opacity(0.85)
],
startPoint: .top,
endPoint: .bottom
)
)
.foregroundColor(isSelected ? .white : .primary)
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
}
}
}
// MARK: - Evolution Chart View
struct EvolutionChartView: 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 = false
enum ChartMode: String, CaseIterable, Identifiable {
case total = "Total"
case byCategory = "By Category"
var id: String { rawValue }
}
private var stackedCategoryData: [CategoryEvolutionPoint] {
guard !categoryData.isEmpty else { return [] }
let totalsByCategory = categoryData.reduce(into: [String: Decimal]()) { result, point in
result[point.categoryName, default: 0] += point.value
}
let categoryOrder = totalsByCategory
.sorted { $0.value > $1.value }
.map { $0.key }
let orderIndex = Dictionary(uniqueKeysWithValues: categoryOrder.enumerated().map { ($0.element, $0.offset) })
let groupedByDate = Dictionary(grouping: categoryData) { $0.date }
let sortedDates = groupedByDate.keys.sorted()
var stacked: [CategoryEvolutionPoint] = []
for date in sortedDates {
guard let points = groupedByDate[date] else { continue }
let sortedPoints = points.sorted {
(orderIndex[$0.categoryName] ?? Int.max) < (orderIndex[$1.categoryName] ?? Int.max)
}
var running: Decimal = 0
for point in sortedPoints {
running += point.value
stacked.append(CategoryEvolutionPoint(
date: point.date,
categoryName: point.categoryName,
colorHex: point.colorHex,
value: running
))
}
}
return stacked
}
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()
if let selected = selectedDataPoint, chartMode == .total {
VStack(alignment: .trailing, spacing: 2) {
Text(selected.value.compactCurrencyString)
.font(.subheadline.weight(.semibold))
Text(selected.date.monthYearString)
.font(.caption)
.foregroundColor(.secondary)
}
}
Button {
showGoalLines.toggle()
} label: {
Image(systemName: showGoalLines ? "target" : "slash.circle")
.foregroundColor(.secondary)
}
.disabled(goals.isEmpty)
.accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals")
}
}
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")
.foregroundColor(.secondary)
.frame(height: 300)
}
}
private var chartView: some View {
Chart {
chartMarks
}
.chartForegroundStyleScale(domain: chartCategoryNames, range: chartCategoryColors)
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 2)) { 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
if chartMode == .total {
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: 300)
// Performance: Use GPU rendering for smoother scrolling on older devices
.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(30)
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(categoryData) { item in
AreaMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(by: .value("Category", item.categoryName))
.interpolationMethod(.catmullRom)
.opacity(0.5)
}
ForEach(stackedCategoryData) { item in
LineMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(by: .value("Category", item.categoryName))
.interpolationMethod(.catmullRom)
PointMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(by: .value("Category", item.categoryName))
.symbolSize(18)
}
}
if showGoalLines {
ForEach(goals) { goal in
RuleMark(y: .value("Goal", NSDecimalNumber(decimal: goal.targetDecimal).doubleValue))
.foregroundStyle(Color.appSecondary.opacity(0.4))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [6, 4]))
}
}
}
private var chartCategoryNames: [String] {
let names = Array(Set(categoryData.map { $0.categoryName })).sorted()
return names
}
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: - Contributions Chart
struct ContributionsChartView: View {
let data: [(date: Date, amount: Decimal)]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Contributions")
.font(.headline)
if data.isEmpty {
Text("No contributions yet.")
.foregroundColor(.secondary)
.frame(height: 260)
} else {
Chart {
ForEach(data, id: \.date) { item in
BarMark(
x: .value("Month", item.date),
y: .value("Amount", NSDecimalNumber(decimal: item.amount).doubleValue)
)
.foregroundStyle(Color.appSecondary)
.cornerRadius(6)
}
}
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 2)) { 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)
}
}
}
}
.frame(height: 260)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
// MARK: - Rolling 12-Month Return
struct RollingReturnChartView: View {
let data: [(date: Date, value: Double)]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Rolling 12-Month Return")
.font(.headline)
if data.isEmpty {
Text("Not enough data for rolling returns.")
.foregroundColor(.secondary)
.frame(height: 260)
} else {
Chart {
ForEach(data, id: \.date) { item in
LineMark(
x: .value("Month", item.date),
y: .value("Return", item.value)
)
.foregroundStyle(Color.appPrimary)
.interpolationMethod(.catmullRom)
PointMark(
x: .value("Month", item.date),
y: .value("Return", item.value)
)
.foregroundStyle(Color.appPrimary)
.symbolSize(24)
}
}
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 2)) { value in
AxisValueLabel(format: .dateTime.month(.abbreviated))
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.1f%%", doubleValue))
.font(.caption)
}
}
}
}
.frame(height: 260)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
// MARK: - Risk vs Return
struct RiskReturnChartView: View {
let data: [(category: String, cagr: Double, volatility: Double, color: String)]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Risk vs Return")
.font(.headline)
if data.isEmpty {
Text("Not enough data to compare categories.")
.foregroundColor(.secondary)
.frame(height: 260)
} else {
Chart {
ForEach(data, id: \.category) { item in
PointMark(
x: .value("Volatility", item.volatility),
y: .value("CAGR", item.cagr)
)
.foregroundStyle(Color(hex: item.color) ?? .gray)
.symbolSize(60)
.annotation(position: .top) {
Text(item.category)
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
.chartXAxis {
AxisMarks(position: .bottom) { value in
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.1f%%", doubleValue))
.font(.caption)
}
}
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.1f%%", doubleValue))
.font(.caption)
}
}
}
}
.frame(height: 260)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
// MARK: - Net Performance vs Contributions
struct CashflowStackedChartView: View {
let data: [(date: Date, contributions: Decimal, netPerformance: Decimal)]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Net Performance vs Contributions")
.font(.headline)
if data.isEmpty {
Text("Not enough data to compare cashflow.")
.foregroundColor(.secondary)
.frame(height: 260)
} else {
Chart {
ForEach(data, id: \.date) { item in
let contributionValue = NSDecimalNumber(decimal: item.contributions).doubleValue
let netValue = NSDecimalNumber(decimal: item.netPerformance).doubleValue
let stackedEnd = contributionValue + netValue
BarMark(
x: .value("Month", item.date),
yStart: .value("Start", 0),
yEnd: .value("Contributions", contributionValue)
)
.foregroundStyle(Color.appSecondary.opacity(0.8))
BarMark(
x: .value("Month", item.date),
yStart: .value("Start", contributionValue),
yEnd: .value("Net", stackedEnd)
)
.foregroundStyle(netValue >= 0 ? Color.positiveGreen : Color.negativeRed)
}
}
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 2)) { 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)
}
}
}
}
.frame(height: 260)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
#Preview {
ChartsContainerView(iapService: IAPService())
.environmentObject(AccountStore(iapService: IAPService()))
}
@@ -0,0 +1,304 @@
import SwiftUI
import Charts
struct DrawdownChart: View {
let data: [(date: Date, drawdown: Double)]
var maxDrawdown: Double {
abs(data.map { $0.drawdown }.min() ?? 0)
}
var currentDrawdown: Double {
abs(data.last?.drawdown ?? 0)
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Drawdown Analysis")
.font(.headline)
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("Max Drawdown")
.font(.caption)
.foregroundColor(.secondary)
Text(String(format: "%.1f%%", maxDrawdown))
.font(.subheadline.weight(.semibold))
.foregroundColor(.negativeRed)
}
}
Text("Shows percentage decline from peak values")
.font(.caption)
.foregroundColor(.secondary)
if data.count >= 2 {
Chart(data, id: \.date) { item in
AreaMark(
x: .value("Date", item.date),
y: .value("Drawdown", item.drawdown)
)
.foregroundStyle(
LinearGradient(
colors: [Color.negativeRed.opacity(0.5), Color.negativeRed.opacity(0.1)],
startPoint: .top,
endPoint: .bottom
)
)
.interpolationMethod(.catmullRom)
LineMark(
x: .value("Date", item.date),
y: .value("Drawdown", item.drawdown)
)
.foregroundStyle(Color.negativeRed)
.interpolationMethod(.catmullRom)
}
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 2)) { value in
AxisValueLabel(format: .dateTime.month(.abbreviated))
}
}
.chartYAxis {
AxisMarks { value in
AxisGridLine()
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.0f%%", doubleValue))
.font(.caption)
}
}
}
}
.chartYScale(domain: (data.map { $0.drawdown }.min() ?? -50)...0)
.frame(height: 250)
// Statistics
HStack(spacing: 20) {
DrawdownStatView(
title: "Current",
value: String(format: "%.1f%%", currentDrawdown),
isHighlighted: currentDrawdown > maxDrawdown * 0.8
)
DrawdownStatView(
title: "Maximum",
value: String(format: "%.1f%%", maxDrawdown),
isHighlighted: true
)
DrawdownStatView(
title: "Average",
value: String(format: "%.1f%%", averageDrawdown),
isHighlighted: false
)
}
.padding(.top, 8)
} else {
Text("Not enough data for drawdown analysis")
.foregroundColor(.secondary)
.frame(height: 250)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var averageDrawdown: Double {
guard !data.isEmpty else { return 0 }
let sum = data.reduce(0.0) { $0 + abs($1.drawdown) }
return sum / Double(data.count)
}
}
struct DrawdownStatView: View {
let title: String
let value: String
let isHighlighted: Bool
var body: some View {
VStack(spacing: 4) {
Text(title)
.font(.caption)
.foregroundColor(.secondary)
Text(value)
.font(.subheadline.weight(.semibold))
.foregroundColor(isHighlighted ? .negativeRed : .primary)
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Volatility Chart View
struct VolatilityChartView: View {
let data: [(date: Date, volatility: Double)]
var currentVolatility: Double {
data.last?.volatility ?? 0
}
var averageVolatility: Double {
guard !data.isEmpty else { return 0 }
return data.reduce(0.0) { $0 + $1.volatility } / Double(data.count)
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Volatility")
.font(.headline)
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("Current")
.font(.caption)
.foregroundColor(.secondary)
Text(String(format: "%.1f%%", currentVolatility))
.font(.subheadline.weight(.semibold))
.foregroundColor(volatilityColor(currentVolatility))
}
}
Text("Measures price variability over time")
.font(.caption)
.foregroundColor(.secondary)
if data.count >= 2 {
Chart(data, id: \.date) { item in
LineMark(
x: .value("Date", item.date),
y: .value("Volatility", item.volatility)
)
.foregroundStyle(Color.appPrimary)
.interpolationMethod(.catmullRom)
AreaMark(
x: .value("Date", item.date),
y: .value("Volatility", item.volatility)
)
.foregroundStyle(
LinearGradient(
colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)],
startPoint: .top,
endPoint: .bottom
)
)
.interpolationMethod(.catmullRom)
// Average line
RuleMark(y: .value("Average", averageVolatility))
.foregroundStyle(Color.gray.opacity(0.5))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
.annotation(position: .top, alignment: .leading) {
Text("Avg: \(String(format: "%.1f%%", averageVolatility))")
.font(.caption2)
.foregroundColor(.secondary)
}
}
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 2)) { value in
AxisValueLabel(format: .dateTime.month(.abbreviated))
}
}
.chartYAxis {
AxisMarks { value in
AxisGridLine()
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.0f%%", doubleValue))
.font(.caption)
}
}
}
}
.frame(height: 250)
// Volatility interpretation
HStack(spacing: 16) {
VolatilityLevelView(level: "Low", range: "0-10%", color: .positiveGreen)
VolatilityLevelView(level: "Medium", range: "10-20%", color: .appWarning)
VolatilityLevelView(level: "High", range: "20%+", color: .negativeRed)
}
.padding(.top, 8)
} else {
Text("Not enough data for volatility analysis")
.foregroundColor(.secondary)
.frame(height: 250)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private func volatilityColor(_ volatility: Double) -> Color {
switch volatility {
case 0..<10:
return .positiveGreen
case 10..<20:
return .appWarning
default:
return .negativeRed
}
}
}
struct VolatilityLevelView: View {
let level: String
let range: String
let color: Color
var body: some View {
HStack(spacing: 4) {
Circle()
.fill(color)
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 0) {
Text(level)
.font(.caption2)
Text(range)
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
}
#Preview {
let drawdownData: [(date: Date, drawdown: Double)] = [
(Date().adding(months: -6), -5),
(Date().adding(months: -5), -8),
(Date().adding(months: -4), -3),
(Date().adding(months: -3), -15),
(Date().adding(months: -2), -10),
(Date().adding(months: -1), -7),
(Date(), -4)
]
let volatilityData: [(date: Date, volatility: Double)] = [
(Date().adding(months: -6), 12),
(Date().adding(months: -5), 15),
(Date().adding(months: -4), 10),
(Date().adding(months: -3), 22),
(Date().adding(months: -2), 18),
(Date().adding(months: -1), 14),
(Date(), 11)
]
return VStack(spacing: 20) {
DrawdownChart(data: drawdownData)
VolatilityChartView(data: volatilityData)
}
.padding()
}
@@ -0,0 +1,159 @@
import SwiftUI
import Charts
struct PerformanceBarChart: View {
let data: [(category: String, cagr: Double, color: String)]
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Performance by Category")
.font(.headline)
Text("Compound Annual Growth Rate (CAGR)")
.font(.caption)
.foregroundColor(.secondary)
if !data.isEmpty {
Chart(data, id: \.category) { item in
BarMark(
x: .value("Category", item.category),
y: .value("CAGR", item.cagr)
)
.foregroundStyle(Color(hex: item.color) ?? .gray)
.cornerRadius(4)
.annotation(position: item.cagr >= 0 ? .top : .bottom) {
Text(String(format: "%.1f%%", item.cagr))
.font(.caption2)
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
}
}
.chartXAxis {
AxisMarks { value in
AxisValueLabel {
if let category = value.as(String.self) {
Text(category)
.font(.caption)
.lineLimit(1)
}
}
}
}
.chartYAxis {
AxisMarks { value in
AxisGridLine()
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.0f%%", doubleValue))
.font(.caption)
}
}
}
}
.frame(height: 250)
// Legend / Details
VStack(spacing: 8) {
ForEach(data.sorted(by: { $0.cagr > $1.cagr }), id: \.category) { item in
HStack {
Circle()
.fill(Color(hex: item.color) ?? .gray)
.frame(width: 10, height: 10)
Text(item.category)
.font(.subheadline)
Spacer()
Text(String(format: "%.2f%%", item.cagr))
.font(.subheadline.weight(.semibold))
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
}
}
}
.padding(.top, 8)
} else {
Text("No performance data available")
.foregroundColor(.secondary)
.frame(height: 250)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
// MARK: - Horizontal Bar Version
struct HorizontalPerformanceChart: View {
let data: [(category: String, cagr: Double, color: String)]
var sortedData: [(category: String, cagr: Double, color: String)] {
data.sorted { $0.cagr > $1.cagr }
}
var maxValue: Double {
max(abs(data.map { $0.cagr }.max() ?? 0), abs(data.map { $0.cagr }.min() ?? 0))
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Performance by Category")
.font(.headline)
ForEach(sortedData, id: \.category) { item in
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(item.category)
.font(.subheadline)
Spacer()
Text(String(format: "%.2f%%", item.cagr))
.font(.subheadline.weight(.semibold))
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
}
GeometryReader { geometry in
let normalizedValue = maxValue > 0 ? abs(item.cagr) / maxValue : 0
let barWidth = geometry.size.width * normalizedValue
ZStack(alignment: item.cagr >= 0 ? .leading : .trailing) {
Rectangle()
.fill(Color.gray.opacity(0.1))
.frame(height: 8)
.cornerRadius(4)
Rectangle()
.fill(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
.frame(width: barWidth, height: 8)
.cornerRadius(4)
}
}
.frame(height: 8)
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
#Preview {
let sampleData: [(category: String, cagr: Double, color: String)] = [
("Stocks", 12.5, "#10B981"),
("Bonds", 4.2, "#3B82F6"),
("Real Estate", 8.1, "#F59E0B"),
("Crypto", -5.3, "#8B5CF6")
]
return VStack(spacing: 20) {
PerformanceBarChart(data: sampleData)
HorizontalPerformanceChart(data: sampleData)
}
.padding()
}
@@ -0,0 +1,265 @@
import SwiftUI
import Charts
struct PredictionChartView: View {
let predictions: [Prediction]
let historicalData: [(date: Date, value: Decimal)]
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text(predictions.isEmpty ? "Prediction" : "\(predictions.count)-Month Prediction")
.font(.headline)
Spacer()
if let lastPrediction = predictions.last {
VStack(alignment: .trailing, spacing: 2) {
Text("Forecast")
.font(.caption)
.foregroundColor(.secondary)
Text(lastPrediction.formattedValue)
.font(.subheadline.weight(.semibold))
.foregroundColor(.appPrimary)
}
}
}
if let algorithm = predictions.first?.algorithm {
Text("Algorithm: \(algorithm.displayName)")
.font(.caption)
.foregroundColor(.secondary)
}
if !predictions.isEmpty && historicalData.count >= 2 {
Chart {
// Historical data
ForEach(historicalData, 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(24)
}
// Confidence interval area
ForEach(predictions) { prediction in
AreaMark(
x: .value("Date", prediction.date),
yStart: .value("Lower", NSDecimalNumber(decimal: prediction.confidenceInterval.lower).doubleValue),
yEnd: .value("Upper", NSDecimalNumber(decimal: prediction.confidenceInterval.upper).doubleValue)
)
.foregroundStyle(Color.appSecondary.opacity(0.2))
}
// Prediction line
ForEach(predictions) { prediction in
LineMark(
x: .value("Date", prediction.date),
y: .value("Predicted", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue)
)
.foregroundStyle(Color.appSecondary)
.lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 5]))
PointMark(
x: .value("Date", prediction.date),
y: .value("Predicted", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue)
)
.foregroundStyle(Color.appSecondary)
.symbolSize(24)
}
// Connect historical to prediction
if let lastHistorical = historicalData.last,
let firstPrediction = predictions.first {
LineMark(
x: .value("Date", lastHistorical.date),
y: .value("Value", NSDecimalNumber(decimal: lastHistorical.value).doubleValue),
series: .value("Connection", "connect")
)
.foregroundStyle(Color.appSecondary)
.lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 5]))
LineMark(
x: .value("Date", firstPrediction.date),
y: .value("Value", NSDecimalNumber(decimal: firstPrediction.predictedValue).doubleValue),
series: .value("Connection", "connect")
)
.foregroundStyle(Color.appSecondary)
.lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 5]))
}
}
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 3)) { value in
AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits))
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(Decimal(doubleValue).shortCurrencyString)
.font(.caption)
}
}
}
}
.frame(height: 280)
// Legend
HStack(spacing: 20) {
HStack(spacing: 6) {
Rectangle()
.fill(Color.appPrimary)
.frame(width: 20, height: 3)
Text("Historical")
.font(.caption)
.foregroundColor(.secondary)
}
HStack(spacing: 6) {
Rectangle()
.fill(Color.appSecondary)
.frame(width: 20, height: 3)
.mask(
HStack(spacing: 2) {
ForEach(0..<5, id: \.self) { _ in
Rectangle()
.frame(width: 3)
}
}
)
Text("Prediction")
.font(.caption)
.foregroundColor(.secondary)
}
HStack(spacing: 6) {
Rectangle()
.fill(Color.appSecondary.opacity(0.3))
.frame(width: 20, height: 10)
Text("Confidence")
.font(.caption)
.foregroundColor(.secondary)
}
}
// Prediction details
if let lastPrediction = predictions.last {
Divider()
.padding(.vertical, 8)
VStack(spacing: 12) {
HStack {
Text("12-Month Forecast")
.font(.subheadline)
Spacer()
Text(lastPrediction.formattedValue)
.font(.subheadline.weight(.semibold))
}
HStack {
Text("Confidence Range")
.font(.subheadline)
Spacer()
Text(lastPrediction.formattedConfidenceRange)
.font(.caption)
.foregroundColor(.secondary)
}
if let currentValue = historicalData.last?.value {
let change = lastPrediction.predictedValue - currentValue
let changePercent = currentValue > 0
? NSDecimalNumber(decimal: change / currentValue).doubleValue * 100
: 0
HStack {
Text("Expected Change")
.font(.subheadline)
Spacer()
HStack(spacing: 4) {
Image(systemName: change >= 0 ? "arrow.up.right" : "arrow.down.right")
.font(.caption)
Text(String(format: "%+.1f%%", changePercent))
.font(.subheadline.weight(.medium))
}
.foregroundColor(change >= 0 ? .positiveGreen : .negativeRed)
}
}
}
}
} else {
VStack(spacing: 12) {
Image(systemName: "wand.and.stars")
.font(.system(size: 40))
.foregroundColor(.secondary)
Text("Not enough data for predictions")
.font(.subheadline)
.foregroundColor(.secondary)
Text("Add at least 3 snapshots to generate predictions")
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(height: 280)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
#Preview {
let historicalData: [(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)
]
let predictions = [
Prediction(
date: Date().adding(months: 3),
predictedValue: 13000,
algorithm: .linear,
confidenceInterval: Prediction.ConfidenceInterval(lower: 12000, upper: 14000)
),
Prediction(
date: Date().adding(months: 6),
predictedValue: 14000,
algorithm: .linear,
confidenceInterval: Prediction.ConfidenceInterval(lower: 12500, upper: 15500)
),
Prediction(
date: Date().adding(months: 9),
predictedValue: 15000,
algorithm: .linear,
confidenceInterval: Prediction.ConfidenceInterval(lower: 13000, upper: 17000)
),
Prediction(
date: Date().adding(months: 12),
predictedValue: 16000,
algorithm: .linear,
confidenceInterval: Prediction.ConfidenceInterval(lower: 13500, upper: 18500)
)
]
return PredictionChartView(predictions: predictions, historicalData: historicalData)
.padding()
}
@@ -0,0 +1,32 @@
import SwiftUI
struct AppBackground: View {
var body: some View {
GeometryReader { proxy in
ZStack {
LinearGradient(
colors: [
Color.appPrimary.opacity(0.15),
Color.appSecondary.opacity(0.12),
Color.appAccent.opacity(0.1)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Circle()
.fill(Color.appPrimary.opacity(0.18))
.frame(width: proxy.size.width * 0.7)
.offset(x: -proxy.size.width * 0.35, y: -proxy.size.height * 0.35)
RoundedRectangle(cornerRadius: 120, style: .continuous)
.fill(Color.appAccent.opacity(0.12))
.frame(width: proxy.size.width * 0.8, height: proxy.size.height * 0.35)
.rotationEffect(.degrees(-12))
.offset(x: proxy.size.width * 0.2, y: proxy.size.height * 0.45)
}
.ignoresSafeArea()
}
}
}
@@ -0,0 +1,260 @@
import SwiftUI
struct LoadingView: View {
var message: String = "Loading..."
var body: some View {
VStack(spacing: 16) {
ProgressView()
.scaleEffect(1.5)
Text(message)
.font(.subheadline)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemBackground))
}
}
// MARK: - Skeleton Loading
struct SkeletonView: View {
@State private var isAnimating = false
var body: some View {
Rectangle()
.fill(
LinearGradient(
colors: [
Color.gray.opacity(0.1),
Color.gray.opacity(0.2),
Color.gray.opacity(0.1)
],
startPoint: .leading,
endPoint: .trailing
)
)
.offset(x: isAnimating ? 200 : -200)
.animation(
Animation.linear(duration: 1.5)
.repeatForever(autoreverses: false),
value: isAnimating
)
.mask(Rectangle())
.onAppear {
isAnimating = true
}
}
}
struct SkeletonCardView: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
SkeletonView()
.frame(width: 100, height: 16)
.cornerRadius(4)
SkeletonView()
.frame(height: 32)
.cornerRadius(4)
HStack {
SkeletonView()
.frame(width: 80, height: 14)
.cornerRadius(4)
Spacer()
SkeletonView()
.frame(width: 60, height: 14)
.cornerRadius(4)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
}
}
// MARK: - Empty State View
struct EmptyStateView: View {
let icon: String
let title: String
let message: String
var actionTitle: String?
var action: (() -> Void)?
var body: some View {
VStack(spacing: 20) {
Image(systemName: icon)
.font(.system(size: 60))
.foregroundColor(.secondary)
Text(title)
.font(.title2.weight(.semibold))
Text(message)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
if let actionTitle = actionTitle, let action = action {
Button(action: action) {
Text(actionTitle)
.font(.headline)
.foregroundColor(.white)
.padding()
.frame(maxWidth: 200)
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Error View
struct ErrorView: View {
let message: String
var retryAction: (() -> Void)?
var body: some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 48))
.foregroundColor(.appWarning)
Text("Something went wrong")
.font(.headline)
Text(message)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
if let retryAction = retryAction {
Button(action: retryAction) {
Label("Try Again", systemImage: "arrow.clockwise")
.font(.subheadline.weight(.semibold))
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(20)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Success View
struct SuccessView: View {
let title: String
let message: String
var action: (() -> Void)?
var body: some View {
VStack(spacing: 20) {
ZStack {
Circle()
.fill(Color.positiveGreen.opacity(0.1))
.frame(width: 100, height: 100)
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 60))
.foregroundColor(.positiveGreen)
}
Text(title)
.font(.title2.weight(.bold))
Text(message)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
if let action = action {
Button(action: action) {
Text("Continue")
.font(.headline)
.foregroundColor(.white)
.padding()
.frame(maxWidth: 200)
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
}
}
}
}
}
// MARK: - Toast View
struct ToastView: View {
enum ToastType {
case success, error, info
var icon: String {
switch self {
case .success: return "checkmark.circle.fill"
case .error: return "xmark.circle.fill"
case .info: return "info.circle.fill"
}
}
var color: Color {
switch self {
case .success: return .positiveGreen
case .error: return .negativeRed
case .info: return .appPrimary
}
}
}
let message: String
let type: ToastType
var body: some View {
HStack(spacing: 12) {
Image(systemName: type.icon)
.foregroundColor(type.color)
Text(message)
.font(.subheadline)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.1), radius: 10, y: 5)
}
}
#Preview {
VStack(spacing: 20) {
LoadingView()
.frame(height: 100)
SkeletonCardView()
EmptyStateView(
icon: "tray",
title: "No Data",
message: "Start adding your investments to see them here.",
actionTitle: "Get Started",
action: {}
)
.frame(height: 300)
ToastView(message: "Successfully saved!", type: .success)
}
.padding()
}
@@ -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()))
}
}
@@ -0,0 +1,99 @@
import SwiftUI
struct GoalEditorView: View {
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var targetAmount = ""
@State private var targetDate = Date()
@State private var includeTargetDate = false
@State private var didLoadGoal = false
let account: Account?
let goal: Goal?
private let goalRepository = GoalRepository()
init(account: Account?, goal: Goal? = nil) {
self.account = account
self.goal = goal
}
var body: some View {
NavigationStack {
Form {
Section {
TextField("Goal name", text: $name)
TextField("Target amount", text: $targetAmount)
.keyboardType(.decimalPad)
Toggle("Add target date", isOn: $includeTargetDate)
if includeTargetDate {
DatePicker("Target date", selection: $targetDate, displayedComponents: .date)
.datePickerStyle(.graphical)
}
} header: {
Text("Goal Details")
}
}
.navigationTitle(goal == nil ? "New Goal" : "Edit Goal")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") { saveGoal() }
.disabled(!isValid)
}
}
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.onAppear {
guard let goal, !didLoadGoal else { return }
name = goal.name ?? ""
if let amount = goal.targetAmount?.decimalValue {
targetAmount = NSDecimalNumber(decimal: amount).stringValue
}
if let target = goal.targetDate {
includeTargetDate = true
targetDate = target
}
didLoadGoal = true
}
}
private var isValid: Bool {
!name.trimmingCharacters(in: .whitespaces).isEmpty && parseDecimal(targetAmount) != nil
}
private func saveGoal() {
guard let value = parseDecimal(targetAmount) else { return }
if let goal {
goalRepository.updateGoal(
goal,
name: name,
targetAmount: value,
targetDate: includeTargetDate ? targetDate : nil,
clearTargetDate: !includeTargetDate
)
} else {
goalRepository.createGoal(
name: name,
targetAmount: value,
targetDate: includeTargetDate ? targetDate : nil,
account: account
)
}
dismiss()
}
private func parseDecimal(_ value: String) -> Decimal? {
let cleaned = value
.replacingOccurrences(of: ",", with: ".")
.replacingOccurrences(of: " ", with: "")
return Decimal(string: cleaned)
}
}
#Preview {
GoalEditorView(account: nil)
}
@@ -0,0 +1,42 @@
import SwiftUI
struct GoalProgressBar: View {
let progress: Double
var tint: Color = .appSecondary
var background: Color = Color.gray.opacity(0.15)
var iconName: String = "flag.checkered"
var iconColor: Color = .appSecondary
private var clampedProgress: CGFloat {
CGFloat(min(max(progress, 0), 1))
}
var body: some View {
GeometryReader { geometry in
let width = geometry.size.width
let iconOffset = max(0, width * clampedProgress - 10)
ZStack(alignment: .leading) {
Capsule()
.fill(background)
Capsule()
.fill(tint)
.frame(width: width * clampedProgress)
}
.overlay(alignment: .leading) {
Image(systemName: iconName)
.font(.caption2)
.foregroundColor(iconColor)
.offset(x: iconOffset)
}
}
.frame(height: 8)
.accessibilityLabel("Goal progress")
.accessibilityValue("\(Int(progress * 100)) percent")
}
}
#Preview {
GoalProgressBar(progress: 0.45)
.padding()
}
@@ -0,0 +1,76 @@
import SwiftUI
struct GoalShareCardView: View {
let name: String
let progress: Double
let currentValue: Decimal
let targetValue: Decimal
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Goal Progress")
.font(.caption.weight(.semibold))
.foregroundColor(.white.opacity(0.8))
Text(name)
.font(.title2.weight(.bold))
.foregroundColor(.white)
}
Spacer()
Image(systemName: "sparkles")
.font(.title2)
.foregroundColor(.white.opacity(0.9))
}
GoalProgressBar(
progress: progress,
tint: .white.opacity(0.9),
background: .white.opacity(0.2),
iconName: "sparkles",
iconColor: .white
)
.frame(height: 10)
HStack {
Text(currentValue.currencyString)
.font(.headline)
.foregroundColor(.white)
Spacer()
Text("of \(targetValue.currencyString)")
.font(.subheadline.weight(.medium))
.foregroundColor(.white.opacity(0.8))
}
HStack {
Image(systemName: "arrow.up.right")
Text("Track yours in Portfolio Journal")
}
.font(.caption.weight(.semibold))
.foregroundColor(.white.opacity(0.85))
}
.padding(24)
.frame(width: 320, height: 220)
.background(
LinearGradient(
colors: [Color.appPrimary, Color.appSecondary],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(Color.white.opacity(0.2), lineWidth: 1)
)
}
}
#Preview {
GoalShareCardView(
name: "1M Goal",
progress: 0.42,
currentValue: 420_000,
targetValue: 1_000_000
)
}
@@ -0,0 +1,155 @@
import SwiftUI
struct GoalsView: View {
@EnvironmentObject private var accountStore: AccountStore
@StateObject private var viewModel = GoalsViewModel()
@State private var showingAddGoal = false
@State private var editingGoal: Goal?
var body: some View {
NavigationStack {
ZStack {
AppBackground()
List {
if viewModel.goals.isEmpty {
emptyState
} else {
Section {
ForEach(viewModel.goals) { goal in
GoalRowView(
goal: goal,
progress: viewModel.progress(for: goal),
totalValue: viewModel.totalValue(for: goal),
paceStatus: viewModel.paceStatus(for: goal)
)
.contentShape(Rectangle())
.onTapGesture {
editingGoal = goal
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
viewModel.deleteGoal(goal)
} label: {
Label("Delete", systemImage: "trash")
}
}
.swipeActions(edge: .leading, allowsFullSwipe: false) {
Button {
editingGoal = goal
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.appSecondary)
}
}
}
}
}
.scrollContentBackground(.hidden)
}
.navigationTitle("Goals")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showingAddGoal = true
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAddGoal) {
GoalEditorView(account: accountStore.showAllAccounts ? nil : accountStore.selectedAccount)
}
.sheet(item: $editingGoal) { goal in
GoalEditorView(account: goal.account, goal: goal)
}
.onAppear {
viewModel.selectedAccount = accountStore.selectedAccount
viewModel.showAllAccounts = accountStore.showAllAccounts
viewModel.refresh()
}
.onReceive(accountStore.$selectedAccount) { account in
viewModel.selectedAccount = account
viewModel.refresh()
}
.onReceive(accountStore.$showAllAccounts) { showAll in
viewModel.showAllAccounts = showAll
viewModel.refresh()
}
}
}
private var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: "target")
.font(.system(size: 48))
.foregroundColor(.secondary)
Text("Set your first goal")
.font(.headline)
Text("Track progress toward milestones like \(AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol)1M and share your wins.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
}
}
struct GoalRowView: View {
let goal: Goal
let progress: Double
let totalValue: Decimal
let paceStatus: GoalPaceStatus?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text(goal.name)
.font(.headline)
Spacer()
Button {
GoalShareService.shared.shareGoal(
name: goal.name,
progress: progress,
currentValue: totalValue,
targetValue: goal.targetDecimal
)
} label: {
Image(systemName: "square.and.arrow.up")
.foregroundColor(.appPrimary)
}
}
GoalProgressBar(progress: progress, tint: .appSecondary, iconColor: .appSecondary)
HStack {
Text(totalValue.currencyString)
.font(.subheadline.weight(.semibold))
Spacer()
Text("of \(goal.targetDecimal.currencyString)")
.font(.subheadline)
.foregroundColor(.secondary)
}
if let targetDate = goal.targetDate {
Text("Target date: \(targetDate.mediumDateString)")
.font(.caption)
.foregroundColor(.secondary)
}
if let paceStatus {
Text(paceStatus.statusText)
.font(.caption.weight(.semibold))
.foregroundColor(paceStatus.isBehind ? .negativeRed : .positiveGreen)
}
}
.padding(.vertical, 8)
}
}
#Preview {
GoalsView()
.environmentObject(AccountStore(iapService: IAPService()))
}
@@ -0,0 +1,224 @@
import SwiftUI
struct JournalView: View {
@StateObject private var viewModel = JournalViewModel()
@State private var searchText = ""
@State private var scrubberActive = false
@State private var scrubberLabel = ""
@State private var scrubberOffset: CGFloat = 0
@State private var currentVisibleMonth: Date?
var body: some View {
NavigationStack {
ScrollViewReader { proxy in
ZStack {
AppBackground()
List {
Section("Monthly Check-ins") {
if filteredMonthlyNotes.isEmpty {
Text(searchText.isEmpty ? "No monthly notes yet." : "No matching notes.")
.font(.subheadline)
.foregroundColor(.secondary)
} else {
ForEach(filteredMonthlyNotes) { entry in
NavigationLink {
MonthlyCheckInView(referenceDate: entry.date)
} label: {
monthlyNoteRow(entry)
}
.id(entry.date)
.onAppear {
currentVisibleMonth = entry.date
}
}
}
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
}
.overlay(alignment: .trailing) {
monthScrubber(proxy: proxy)
}
.navigationTitle("Journal")
.searchable(text: $searchText, prompt: "Search monthly notes")
.onAppear {
viewModel.refresh()
}
}
}
}
private var filteredMonthlyNotes: [MonthlyNoteItem] {
let trimmedQuery = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
let hasQuery = !trimmedQuery.isEmpty
let query = trimmedQuery.lowercased()
return viewModel.monthlyNotes.filter { entry in
!hasQuery
|| entry.note.lowercased().contains(query)
|| entry.date.monthYearString.lowercased().contains(query)
}
}
private func monthlyNoteRow(_ entry: MonthlyNoteItem) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(entry.date.monthYearString)
.font(.subheadline.weight(.semibold))
Spacer()
if let mood = entry.mood {
moodPill(mood)
} else {
Text("Mood not set")
.font(.caption)
.foregroundColor(.secondary)
}
}
HStack(spacing: 6) {
starRow(rating: entry.rating)
if entry.rating == nil {
Text("No rating")
.font(.caption)
.foregroundColor(.secondary)
}
}
Text(entry.note.isEmpty ? "No note yet." : entry.note)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(2)
}
.padding(.vertical, 4)
}
private func starRow(rating: Int?) -> some View {
let value = rating ?? 0
return HStack(spacing: 4) {
ForEach(1...5, id: \.self) { index in
Image(systemName: index <= value ? "star.fill" : "star")
.foregroundColor(index <= value ? .yellow : .secondary)
}
}
.accessibilityLabel(
Text(String(format: NSLocalizedString("rating_accessibility", comment: ""), value))
)
}
private func moodPill(_ mood: MonthlyCheckInMood) -> some View {
Label(mood.title, systemImage: mood.iconName)
.font(.caption.weight(.semibold))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.gray.opacity(0.15))
.foregroundColor(.primary)
.clipShape(Capsule())
}
private func monthScrubber(proxy: ScrollViewProxy) -> some View {
GeometryReader { geometry in
let height = geometry.size.height
let count = filteredMonthlyNotes.count
let currentIndex = currentVisibleMonth.flatMap { month in
filteredMonthlyNotes.firstIndex(where: { $0.date.isSameMonth(as: month) })
}
ZStack(alignment: .trailing) {
if let currentVisibleMonth, !scrubberActive {
Text(currentVisibleMonth.monthYearString)
.font(.caption.weight(.semibold))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color(.systemBackground).opacity(0.95))
.cornerRadius(12)
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
.offset(x: -16)
}
if scrubberActive {
Text(scrubberLabel)
.font(.caption.weight(.semibold))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color(.systemBackground).opacity(0.95))
.cornerRadius(12)
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
.offset(x: -16, y: scrubberOffset - height / 2)
}
if let currentIndex, count > 1 {
let progress = CGFloat(currentIndex) / CGFloat(max(count - 1, 1))
Circle()
.fill(Color.appSecondary.opacity(0.9))
.frame(width: 12, height: 12)
.offset(y: progress * height - height / 2)
}
Capsule()
.fill(Color.secondary.opacity(0.35))
.frame(width: 6)
.frame(maxHeight: .infinity, alignment: .trailing)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
.padding(.trailing, 8)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
guard count > 0 else { return }
scrubberActive = true
let clampedY = min(max(value.location.y, 0), height)
scrubberOffset = clampedY
let ratio = clampedY / max(height, 1)
let index = min(max(Int(round(ratio * CGFloat(count - 1))), 0), count - 1)
let entry = filteredMonthlyNotes[index]
scrubberLabel = entry.date.monthYearString
proxy.scrollTo(entry.id, anchor: .top)
}
.onEnded { _ in
scrubberActive = false
}
)
.allowsHitTesting(count > 0)
}
.frame(width: 72)
}
}
struct MonthlyNoteEditorView: View {
@Environment(\.dismiss) private var dismiss
let date: Date
@Binding var note: String
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(date.monthYearString)
.font(.headline)
TextEditor(text: $note)
.frame(minHeight: 200)
.padding(8)
.background(Color.gray.opacity(0.08))
.cornerRadius(12)
}
.padding()
.navigationTitle("Monthly Note")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
MonthlyCheckInStore.setNote(note, for: date)
dismiss()
}
.fontWeight(.semibold)
}
}
}
}
#Preview {
NavigationStack {
JournalView()
}
}
@@ -0,0 +1,366 @@
import SwiftUI
struct OnboardingView: View {
@Binding var onboardingCompleted: Bool
@State private var currentPage = 0
@State private var selectedCurrency = Locale.current.currency?.identifier ?? "EUR"
@State private var useSampleData = true
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
@State private var showingImportSheet = false
@State private var showingAddSource = false
private let pages: [OnboardingPage] = [
OnboardingPage(
icon: "chart.pie.fill",
title: "Long-Term Tracking",
description: "A calm, offline-first portfolio tracker for investors who update monthly.",
color: .appPrimary
),
OnboardingPage(
icon: "calendar.circle.fill",
title: "Monthly Check-ins",
description: "Build a deliberate habit. Update sources, log contributions, and add a short note.",
color: .positiveGreen
),
OnboardingPage(
icon: "bell.badge.fill",
title: "Gentle Reminders",
description: "Get a monthly nudge to review your portfolio without realtime noise.",
color: .appWarning
),
OnboardingPage(
icon: "leaf.fill",
title: "Calm Mode",
description: "Hide short-term swings and focus on contributions and long-term growth.",
color: .appSecondary
)
]
var body: some View {
VStack {
// Pages
TabView(selection: $currentPage) {
ForEach(0..<pages.count, id: \.self) { index in
OnboardingPageView(page: pages[index])
.tag(index)
}
OnboardingQuickStartView(
selectedCurrency: $selectedCurrency,
useSampleData: $useSampleData,
calmModeEnabled: $calmModeEnabled,
cloudSyncEnabled: $cloudSyncEnabled,
onImport: { showingImportSheet = true },
onAddSource: { showingAddSource = true }
)
.tag(pages.count)
}
.tabViewStyle(.page(indexDisplayMode: .never))
.animation(.easeInOut, value: currentPage)
// Page indicators
HStack(spacing: 8) {
ForEach(0..<(pages.count + 1), id: \.self) { index in
Circle()
.fill(currentPage == index ? Color.appPrimary : Color.gray.opacity(0.3))
.frame(width: 8, height: 8)
.animation(.easeInOut, value: currentPage)
}
}
.padding(.bottom, 20)
// Buttons
VStack(spacing: 12) {
if currentPage < pages.count {
Button {
withAnimation {
currentPage += 1
}
} label: {
Text("Continue")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
}
Button {
completeOnboarding()
} label: {
Text("Skip")
.font(.subheadline)
.foregroundColor(.secondary)
}
} else {
Button {
completeOnboarding()
} label: {
Text("Get Started")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
}
}
}
.padding(.horizontal, 24)
.padding(.bottom, 40)
}
.background(AppBackground())
.onAppear {
selectedCurrency = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
}
.sheet(isPresented: $showingImportSheet) {
ImportDataView()
}
.sheet(isPresented: $showingAddSource) {
AddSourceView()
}
}
private func completeOnboarding() {
// Create default categories
let categoryRepository = CategoryRepository()
categoryRepository.createDefaultCategoriesIfNeeded()
// Create default account if needed
AccountRepository().createDefaultAccountIfNeeded()
// Mark onboarding as complete
let context = CoreDataStack.shared.viewContext
let settings = AppSettings.getOrCreate(in: context)
settings.currency = selectedCurrency
settings.onboardingCompleted = true
CoreDataStack.shared.save()
if useSampleData {
SampleDataService.shared.seedSampleData()
}
// Log analytics
FirebaseService.shared.logOnboardingCompleted(stepCount: pages.count + 1)
// Request notification permission
Task {
await NotificationService.shared.requestAuthorization()
}
withAnimation {
onboardingCompleted = true
}
}
}
// MARK: - Onboarding Page Model
struct OnboardingPage {
let icon: String
let title: String
let description: String
let color: Color
}
// MARK: - Onboarding Page View
struct OnboardingPageView: View {
let page: OnboardingPage
var body: some View {
VStack(spacing: 32) {
Spacer()
// Icon
ZStack {
Circle()
.fill(page.color.opacity(0.15))
.frame(width: 140, height: 140)
Circle()
.fill(page.color.opacity(0.3))
.frame(width: 100, height: 100)
Image(systemName: page.icon)
.font(.system(size: 50))
.foregroundColor(page.color)
}
// Content
VStack(spacing: 16) {
Text(page.title)
.font(.title.weight(.bold))
.multilineTextAlignment(.center)
Text(page.description)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
Spacer()
Spacer()
}
}
}
// MARK: - Quick Start
struct OnboardingQuickStartView: View {
@Binding var selectedCurrency: String
@Binding var useSampleData: Bool
@Binding var calmModeEnabled: Bool
@Binding var cloudSyncEnabled: Bool
let onImport: () -> Void
let onAddSource: () -> Void
var body: some View {
VStack(spacing: 24) {
Spacer()
VStack(spacing: 12) {
Image("BrandMark")
.resizable()
.scaledToFit()
.frame(width: 72, height: 72)
Text("Quick Start")
.font(.title.weight(.bold))
Text("Pick your currency and start with sample data or import your own.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 30)
}
VStack(spacing: 12) {
HStack {
Text("Currency")
Spacer()
Picker("Currency", selection: $selectedCurrency) {
ForEach(CurrencyPicker.commonCodes, id: \.self) { code in
Text(code).tag(code)
}
}
.pickerStyle(.menu)
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
Toggle("Load sample portfolio", isOn: $useSampleData)
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
Toggle("Enable Calm Mode (recommended)", isOn: $calmModeEnabled)
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
Toggle("Sync with iCloud (optional)", isOn: $cloudSyncEnabled)
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
if cloudSyncEnabled {
Text("iCloud sync starts after you restart the app.")
.font(.caption)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
HStack(spacing: 12) {
Button {
onImport()
} label: {
Label("Import", systemImage: "square.and.arrow.down")
.frame(maxWidth: .infinity)
}
.padding()
.background(Color.appPrimary.opacity(0.1))
.cornerRadius(12)
Button {
onAddSource()
} label: {
Label("Add Source", systemImage: "plus")
.frame(maxWidth: .infinity)
}
.padding()
.background(Color.appSecondary.opacity(0.1))
.cornerRadius(12)
}
}
.padding(.horizontal, 24)
Spacer()
Spacer()
}
}
}
// MARK: - First Launch Welcome
struct WelcomeView: View {
@Binding var showWelcome: Bool
let userName: String?
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
var body: some View {
VStack(spacing: 24) {
Spacer()
Image(systemName: "hand.wave.fill")
.font(.system(size: 60))
.foregroundColor(.appPrimary)
VStack(spacing: 8) {
if let name = userName {
Text("Welcome back, \(name)!")
.font(.title.weight(.bold))
} else {
Text("Welcome!")
.font(.title.weight(.bold))
}
Text(cloudSyncEnabled
? "Your investment data has been synced from iCloud."
: "Your investment data stays on this device.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
Spacer()
Button {
withAnimation {
showWelcome = false
}
} label: {
Text("Continue")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
}
.padding(.horizontal, 24)
.padding(.bottom, 40)
}
.background(Color(.systemBackground))
}
}
#Preview {
OnboardingView(onboardingCompleted: .constant(false))
.environmentObject(AccountStore(iapService: IAPService()))
}
@@ -0,0 +1,370 @@
import SwiftUI
struct PaywallView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject var iapService: IAPService
@State private var isPurchasing = false
@State private var errorMessage: String?
@State private var showingError = false
var body: some View {
NavigationStack {
ZStack {
AppBackground()
ScrollView {
VStack(spacing: 24) {
// Header
headerSection
// Features List
featuresSection
// Price Card
priceCard
// Purchase Button
purchaseButton
// Restore Button
restoreButton
// Legal
legalSection
}
.padding()
}
}
.navigationTitle("Upgrade to Premium")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
dismiss()
} label: {
Image(systemName: "xmark")
.foregroundColor(.secondary)
}
}
}
.alert("Error", isPresented: $showingError) {
Button("OK", role: .cancel) {}
} message: {
Text(errorMessage ?? "An error occurred")
}
.onChange(of: iapService.isPremium) { _, isPremium in
if isPremium {
dismiss()
}
}
}
}
// MARK: - Header Section
private var headerSection: some View {
VStack(spacing: 16) {
// Crown icon
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color.yellow.opacity(0.3), Color.orange.opacity(0.3)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 80, height: 80)
Image(systemName: "crown.fill")
.font(.system(size: 36))
.foregroundStyle(
LinearGradient(
colors: [.yellow, .orange],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
Text("Unlock Full Potential")
.font(.title.weight(.bold))
Text("Get unlimited access to all features with a one-time purchase")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
}
// MARK: - Features Section
private var featuresSection: some View {
VStack(spacing: 12) {
ForEach(IAPService.premiumFeatures, id: \.title) { feature in
FeatureRow(
icon: feature.icon,
title: feature.title,
description: feature.description
)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
// MARK: - Price Card
private var priceCard: some View {
VStack(spacing: 8) {
HStack(alignment: .top, spacing: 4) {
Text("")
.font(.title2.weight(.semibold))
.foregroundColor(.appPrimary)
Text("4.69")
.font(.system(size: 48, weight: .bold, design: .rounded))
.foregroundColor(.appPrimary)
}
Text("One-time purchase")
.font(.subheadline)
.foregroundColor(.secondary)
HStack(spacing: 4) {
Image(systemName: "person.2.fill")
.font(.caption)
Text("Includes Family Sharing")
.font(.caption)
}
.foregroundColor(.appSecondary)
.padding(.top, 4)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
.background(
RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)
.fill(Color.appPrimary.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)
.stroke(Color.appPrimary, lineWidth: 2)
)
)
}
// MARK: - Purchase Button
private var purchaseButton: some View {
Button {
purchase()
} label: {
HStack {
if isPurchasing {
ProgressView()
.tint(.white)
} else {
Text("Upgrade Now")
.font(.headline)
}
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(AppConstants.UI.cornerRadius)
}
.disabled(isPurchasing)
}
// MARK: - Restore Button
private var restoreButton: some View {
Button {
restore()
} label: {
Text("Restore Purchases")
.font(.subheadline)
.foregroundColor(.appPrimary)
}
.disabled(isPurchasing)
}
// MARK: - Legal Section
private var legalSection: some View {
VStack(spacing: 8) {
Text("Payment will be charged to your Apple ID account. By purchasing, you agree to our Terms of Service and Privacy Policy.")
.font(.caption2)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
HStack(spacing: 16) {
Link("Terms", destination: URL(string: AppConstants.URLs.termsOfService)!)
.font(.caption)
Link("Privacy", destination: URL(string: AppConstants.URLs.privacyPolicy)!)
.font(.caption)
}
}
.padding(.top, 8)
}
// MARK: - Actions
private func purchase() {
isPurchasing = true
FirebaseService.shared.logPurchaseAttempt(productId: IAPService.premiumProductID)
Task {
do {
try await iapService.purchase()
} catch {
errorMessage = error.localizedDescription
showingError = true
}
isPurchasing = false
}
}
private func restore() {
isPurchasing = true
Task {
await iapService.restorePurchases()
if !iapService.isPremium {
errorMessage = "No purchases found to restore"
showingError = true
}
isPurchasing = false
}
}
}
// MARK: - Feature Row
struct FeatureRow: View {
let icon: String
let title: String
let description: String
var body: some View {
HStack(spacing: 16) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 44, height: 44)
Image(systemName: icon)
.font(.system(size: 18))
.foregroundColor(.appPrimary)
}
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline.weight(.semibold))
Text(description)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.positiveGreen)
}
}
}
// MARK: - Compact Paywall (for inline use)
struct CompactPaywallBanner: View {
@Binding var showingPaywall: Bool
var body: some View {
HStack(spacing: 12) {
Image(systemName: "crown.fill")
.font(.title2)
.foregroundStyle(
LinearGradient(
colors: [.yellow, .orange],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
VStack(alignment: .leading, spacing: 2) {
Text("Unlock Premium")
.font(.subheadline.weight(.semibold))
Text("Get unlimited access to all features")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button {
showingPaywall = true
} label: {
Text("€4.69")
.font(.caption.weight(.semibold))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(16)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
// MARK: - Premium Lock Overlay
struct PremiumLockOverlay: View {
let feature: String
@Binding var showingPaywall: Bool
var body: some View {
VStack(spacing: 16) {
Image(systemName: "lock.fill")
.font(.system(size: 32))
.foregroundColor(.appWarning)
Text("Premium Feature")
.font(.headline)
Text(feature)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button {
showingPaywall = true
} label: {
Label("Unlock", systemImage: "crown.fill")
.font(.subheadline.weight(.semibold))
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(20)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemBackground).opacity(0.95))
}
}
#Preview {
PaywallView()
.environmentObject(IAPService())
}
@@ -0,0 +1,124 @@
import SwiftUI
struct AppLockView: View {
@Binding var isUnlocked: Bool
@AppStorage("faceIdEnabled") private var faceIdEnabled = false
@AppStorage("pinEnabled") private var pinEnabled = false
@State private var pin = ""
@State private var errorMessage: String?
@State private var didAttemptBiometrics = false
@FocusState private var pinFocused: Bool
var body: some View {
ZStack {
Color(.systemBackground)
.ignoresSafeArea()
VStack(spacing: 20) {
Spacer()
Image("BrandMark")
.resizable()
.scaledToFit()
.frame(width: 64, height: 64)
Text("Locked")
.font(.title.weight(.bold))
Text("Unlock Portfolio Journal to view your data.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 30)
if faceIdEnabled {
Button {
authenticateWithBiometrics()
} label: {
Label("Unlock with Face ID", systemImage: "faceid")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(AppConstants.UI.cornerRadius)
}
.padding(.horizontal, 24)
}
if pinEnabled {
VStack(spacing: 10) {
SecureField("4-digit PIN", text: $pin)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
.multilineTextAlignment(.center)
.font(.title3.weight(.semibold))
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
.focused($pinFocused)
.onChange(of: pin) { _, newValue in
pin = String(newValue.filter(\.isNumber).prefix(4))
errorMessage = nil
if pin.count == 4 {
validatePin()
}
}
Button("Unlock") {
validatePin()
}
.font(.subheadline.weight(.semibold))
.disabled(pin.count < 4)
}
.padding(.horizontal, 24)
}
if let errorMessage {
Text(errorMessage)
.font(.caption)
.foregroundColor(.negativeRed)
}
Spacer()
}
}
.onAppear {
if faceIdEnabled && !didAttemptBiometrics {
didAttemptBiometrics = true
authenticateWithBiometrics()
} else if pinEnabled {
pinFocused = true
}
}
}
private func authenticateWithBiometrics() {
AppLockService.authenticate(reason: "Unlock your portfolio") { success in
if success {
isUnlocked = true
} else if pinEnabled {
pinFocused = true
} else {
errorMessage = "Face ID failed. Please try again."
}
}
}
private func validatePin() {
guard let storedPin = KeychainService.readPin() else {
errorMessage = "PIN not set."
pin = ""
return
}
if pin == storedPin {
isUnlocked = true
pin = ""
errorMessage = nil
} else {
errorMessage = "Incorrect PIN."
pin = ""
}
}
}

Some files were not shown because too many files have changed in this diff Show More