initial version

main
alexandrev-tibco 2026-01-15 09:24:06 +01:00
parent bab350dd22
commit 7988257399
No known key found for this signature in database
GPG Key ID: 205DAC70EF7BDFD9
139 changed files with 13149 additions and 3233 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@ -1,65 +0,0 @@
import SwiftUI
struct ContentView: View {
@EnvironmentObject var iapService: IAPService
@EnvironmentObject var adMobService: AdMobService
@AppStorage("onboardingCompleted") private var onboardingCompleted = false
@State private var selectedTab = 0
var body: some View {
Group {
if !onboardingCompleted {
OnboardingView(onboardingCompleted: $onboardingCompleted)
} else {
mainContent
}
}
}
private var mainContent: some View {
ZStack(alignment: .bottom) {
TabView(selection: $selectedTab) {
DashboardView()
.tabItem {
Label("Dashboard", systemImage: "chart.pie.fill")
}
.tag(0)
SourceListView()
.tabItem {
Label("Sources", systemImage: "list.bullet")
}
.tag(1)
ChartsContainerView()
.tabItem {
Label("Charts", systemImage: "chart.xyaxis.line")
}
.tag(2)
SettingsView()
.tabItem {
Label("Settings", systemImage: "gearshape.fill")
}
.tag(3)
}
// Banner ad at bottom for free users
if !iapService.isPremium {
VStack(spacing: 0) {
Spacer()
BannerAdView()
.frame(height: 50)
}
.padding(.bottom, 49) // Account for tab bar height
}
}
}
}
#Preview {
ContentView()
.environmentObject(IAPService())
.environmentObject(AdMobService())
}

View File

@ -1,20 +0,0 @@
import SwiftUI
import FirebaseCore
@main
struct InvestmentTrackerApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var iapService = IAPService()
@StateObject private var adMobService = AdMobService()
let coreDataStack = CoreDataStack.shared
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, coreDataStack.viewContext)
.environmentObject(iapService)
.environmentObject(adMobService)
}
}
}

View File

@ -1,144 +0,0 @@
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 snapshots: NSSet?
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 {
var snapshotsArray: [Snapshot] {
let set = snapshots as? Set<Snapshot> ?? []
return set.sorted { $0.date > $1.date }
}
var sortedSnapshotsByDateAscending: [Snapshot] {
let set = snapshots as? Set<Snapshot> ?? []
return set.sorted { $0.date < $1.date }
}
var latestSnapshot: Snapshot? {
snapshotsArray.first
}
var latestValue: Decimal {
latestSnapshot?.value?.decimalValue ?? Decimal.zero
}
var snapshotCount: Int {
snapshots?.count ?? 0
}
var frequency: NotificationFrequency {
NotificationFrequency(rawValue: notificationFrequency) ?? .monthly
}
var nextReminderDate: Date? {
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 {
guard let nextDate = nextReminderDate else { return false }
return Date() >= nextDate
}
// MARK: - Performance Metrics
var totalReturn: Decimal {
guard let first = sortedSnapshotsByDateAscending.first,
let last = snapshotsArray.first,
let firstValue = first.value?.decimalValue,
firstValue != Decimal.zero else {
return Decimal.zero
}
let lastValue = last.value?.decimalValue ?? Decimal.zero
return ((lastValue - firstValue) / firstValue) * 100
}
var totalContributions: Decimal {
snapshotsArray.reduce(Decimal.zero) { result, snapshot in
result + (snapshot.contribution?.decimalValue ?? Decimal.zero)
}
}
}
// 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)
}

View File

@ -1,58 +0,0 @@
<?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" 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" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="lastSyncDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="onboardingCompleted" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
</entity>
<entity name="Category" representedClassName="Category" syncable="YES">
<attribute name="colorHex" attributeType="String" defaultValueString="#3B82F6"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="icon" attributeType="String" defaultValueString="chart.pie.fill"/>
<attribute name="id" 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" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="customFrequencyMonths" attributeType="Integer 16" defaultValueString="1" usesScalarValueType="YES"/>
<attribute name="id" 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="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"/>
</entity>
<entity name="PredictionCache" representedClassName="PredictionCache" syncable="YES">
<attribute name="algorithm" attributeType="String" defaultValueString="linear"/>
<attribute name="calculatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="predictionData" optional="YES" attributeType="Binary"/>
<attribute name="sourceId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="validUntil" attributeType="Date" usesScalarValueType="NO"/>
</entity>
<entity name="PremiumStatus" representedClassName="PremiumStatus" syncable="YES">
<attribute name="id" 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" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="date" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" 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>

View File

@ -1,144 +0,0 @@
import CoreData
import CloudKit
class CoreDataStack: ObservableObject {
static let shared = CoreDataStack()
static let appGroupIdentifier = "group.com.yourteam.investmenttracker"
static let cloudKitContainerIdentifier = "iCloud.com.yourteam.investmenttracker"
lazy var persistentContainer: NSPersistentCloudKitContainer = {
let container = NSPersistentCloudKitContainer(name: "InvestmentTracker")
// App Group store URL for sharing with widgets
guard let storeURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: Self.appGroupIdentifier)?
.appendingPathComponent("InvestmentTracker.sqlite") else {
fatalError("Unable to get App Group container URL")
}
let description = NSPersistentStoreDescription(url: storeURL)
// CloudKit configuration
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: Self.cloudKitContainerIdentifier
)
// History tracking for sync
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)")
}
}
// Merge policy - remote changes win
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
// Performance optimizations
container.viewContext.undoManager = nil
container.viewContext.shouldDeleteInaccessibleFaults = true
// Listen for remote changes
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()
}
}
// MARK: - Widget Data Refresh
func refreshWidgetData() {
// Trigger widget timeline refresh when data changes
#if os(iOS)
if #available(iOS 14.0, *) {
import WidgetKit
WidgetCenter.shared.reloadAllTimelines()
}
#endif
}
}
// MARK: - Shared Container for Widgets
extension CoreDataStack {
static var sharedStoreURL: URL? {
return FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)?
.appendingPathComponent("InvestmentTracker.sqlite")
}
/// Creates a lightweight Core Data stack for widgets (read-only)
static func createWidgetContainer() -> NSPersistentContainer {
let container = NSPersistentContainer(name: "InvestmentTracker")
guard let storeURL = sharedStoreURL else {
fatalError("Unable to get shared store URL")
}
let description = NSPersistentStoreDescription(url: storeURL)
description.isReadOnly = true
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { _, error in
if let error = error {
print("Widget Core Data failed: \(error)")
}
}
return container
}
}

View File

@ -1,118 +0,0 @@
<?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>
<!-- App Info -->
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Investment Tracker</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>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<!-- Supported Orientations -->
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<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>
<!-- Launch Screen -->
<key>UILaunchScreen</key>
<dict/>
<!-- Scene Configuration -->
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
</dict>
<!-- AdMob App ID - REPLACE WITH YOUR REAL APP ID FOR PRODUCTION -->
<key>GADApplicationIdentifier</key>
<string>ca-app-pub-3940256099942544~1458002511</string>
<!-- AdMob Delay App Measurement -->
<key>GADDelayAppMeasurementInit</key>
<true/>
<!-- App Tracking Transparency -->
<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>
<!-- iCloud -->
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
<true/>
<!-- Background Modes -->
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>remote-notification</string>
</array>
<!-- Privacy Descriptions -->
<key>NSCalendarsUsageDescription</key>
<string>Used to set investment update reminders.</string>
<!-- URL Schemes for Deep Linking -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleURLSchemes</key>
<array>
<string>investmenttracker</string>
</array>
</dict>
</array>
<!-- Supports iPad Multitasking -->
<key>UISupportsDocumentBrowser</key>
<false/>
<!-- App Transport Security -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSAllowsArbitraryLoadsForMedia</key>
<false/>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<false/>
</dict>
<!-- Status Bar Style -->
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
</dict>
</plist>

View File

@ -1,329 +0,0 @@
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 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 .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, .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 .drawdown:
return "Analyze declines from peak values"
case .volatility:
return "Understand investment risk levels"
case .prediction:
return "View 12-month forecasts"
}
}
}
// MARK: - Published Properties
@Published var selectedChartType: ChartType = .evolution
@Published var selectedCategory: Category?
@Published var selectedTimeRange: TimeRange = .year
@Published var evolutionData: [(date: Date, value: Decimal)] = []
@Published var allocationData: [(category: String, value: Decimal, color: String)] = []
@Published var performanceData: [(category: String, cagr: Double, color: String)] = []
@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
// 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 var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init(
sourceRepository: InvestmentSourceRepository = InvestmentSourceRepository(),
categoryRepository: CategoryRepository = CategoryRepository(),
snapshotRepository: SnapshotRepository = SnapshotRepository(),
calculationService: CalculationService = .shared,
predictionEngine: PredictionEngine = .shared,
iapService: IAPService
) {
self.sourceRepository = sourceRepository
self.categoryRepository = categoryRepository
self.snapshotRepository = snapshotRepository
self.calculationService = calculationService
self.predictionEngine = predictionEngine
self.freemiumValidator = FreemiumValidator(iapService: iapService)
setupObservers()
loadData()
}
// MARK: - Setup
private func setupObservers() {
Publishers.CombineLatest3($selectedChartType, $selectedCategory, $selectedTimeRange)
.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)
.sink { [weak self] chartType, category, timeRange in
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) {
isLoading = true
let sources: [InvestmentSource]
if let category = category {
sources = sourceRepository.fetchSources(for: category)
} else {
sources = sourceRepository.sources
}
var snapshots = sources.flatMap { $0.snapshotsArray }
// Filter by time range
if let months = timeRange.months {
let cutoff = Calendar.current.date(byAdding: .month, value: -months, to: Date()) ?? Date()
snapshots = snapshots.filter { $0.date >= cutoff }
}
// Apply freemium filter
snapshots = freemiumValidator.filterSnapshots(snapshots)
switch chartType {
case .evolution:
calculateEvolutionData(from: snapshots)
case .allocation:
calculateAllocationData()
case .performance:
calculatePerformanceData()
case .drawdown:
calculateDrawdownData(from: snapshots)
case .volatility:
calculateVolatilityData(from: snapshots)
case .prediction:
calculatePredictionData(from: snapshots)
}
isLoading = false
}
// 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
}
evolutionData = dateValues
.map { (date: $0.key, value: $0.value) }
.sorted { $0.date < $1.date }
}
private func calculateAllocationData() {
let categories = categoryRepository.categories
let total = categories.reduce(Decimal.zero) { $0 + $1.totalValue }
allocationData = categories
.filter { $0.totalValue > 0 }
.map { category in
(
category: category.name,
value: category.totalValue,
color: category.colorHex
)
}
.sorted { $0.value > $1.value }
}
private func calculatePerformanceData() {
let categories = categoryRepository.categories
performanceData = categories.compactMap { category in
let snapshots = category.sourcesArray.flatMap { $0.snapshotsArray }
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 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)
predictionData = result.predictions
}
// MARK: - Computed Properties
var categories: [Category] {
categoryRepository.categories
}
var availableChartTypes: [ChartType] {
ChartType.allCases
}
var isPremium: Bool {
freemiumValidator.isPremium
}
var hasData: Bool {
!sourceRepository.sources.isEmpty
}
}

View File

@ -1,203 +0,0 @@
import Foundation
import Combine
import CoreData
@MainActor
class DashboardViewModel: ObservableObject {
// MARK: - Published Properties
@Published var portfolioSummary: PortfolioSummary = .empty
@Published var categoryMetrics: [CategoryMetrics] = []
@Published var recentSnapshots: [Snapshot] = []
@Published var sourcesNeedingUpdate: [InvestmentSource] = []
@Published var isLoading = false
@Published var errorMessage: String?
// 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>()
// MARK: - Initialization
init(
categoryRepository: CategoryRepository = CategoryRepository(),
sourceRepository: InvestmentSourceRepository = InvestmentSourceRepository(),
snapshotRepository: SnapshotRepository = SnapshotRepository(),
calculationService: CalculationService = .shared
) {
self.categoryRepository = categoryRepository
self.sourceRepository = sourceRepository
self.snapshotRepository = snapshotRepository
self.calculationService = calculationService
setupObservers()
loadData()
}
// MARK: - Setup
private func setupObservers() {
// Observe category changes
categoryRepository.$categories
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshData()
}
.store(in: &cancellables)
// Observe source changes
sourceRepository.$sources
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshData()
}
.store(in: &cancellables)
// Observe Core Data changes
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshData()
}
.store(in: &cancellables)
}
// MARK: - Data Loading
func loadData() {
isLoading = true
errorMessage = nil
Task {
await refreshAllData()
isLoading = false
}
}
func refreshData() {
Task {
await refreshAllData()
}
}
private func refreshAllData() async {
let categories = categoryRepository.categories
let sources = sourceRepository.sources
let allSnapshots = snapshotRepository.fetchAllSnapshots()
// Calculate portfolio summary
portfolioSummary = calculationService.calculatePortfolioSummary(
from: sources,
snapshots: allSnapshots
)
// Calculate category metrics
categoryMetrics = calculationService.calculateCategoryMetrics(
for: categories,
totalPortfolioValue: portfolioSummary.totalValue
).sorted { $0.totalValue > $1.totalValue }
// Get recent snapshots
recentSnapshots = Array(allSnapshots.prefix(10))
// Get sources needing update
sourcesNeedingUpdate = sourceRepository.fetchSourcesNeedingUpdate()
// Calculate evolution data for chart
calculateEvolutionData(from: allSnapshots)
// Log screen view
FirebaseService.shared.logScreenView(screenName: "Dashboard")
}
private func calculateEvolutionData(from snapshots: [Snapshot]) {
// Group snapshots by date and sum values
var dateValues: [Date: Decimal] = [:]
// Get unique dates
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
for snapshot in sortedSnapshots {
let startOfDay = Calendar.current.startOfDay(for: snapshot.date)
// For each date, we need the total portfolio value at that point
// This requires summing the latest value for each source up to that date
let sourcesAtDate = Dictionary(grouping: sortedSnapshots.filter { $0.date <= snapshot.date }) {
$0.source?.id ?? UUID()
}
var totalAtDate: Decimal = 0
for (_, sourceSnapshots) in sourcesAtDate {
if let latestForSource = sourceSnapshots.max(by: { $0.date < $1.date }) {
totalAtDate += latestForSource.decimalValue
}
}
dateValues[startOfDay] = totalAtDate
}
evolutionData = dateValues
.map { (date: $0.key, value: $0.value) }
.sorted { $0.date < $1.date }
}
// MARK: - Computed Properties
var hasData: Bool {
!sourceRepository.sources.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
}
}

View File

@ -1,229 +0,0 @@
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 isLoading = false
@Published var showingAddSnapshot = false
@Published var showingEditSource = false
@Published var showingPaywall = 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 calculationService: CalculationService
private let predictionEngine: PredictionEngine
private let freemiumValidator: FreemiumValidator
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init(
source: InvestmentSource,
snapshotRepository: SnapshotRepository = SnapshotRepository(),
sourceRepository: InvestmentSourceRepository = InvestmentSourceRepository(),
calculationService: CalculationService = .shared,
predictionEngine: PredictionEngine = .shared,
iapService: IAPService
) {
self.source = source
self.snapshotRepository = snapshotRepository
self.sourceRepository = sourceRepository
self.calculationService = calculationService
self.predictionEngine = predictionEngine
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] _ in
self?.refreshData()
}
.store(in: &cancellables)
}
// MARK: - Data Loading
func loadData() {
isLoading = true
refreshData()
isLoading = false
FirebaseService.shared.logScreenView(screenName: "SourceDetail")
}
func refreshData() {
// Fetch snapshots (filtered by freemium limits)
let allSnapshots = snapshotRepository.fetchSnapshots(for: source)
snapshots = freemiumValidator.filterSnapshots(allSnapshots)
// Calculate metrics
metrics = calculationService.calculateMetrics(for: snapshots)
// Prepare chart data
chartData = snapshots
.sorted { $0.date < $1.date }
.map { (date: $0.date, value: $0.decimalValue) }
// Calculate predictions if premium
if freemiumValidator.canViewPredictions() && snapshots.count >= 3 {
predictionResult = predictionEngine.predict(snapshots: snapshots)
predictions = predictionResult?.predictions ?? []
} else {
predictions = []
predictionResult = nil
}
}
// 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: - 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 &&
snapshotRepository.fetchSnapshots(for: source).count > snapshots.count
}
var hiddenSnapshotCount: Int {
let allCount = snapshotRepository.fetchSnapshots(for: source).count
return allCount - snapshots.count
}
}

View File

@ -1,298 +0,0 @@
import SwiftUI
import Charts
struct ChartsContainerView: View {
@EnvironmentObject var iapService: IAPService
@StateObject private var viewModel: ChartsViewModel
init() {
_viewModel = StateObject(wrappedValue: ChartsViewModel(iapService: IAPService()))
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
// Chart Type Selector
chartTypeSelector
// Time Range Selector
if viewModel.selectedChartType != .allocation &&
viewModel.selectedChartType != .performance {
timeRangeSelector
}
// Category Filter
if viewModel.selectedChartType == .evolution ||
viewModel.selectedChartType == .prediction {
categoryFilter
}
// Chart Content
chartContent
}
.padding()
}
.navigationTitle("Charts")
.sheet(isPresented: $viewModel.showingPaywall) {
PaywallView()
}
}
}
// MARK: - Chart Type Selector
private var chartTypeSelector: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(ChartsViewModel.ChartType.allCases) { 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
private var categoryFilter: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
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(viewModel.categories) { 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)
case .allocation:
AllocationPieChart(data: viewModel.allocationData)
case .performance:
PerformanceBarChart(data: viewModel.performanceData)
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)
}
}
// 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
? Color.appPrimary
: Color(.systemBackground)
)
.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)]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Portfolio Evolution")
.font(.headline)
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.appPrimary)
.interpolationMethod(.catmullRom)
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)
}
.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: 300)
} else {
Text("Not enough data")
.foregroundColor(.secondary)
.frame(height: 300)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
#Preview {
ChartsContainerView()
.environmentObject(IAPService())
}

View File

@ -1,262 +0,0 @@
import SwiftUI
import Charts
struct DashboardView: View {
@EnvironmentObject var iapService: IAPService
@StateObject private var viewModel: DashboardViewModel
init() {
_viewModel = StateObject(wrappedValue: DashboardViewModel())
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
if viewModel.hasData {
// Total Value Card
TotalValueCard(
totalValue: viewModel.portfolioSummary.formattedTotalValue,
dayChange: viewModel.portfolioSummary.formattedDayChange,
isPositive: viewModel.isDayChangePositive
)
// Evolution Chart
if !viewModel.evolutionData.isEmpty {
EvolutionChartCard(data: viewModel.evolutionData)
}
// Period Returns
PeriodReturnsCard(
monthChange: viewModel.portfolioSummary.formattedMonthChange,
yearChange: viewModel.portfolioSummary.formattedYearChange,
allTimeChange: viewModel.portfolioSummary.formattedAllTimeReturn,
isMonthPositive: viewModel.isMonthChangePositive,
isYearPositive: viewModel.isYearChangePositive,
isAllTimePositive: viewModel.portfolioSummary.allTimeReturn >= 0
)
// Category Breakdown
if !viewModel.categoryMetrics.isEmpty {
CategoryBreakdownCard(categories: viewModel.topCategories)
}
// Pending Updates
if !viewModel.sourcesNeedingUpdate.isEmpty {
PendingUpdatesCard(sources: viewModel.sourcesNeedingUpdate)
}
} else {
EmptyDashboardView()
}
}
.padding()
}
.navigationTitle("Dashboard")
.refreshable {
viewModel.refreshData()
}
.overlay {
if viewModel.isLoading {
ProgressView()
}
}
}
}
}
// MARK: - Total Value Card
struct TotalValueCard: View {
let totalValue: String
let dayChange: String
let isPositive: Bool
var body: some View {
VStack(spacing: 8) {
Text("Total Portfolio Value")
.font(.subheadline)
.foregroundColor(.secondary)
Text(totalValue)
.font(.system(size: 42, weight: .bold, design: .rounded))
.foregroundColor(.primary)
HStack(spacing: 4) {
Image(systemName: isPositive ? "arrow.up.right" : "arrow.down.right")
.font(.caption)
Text(dayChange)
.font(.subheadline.weight(.medium))
Text("today")
.font(.subheadline)
.foregroundColor(.secondary)
}
.foregroundColor(isPositive ? .positiveGreen : .negativeRed)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
// MARK: - Period Returns Card
struct PeriodReturnsCard: View {
let monthChange: String
let yearChange: String
let allTimeChange: String
let isMonthPositive: Bool
let isYearPositive: Bool
let isAllTimePositive: Bool
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Returns")
.font(.headline)
HStack(spacing: 16) {
ReturnPeriodView(
period: "1M",
change: monthChange,
isPositive: isMonthPositive
)
Divider()
ReturnPeriodView(
period: "1Y",
change: yearChange,
isPositive: isYearPositive
)
Divider()
ReturnPeriodView(
period: "All",
change: allTimeChange,
isPositive: isAllTimePositive
)
}
.frame(maxWidth: .infinity)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
struct ReturnPeriodView: View {
let period: String
let change: String
let isPositive: Bool
var body: some View {
VStack(spacing: 4) {
Text(period)
.font(.caption)
.foregroundColor(.secondary)
Text(change)
.font(.subheadline.weight(.semibold))
.foregroundColor(isPositive ? .positiveGreen : .negativeRed)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Empty Dashboard View
struct EmptyDashboardView: View {
var body: some View {
VStack(spacing: 20) {
Image(systemName: "chart.pie")
.font(.system(size: 60))
.foregroundColor(.secondary)
Text("Welcome to Investment Tracker")
.font(.title2.weight(.semibold))
Text("Start by adding your first investment source to track your portfolio.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
NavigationLink {
AddSourceView()
} label: {
Label("Add Investment Source", systemImage: "plus")
.font(.headline)
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity)
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
}
}
.padding()
}
}
// MARK: - Pending Updates Card
struct PendingUpdatesCard: View {
let sources: [InvestmentSource]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "bell.badge.fill")
.foregroundColor(.appWarning)
Text("Pending Updates")
.font(.headline)
Spacer()
Text("\(sources.count)")
.font(.subheadline)
.foregroundColor(.secondary)
}
ForEach(sources.prefix(3)) { source in
NavigationLink(destination: SourceDetailView(source: source)) {
HStack {
Circle()
.fill(source.category?.color ?? .gray)
.frame(width: 8, height: 8)
Text(source.name)
.font(.subheadline)
Spacer()
Text(source.latestSnapshot?.date.relativeDescription ?? "Never")
.font(.caption)
.foregroundColor(.secondary)
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
.buttonStyle(.plain)
}
if sources.count > 3 {
Text("+ \(sources.count - 3) more")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
#Preview {
DashboardView()
.environmentObject(IAPService())
}

View File

@ -1,158 +0,0 @@
import SwiftUI
import Charts
struct EvolutionChartCard: View {
let data: [(date: Date, value: Decimal)]
@State private var selectedDataPoint: (date: Date, value: Decimal)?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Portfolio Evolution")
.font(.headline)
Spacer()
if let selected = selectedDataPoint {
VStack(alignment: .trailing) {
Text(selected.value.compactCurrencyString)
.font(.subheadline.weight(.semibold))
Text(selected.date.monthYearString)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
if data.count >= 2 {
Chart {
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)
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)
}
if let selected = selectedDataPoint {
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)
}
}
.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
let x = value.location.x - geometry[proxy.plotAreaFrame].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)
} else {
Text("Not enough data to display chart")
.font(.subheadline)
.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: - 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)
.padding()
}

View File

@ -1,426 +0,0 @@
import SwiftUI
import Charts
struct SourceDetailView: View {
@EnvironmentObject var iapService: IAPService
@StateObject private var viewModel: SourceDetailViewModel
@Environment(\.dismiss) private var dismiss
@State private var showingDeleteConfirmation = false
init(source: InvestmentSource) {
_viewModel = StateObject(wrappedValue: SourceDetailViewModel(
source: source,
iapService: IAPService()
))
}
var body: some View {
ScrollView {
VStack(spacing: 20) {
// Header Card
headerCard
// Quick Actions
quickActions
// Chart
if !viewModel.chartData.isEmpty {
chartSection
}
// Metrics
metricsSection
// Predictions (Premium)
if viewModel.hasEnoughDataForPredictions {
predictionsSection
}
// Snapshots List
snapshotsSection
}
.padding()
}
.navigationTitle(viewModel.source.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Button {
viewModel.showingEditSource = true
} label: {
Label("Edit Source", systemImage: "pencil")
}
Button(role: .destructive) {
showingDeleteConfirmation = true
} label: {
Label("Delete Source", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.sheet(isPresented: $viewModel.showingAddSnapshot) {
AddSnapshotView(source: viewModel.source)
}
.sheet(isPresented: $viewModel.showingEditSource) {
EditSourceView(source: viewModel.source)
}
.sheet(isPresented: $viewModel.showingPaywall) {
PaywallView()
}
.confirmationDialog(
"Delete Source",
isPresented: $showingDeleteConfirmation,
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
// Delete and dismiss
let repository = InvestmentSourceRepository()
repository.deleteSource(viewModel.source)
dismiss()
}
} message: {
Text("This will permanently delete \(viewModel.source.name) and all its snapshots.")
}
}
// MARK: - Header Card
private var headerCard: some View {
VStack(spacing: 12) {
// Category badge
HStack {
Image(systemName: viewModel.source.category?.icon ?? "questionmark")
Text(viewModel.categoryName)
}
.font(.caption)
.foregroundColor(Color(hex: viewModel.categoryColor) ?? .gray)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background((Color(hex: viewModel.categoryColor) ?? .gray).opacity(0.1))
.cornerRadius(20)
// Current value
Text(viewModel.formattedCurrentValue)
.font(.system(size: 36, weight: .bold, design: .rounded))
// Return
HStack(spacing: 4) {
Image(systemName: viewModel.isPositiveReturn ? "arrow.up.right" : "arrow.down.right")
Text(viewModel.formattedTotalReturn)
Text("(\(viewModel.formattedPercentageReturn))")
}
.font(.subheadline.weight(.medium))
.foregroundColor(viewModel.isPositiveReturn ? .positiveGreen : .negativeRed)
// Last updated
Text("Last updated: \(viewModel.lastUpdated)")
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
// MARK: - Quick Actions
private var quickActions: some View {
HStack(spacing: 12) {
Button {
viewModel.showingAddSnapshot = true
} label: {
Label("Add Snapshot", systemImage: "plus.circle.fill")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(AppConstants.UI.cornerRadius)
}
}
}
// MARK: - Chart Section
private var chartSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Value History")
.font(.headline)
Chart(viewModel.chartData, id: \.date) { item in
LineMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(Color.appPrimary)
.interpolationMethod(.catmullRom)
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)
}
.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: 200)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
// MARK: - Metrics Section
private var metricsSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Performance Metrics")
.font(.headline)
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible())
], spacing: 16) {
MetricCard(title: "CAGR", value: viewModel.metrics.formattedCAGR)
MetricCard(title: "Volatility", value: viewModel.metrics.formattedVolatility)
MetricCard(title: "Max Drawdown", value: viewModel.metrics.formattedMaxDrawdown)
MetricCard(title: "Sharpe Ratio", value: viewModel.metrics.formattedSharpeRatio)
MetricCard(title: "Win Rate", value: viewModel.metrics.formattedWinRate)
MetricCard(title: "Avg Monthly", value: viewModel.metrics.formattedAverageMonthlyReturn)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
// MARK: - Predictions Section
private var predictionsSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Predictions")
.font(.headline)
Spacer()
if !viewModel.canViewPredictions {
Label("Premium", systemImage: "crown.fill")
.font(.caption)
.foregroundColor(.appWarning)
}
}
if viewModel.canViewPredictions {
if let result = viewModel.predictionResult, !viewModel.predictions.isEmpty {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Algorithm: \(result.algorithm.displayName)")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text("Confidence: \(result.confidenceLevel.rawValue)")
.font(.caption)
.foregroundColor(Color(hex: result.confidenceLevel.color) ?? .gray)
}
// Show 12-month prediction
if let lastPrediction = viewModel.predictions.last {
HStack {
VStack(alignment: .leading) {
Text("12-Month Forecast")
.font(.subheadline)
.foregroundColor(.secondary)
Text(lastPrediction.formattedValue)
.font(.title3.weight(.semibold))
}
Spacer()
VStack(alignment: .trailing) {
Text("Range")
.font(.subheadline)
.foregroundColor(.secondary)
Text(lastPrediction.formattedConfidenceRange)
.font(.caption)
}
}
}
}
}
} else {
Button {
viewModel.showingPaywall = true
} label: {
HStack {
Image(systemName: "lock.fill")
Text("Unlock Predictions with Premium")
}
.font(.subheadline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary.opacity(0.1))
.foregroundColor(.appPrimary)
.cornerRadius(AppConstants.UI.smallCornerRadius)
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
// MARK: - Snapshots Section
private var snapshotsSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Snapshots")
.font(.headline)
Spacer()
Text("\(viewModel.snapshotCount)")
.font(.subheadline)
.foregroundColor(.secondary)
}
if viewModel.isHistoryLimited {
HStack {
Image(systemName: "info.circle")
.foregroundColor(.appWarning)
Text("\(viewModel.hiddenSnapshotCount) older snapshots hidden. Upgrade for full history.")
.font(.caption)
Spacer()
Button("Upgrade") {
viewModel.showingPaywall = true
}
.font(.caption.weight(.semibold))
}
.padding(8)
.background(Color.appWarning.opacity(0.1))
.cornerRadius(AppConstants.UI.smallCornerRadius)
}
ForEach(viewModel.snapshots.prefix(10)) { snapshot in
SnapshotRowView(snapshot: snapshot)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
viewModel.deleteSnapshot(snapshot)
} label: {
Label("Delete", systemImage: "trash")
}
}
if snapshot.id != viewModel.snapshots.prefix(10).last?.id {
Divider()
}
}
if viewModel.snapshots.count > 10 {
Text("+ \(viewModel.snapshots.count - 10) more snapshots")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
// MARK: - Metric Card
struct MetricCard: View {
let title: String
let value: String
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption)
.foregroundColor(.secondary)
Text(value)
.font(.subheadline.weight(.semibold))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
// MARK: - Snapshot Row View
struct SnapshotRowView: View {
let snapshot: Snapshot
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(snapshot.date.friendlyDescription)
.font(.subheadline)
if let notes = snapshot.notes, !notes.isEmpty {
Text(notes)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
}
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(snapshot.formattedValue)
.font(.subheadline.weight(.medium))
if snapshot.contribution != nil && snapshot.decimalContribution > 0 {
Text("+ \(snapshot.decimalContribution.currencyString)")
.font(.caption)
.foregroundColor(.appPrimary)
}
}
}
.padding(.vertical, 4)
}
}
#Preview {
NavigationStack {
Text("Source Detail Preview")
}
}

View File

@ -1,299 +0,0 @@
import WidgetKit
import SwiftUI
import CoreData
// MARK: - Widget Entry
struct InvestmentWidgetEntry: TimelineEntry {
let date: Date
let totalValue: Decimal
let dayChange: Decimal
let dayChangePercentage: Double
let topSources: [(name: String, value: Decimal, color: String)]
let sparklineData: [Decimal]
}
// MARK: - Widget Provider
struct InvestmentWidgetProvider: TimelineProvider {
func placeholder(in context: Context) -> InvestmentWidgetEntry {
InvestmentWidgetEntry(
date: Date(),
totalValue: 50000,
dayChange: 250,
dayChangePercentage: 0.5,
topSources: [
("Stocks", 30000, "#10B981"),
("Bonds", 15000, "#3B82F6"),
("Real Estate", 5000, "#F59E0B")
],
sparklineData: [45000, 46000, 47000, 48000, 49000, 50000]
)
}
func getSnapshot(in context: Context, completion: @escaping (InvestmentWidgetEntry) -> Void) {
let entry = fetchData()
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<InvestmentWidgetEntry>) -> Void) {
let entry = fetchData()
// Refresh every hour
let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: Date()) ?? Date()
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
completion(timeline)
}
private func fetchData() -> InvestmentWidgetEntry {
let container = CoreDataStack.createWidgetContainer()
let context = container.viewContext
// Fetch sources
let sourceRequest: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
let sources = (try? context.fetch(sourceRequest)) ?? []
// Calculate total value
let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue }
// Get top sources
let topSources = sources
.sorted { $0.latestValue > $1.latestValue }
.prefix(3)
.map { (name: $0.name, value: $0.latestValue, color: $0.category?.colorHex ?? "#3B82F6") }
// Calculate day change (simplified - would need proper historical data)
let dayChange: Decimal = 0
let dayChangePercentage: Double = 0
// Get sparkline data (last 6 data points)
let sparklineData: [Decimal] = []
return InvestmentWidgetEntry(
date: Date(),
totalValue: totalValue,
dayChange: dayChange,
dayChangePercentage: dayChangePercentage,
topSources: Array(topSources),
sparklineData: sparklineData
)
}
}
// MARK: - Small Widget View
struct SmallWidgetView: View {
let entry: InvestmentWidgetEntry
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Portfolio")
.font(.caption)
.foregroundColor(.secondary)
Text(entry.totalValue.compactCurrencyString)
.font(.title2.weight(.bold))
.minimumScaleFactor(0.7)
.lineLimit(1)
Spacer()
HStack(spacing: 4) {
Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right")
.font(.caption2)
Text(String(format: "%.1f%%", entry.dayChangePercentage))
.font(.caption.weight(.medium))
}
.foregroundColor(entry.dayChange >= 0 ? .green : .red)
}
.padding()
.containerBackground(.background, for: .widget)
}
}
// MARK: - Medium Widget View
struct MediumWidgetView: View {
let entry: InvestmentWidgetEntry
var body: some View {
HStack(spacing: 16) {
// Left side - Total value
VStack(alignment: .leading, spacing: 8) {
Text("Portfolio")
.font(.caption)
.foregroundColor(.secondary)
Text(entry.totalValue.compactCurrencyString)
.font(.title.weight(.bold))
.minimumScaleFactor(0.7)
.lineLimit(1)
HStack(spacing: 4) {
Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right")
.font(.caption2)
Text(entry.dayChange.compactCurrencyString)
.font(.caption.weight(.medium))
Text("(\(String(format: "%.1f%%", entry.dayChangePercentage)))")
.font(.caption)
.foregroundColor(.secondary)
}
.foregroundColor(entry.dayChange >= 0 ? .green : .red)
}
Spacer()
// Right side - Top sources
VStack(alignment: .trailing, spacing: 6) {
ForEach(entry.topSources, id: \.name) { source in
HStack(spacing: 6) {
Text(source.name)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
Text(source.value.shortCurrencyString)
.font(.caption.weight(.medium))
}
}
}
}
.padding()
.containerBackground(.background, for: .widget)
}
}
// MARK: - Accessory Circular View (Lock Screen)
struct AccessoryCircularView: View {
let entry: InvestmentWidgetEntry
var body: some View {
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 2) {
Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right")
.font(.caption)
Text(String(format: "%.1f%%", entry.dayChangePercentage))
.font(.caption2.weight(.semibold))
}
}
.containerBackground(.background, for: .widget)
}
}
// MARK: - Accessory Rectangular View (Lock Screen)
struct AccessoryRectangularView: View {
let entry: InvestmentWidgetEntry
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text("Portfolio")
.font(.caption2)
.foregroundColor(.secondary)
Text(entry.totalValue.compactCurrencyString)
.font(.headline)
HStack(spacing: 4) {
Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right")
.font(.caption2)
Text(String(format: "%.1f%%", entry.dayChangePercentage))
.font(.caption2)
}
.foregroundColor(entry.dayChange >= 0 ? .green : .red)
}
.containerBackground(.background, for: .widget)
}
}
// MARK: - Widget Configuration
struct InvestmentWidget: Widget {
let kind: String = "InvestmentWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: InvestmentWidgetProvider()) { entry in
InvestmentWidgetEntryView(entry: entry)
}
.configurationDisplayName("Portfolio Value")
.description("View your total investment portfolio value.")
.supportedFamilies([
.systemSmall,
.systemMedium,
.accessoryCircular,
.accessoryRectangular
])
}
}
// MARK: - Widget Entry View
struct InvestmentWidgetEntryView: View {
@Environment(\.widgetFamily) var family
let entry: InvestmentWidgetEntry
var body: some View {
switch family {
case .systemSmall:
SmallWidgetView(entry: entry)
case .systemMedium:
MediumWidgetView(entry: entry)
case .accessoryCircular:
AccessoryCircularView(entry: entry)
case .accessoryRectangular:
AccessoryRectangularView(entry: entry)
default:
SmallWidgetView(entry: entry)
}
}
}
// MARK: - Widget Bundle
@main
struct InvestmentTrackerWidgetBundle: WidgetBundle {
var body: some Widget {
InvestmentWidget()
}
}
// MARK: - Previews
#Preview("Small", as: .systemSmall) {
InvestmentWidget()
} timeline: {
InvestmentWidgetEntry(
date: Date(),
totalValue: 50000,
dayChange: 250,
dayChangePercentage: 0.5,
topSources: [],
sparklineData: []
)
}
#Preview("Medium", as: .systemMedium) {
InvestmentWidget()
} timeline: {
InvestmentWidgetEntry(
date: Date(),
totalValue: 50000,
dayChange: 250,
dayChangePercentage: 0.5,
topSources: [
("Stocks", 30000, "#10B981"),
("Bonds", 15000, "#3B82F6"),
("Real Estate", 5000, "#F59E0B")
],
sparklineData: []
)
}

View File

@ -1,12 +0,0 @@
<?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/>
<key>com.apple.security.application-groups</key>
<array/>
</dict>
</plist>

View File

@ -9,7 +9,11 @@
/* Begin PBXBuildFile section */
0E241ECF2F0DAA3C00283E2F /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E241ECE2F0DAA3C00283E2F /* WidgetKit.framework */; };
0E241ED12F0DAA3C00283E2F /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E241ED02F0DAA3C00283E2F /* SwiftUI.framework */; };
0E241EE22F0DAA3E00283E2F /* InvestmentTrackerWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0E241ECC2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
0E241EE22F0DAA3E00283E2F /* PortfolioJournalWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0E241ECC2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
0E53752B2F0FD08100F31390 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 0E53752A2F0FD08100F31390 /* FirebaseCore */; };
0E53752D2F0FD08600F31390 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 0E53752C2F0FD08600F31390 /* FirebaseAnalytics */; };
0E53752F2F0FD09F00F31390 /* CoreData.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E53752E2F0FD09F00F31390 /* CoreData.framework */; };
0E5375312F0FD12E00F31390 /* GoogleMobileAds in Frameworks */ = {isa = PBXBuildFile; productRef = 0E5375302F0FD12E00F31390 /* GoogleMobileAds */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -18,7 +22,7 @@
containerPortal = 0E241E312F0DA93A00283E2F /* Project object */;
proxyType = 1;
remoteGlobalIDString = 0E241ECB2F0DAA3C00283E2F;
remoteInfo = InvestmentTrackerWidgetExtension;
remoteInfo = PortfolioJournalWidgetExtension;
};
/* End PBXContainerItemProxy section */
@ -29,7 +33,7 @@
dstPath = "";
dstSubfolderSpec = 13;
files = (
0E241EE22F0DAA3E00283E2F /* InvestmentTrackerWidgetExtension.appex in Embed Foundation Extensions */,
0E241EE22F0DAA3E00283E2F /* PortfolioJournalWidgetExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
@ -37,37 +41,54 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
0E241E392F0DA93A00283E2F /* InvestmentTracker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = InvestmentTracker.app; sourceTree = BUILT_PRODUCTS_DIR; };
0E241ECC2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = InvestmentTrackerWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
0E241E392F0DA93A00283E2F /* PortfolioJournal.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PortfolioJournal.app; sourceTree = BUILT_PRODUCTS_DIR; };
0E241ECC2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PortfolioJournalWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
0E241ECE2F0DAA3C00283E2F /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
0E241ED02F0DAA3C00283E2F /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
0E241EED2F0DAC7D00283E2F /* InvestmentTrackerWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = InvestmentTrackerWidgetExtension.entitlements; sourceTree = "<group>"; };
0E241EED2F0DAC7D00283E2F /* PortfolioJournalWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PortfolioJournalWidgetExtension.entitlements; sourceTree = "<group>"; };
0E53752E2F0FD09F00F31390 /* CoreData.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreData.framework; path = System/Library/Frameworks/CoreData.framework; sourceTree = SDKROOT; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
0E8318942F0DB2FB0030C2F9 /* Exceptions for "InvestmentTracker" folder in "InvestmentTracker" target */ = {
0E44005C2F184C7100525EE3 /* Exceptions for "PortfolioJournal" folder in "PortfolioJournalWidgetExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Assets.xcassets,
Resources/Info.plist,
Resources/Localizable.strings,
Models/CoreData/PortfolioJournal.xcdatamodeld,
);
target = 0E241E382F0DA93A00283E2F /* InvestmentTracker */;
target = 0E241ECB2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension */;
};
0E8318942F0DB2FB0030C2F9 /* Exceptions for "PortfolioJournal" folder in "PortfolioJournal" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Resources/Info.plist,
);
target = 0E241E382F0DA93A00283E2F /* PortfolioJournal */;
};
0E8318952F0DB2FB0030C2F9 /* Exceptions for "PortfolioJournalWidget" folder in "PortfolioJournalWidgetExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 0E241ECB2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
0E241E3B2F0DA93A00283E2F /* InvestmentTracker */ = {
0E241E3B2F0DA93A00283E2F /* PortfolioJournal */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
0E8318942F0DB2FB0030C2F9 /* Exceptions for "InvestmentTracker" folder in "InvestmentTracker" target */,
0E8318942F0DB2FB0030C2F9 /* Exceptions for "PortfolioJournal" folder in "PortfolioJournal" target */,
0E44005C2F184C7100525EE3 /* Exceptions for "PortfolioJournal" folder in "PortfolioJournalWidgetExtension" target */,
);
path = InvestmentTracker;
path = PortfolioJournal;
sourceTree = "<group>";
};
0E241ED22F0DAA3C00283E2F /* InvestmentTrackerWidget */ = {
0E241ED22F0DAA3C00283E2F /* PortfolioJournalWidget */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = InvestmentTrackerWidget;
exceptions = (
0E8318952F0DB2FB0030C2F9 /* Exceptions for "PortfolioJournalWidget" folder in "PortfolioJournalWidgetExtension" target */,
);
path = PortfolioJournalWidget;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
@ -77,6 +98,10 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
0E5375312F0FD12E00F31390 /* GoogleMobileAds in Frameworks */,
0E53752D2F0FD08600F31390 /* FirebaseAnalytics in Frameworks */,
0E53752F2F0FD09F00F31390 /* CoreData.framework in Frameworks */,
0E53752B2F0FD08100F31390 /* FirebaseCore in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -95,9 +120,9 @@
0E241E302F0DA93A00283E2F = {
isa = PBXGroup;
children = (
0E241EED2F0DAC7D00283E2F /* InvestmentTrackerWidgetExtension.entitlements */,
0E241E3B2F0DA93A00283E2F /* InvestmentTracker */,
0E241ED22F0DAA3C00283E2F /* InvestmentTrackerWidget */,
0E241EED2F0DAC7D00283E2F /* PortfolioJournalWidgetExtension.entitlements */,
0E241E3B2F0DA93A00283E2F /* PortfolioJournal */,
0E241ED22F0DAA3C00283E2F /* PortfolioJournalWidget */,
0E241ECD2F0DAA3C00283E2F /* Frameworks */,
0E241E3A2F0DA93A00283E2F /* Products */,
);
@ -106,8 +131,8 @@
0E241E3A2F0DA93A00283E2F /* Products */ = {
isa = PBXGroup;
children = (
0E241E392F0DA93A00283E2F /* InvestmentTracker.app */,
0E241ECC2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension.appex */,
0E241E392F0DA93A00283E2F /* PortfolioJournal.app */,
0E241ECC2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@ -115,6 +140,7 @@
0E241ECD2F0DAA3C00283E2F /* Frameworks */ = {
isa = PBXGroup;
children = (
0E53752E2F0FD09F00F31390 /* CoreData.framework */,
0E241ECE2F0DAA3C00283E2F /* WidgetKit.framework */,
0E241ED02F0DAA3C00283E2F /* SwiftUI.framework */,
);
@ -124,9 +150,9 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
0E241E382F0DA93A00283E2F /* InvestmentTracker */ = {
0E241E382F0DA93A00283E2F /* PortfolioJournal */ = {
isa = PBXNativeTarget;
buildConfigurationList = 0E241E4A2F0DA93C00283E2F /* Build configuration list for PBXNativeTarget "InvestmentTracker" */;
buildConfigurationList = 0E241E4A2F0DA93C00283E2F /* Build configuration list for PBXNativeTarget "PortfolioJournal" */;
buildPhases = (
0E241E352F0DA93A00283E2F /* Sources */,
0E241E362F0DA93A00283E2F /* Frameworks */,
@ -139,18 +165,21 @@
0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
0E241E3B2F0DA93A00283E2F /* InvestmentTracker */,
0E241E3B2F0DA93A00283E2F /* PortfolioJournal */,
);
name = InvestmentTracker;
name = PortfolioJournal;
packageProductDependencies = (
0E53752A2F0FD08100F31390 /* FirebaseCore */,
0E53752C2F0FD08600F31390 /* FirebaseAnalytics */,
0E5375302F0FD12E00F31390 /* GoogleMobileAds */,
);
productName = InvestmentTracker;
productReference = 0E241E392F0DA93A00283E2F /* InvestmentTracker.app */;
productName = PortfolioJournal;
productReference = 0E241E392F0DA93A00283E2F /* PortfolioJournal.app */;
productType = "com.apple.product-type.application";
};
0E241ECB2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension */ = {
0E241ECB2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = 0E241EE32F0DAA3E00283E2F /* Build configuration list for PBXNativeTarget "InvestmentTrackerWidgetExtension" */;
buildConfigurationList = 0E241EE32F0DAA3E00283E2F /* Build configuration list for PBXNativeTarget "PortfolioJournalWidgetExtension" */;
buildPhases = (
0E241EC82F0DAA3C00283E2F /* Sources */,
0E241EC92F0DAA3C00283E2F /* Frameworks */,
@ -161,13 +190,13 @@
dependencies = (
);
fileSystemSynchronizedGroups = (
0E241ED22F0DAA3C00283E2F /* InvestmentTrackerWidget */,
0E241ED22F0DAA3C00283E2F /* PortfolioJournalWidget */,
);
name = InvestmentTrackerWidgetExtension;
name = PortfolioJournalWidgetExtension;
packageProductDependencies = (
);
productName = InvestmentTrackerWidgetExtension;
productReference = 0E241ECC2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension.appex */;
productName = PortfolioJournalWidgetExtension;
productReference = 0E241ECC2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
@ -188,12 +217,14 @@
};
};
};
buildConfigurationList = 0E241E342F0DA93A00283E2F /* Build configuration list for PBXProject "InvestmentTracker" */;
buildConfigurationList = 0E241E342F0DA93A00283E2F /* Build configuration list for PBXProject "PortfolioJournal" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
es,
"es-ES",
);
mainGroup = 0E241E302F0DA93A00283E2F;
minimizedProjectReferenceProxies = 1;
@ -206,8 +237,8 @@
projectDirPath = "";
projectRoot = "";
targets = (
0E241E382F0DA93A00283E2F /* InvestmentTracker */,
0E241ECB2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension */,
0E241E382F0DA93A00283E2F /* PortfolioJournal */,
0E241ECB2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension */,
);
};
/* End PBXProject section */
@ -249,7 +280,7 @@
/* Begin PBXTargetDependency section */
0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 0E241ECB2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension */;
target = 0E241ECB2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension */;
targetProxy = 0E241EE02F0DAA3E00283E2F /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
@ -260,13 +291,16 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = InvestmentTracker/InvestmentTracker.entitlements;
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = InvestmentTracker/Info.plist;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PortfolioJournal/Resources/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Journal";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@ -277,7 +311,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.InvestmentTracker;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
@ -294,13 +328,16 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = InvestmentTracker/InvestmentTracker.entitlements;
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournal.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = InvestmentTracker/Info.plist;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PortfolioJournal/Resources/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Journal";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@ -311,7 +348,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.InvestmentTracker;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
@ -447,12 +484,14 @@
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = InvestmentTrackerWidgetExtension.entitlements;
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "";
INFOPLIST_KEY_CFBundleDisplayName = InvestmentTrackerWidget;
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PortfolioJournalWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Journal Widget";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@ -460,7 +499,7 @@
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.InvestmentTracker.InvestmentTrackerWidget;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@ -477,12 +516,14 @@
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = InvestmentTrackerWidgetExtension.entitlements;
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "";
INFOPLIST_KEY_CFBundleDisplayName = InvestmentTrackerWidget;
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PortfolioJournalWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Journal Widget";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@ -490,7 +531,7 @@
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.InvestmentTracker.InvestmentTrackerWidget;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@ -505,7 +546,7 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
0E241E342F0DA93A00283E2F /* Build configuration list for PBXProject "InvestmentTracker" */ = {
0E241E342F0DA93A00283E2F /* Build configuration list for PBXProject "PortfolioJournal" */ = {
isa = XCConfigurationList;
buildConfigurations = (
0E241E4D2F0DA93C00283E2F /* Debug */,
@ -514,7 +555,7 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
0E241E4A2F0DA93C00283E2F /* Build configuration list for PBXNativeTarget "InvestmentTracker" */ = {
0E241E4A2F0DA93C00283E2F /* Build configuration list for PBXNativeTarget "PortfolioJournal" */ = {
isa = XCConfigurationList;
buildConfigurations = (
0E241E4B2F0DA93C00283E2F /* Debug */,
@ -523,7 +564,7 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
0E241EE32F0DAA3E00283E2F /* Build configuration list for PBXNativeTarget "InvestmentTrackerWidgetExtension" */ = {
0E241EE32F0DAA3E00283E2F /* Build configuration list for PBXNativeTarget "PortfolioJournalWidgetExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
0E241EE42F0DAA3E00283E2F /* Debug */,
@ -552,6 +593,24 @@
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
0E53752A2F0FD08100F31390 /* FirebaseCore */ = {
isa = XCSwiftPackageProductDependency;
package = 0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
productName = FirebaseCore;
};
0E53752C2F0FD08600F31390 /* FirebaseAnalytics */ = {
isa = XCSwiftPackageProductDependency;
package = 0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
productName = FirebaseAnalytics;
};
0E5375302F0FD12E00F31390 /* GoogleMobileAds */ = {
isa = XCSwiftPackageProductDependency;
package = 0E241EEC2F0DAC2D00283E2F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */;
productName = GoogleMobileAds;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 0E241E312F0DA93A00283E2F /* Project object */;
}

View File

@ -16,9 +16,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0E241E382F0DA93A00283E2F"
BuildableName = "InvestmentTracker.app"
BlueprintName = "InvestmentTracker"
ReferencedContainer = "container:InvestmentTracker.xcodeproj">
BuildableName = "PortfolioJournal.app"
BlueprintName = "PortfolioJournal"
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
@ -45,9 +45,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0E241E382F0DA93A00283E2F"
BuildableName = "InvestmentTracker.app"
BlueprintName = "InvestmentTracker"
ReferencedContainer = "container:InvestmentTracker.xcodeproj">
BuildableName = "PortfolioJournal.app"
BlueprintName = "PortfolioJournal"
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
@ -62,9 +62,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0E241E382F0DA93A00283E2F"
BuildableName = "InvestmentTracker.app"
BlueprintName = "InvestmentTracker"
ReferencedContainer = "container:InvestmentTracker.xcodeproj">
BuildableName = "PortfolioJournal.app"
BlueprintName = "PortfolioJournal"
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>

View File

@ -17,9 +17,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0E241ECB2F0DAA3C00283E2F"
BuildableName = "InvestmentTrackerWidgetExtension.appex"
BlueprintName = "InvestmentTrackerWidgetExtension"
ReferencedContainer = "container:InvestmentTracker.xcodeproj">
BuildableName = "PortfolioJournalWidgetExtension.appex"
BlueprintName = "PortfolioJournalWidgetExtension"
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
@ -31,9 +31,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0E241E382F0DA93A00283E2F"
BuildableName = "InvestmentTracker.app"
BlueprintName = "InvestmentTracker"
ReferencedContainer = "container:InvestmentTracker.xcodeproj">
BuildableName = "PortfolioJournal.app"
BlueprintName = "PortfolioJournal"
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
@ -62,9 +62,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0E241E382F0DA93A00283E2F"
BuildableName = "InvestmentTracker.app"
BlueprintName = "InvestmentTracker"
ReferencedContainer = "container:InvestmentTracker.xcodeproj">
BuildableName = "PortfolioJournal.app"
BlueprintName = "PortfolioJournal"
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
@ -81,9 +81,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0E241E382F0DA93A00283E2F"
BuildableName = "InvestmentTracker.app"
BlueprintName = "InvestmentTracker"
ReferencedContainer = "container:InvestmentTracker.xcodeproj">
BuildableName = "PortfolioJournal.app"
BlueprintName = "PortfolioJournal"
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>

View File

@ -4,12 +4,12 @@
<dict>
<key>SchemeUserState</key>
<dict>
<key>InvestmentTracker.xcscheme_^#shared#^_</key>
<key>PortfolioJournal.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
<key>InvestmentTrackerWidgetExtension.xcscheme_^#shared#^_</key>
<key>PortfolioJournalWidgetExtension.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>

BIN
PortfolioJournal/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -1,17 +1,27 @@
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()
// Register background tasks
registerBackgroundTasks()
return true
}
@ -25,11 +35,6 @@ class AppDelegate: NSObject, UIApplicationDelegate {
}
}
private func registerBackgroundTasks() {
// Background fetch for updating widget data and notification badges
UIApplication.shared.setMinimumBackgroundFetchInterval(UIApplication.backgroundFetchIntervalMinimum)
}
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data

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())
}

View File

@ -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()
}
}
}
}

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

View File

@ -1,6 +1,7 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
@ -12,6 +13,7 @@
"value" : "dark"
}
],
"filename" : "AppIcon-dark.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
@ -23,6 +25,7 @@
"value" : "tinted"
}
],
"filename" : "AppIcon-tinted.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"

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

View File

@ -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
}
}

View File

@ -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)"
}
}

View File

@ -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
}
}

View File

@ -14,13 +14,21 @@ public class AppSettings: NSManagedObject, Identifiable {
@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()
currency = "EUR"
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
@ -35,8 +43,10 @@ public class AppSettings: NSManagedObject, Identifiable {
extension AppSettings {
var currencySymbol: String {
let locale = Locale.current
return locale.currencySymbol ?? ""
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = currency
return formatter.currencySymbol ?? ""
}
var notificationTimeString: String {

View File

@ -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"
}
}
}

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -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>

View File

@ -30,6 +30,7 @@ enum PredictionAlgorithm: String, CaseIterable, Identifiable {
case linear = "linear"
case exponentialSmoothing = "exponential_smoothing"
case movingAverage = "moving_average"
case holtTrend = "holt_trend"
var id: String { rawValue }
@ -38,6 +39,7 @@ enum PredictionAlgorithm: String, CaseIterable, Identifiable {
case .linear: return "Linear Regression"
case .exponentialSmoothing: return "Exponential Smoothing"
case .movingAverage: return "Moving Average"
case .holtTrend: return "Holt Trend"
}
}
@ -49,6 +51,8 @@ enum PredictionAlgorithm: String, CaseIterable, Identifiable {
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"
}
}
}

View File

@ -35,11 +35,7 @@ extension Snapshot {
}
var formattedValue: String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = "EUR"
formatter.maximumFractionDigits = 2
return formatter.string(from: value ?? 0) ?? "€0.00"
CurrencyFormatter.format(value?.decimalValue ?? Decimal.zero, style: .currency, maximumFractionDigits: 2)
}
var formattedDate: String {

View File

@ -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
}
}

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
}
}

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."
}
}
}

View File

@ -87,11 +87,7 @@ extension InvestmentMetrics {
}
private func formatCurrency(_ value: Decimal) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = "EUR"
formatter.maximumFractionDigits = 2
return formatter.string(from: value as NSDecimalNumber) ?? "€0.00"
CurrencyFormatter.format(value, style: .currency, maximumFractionDigits: 2)
}
}
@ -132,11 +128,7 @@ struct CategoryMetrics: Identifiable {
let metrics: InvestmentMetrics
var formattedTotalValue: String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = "EUR"
formatter.maximumFractionDigits = 0
return formatter.string(from: totalValue as NSDecimalNumber) ?? "€0"
CurrencyFormatter.format(totalValue, style: .currency, maximumFractionDigits: 0)
}
var formattedPercentage: String {
@ -163,11 +155,7 @@ struct PortfolioSummary {
let lastUpdated: Date?
var formattedTotalValue: String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = "EUR"
formatter.maximumFractionDigits = 0
return formatter.string(from: totalValue as NSDecimalNumber) ?? "€0"
CurrencyFormatter.format(totalValue, style: .currency, maximumFractionDigits: 0)
}
var formattedDayChange: String {
@ -187,13 +175,8 @@ struct PortfolioSummary {
}
private func formatChange(_ absolute: Decimal, _ percentage: Double) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = "EUR"
formatter.maximumFractionDigits = 0
let prefix = absolute >= 0 ? "+" : ""
let absString = formatter.string(from: absolute as NSDecimalNumber) ?? "€0"
let absString = CurrencyFormatter.format(absolute, style: .currency, maximumFractionDigits: 0)
let pctString = String(format: "%.2f%%", percentage)
return "\(prefix)\(absString) (\(prefix)\(pctString))"

View File

@ -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)
}
}

View File

@ -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")
}
}

View File

@ -44,11 +44,7 @@ extension PredictionAlgorithm: Codable {}
extension Prediction {
var formattedValue: String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = "EUR"
formatter.maximumFractionDigits = 0
return formatter.string(from: predictedValue as NSDecimalNumber) ?? "€0"
CurrencyFormatter.format(predictedValue, style: .currency, maximumFractionDigits: 0)
}
var formattedDate: String {
@ -58,13 +54,16 @@ extension Prediction {
}
var formattedConfidenceRange: String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = "EUR"
formatter.maximumFractionDigits = 0
let lower = formatter.string(from: confidenceInterval.lower as NSDecimalNumber) ?? "€0"
let upper = formatter.string(from: confidenceInterval.upper as NSDecimalNumber) ?? "€0"
let lower = CurrencyFormatter.format(
confidenceInterval.lower,
style: .currency,
maximumFractionDigits: 0
)
let upper = CurrencyFormatter.format(
confidenceInterval.upper,
style: .currency,
maximumFractionDigits: 0
)
return "\(lower) - \(upper)"
}

View File

@ -6,15 +6,17 @@
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.yourteam.investmenttracker</string>
<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)$(CFBundleIdentifier)</string>
<string>$(TeamIdentifierPrefix)com.alexandrevazquez.portfoliojournal</string>
<key>com.apple.security.application-groups</key>
<array/>
<array>
<string>group.com.alexandrevazquez.portfoliojournal</string>
</array>
</dict>
</plist>

View File

@ -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>

View File

@ -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)")
}
}
}

View File

@ -23,12 +23,15 @@ class CategoryRepository: ObservableObject {
}
@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),
@ -36,10 +39,11 @@ class CategoryRepository: ObservableObject {
]
do {
categories = try context.fetch(request)
self.categories = try self.context.fetch(request)
} catch {
print("Failed to fetch categories: \(error)")
categories = []
self.categories = []
}
}
}
@ -140,8 +144,28 @@ class CategoryRepository: ObservableObject {
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
}
}

View File

@ -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)")
}
}
}

View File

@ -23,22 +23,29 @@ class InvestmentSourceRepository: ObservableObject {
}
@objc private func contextDidChange(_ notification: Notification) {
guard isRelevantChange(notification) else { return }
fetchSources()
}
// MARK: - Fetch
func fetchSources() {
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 {
sources = try context.fetch(request)
self.sources = try self.context.fetch(request)
} catch {
print("Failed to fetch sources: \(error)")
sources = []
self.sources = []
}
}
}
@ -63,8 +70,9 @@ class InvestmentSourceRepository: ObservableObject {
sources.filter { $0.isActive }
}
func fetchSourcesNeedingUpdate() -> [InvestmentSource] {
sources.filter { $0.needsUpdate }
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
@ -74,13 +82,15 @@ class InvestmentSourceRepository: ObservableObject {
name: String,
category: Category,
notificationFrequency: NotificationFrequency = .monthly,
customFrequencyMonths: Int = 1
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
@ -94,7 +104,8 @@ class InvestmentSourceRepository: ObservableObject {
category: Category? = nil,
notificationFrequency: NotificationFrequency? = nil,
customFrequencyMonths: Int? = nil,
isActive: Bool? = nil
isActive: Bool? = nil,
account: Account? = nil
) {
if let name = name {
source.name = name
@ -111,6 +122,9 @@ class InvestmentSourceRepository: ObservableObject {
if let isActive = isActive {
source.isActive = isActive
}
if let account = account {
source.account = account
}
save()
}
@ -170,8 +184,28 @@ class InvestmentSourceRepository: ObservableObject {
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
}
}

View File

@ -4,11 +4,27 @@ 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
@ -19,6 +35,7 @@ class SnapshotRepository: ObservableObject {
request.sortDescriptors = [
NSSortDescriptor(keyPath: \Snapshot.date, ascending: false)
]
request.fetchBatchSize = 200
return (try? context.fetch(request)) ?? []
}
@ -35,6 +52,18 @@ class SnapshotRepository: ObservableObject {
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)) ?? []
}
@ -49,6 +78,7 @@ class SnapshotRepository: ObservableObject {
request.sortDescriptors = [
NSSortDescriptor(keyPath: \Snapshot.date, ascending: true)
]
request.fetchBatchSize = 200
return (try? context.fetch(request)) ?? []
}
@ -91,7 +121,9 @@ class SnapshotRepository: ObservableObject {
date: Date? = nil,
value: Decimal? = nil,
contribution: Decimal? = nil,
notes: String? = nil
notes: String? = nil,
clearContribution: Bool = false,
clearNotes: Bool = false
) {
if let date = date {
snapshot.date = date
@ -99,10 +131,14 @@ class SnapshotRepository: ObservableObject {
if let value = value {
snapshot.value = NSDecimalNumber(decimal: value)
}
if let contribution = contribution {
if clearContribution {
snapshot.contribution = nil
} else if let contribution = contribution {
snapshot.contribution = NSDecimalNumber(decimal: contribution)
}
if let notes = notes {
if clearNotes {
snapshot.notes = nil
} else if let notes = notes {
snapshot.notes = notes
}
@ -138,7 +174,8 @@ class SnapshotRepository: ObservableObject {
var snapshots = fetchSnapshots(for: source)
if let months = months {
let cutoffDate = Calendar.current.date(
// Performance: Use cached calendar
let cutoffDate = Self.calendar.date(
byAdding: .month,
value: -months,
to: Date()
@ -150,16 +187,59 @@ class SnapshotRepository: ObservableObject {
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)
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM"
// Performance: Use shared formatter
let formatter = Self.monthYearFormatter
for snapshot in snapshots {
let monthKey = formatter.string(from: snapshot.date)
@ -175,11 +255,15 @@ class SnapshotRepository: ObservableObject {
func getPortfolioHistory() -> [(date: Date, totalValue: Decimal)] {
let allSnapshots = fetchAllSnapshots()
// Group by date
// 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.current.startOfDay(for: snapshot.date)
let startOfDay = calendar.startOfDay(for: snapshot.date)
dateValues[startOfDay, default: Decimal.zero] += snapshot.decimalValue
}
@ -194,8 +278,39 @@ class SnapshotRepository: ObservableObject {
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()
}
}

View File

@ -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)")
}
}
}

View File

@ -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>

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>

View File

@ -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" : [ ]
}

View File

@ -1,12 +1,12 @@
/*
Localizable.strings
InvestmentTracker
PortfolioJournal
English (Base) localization
*/
// MARK: - General
"app_name" = "Investment Tracker";
"app_name" = "Portfolio Journal";
"ok" = "OK";
"cancel" = "Cancel";
"save" = "Save";
@ -22,13 +22,13 @@
"loading" = "Loading...";
// MARK: - Tab Bar
"tab_dashboard" = "Dashboard";
"tab_dashboard" = "Home";
"tab_sources" = "Sources";
"tab_charts" = "Charts";
"tab_settings" = "Settings";
// MARK: - Dashboard
"dashboard_title" = "Dashboard";
"dashboard_title" = "Home";
"total_portfolio_value" = "Total Portfolio Value";
"today" = "today";
"returns" = "Returns";
@ -186,3 +186,51 @@
"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.";

View File

@ -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";

View File

@ -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
}
}

View File

@ -1,4 +1,6 @@
import Foundation
import SwiftUI
import Combine
import GoogleMobileAds
import AppTrackingTransparency
import AdSupport
@ -36,7 +38,6 @@ class AdMobService: ObservableObject {
}
func requestConsent() async {
// Request App Tracking Transparency authorization
if #available(iOS 14.5, *) {
let status = await ATTrackingManager.requestTrackingAuthorization()
@ -109,48 +110,46 @@ class AdMobService: ObservableObject {
// MARK: - Banner Ad Coordinator
class BannerAdCoordinator: NSObject, GADBannerViewDelegate {
class BannerAdCoordinator: NSObject, BannerViewDelegate {
weak var adMobService: AdMobService?
func bannerViewDidReceiveAd(_ bannerView: GADBannerView) {
func bannerViewDidReceiveAd(_ bannerView: BannerView) {
print("Banner ad received")
adMobService?.logAdImpression()
}
func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) {
func bannerView(_ bannerView: BannerView, didFailToReceiveAdWithError error: Error) {
print("Banner ad failed to load: \(error.localizedDescription)")
}
func bannerViewDidRecordImpression(_ bannerView: GADBannerView) {
func bannerViewDidRecordImpression(_ bannerView: BannerView) {
// Impression recorded
}
func bannerViewDidRecordClick(_ bannerView: GADBannerView) {
func bannerViewDidRecordClick(_ bannerView: BannerView) {
adMobService?.logAdClick()
}
func bannerViewWillPresentScreen(_ bannerView: GADBannerView) {
func bannerViewWillPresentScreen(_ bannerView: BannerView) {
// Ad will present full screen
}
func bannerViewWillDismissScreen(_ bannerView: GADBannerView) {
func bannerViewWillDismissScreen(_ bannerView: BannerView) {
// Ad will dismiss
}
func bannerViewDidDismissScreen(_ bannerView: GADBannerView) {
func bannerViewDidDismissScreen(_ bannerView: BannerView) {
// Ad dismissed
}
}
// MARK: - UIKit Banner View Wrapper
import SwiftUI
struct BannerAdView: UIViewRepresentable {
@EnvironmentObject var adMobService: AdMobService
func makeUIView(context: Context) -> GADBannerView {
let bannerView = GADBannerView(adSize: GADAdSizeBanner)
func makeUIView(context: Context) -> BannerView {
let bannerView = BannerView(adSize: AdSizeBanner)
bannerView.adUnitID = AdMobService.bannerAdUnitID
// Get root view controller
@ -160,12 +159,12 @@ struct BannerAdView: UIViewRepresentable {
}
bannerView.delegate = context.coordinator
bannerView.load(GADRequest())
bannerView.load(Request())
return bannerView
}
func updateUIView(_ uiView: GADBannerView, context: Context) {
func updateUIView(_ uiView: BannerView, context: Context) {
// Banner updates automatically
}

View File

@ -3,8 +3,25 @@ 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(
@ -135,6 +152,51 @@ class CalculationService {
)
}
// 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(
@ -254,13 +316,12 @@ class CalculationService {
func calculateMonthlyReturns(from snapshots: [Snapshot]) -> [InvestmentMetrics.MonthlyReturn] {
guard snapshots.count >= 2 else { return [] }
var monthlyReturns: [InvestmentMetrics.MonthlyReturn] = []
let calendar = Calendar.current
// Performance: Use shared formatter instead of creating new one each call
let formatter = Self.monthYearFormatter
// Group snapshots by month
// Group snapshots by month - pre-allocate capacity
var monthlySnapshots: [String: [Snapshot]] = [:]
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM"
monthlySnapshots.reserveCapacity(min(snapshots.count, 60)) // Reasonable max months
for snapshot in snapshots {
let key = formatter.string(from: snapshot.date)
@ -269,6 +330,11 @@ class CalculationService {
// 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]
@ -301,14 +367,17 @@ class CalculationService {
func calculateCategoryMetrics(
for categories: [Category],
sources: [InvestmentSource],
totalPortfolioValue: Decimal
) -> [CategoryMetrics] {
categories.map { category in
let allSnapshots = category.sourcesArray.flatMap { $0.snapshotsArray }
let metrics = calculateMetrics(for: allSnapshots)
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: category.totalValue / totalPortfolioValue).doubleValue * 100
? NSDecimalNumber(decimal: categoryValue / totalPortfolioValue).doubleValue * 100
: 0
return CategoryMetrics(
@ -316,12 +385,196 @@ class CalculationService {
categoryName: category.name,
colorHex: category.colorHex,
icon: category.icon,
totalValue: category.totalValue,
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

View File

@ -35,9 +35,11 @@ class ExportService {
sources: [InvestmentSource],
categories: [Category]
) -> String {
var csv = "Category,Source,Date,Value (EUR),Contribution (EUR),Notes\n"
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 {
@ -48,7 +50,7 @@ class ExportService {
: ""
let notes = escapeCSV(snapshot.notes ?? "")
csv += "\(escapeCSV(categoryName)),\(escapeCSV(source.name)),\(date),\(value),\(contribution),\(notes)\n"
csv += "\(escapeCSV(accountName)),\(escapeCSV(categoryName)),\(escapeCSV(source.name)),\(date),\(value),\(contribution),\(notes)\n"
}
}
@ -61,33 +63,46 @@ class ExportService {
) -> String {
var exportData: [String: Any] = [:]
exportData["exportDate"] = ISO8601DateFormatter().string(from: Date())
exportData["currency"] = "EUR"
exportData["version"] = 2
exportData["currency"] = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
// Export categories
var categoriesArray: [[String: Any]] = []
for category in categories {
var categoryDict: [String: Any] = [
"id": category.id.uuidString,
"name": category.name,
"color": category.colorHex,
"icon": category.icon
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"
]
// Export sources in this category
var sourcesArray: [[String: Any]] = []
for source in category.sourcesArray {
for source in categorySources {
var sourceDict: [String: Any] = [
"id": source.id.uuidString,
"name": source.name,
"isActive": source.isActive,
"notificationFrequency": source.notificationFrequency
]
// Export snapshots
var snapshotsArray: [[String: Any]] = []
for snapshot in source.snapshotsArray {
var snapshotDict: [String: Any] = [
"id": snapshot.id.uuidString,
"date": ISO8601DateFormatter().string(from: snapshot.date),
"value": NSDecimalNumber(decimal: snapshot.decimalValue).doubleValue
]
@ -113,7 +128,11 @@ class ExportService {
categoriesArray.append(categoryDict)
}
exportData["categories"] = categoriesArray
accountDict["categories"] = categoriesArray
accountsArray.append(accountDict)
}
exportData["accounts"] = accountsArray
// Add summary
let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue }

View File

@ -1,14 +1,20 @@
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")
}
@ -20,6 +26,7 @@ class FirebaseService {
// MARK: - Screen Tracking
func logScreenView(screenName: String, screenClass: String? = nil) {
guard isConfigured else { return }
Analytics.logEvent(AnalyticsEventScreenView, parameters: [
AnalyticsParameterScreenName: screenName,
AnalyticsParameterScreenClass: screenClass ?? screenName
@ -29,6 +36,7 @@ class FirebaseService {
// MARK: - Investment Events
func logSourceAdded(categoryName: String, sourceCount: Int) {
guard isConfigured else { return }
Analytics.logEvent("source_added", parameters: [
"category_name": categoryName,
"total_sources": sourceCount
@ -36,12 +44,14 @@ class FirebaseService {
}
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
@ -49,6 +59,7 @@ class FirebaseService {
}
func logCategoryCreated(name: String) {
guard isConfigured else { return }
Analytics.logEvent("category_created", parameters: [
"category_name": name
])
@ -57,18 +68,21 @@ class FirebaseService {
// 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,
@ -78,6 +92,7 @@ class FirebaseService {
}
func logPurchaseFailure(productId: String, error: String) {
guard isConfigured else { return }
Analytics.logEvent("purchase_failed", parameters: [
"product_id": productId,
"error": error
@ -85,6 +100,7 @@ class FirebaseService {
}
func logRestorePurchases(success: Bool) {
guard isConfigured else { return }
Analytics.logEvent("restore_purchases", parameters: [
"success": success
])
@ -93,6 +109,7 @@ class FirebaseService {
// 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
@ -100,12 +117,14 @@ class FirebaseService {
}
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
@ -113,6 +132,7 @@ class FirebaseService {
}
func logNotificationScheduled(frequency: String) {
guard isConfigured else { return }
Analytics.logEvent("notification_scheduled", parameters: [
"frequency": frequency
])
@ -121,12 +141,14 @@ class FirebaseService {
// 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
])
@ -135,18 +157,21 @@ class FirebaseService {
// 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
])
@ -155,6 +180,7 @@ class FirebaseService {
// 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
@ -164,6 +190,7 @@ class FirebaseService {
// 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,
@ -171,3 +198,4 @@ class FirebaseService {
])
}
}

View File

@ -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)
}
}
}

View File

@ -10,16 +10,20 @@ class IAPService: ObservableObject {
@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.investmenttracker.premium"
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
@ -34,6 +38,9 @@ class IAPService: ObservableObject {
// MARK: - Initialization
init() {
#if DEBUG
debugOverrideEnabled = UserDefaults.standard.bool(forKey: "debugPremiumOverride")
#endif
updateListenerTask = listenForTransactions()
Task {
@ -132,7 +139,15 @@ class IAPService: ObservableObject {
var isEntitled = false
var familyShared = false
for await result in Transaction.currentEntitlements {
#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
@ -144,6 +159,7 @@ class IAPService: ObservableObject {
isPremium = isEntitled
isFamilyShared = familyShared
sharedDefaults?.set(isEntitled, forKey: "premiumUnlocked")
// Update Core Data
let context = CoreDataStack.shared.viewContext
@ -156,11 +172,25 @@ class IAPService: ObservableObject {
)
}
#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 Transaction.updates {
for await result in StoreKit.Transaction.updates {
if case .verified(let transaction) = result {
await transaction.finish()
await self?.updatePremiumStatus()
@ -214,6 +244,7 @@ enum IAPError: LocalizedError {
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"),

View File

@ -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]
}
}

View File

@ -1,4 +1,5 @@
import Foundation
import Combine
import UserNotifications
import UIKit
@ -120,16 +121,12 @@ class NotificationService: ObservableObject {
let needsUpdate = repository.fetchSourcesNeedingUpdate()
pendingCount = needsUpdate.count
DispatchQueue.main.async {
UIApplication.shared.applicationIconBadgeNumber = self.pendingCount
}
center.setBadgeCount(pendingCount) { _ in }
}
func clearBadge() {
pendingCount = 0
DispatchQueue.main.async {
UIApplication.shared.applicationIconBadgeNumber = 0
}
center.setBadgeCount(0) { _ in }
}
// MARK: - Pending Notifications

View File

@ -5,6 +5,9 @@ class PredictionEngine {
private let context = CoreDataStack.shared.viewContext
// MARK: - Performance: Cached Calendar reference
private static let calendar = Calendar.current
private init() {}
// MARK: - Main Prediction Interface
@ -46,6 +49,9 @@ class PredictionEngine {
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(
@ -60,12 +66,12 @@ class PredictionEngine {
private func selectBestAlgorithm(volatility: Double) -> PredictionAlgorithm {
switch volatility {
case 0..<10:
return .linear // Low volatility - linear works well
case 10..<25:
return .exponentialSmoothing // Medium volatility - weight recent data
case 0..<8:
return .holtTrend
case 8..<20:
return .exponentialSmoothing
default:
return .movingAverage // High volatility - smooth out fluctuations
return .movingAverage
}
}
@ -88,7 +94,7 @@ class PredictionEngine {
let lastDate = snapshots.last!.date
for month in 1...monthsAhead {
guard let futureDate = Calendar.current.date(
guard let futureDate = Self.calendar.date(
byAdding: .month,
value: month,
to: lastDate
@ -219,7 +225,7 @@ class PredictionEngine {
let lastDate = snapshots.last!.date
for month in 1...monthsAhead {
guard let futureDate = Calendar.current.date(
guard let futureDate = Self.calendar.date(
byAdding: .month,
value: month,
to: lastDate
@ -297,7 +303,7 @@ class PredictionEngine {
let lastDate = snapshots.last!.date
for month in 1...monthsAhead {
guard let futureDate = Calendar.current.date(
guard let futureDate = Self.calendar.date(
byAdding: .month,
value: month,
to: lastDate
@ -347,6 +353,90 @@ class PredictionEngine {
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 {
@ -373,11 +463,3 @@ class PredictionEngine {
return sqrt(variance)
}
}
// MARK: - Decimal Extension
private extension Decimal {
var doubleValue: Double {
NSDecimalNumber(decimal: self).doubleValue
}
}

View File

@ -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)
}
}
}

View File

@ -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: "\\,")
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -3,19 +3,19 @@ import Foundation
enum AppConstants {
// MARK: - App Info
static let appName = "Investment Tracker"
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.yourteam.investmenttracker"
static let appGroupIdentifier = "group.com.yourteam.investmenttracker"
static let cloudKitContainerIdentifier = "iCloud.com.yourteam.investmenttracker"
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.investmenttracker.premium"
static let premiumProductID = "com.portfoliojournal.premium"
static let premiumPrice = "€4.69"
// MARK: - AdMob
@ -98,7 +98,7 @@ enum AppConstants {
// MARK: - Deep Links
enum DeepLinks {
static let scheme = "investmenttracker"
static let scheme = "portfoliojournal"
static let sourceDetail = "source"
static let addSnapshot = "addSnapshot"
static let premium = "premium"
@ -107,9 +107,9 @@ enum AppConstants {
// MARK: - URLs
enum URLs {
static let privacyPolicy = "https://yourwebsite.com/privacy"
static let termsOfService = "https://yourwebsite.com/terms"
static let support = "https://yourwebsite.com/support"
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"
}

View File

@ -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
}
}

View File

@ -0,0 +1,9 @@
import Foundation
enum CurrencyPicker {
static let commonCodes: [String] = [
"EUR", "USD", "GBP", "CHF", "JPY",
"CAD", "AUD", "SEK", "NOK", "DKK"
]
}

View File

@ -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
)
}
}
}

View File

@ -138,9 +138,9 @@ extension Date {
var friendlyDescription: String {
if isToday {
return "Today"
return String(localized: "date_today")
} else if isYesterday {
return "Yesterday"
return String(localized: "date_yesterday")
} else if isThisMonth {
let formatter = DateFormatter()
formatter.dateFormat = "EEEE, d"
@ -183,6 +183,10 @@ struct DateRange {
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)

View File

@ -1,43 +1,72 @@
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 {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = "EUR"
formatter.maximumFractionDigits = 2
return formatter.string(from: self as NSDecimalNumber) ?? "€0.00"
CurrencyFormatter.format(self, style: .currency, maximumFractionDigits: 2)
}
var compactCurrencyString: String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = "EUR"
formatter.maximumFractionDigits = 0
return formatter.string(from: self as NSDecimalNumber) ?? "€0"
CurrencyFormatter.format(self, style: .currency, maximumFractionDigits: 0)
}
var shortCurrencyString: String {
let value = NSDecimalNumber(decimal: self).doubleValue
let symbol = Self.cachedCurrencySymbol
switch abs(value) {
switch Swift.abs(value) {
case 1_000_000...:
return String(format: "€%.1fM", value / 1_000_000)
return String(format: "%@%.1fM", symbol, value / 1_000_000)
case 1_000...:
return String(format: "€%.1fK", value / 1_000)
return String(format: "%@%.1fK", symbol, value / 1_000)
default:
return compactCurrencyString
}
}
var percentageString: String {
let formatter = NumberFormatter()
formatter.numberStyle = .percent
formatter.maximumFractionDigits = 2
formatter.multiplier = 1
return formatter.string(from: self as NSDecimalNumber) ?? "0%"
Self.percentFormatter.string(from: self as NSDecimalNumber) ?? "0%"
}
var signedPercentageString: String {
@ -46,10 +75,7 @@ extension Decimal {
}
var decimalString: String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = 2
return formatter.string(from: self as NSDecimalNumber) ?? "0"
Self.decimalFormatter.string(from: self as NSDecimalNumber) ?? "0"
}
// MARK: - Conversions

View File

@ -1,4 +1,5 @@
import Foundation
import Combine
enum FreemiumLimits {
static let maxSources = 5
@ -94,6 +95,8 @@ class FreemiumValidator: ObservableObject {
if iapService.isPremium { return true }
switch feature {
case .multipleAccounts:
return false
case .unlimitedSources:
return false
case .fullHistory:
@ -112,6 +115,7 @@ class FreemiumValidator: ObservableObject {
// 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"
@ -123,6 +127,7 @@ class FreemiumValidator: ObservableObject {
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"
@ -134,6 +139,7 @@ class FreemiumValidator: ObservableObject {
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"
@ -145,6 +151,8 @@ class FreemiumValidator: ObservableObject {
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:

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -0,0 +1,7 @@
import Foundation
import Combine
@MainActor
final class TabSelectionStore: ObservableObject {
@Published var selectedTab = 0
}

View File

@ -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
}
}

View File

@ -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"
}
}

View File

@ -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
}

View File

@ -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?
}

View File

@ -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
}
}
}

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