initial version
parent
bab350dd22
commit
7988257399
|
|
@ -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())
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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())
|
|
||||||
}
|
|
||||||
|
|
@ -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())
|
|
||||||
}
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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: []
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -9,7 +9,11 @@
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
0E241ECF2F0DAA3C00283E2F /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E241ECE2F0DAA3C00283E2F /* WidgetKit.framework */; };
|
0E241ECF2F0DAA3C00283E2F /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E241ECE2F0DAA3C00283E2F /* WidgetKit.framework */; };
|
||||||
0E241ED12F0DAA3C00283E2F /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E241ED02F0DAA3C00283E2F /* SwiftUI.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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
|
@ -18,7 +22,7 @@
|
||||||
containerPortal = 0E241E312F0DA93A00283E2F /* Project object */;
|
containerPortal = 0E241E312F0DA93A00283E2F /* Project object */;
|
||||||
proxyType = 1;
|
proxyType = 1;
|
||||||
remoteGlobalIDString = 0E241ECB2F0DAA3C00283E2F;
|
remoteGlobalIDString = 0E241ECB2F0DAA3C00283E2F;
|
||||||
remoteInfo = InvestmentTrackerWidgetExtension;
|
remoteInfo = PortfolioJournalWidgetExtension;
|
||||||
};
|
};
|
||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
|
@ -29,7 +33,7 @@
|
||||||
dstPath = "";
|
dstPath = "";
|
||||||
dstSubfolderSpec = 13;
|
dstSubfolderSpec = 13;
|
||||||
files = (
|
files = (
|
||||||
0E241EE22F0DAA3E00283E2F /* InvestmentTrackerWidgetExtension.appex in Embed Foundation Extensions */,
|
0E241EE22F0DAA3E00283E2F /* PortfolioJournalWidgetExtension.appex in Embed Foundation Extensions */,
|
||||||
);
|
);
|
||||||
name = "Embed Foundation Extensions";
|
name = "Embed Foundation Extensions";
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|
@ -37,37 +41,54 @@
|
||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
0E241E392F0DA93A00283E2F /* InvestmentTracker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = InvestmentTracker.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
0E241E392F0DA93A00283E2F /* PortfolioJournal.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PortfolioJournal.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
0E241ECC2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = InvestmentTrackerWidgetExtension.appex; 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; };
|
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; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
0E8318942F0DB2FB0030C2F9 /* Exceptions for "InvestmentTracker" folder in "InvestmentTracker" target */ = {
|
0E44005C2F184C7100525EE3 /* Exceptions for "PortfolioJournal" folder in "PortfolioJournalWidgetExtension" target */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
Assets.xcassets,
|
Models/CoreData/PortfolioJournal.xcdatamodeld,
|
||||||
Resources/Info.plist,
|
|
||||||
Resources/Localizable.strings,
|
|
||||||
);
|
);
|
||||||
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 */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
0E241E3B2F0DA93A00283E2F /* InvestmentTracker */ = {
|
0E241E3B2F0DA93A00283E2F /* PortfolioJournal */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
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>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
0E241ED22F0DAA3C00283E2F /* InvestmentTrackerWidget */ = {
|
0E241ED22F0DAA3C00283E2F /* PortfolioJournalWidget */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
path = InvestmentTrackerWidget;
|
exceptions = (
|
||||||
|
0E8318952F0DB2FB0030C2F9 /* Exceptions for "PortfolioJournalWidget" folder in "PortfolioJournalWidgetExtension" target */,
|
||||||
|
);
|
||||||
|
path = PortfolioJournalWidget;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
@ -77,6 +98,10 @@
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
0E5375312F0FD12E00F31390 /* GoogleMobileAds in Frameworks */,
|
||||||
|
0E53752D2F0FD08600F31390 /* FirebaseAnalytics in Frameworks */,
|
||||||
|
0E53752F2F0FD09F00F31390 /* CoreData.framework in Frameworks */,
|
||||||
|
0E53752B2F0FD08100F31390 /* FirebaseCore in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|
@ -95,9 +120,9 @@
|
||||||
0E241E302F0DA93A00283E2F = {
|
0E241E302F0DA93A00283E2F = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
0E241EED2F0DAC7D00283E2F /* InvestmentTrackerWidgetExtension.entitlements */,
|
0E241EED2F0DAC7D00283E2F /* PortfolioJournalWidgetExtension.entitlements */,
|
||||||
0E241E3B2F0DA93A00283E2F /* InvestmentTracker */,
|
0E241E3B2F0DA93A00283E2F /* PortfolioJournal */,
|
||||||
0E241ED22F0DAA3C00283E2F /* InvestmentTrackerWidget */,
|
0E241ED22F0DAA3C00283E2F /* PortfolioJournalWidget */,
|
||||||
0E241ECD2F0DAA3C00283E2F /* Frameworks */,
|
0E241ECD2F0DAA3C00283E2F /* Frameworks */,
|
||||||
0E241E3A2F0DA93A00283E2F /* Products */,
|
0E241E3A2F0DA93A00283E2F /* Products */,
|
||||||
);
|
);
|
||||||
|
|
@ -106,8 +131,8 @@
|
||||||
0E241E3A2F0DA93A00283E2F /* Products */ = {
|
0E241E3A2F0DA93A00283E2F /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
0E241E392F0DA93A00283E2F /* InvestmentTracker.app */,
|
0E241E392F0DA93A00283E2F /* PortfolioJournal.app */,
|
||||||
0E241ECC2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension.appex */,
|
0E241ECC2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -115,6 +140,7 @@
|
||||||
0E241ECD2F0DAA3C00283E2F /* Frameworks */ = {
|
0E241ECD2F0DAA3C00283E2F /* Frameworks */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
0E53752E2F0FD09F00F31390 /* CoreData.framework */,
|
||||||
0E241ECE2F0DAA3C00283E2F /* WidgetKit.framework */,
|
0E241ECE2F0DAA3C00283E2F /* WidgetKit.framework */,
|
||||||
0E241ED02F0DAA3C00283E2F /* SwiftUI.framework */,
|
0E241ED02F0DAA3C00283E2F /* SwiftUI.framework */,
|
||||||
);
|
);
|
||||||
|
|
@ -124,9 +150,9 @@
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
0E241E382F0DA93A00283E2F /* InvestmentTracker */ = {
|
0E241E382F0DA93A00283E2F /* PortfolioJournal */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 0E241E4A2F0DA93C00283E2F /* Build configuration list for PBXNativeTarget "InvestmentTracker" */;
|
buildConfigurationList = 0E241E4A2F0DA93C00283E2F /* Build configuration list for PBXNativeTarget "PortfolioJournal" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
0E241E352F0DA93A00283E2F /* Sources */,
|
0E241E352F0DA93A00283E2F /* Sources */,
|
||||||
0E241E362F0DA93A00283E2F /* Frameworks */,
|
0E241E362F0DA93A00283E2F /* Frameworks */,
|
||||||
|
|
@ -139,18 +165,21 @@
|
||||||
0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */,
|
0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
0E241E3B2F0DA93A00283E2F /* InvestmentTracker */,
|
0E241E3B2F0DA93A00283E2F /* PortfolioJournal */,
|
||||||
);
|
);
|
||||||
name = InvestmentTracker;
|
name = PortfolioJournal;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
|
0E53752A2F0FD08100F31390 /* FirebaseCore */,
|
||||||
|
0E53752C2F0FD08600F31390 /* FirebaseAnalytics */,
|
||||||
|
0E5375302F0FD12E00F31390 /* GoogleMobileAds */,
|
||||||
);
|
);
|
||||||
productName = InvestmentTracker;
|
productName = PortfolioJournal;
|
||||||
productReference = 0E241E392F0DA93A00283E2F /* InvestmentTracker.app */;
|
productReference = 0E241E392F0DA93A00283E2F /* PortfolioJournal.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
0E241ECB2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension */ = {
|
0E241ECB2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 0E241EE32F0DAA3E00283E2F /* Build configuration list for PBXNativeTarget "InvestmentTrackerWidgetExtension" */;
|
buildConfigurationList = 0E241EE32F0DAA3E00283E2F /* Build configuration list for PBXNativeTarget "PortfolioJournalWidgetExtension" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
0E241EC82F0DAA3C00283E2F /* Sources */,
|
0E241EC82F0DAA3C00283E2F /* Sources */,
|
||||||
0E241EC92F0DAA3C00283E2F /* Frameworks */,
|
0E241EC92F0DAA3C00283E2F /* Frameworks */,
|
||||||
|
|
@ -161,13 +190,13 @@
|
||||||
dependencies = (
|
dependencies = (
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
0E241ED22F0DAA3C00283E2F /* InvestmentTrackerWidget */,
|
0E241ED22F0DAA3C00283E2F /* PortfolioJournalWidget */,
|
||||||
);
|
);
|
||||||
name = InvestmentTrackerWidgetExtension;
|
name = PortfolioJournalWidgetExtension;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
);
|
);
|
||||||
productName = InvestmentTrackerWidgetExtension;
|
productName = PortfolioJournalWidgetExtension;
|
||||||
productReference = 0E241ECC2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension.appex */;
|
productReference = 0E241ECC2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension.appex */;
|
||||||
productType = "com.apple.product-type.app-extension";
|
productType = "com.apple.product-type.app-extension";
|
||||||
};
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* 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;
|
developmentRegion = en;
|
||||||
hasScannedForEncodings = 0;
|
hasScannedForEncodings = 0;
|
||||||
knownRegions = (
|
knownRegions = (
|
||||||
en,
|
en,
|
||||||
Base,
|
Base,
|
||||||
|
es,
|
||||||
|
"es-ES",
|
||||||
);
|
);
|
||||||
mainGroup = 0E241E302F0DA93A00283E2F;
|
mainGroup = 0E241E302F0DA93A00283E2F;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
|
@ -206,8 +237,8 @@
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
0E241E382F0DA93A00283E2F /* InvestmentTracker */,
|
0E241E382F0DA93A00283E2F /* PortfolioJournal */,
|
||||||
0E241ECB2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension */,
|
0E241ECB2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
|
|
@ -249,7 +280,7 @@
|
||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */ = {
|
0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */ = {
|
||||||
isa = PBXTargetDependency;
|
isa = PBXTargetDependency;
|
||||||
target = 0E241ECB2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension */;
|
target = 0E241ECB2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension */;
|
||||||
targetProxy = 0E241EE02F0DAA3E00283E2F /* PBXContainerItemProxy */;
|
targetProxy = 0E241EE02F0DAA3E00283E2F /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
@ -260,13 +291,16 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = InvestmentTracker/InvestmentTracker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||||
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = InvestmentTracker/Info.plist;
|
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_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
|
@ -277,7 +311,7 @@
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.InvestmentTracker;
|
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
|
@ -294,13 +328,16 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = InvestmentTracker/InvestmentTracker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournal.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||||
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = InvestmentTracker/Info.plist;
|
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_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
|
@ -311,7 +348,7 @@
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.InvestmentTracker;
|
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
|
@ -447,12 +484,14 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = InvestmentTrackerWidgetExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||||
INFOPLIST_FILE = "";
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = InvestmentTrackerWidget;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = PortfolioJournalWidget/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Journal Widget";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
|
|
@ -460,7 +499,7 @@
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.InvestmentTracker.InvestmentTrackerWidget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
|
@ -477,12 +516,14 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = InvestmentTrackerWidgetExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||||
INFOPLIST_FILE = "";
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = InvestmentTrackerWidget;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = PortfolioJournalWidget/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Journal Widget";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
|
|
@ -490,7 +531,7 @@
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.InvestmentTracker.InvestmentTrackerWidget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
|
@ -505,7 +546,7 @@
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
0E241E342F0DA93A00283E2F /* Build configuration list for PBXProject "InvestmentTracker" */ = {
|
0E241E342F0DA93A00283E2F /* Build configuration list for PBXProject "PortfolioJournal" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
0E241E4D2F0DA93C00283E2F /* Debug */,
|
0E241E4D2F0DA93C00283E2F /* Debug */,
|
||||||
|
|
@ -514,7 +555,7 @@
|
||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
0E241E4A2F0DA93C00283E2F /* Build configuration list for PBXNativeTarget "InvestmentTracker" */ = {
|
0E241E4A2F0DA93C00283E2F /* Build configuration list for PBXNativeTarget "PortfolioJournal" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
0E241E4B2F0DA93C00283E2F /* Debug */,
|
0E241E4B2F0DA93C00283E2F /* Debug */,
|
||||||
|
|
@ -523,7 +564,7 @@
|
||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
0E241EE32F0DAA3E00283E2F /* Build configuration list for PBXNativeTarget "InvestmentTrackerWidgetExtension" */ = {
|
0E241EE32F0DAA3E00283E2F /* Build configuration list for PBXNativeTarget "PortfolioJournalWidgetExtension" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
0E241EE42F0DAA3E00283E2F /* Debug */,
|
0E241EE42F0DAA3E00283E2F /* Debug */,
|
||||||
|
|
@ -552,6 +593,24 @@
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
/* 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 */;
|
rootObject = 0E241E312F0DA93A00283E2F /* Project object */;
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
|
@ -16,9 +16,9 @@
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "0E241E382F0DA93A00283E2F"
|
BlueprintIdentifier = "0E241E382F0DA93A00283E2F"
|
||||||
BuildableName = "InvestmentTracker.app"
|
BuildableName = "PortfolioJournal.app"
|
||||||
BlueprintName = "InvestmentTracker"
|
BlueprintName = "PortfolioJournal"
|
||||||
ReferencedContainer = "container:InvestmentTracker.xcodeproj">
|
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
</BuildActionEntries>
|
</BuildActionEntries>
|
||||||
|
|
@ -45,9 +45,9 @@
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "0E241E382F0DA93A00283E2F"
|
BlueprintIdentifier = "0E241E382F0DA93A00283E2F"
|
||||||
BuildableName = "InvestmentTracker.app"
|
BuildableName = "PortfolioJournal.app"
|
||||||
BlueprintName = "InvestmentTracker"
|
BlueprintName = "PortfolioJournal"
|
||||||
ReferencedContainer = "container:InvestmentTracker.xcodeproj">
|
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
|
|
@ -62,9 +62,9 @@
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "0E241E382F0DA93A00283E2F"
|
BlueprintIdentifier = "0E241E382F0DA93A00283E2F"
|
||||||
BuildableName = "InvestmentTracker.app"
|
BuildableName = "PortfolioJournal.app"
|
||||||
BlueprintName = "InvestmentTracker"
|
BlueprintName = "PortfolioJournal"
|
||||||
ReferencedContainer = "container:InvestmentTracker.xcodeproj">
|
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
</ProfileAction>
|
</ProfileAction>
|
||||||
|
|
@ -17,9 +17,9 @@
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "0E241ECB2F0DAA3C00283E2F"
|
BlueprintIdentifier = "0E241ECB2F0DAA3C00283E2F"
|
||||||
BuildableName = "InvestmentTrackerWidgetExtension.appex"
|
BuildableName = "PortfolioJournalWidgetExtension.appex"
|
||||||
BlueprintName = "InvestmentTrackerWidgetExtension"
|
BlueprintName = "PortfolioJournalWidgetExtension"
|
||||||
ReferencedContainer = "container:InvestmentTracker.xcodeproj">
|
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
|
|
@ -31,9 +31,9 @@
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "0E241E382F0DA93A00283E2F"
|
BlueprintIdentifier = "0E241E382F0DA93A00283E2F"
|
||||||
BuildableName = "InvestmentTracker.app"
|
BuildableName = "PortfolioJournal.app"
|
||||||
BlueprintName = "InvestmentTracker"
|
BlueprintName = "PortfolioJournal"
|
||||||
ReferencedContainer = "container:InvestmentTracker.xcodeproj">
|
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
</BuildActionEntries>
|
</BuildActionEntries>
|
||||||
|
|
@ -62,9 +62,9 @@
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "0E241E382F0DA93A00283E2F"
|
BlueprintIdentifier = "0E241E382F0DA93A00283E2F"
|
||||||
BuildableName = "InvestmentTracker.app"
|
BuildableName = "PortfolioJournal.app"
|
||||||
BlueprintName = "InvestmentTracker"
|
BlueprintName = "PortfolioJournal"
|
||||||
ReferencedContainer = "container:InvestmentTracker.xcodeproj">
|
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
|
|
@ -81,9 +81,9 @@
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "0E241E382F0DA93A00283E2F"
|
BlueprintIdentifier = "0E241E382F0DA93A00283E2F"
|
||||||
BuildableName = "InvestmentTracker.app"
|
BuildableName = "PortfolioJournal.app"
|
||||||
BlueprintName = "InvestmentTracker"
|
BlueprintName = "PortfolioJournal"
|
||||||
ReferencedContainer = "container:InvestmentTracker.xcodeproj">
|
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
</ProfileAction>
|
</ProfileAction>
|
||||||
|
|
@ -4,12 +4,12 @@
|
||||||
<dict>
|
<dict>
|
||||||
<key>SchemeUserState</key>
|
<key>SchemeUserState</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>InvestmentTracker.xcscheme_^#shared#^_</key>
|
<key>PortfolioJournal.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>1</integer>
|
<integer>1</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>InvestmentTrackerWidgetExtension.xcscheme_^#shared#^_</key>
|
<key>PortfolioJournalWidgetExtension.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>0</integer>
|
<integer>0</integer>
|
||||||
Binary file not shown.
|
|
@ -1,17 +1,27 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
import FirebaseCore
|
||||||
|
import FirebaseAnalytics
|
||||||
|
import GoogleMobileAds
|
||||||
|
|
||||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
func application(
|
func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||||
) -> Bool {
|
) -> 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
|
// Request notification permissions
|
||||||
requestNotificationPermissions()
|
requestNotificationPermissions()
|
||||||
|
|
||||||
// Register background tasks
|
|
||||||
registerBackgroundTasks()
|
|
||||||
|
|
||||||
return true
|
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(
|
func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
|
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@EnvironmentObject var iapService: IAPService
|
||||||
|
@EnvironmentObject var adMobService: AdMobService
|
||||||
|
@EnvironmentObject var tabSelection: TabSelectionStore
|
||||||
|
@AppStorage("onboardingCompleted") private var onboardingCompleted = false
|
||||||
|
@AppStorage("faceIdEnabled") private var faceIdEnabled = false
|
||||||
|
@AppStorage("pinEnabled") private var pinEnabled = false
|
||||||
|
@AppStorage("lockOnLaunch") private var lockOnLaunch = true
|
||||||
|
@AppStorage("lockOnBackground") private var lockOnBackground = false
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
@State private var isUnlocked = false
|
||||||
|
|
||||||
|
private var lockEnabled: Bool {
|
||||||
|
faceIdEnabled || pinEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Group {
|
||||||
|
if !onboardingCompleted {
|
||||||
|
OnboardingView(onboardingCompleted: $onboardingCompleted)
|
||||||
|
} else {
|
||||||
|
mainContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if onboardingCompleted && lockEnabled && !isUnlocked {
|
||||||
|
AppLockView(isUnlocked: $isUnlocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if !lockEnabled {
|
||||||
|
isUnlocked = true
|
||||||
|
} else {
|
||||||
|
isUnlocked = !lockOnLaunch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: lockEnabled) { _, enabled in
|
||||||
|
if !enabled {
|
||||||
|
isUnlocked = true
|
||||||
|
} else {
|
||||||
|
isUnlocked = !lockOnLaunch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: onboardingCompleted) { _, completed in
|
||||||
|
if completed && lockEnabled {
|
||||||
|
isUnlocked = !lockOnLaunch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: scenePhase) { _, phase in
|
||||||
|
guard onboardingCompleted, lockEnabled else { return }
|
||||||
|
if phase == .background && lockOnBackground {
|
||||||
|
isUnlocked = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mainContent: some View {
|
||||||
|
ZStack {
|
||||||
|
TabView(selection: $tabSelection.selectedTab) {
|
||||||
|
DashboardView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Home", systemImage: "house.fill")
|
||||||
|
}
|
||||||
|
.tag(0)
|
||||||
|
|
||||||
|
SourceListView(iapService: iapService)
|
||||||
|
.tabItem {
|
||||||
|
Label("Sources", systemImage: "list.bullet")
|
||||||
|
}
|
||||||
|
.tag(1)
|
||||||
|
|
||||||
|
ChartsContainerView(iapService: iapService)
|
||||||
|
.tabItem {
|
||||||
|
Label("Charts", systemImage: "chart.xyaxis.line")
|
||||||
|
}
|
||||||
|
.tag(2)
|
||||||
|
|
||||||
|
JournalView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Journal", systemImage: "book.closed")
|
||||||
|
}
|
||||||
|
.tag(3)
|
||||||
|
|
||||||
|
SettingsView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Settings", systemImage: "gearshape.fill")
|
||||||
|
}
|
||||||
|
.tag(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Banner ad at bottom for free users
|
||||||
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
|
if !iapService.isPremium {
|
||||||
|
BannerAdView()
|
||||||
|
.frame(height: AppConstants.UI.bannerAdHeight)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ContentView()
|
||||||
|
.environmentObject(IAPService())
|
||||||
|
.environmentObject(AdMobService())
|
||||||
|
.environmentObject(AccountStore(iapService: IAPService()))
|
||||||
|
.environmentObject(TabSelectionStore())
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct PortfolioJournalApp: App {
|
||||||
|
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
|
@StateObject private var iapService: IAPService
|
||||||
|
@StateObject private var adMobService: AdMobService
|
||||||
|
@StateObject private var accountStore: AccountStore
|
||||||
|
@StateObject private var tabSelection = TabSelectionStore()
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
|
let coreDataStack = CoreDataStack.shared
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let iap = IAPService()
|
||||||
|
_iapService = StateObject(wrappedValue: iap)
|
||||||
|
_adMobService = StateObject(wrappedValue: AdMobService())
|
||||||
|
_accountStore = StateObject(wrappedValue: AccountStore(iapService: iap))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.environment(\.managedObjectContext, coreDataStack.viewContext)
|
||||||
|
.environmentObject(iapService)
|
||||||
|
.environmentObject(adMobService)
|
||||||
|
.environmentObject(accountStore)
|
||||||
|
.environmentObject(tabSelection)
|
||||||
|
}
|
||||||
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
|
if newPhase == .active {
|
||||||
|
coreDataStack.refreshWidgetData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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 |
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "AppIcon.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|
@ -12,6 +13,7 @@
|
||||||
"value" : "dark"
|
"value" : "dark"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"filename" : "AppIcon-dark.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|
@ -23,6 +25,7 @@
|
||||||
"value" : "tinted"
|
"value" : "tinted"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"filename" : "AppIcon-tinted.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"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 |
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "BrandMark@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "BrandMark@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "BrandMark@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct CategoryEvolutionPoint: Identifiable {
|
||||||
|
let date: Date
|
||||||
|
let categoryName: String
|
||||||
|
let colorHex: String
|
||||||
|
let value: Decimal
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
"\(categoryName)-\(date.timeIntervalSince1970)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
@objc(Account)
|
||||||
|
public class Account: NSManagedObject, Identifiable {
|
||||||
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<Account> {
|
||||||
|
return NSFetchRequest<Account>(entityName: "Account")
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged public var id: UUID
|
||||||
|
@NSManaged public var name: String
|
||||||
|
@NSManaged public var createdAt: Date
|
||||||
|
@NSManaged public var currency: String?
|
||||||
|
@NSManaged public var inputMode: String
|
||||||
|
@NSManaged public var notificationFrequency: String
|
||||||
|
@NSManaged public var customFrequencyMonths: Int16
|
||||||
|
@NSManaged public var sortOrder: Int16
|
||||||
|
@NSManaged public var sources: NSSet?
|
||||||
|
@NSManaged public var goals: NSSet?
|
||||||
|
|
||||||
|
public override func awakeFromInsert() {
|
||||||
|
super.awakeFromInsert()
|
||||||
|
id = UUID()
|
||||||
|
createdAt = Date()
|
||||||
|
name = "Account"
|
||||||
|
inputMode = InputMode.simple.rawValue
|
||||||
|
notificationFrequency = NotificationFrequency.monthly.rawValue
|
||||||
|
customFrequencyMonths = 1
|
||||||
|
sortOrder = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
extension Account {
|
||||||
|
var sourcesArray: [InvestmentSource] {
|
||||||
|
let set = sources as? Set<InvestmentSource> ?? []
|
||||||
|
return set.sorted { $0.name < $1.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
var goalsArray: [Goal] {
|
||||||
|
let set = goals as? Set<Goal> ?? []
|
||||||
|
return set.sorted { $0.createdAt < $1.createdAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
var frequency: NotificationFrequency {
|
||||||
|
NotificationFrequency(rawValue: notificationFrequency) ?? .monthly
|
||||||
|
}
|
||||||
|
|
||||||
|
var currencyCode: String? {
|
||||||
|
currency?.isEmpty == false ? currency : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -14,13 +14,21 @@ public class AppSettings: NSManagedObject, Identifiable {
|
||||||
@NSManaged public var onboardingCompleted: Bool
|
@NSManaged public var onboardingCompleted: Bool
|
||||||
@NSManaged public var lastSyncDate: Date?
|
@NSManaged public var lastSyncDate: Date?
|
||||||
@NSManaged public var createdAt: 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() {
|
public override func awakeFromInsert() {
|
||||||
super.awakeFromInsert()
|
super.awakeFromInsert()
|
||||||
id = UUID()
|
id = UUID()
|
||||||
currency = "EUR"
|
let localeCode = Locale.current.currency?.identifier
|
||||||
|
currency = CurrencyPicker.commonCodes.contains(localeCode ?? "")
|
||||||
|
? (localeCode ?? "EUR")
|
||||||
|
: "EUR"
|
||||||
enableAnalytics = true
|
enableAnalytics = true
|
||||||
onboardingCompleted = false
|
onboardingCompleted = false
|
||||||
|
inputMode = InputMode.simple.rawValue
|
||||||
|
showAllAccounts = true
|
||||||
createdAt = Date()
|
createdAt = Date()
|
||||||
|
|
||||||
// Default notification time: 9:00 AM
|
// Default notification time: 9:00 AM
|
||||||
|
|
@ -35,8 +43,10 @@ public class AppSettings: NSManagedObject, Identifiable {
|
||||||
|
|
||||||
extension AppSettings {
|
extension AppSettings {
|
||||||
var currencySymbol: String {
|
var currencySymbol: String {
|
||||||
let locale = Locale.current
|
let formatter = NumberFormatter()
|
||||||
return locale.currencySymbol ?? "€"
|
formatter.numberStyle = .currency
|
||||||
|
formatter.currencyCode = currency
|
||||||
|
return formatter.currencySymbol ?? "€"
|
||||||
}
|
}
|
||||||
|
|
||||||
var notificationTimeString: String {
|
var notificationTimeString: String {
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
@objc(Asset)
|
||||||
|
public class Asset: NSManagedObject, Identifiable {
|
||||||
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<Asset> {
|
||||||
|
return NSFetchRequest<Asset>(entityName: "Asset")
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged public var id: UUID
|
||||||
|
@NSManaged public var name: String
|
||||||
|
@NSManaged public var symbol: String?
|
||||||
|
@NSManaged public var type: String
|
||||||
|
@NSManaged public var currency: String?
|
||||||
|
@NSManaged public var createdAt: Date
|
||||||
|
@NSManaged public var sources: NSSet?
|
||||||
|
|
||||||
|
public override func awakeFromInsert() {
|
||||||
|
super.awakeFromInsert()
|
||||||
|
id = UUID()
|
||||||
|
createdAt = Date()
|
||||||
|
name = ""
|
||||||
|
type = AssetType.stock.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AssetType: String, CaseIterable, Identifiable {
|
||||||
|
case stock
|
||||||
|
case etf
|
||||||
|
case crypto
|
||||||
|
case fund
|
||||||
|
case cash
|
||||||
|
case realEstate
|
||||||
|
case other
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .stock: return "Stock"
|
||||||
|
case .etf: return "ETF"
|
||||||
|
case .crypto: return "Crypto"
|
||||||
|
case .fund: return "Fund"
|
||||||
|
case .cash: return "Cash"
|
||||||
|
case .realEstate: return "Real Estate"
|
||||||
|
case .other: return "Other"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
@objc(Goal)
|
||||||
|
public class Goal: NSManagedObject, Identifiable {
|
||||||
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<Goal> {
|
||||||
|
return NSFetchRequest<Goal>(entityName: "Goal")
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged public var id: UUID
|
||||||
|
@NSManaged public var name: String
|
||||||
|
@NSManaged public var targetAmount: NSDecimalNumber?
|
||||||
|
@NSManaged public var targetDate: Date?
|
||||||
|
@NSManaged public var isActive: Bool
|
||||||
|
@NSManaged public var createdAt: Date
|
||||||
|
@NSManaged public var account: Account?
|
||||||
|
|
||||||
|
public override func awakeFromInsert() {
|
||||||
|
super.awakeFromInsert()
|
||||||
|
id = UUID()
|
||||||
|
createdAt = Date()
|
||||||
|
name = ""
|
||||||
|
isActive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
extension Goal {
|
||||||
|
var targetDecimal: Decimal {
|
||||||
|
targetAmount?.decimalValue ?? Decimal.zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,239 @@
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
@objc(InvestmentSource)
|
||||||
|
public class InvestmentSource: NSManagedObject, Identifiable {
|
||||||
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<InvestmentSource> {
|
||||||
|
return NSFetchRequest<InvestmentSource>(entityName: "InvestmentSource")
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged public var id: UUID
|
||||||
|
@NSManaged public var name: String
|
||||||
|
@NSManaged public var notificationFrequency: String
|
||||||
|
@NSManaged public var customFrequencyMonths: Int16
|
||||||
|
@NSManaged public var isActive: Bool
|
||||||
|
@NSManaged public var createdAt: Date
|
||||||
|
@NSManaged public var category: Category?
|
||||||
|
@NSManaged public var account: Account?
|
||||||
|
@NSManaged public var snapshots: NSSet?
|
||||||
|
@NSManaged public var transactions: NSSet?
|
||||||
|
@NSManaged public var asset: Asset?
|
||||||
|
|
||||||
|
public override func awakeFromInsert() {
|
||||||
|
super.awakeFromInsert()
|
||||||
|
id = UUID()
|
||||||
|
createdAt = Date()
|
||||||
|
isActive = true
|
||||||
|
notificationFrequency = NotificationFrequency.monthly.rawValue
|
||||||
|
customFrequencyMonths = 1
|
||||||
|
name = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notification Frequency
|
||||||
|
|
||||||
|
enum NotificationFrequency: String, CaseIterable, Identifiable {
|
||||||
|
case monthly = "monthly"
|
||||||
|
case quarterly = "quarterly"
|
||||||
|
case semiannual = "semiannual"
|
||||||
|
case annual = "annual"
|
||||||
|
case custom = "custom"
|
||||||
|
case never = "never"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .monthly: return "Monthly"
|
||||||
|
case .quarterly: return "Quarterly"
|
||||||
|
case .semiannual: return "Semi-Annual"
|
||||||
|
case .annual: return "Annual"
|
||||||
|
case .custom: return "Custom"
|
||||||
|
case .never: return "Never"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var months: Int {
|
||||||
|
switch self {
|
||||||
|
case .monthly: return 1
|
||||||
|
case .quarterly: return 3
|
||||||
|
case .semiannual: return 6
|
||||||
|
case .annual: return 12
|
||||||
|
case .custom, .never: return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
extension InvestmentSource {
|
||||||
|
/// Returns snapshots sorted by date descending (most recent first)
|
||||||
|
/// Performance note: This sorts on every call. For repeated access, cache the result.
|
||||||
|
var snapshotsArray: [Snapshot] {
|
||||||
|
let set = snapshots as? Set<Snapshot> ?? []
|
||||||
|
return set.sorted { $0.date > $1.date }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns snapshots sorted by date ascending (oldest first)
|
||||||
|
/// Performance note: This sorts on every call. For repeated access, cache the result.
|
||||||
|
var sortedSnapshotsByDateAscending: [Snapshot] {
|
||||||
|
let set = snapshots as? Set<Snapshot> ?? []
|
||||||
|
return set.sorted { $0.date < $1.date }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the most recent snapshot without sorting all snapshots
|
||||||
|
/// Performance: O(n) instead of O(n log n) for sorting
|
||||||
|
var latestSnapshot: Snapshot? {
|
||||||
|
guard let set = snapshots as? Set<Snapshot>, !set.isEmpty else { return nil }
|
||||||
|
return set.max(by: { $0.date < $1.date })
|
||||||
|
}
|
||||||
|
|
||||||
|
var latestValue: Decimal {
|
||||||
|
latestSnapshot?.value?.decimalValue ?? Decimal.zero
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshotCount: Int {
|
||||||
|
snapshots?.count ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns transactions sorted by date descending
|
||||||
|
/// Performance note: This sorts on every call. For repeated access, cache the result.
|
||||||
|
var transactionsArray: [Transaction] {
|
||||||
|
let set = transactions as? Set<Transaction> ?? []
|
||||||
|
return set.sorted { $0.date > $1.date }
|
||||||
|
}
|
||||||
|
|
||||||
|
var frequency: NotificationFrequency {
|
||||||
|
NotificationFrequency(rawValue: notificationFrequency) ?? .monthly
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextReminderDate: Date? {
|
||||||
|
if let account = account {
|
||||||
|
return account.nextReminderDate
|
||||||
|
}
|
||||||
|
|
||||||
|
guard frequency != .never else { return nil }
|
||||||
|
|
||||||
|
let months = frequency == .custom ? Int(customFrequencyMonths) : frequency.months
|
||||||
|
guard let lastSnapshot = latestSnapshot else {
|
||||||
|
return Date() // Remind now if no snapshots
|
||||||
|
}
|
||||||
|
|
||||||
|
return Calendar.current.date(byAdding: .month, value: months, to: lastSnapshot.date)
|
||||||
|
}
|
||||||
|
|
||||||
|
var needsUpdate: Bool {
|
||||||
|
if let account = account {
|
||||||
|
guard let nextDate = account.nextReminderDate else { return false }
|
||||||
|
return Date() >= nextDate
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let nextDate = nextReminderDate else { return false }
|
||||||
|
return Date() >= nextDate
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Performance Metrics
|
||||||
|
|
||||||
|
/// Calculates total return percentage. Performance: Uses O(n) min/max instead of sorting.
|
||||||
|
var totalReturn: Decimal {
|
||||||
|
guard let set = snapshots as? Set<Snapshot>, set.count >= 2 else {
|
||||||
|
return Decimal.zero
|
||||||
|
}
|
||||||
|
|
||||||
|
// Performance: Find min and max by date in one pass instead of sorting twice
|
||||||
|
guard let first = set.min(by: { $0.date < $1.date }),
|
||||||
|
let last = set.max(by: { $0.date < $1.date }),
|
||||||
|
let firstValue = first.value?.decimalValue,
|
||||||
|
firstValue != Decimal.zero else {
|
||||||
|
return Decimal.zero
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastValue = last.value?.decimalValue ?? Decimal.zero
|
||||||
|
return ((lastValue - firstValue) / firstValue) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performance: Iterates snapshots directly without sorting
|
||||||
|
var totalContributions: Decimal {
|
||||||
|
guard let set = snapshots as? Set<Snapshot> else { return Decimal.zero }
|
||||||
|
return set.reduce(Decimal.zero) { result, snapshot in
|
||||||
|
result + (snapshot.contribution?.decimalValue ?? Decimal.zero)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performance: Iterates transactions directly without sorting
|
||||||
|
var totalInvested: Decimal {
|
||||||
|
guard let set = transactions as? Set<Transaction> else { return Decimal.zero }
|
||||||
|
return set.reduce(Decimal.zero) { result, transaction in
|
||||||
|
let amount = transaction.decimalAmount
|
||||||
|
switch transaction.transactionType {
|
||||||
|
case .buy:
|
||||||
|
return result + amount
|
||||||
|
case .sell:
|
||||||
|
return result - amount
|
||||||
|
default:
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performance: Iterates transactions directly without sorting
|
||||||
|
var totalDividends: Decimal {
|
||||||
|
guard let set = transactions as? Set<Transaction> else { return Decimal.zero }
|
||||||
|
return set.reduce(Decimal.zero) { result, transaction in
|
||||||
|
transaction.transactionType == .dividend ? result + transaction.decimalAmount : result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performance: Iterates transactions directly without sorting
|
||||||
|
var totalFees: Decimal {
|
||||||
|
guard let set = transactions as? Set<Transaction> else { return Decimal.zero }
|
||||||
|
return set.reduce(Decimal.zero) { result, transaction in
|
||||||
|
transaction.transactionType == .fee ? result + transaction.decimalAmount : result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Account Scheduling
|
||||||
|
|
||||||
|
extension Account {
|
||||||
|
var nextReminderDate: Date? {
|
||||||
|
guard frequency != .never else { return nil }
|
||||||
|
|
||||||
|
let months = frequency == .custom ? Int(customFrequencyMonths) : frequency.months
|
||||||
|
guard let latestSnapshotDate = sourcesArray
|
||||||
|
.compactMap({ $0.latestSnapshot?.date })
|
||||||
|
.max() else {
|
||||||
|
return Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
return Calendar.current.date(byAdding: .month, value: months, to: latestSnapshotDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Generated accessors for snapshots
|
||||||
|
|
||||||
|
extension InvestmentSource {
|
||||||
|
@objc(addSnapshotsObject:)
|
||||||
|
@NSManaged public func addToSnapshots(_ value: Snapshot)
|
||||||
|
|
||||||
|
@objc(removeSnapshotsObject:)
|
||||||
|
@NSManaged public func removeFromSnapshots(_ value: Snapshot)
|
||||||
|
|
||||||
|
@objc(addSnapshots:)
|
||||||
|
@NSManaged public func addToSnapshots(_ values: NSSet)
|
||||||
|
|
||||||
|
@objc(removeSnapshots:)
|
||||||
|
@NSManaged public func removeFromSnapshots(_ values: NSSet)
|
||||||
|
|
||||||
|
@objc(addTransactionsObject:)
|
||||||
|
@NSManaged public func addToTransactions(_ value: Transaction)
|
||||||
|
|
||||||
|
@objc(removeTransactionsObject:)
|
||||||
|
@NSManaged public func removeFromTransactions(_ value: Transaction)
|
||||||
|
|
||||||
|
@objc(addTransactions:)
|
||||||
|
@NSManaged public func addToTransactions(_ values: NSSet)
|
||||||
|
|
||||||
|
@objc(removeTransactions:)
|
||||||
|
@NSManaged public func removeFromTransactions(_ values: NSSet)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier="">
|
||||||
|
<entity name="AppSettings" representedClassName="AppSettings" syncable="YES">
|
||||||
|
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="currency" attributeType="String" defaultValueString="EUR"/>
|
||||||
|
<attribute name="defaultNotificationTime" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="enableAnalytics" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="inputMode" attributeType="String" defaultValueString="simple"/>
|
||||||
|
<attribute name="lastSyncDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="onboardingCompleted" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="selectedAccountId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="showAllAccounts" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="Account" representedClassName="Account" syncable="YES">
|
||||||
|
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="currency" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="customFrequencyMonths" attributeType="Integer 16" defaultValueString="1" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="inputMode" attributeType="String" defaultValueString="simple"/>
|
||||||
|
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="notificationFrequency" attributeType="String" defaultValueString="monthly"/>
|
||||||
|
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
|
<relationship name="goals" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Goal" inverseName="account" inverseEntity="Goal"/>
|
||||||
|
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="InvestmentSource" inverseName="account" inverseEntity="InvestmentSource"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="Category" representedClassName="Category" syncable="YES">
|
||||||
|
<attribute name="colorHex" attributeType="String" defaultValueString="#3B82F6"/>
|
||||||
|
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="icon" attributeType="String" defaultValueString="chart.pie.fill"/>
|
||||||
|
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
|
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="InvestmentSource" inverseName="category" inverseEntity="InvestmentSource"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="InvestmentSource" representedClassName="InvestmentSource" syncable="YES">
|
||||||
|
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="customFrequencyMonths" attributeType="Integer 16" defaultValueString="1" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="isActive" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="notificationFrequency" attributeType="String" defaultValueString="monthly"/>
|
||||||
|
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="sources" inverseEntity="Account"/>
|
||||||
|
<relationship name="asset" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Asset" inverseName="sources" inverseEntity="Asset"/>
|
||||||
|
<relationship name="category" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Category" inverseName="sources" inverseEntity="Category"/>
|
||||||
|
<relationship name="snapshots" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Snapshot" inverseName="source" inverseEntity="Snapshot"/>
|
||||||
|
<relationship name="transactions" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Transaction" inverseName="source" inverseEntity="Transaction"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="Asset" representedClassName="Asset" syncable="YES">
|
||||||
|
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="currency" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="symbol" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="type" attributeType="String" defaultValueString="stock"/>
|
||||||
|
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="asset" inverseEntity="InvestmentSource"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="Transaction" representedClassName="Transaction" syncable="YES">
|
||||||
|
<attribute name="amount" optional="YES" attributeType="Decimal"/>
|
||||||
|
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="notes" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="price" optional="YES" attributeType="Decimal"/>
|
||||||
|
<attribute name="shares" optional="YES" attributeType="Decimal"/>
|
||||||
|
<attribute name="type" attributeType="String" defaultValueString="buy"/>
|
||||||
|
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="transactions" inverseEntity="InvestmentSource"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="Goal" representedClassName="Goal" syncable="YES">
|
||||||
|
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="isActive" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="targetAmount" optional="YES" attributeType="Decimal"/>
|
||||||
|
<attribute name="targetDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="goals" inverseEntity="Account"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="PredictionCache" representedClassName="PredictionCache" syncable="YES">
|
||||||
|
<attribute name="algorithm" attributeType="String" defaultValueString="linear"/>
|
||||||
|
<attribute name="calculatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="predictionData" optional="YES" attributeType="Binary"/>
|
||||||
|
<attribute name="sourceId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="validUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="PremiumStatus" representedClassName="PremiumStatus" syncable="YES">
|
||||||
|
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="isFamilyShared" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="isPremium" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="lastVerificationDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="originalPurchaseDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="productIdentifier" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="purchaseDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="transactionId" optional="YES" attributeType="String"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="Snapshot" representedClassName="Snapshot" syncable="YES">
|
||||||
|
<attribute name="contribution" optional="YES" attributeType="Decimal"/>
|
||||||
|
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="notes" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="value" optional="YES" attributeType="Decimal"/>
|
||||||
|
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="snapshots" inverseEntity="InvestmentSource"/>
|
||||||
|
</entity>
|
||||||
|
</model>
|
||||||
|
|
@ -30,6 +30,7 @@ enum PredictionAlgorithm: String, CaseIterable, Identifiable {
|
||||||
case linear = "linear"
|
case linear = "linear"
|
||||||
case exponentialSmoothing = "exponential_smoothing"
|
case exponentialSmoothing = "exponential_smoothing"
|
||||||
case movingAverage = "moving_average"
|
case movingAverage = "moving_average"
|
||||||
|
case holtTrend = "holt_trend"
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
|
@ -38,6 +39,7 @@ enum PredictionAlgorithm: String, CaseIterable, Identifiable {
|
||||||
case .linear: return "Linear Regression"
|
case .linear: return "Linear Regression"
|
||||||
case .exponentialSmoothing: return "Exponential Smoothing"
|
case .exponentialSmoothing: return "Exponential Smoothing"
|
||||||
case .movingAverage: return "Moving Average"
|
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"
|
return "Gives more weight to recent data points"
|
||||||
case .movingAverage:
|
case .movingAverage:
|
||||||
return "Smooths out short-term fluctuations"
|
return "Smooths out short-term fluctuations"
|
||||||
|
case .holtTrend:
|
||||||
|
return "Captures level and trend changes over time"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -35,11 +35,7 @@ extension Snapshot {
|
||||||
}
|
}
|
||||||
|
|
||||||
var formattedValue: String {
|
var formattedValue: String {
|
||||||
let formatter = NumberFormatter()
|
CurrencyFormatter.format(value?.decimalValue ?? Decimal.zero, style: .currency, maximumFractionDigits: 2)
|
||||||
formatter.numberStyle = .currency
|
|
||||||
formatter.currencyCode = "EUR"
|
|
||||||
formatter.maximumFractionDigits = 2
|
|
||||||
return formatter.string(from: value ?? 0) ?? "€0.00"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var formattedDate: String {
|
var formattedDate: String {
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -87,11 +87,7 @@ extension InvestmentMetrics {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatCurrency(_ value: Decimal) -> String {
|
private func formatCurrency(_ value: Decimal) -> String {
|
||||||
let formatter = NumberFormatter()
|
CurrencyFormatter.format(value, style: .currency, maximumFractionDigits: 2)
|
||||||
formatter.numberStyle = .currency
|
|
||||||
formatter.currencyCode = "EUR"
|
|
||||||
formatter.maximumFractionDigits = 2
|
|
||||||
return formatter.string(from: value as NSDecimalNumber) ?? "€0.00"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,11 +128,7 @@ struct CategoryMetrics: Identifiable {
|
||||||
let metrics: InvestmentMetrics
|
let metrics: InvestmentMetrics
|
||||||
|
|
||||||
var formattedTotalValue: String {
|
var formattedTotalValue: String {
|
||||||
let formatter = NumberFormatter()
|
CurrencyFormatter.format(totalValue, style: .currency, maximumFractionDigits: 0)
|
||||||
formatter.numberStyle = .currency
|
|
||||||
formatter.currencyCode = "EUR"
|
|
||||||
formatter.maximumFractionDigits = 0
|
|
||||||
return formatter.string(from: totalValue as NSDecimalNumber) ?? "€0"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var formattedPercentage: String {
|
var formattedPercentage: String {
|
||||||
|
|
@ -163,11 +155,7 @@ struct PortfolioSummary {
|
||||||
let lastUpdated: Date?
|
let lastUpdated: Date?
|
||||||
|
|
||||||
var formattedTotalValue: String {
|
var formattedTotalValue: String {
|
||||||
let formatter = NumberFormatter()
|
CurrencyFormatter.format(totalValue, style: .currency, maximumFractionDigits: 0)
|
||||||
formatter.numberStyle = .currency
|
|
||||||
formatter.currencyCode = "EUR"
|
|
||||||
formatter.maximumFractionDigits = 0
|
|
||||||
return formatter.string(from: totalValue as NSDecimalNumber) ?? "€0"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var formattedDayChange: String {
|
var formattedDayChange: String {
|
||||||
|
|
@ -187,13 +175,8 @@ struct PortfolioSummary {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatChange(_ absolute: Decimal, _ percentage: Double) -> String {
|
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 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)
|
let pctString = String(format: "%.2f%%", percentage)
|
||||||
|
|
||||||
return "\(prefix)\(absString) (\(prefix)\(pctString))"
|
return "\(prefix)\(absString) (\(prefix)\(pctString))"
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct MonthlyCheckInEntry: Codable, Equatable {
|
||||||
|
var note: String?
|
||||||
|
var rating: Int?
|
||||||
|
var mood: MonthlyCheckInMood?
|
||||||
|
var completionTime: Double?
|
||||||
|
var createdAt: Double
|
||||||
|
|
||||||
|
var completionDate: Date? {
|
||||||
|
completionTime.map { Date(timeIntervalSince1970: $0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MonthlyCheckInMood: String, Codable, CaseIterable, Identifiable {
|
||||||
|
case energized
|
||||||
|
case confident
|
||||||
|
case balanced
|
||||||
|
case cautious
|
||||||
|
case stressed
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .energized: return String(localized: "mood_energized_title")
|
||||||
|
case .confident: return String(localized: "mood_confident_title")
|
||||||
|
case .balanced: return String(localized: "mood_balanced_title")
|
||||||
|
case .cautious: return String(localized: "mood_cautious_title")
|
||||||
|
case .stressed: return String(localized: "mood_stressed_title")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var iconName: String {
|
||||||
|
switch self {
|
||||||
|
case .energized: return "flame.fill"
|
||||||
|
case .confident: return "hand.thumbsup.fill"
|
||||||
|
case .balanced: return "leaf.fill"
|
||||||
|
case .cautious: return "exclamationmark.triangle.fill"
|
||||||
|
case .stressed: return "exclamationmark.octagon.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var detail: String {
|
||||||
|
switch self {
|
||||||
|
case .energized: return String(localized: "mood_energized_detail")
|
||||||
|
case .confident: return String(localized: "mood_confident_detail")
|
||||||
|
case .balanced: return String(localized: "mood_balanced_detail")
|
||||||
|
case .cautious: return String(localized: "mood_cautious_detail")
|
||||||
|
case .stressed: return String(localized: "mood_stressed_detail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MonthlyCheckInAchievement: Identifiable, Equatable {
|
||||||
|
let key: String
|
||||||
|
let title: String
|
||||||
|
let detail: String
|
||||||
|
let icon: String
|
||||||
|
|
||||||
|
var id: String { key }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MonthlyCheckInAchievementStatus: Identifiable, Equatable {
|
||||||
|
let achievement: MonthlyCheckInAchievement
|
||||||
|
let isUnlocked: Bool
|
||||||
|
|
||||||
|
var id: String { achievement.key }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MonthlyCheckInStats: Equatable {
|
||||||
|
let currentStreak: Int
|
||||||
|
let bestStreak: Int
|
||||||
|
let onTimeCount: Int
|
||||||
|
let totalCheckIns: Int
|
||||||
|
let averageDaysBeforeDeadline: Double?
|
||||||
|
let closestCutoffDays: Double?
|
||||||
|
let recentMood: MonthlyCheckInMood?
|
||||||
|
let achievements: [MonthlyCheckInAchievement]
|
||||||
|
|
||||||
|
static var empty: MonthlyCheckInStats {
|
||||||
|
MonthlyCheckInStats(
|
||||||
|
currentStreak: 0,
|
||||||
|
bestStreak: 0,
|
||||||
|
onTimeCount: 0,
|
||||||
|
totalCheckIns: 0,
|
||||||
|
averageDaysBeforeDeadline: nil,
|
||||||
|
closestCutoffDays: nil,
|
||||||
|
recentMood: nil,
|
||||||
|
achievements: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var onTimeRate: Double {
|
||||||
|
guard totalCheckIns > 0 else { return 0 }
|
||||||
|
return Double(onTimeCount) / Double(totalCheckIns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct MonthlySummary {
|
||||||
|
let periodLabel: String
|
||||||
|
let startDate: Date
|
||||||
|
let endDate: Date
|
||||||
|
let startingValue: Decimal
|
||||||
|
let endingValue: Decimal
|
||||||
|
let contributions: Decimal
|
||||||
|
let netPerformance: Decimal
|
||||||
|
|
||||||
|
var netPerformancePercentage: Double {
|
||||||
|
let base = startingValue + contributions
|
||||||
|
guard base > 0 else { return 0 }
|
||||||
|
return NSDecimalNumber(decimal: netPerformance / base).doubleValue * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedStartingValue: String {
|
||||||
|
CurrencyFormatter.format(startingValue, style: .currency, maximumFractionDigits: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedEndingValue: String {
|
||||||
|
CurrencyFormatter.format(endingValue, style: .currency, maximumFractionDigits: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedContributions: String {
|
||||||
|
CurrencyFormatter.format(contributions, style: .currency, maximumFractionDigits: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedNetPerformance: String {
|
||||||
|
let prefix = netPerformance >= 0 ? "+" : ""
|
||||||
|
let value = CurrencyFormatter.format(netPerformance, style: .currency, maximumFractionDigits: 0)
|
||||||
|
return prefix + value
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedNetPerformancePercentage: String {
|
||||||
|
let prefix = netPerformance >= 0 ? "+" : ""
|
||||||
|
return String(format: "\(prefix)%.2f%%", netPerformancePercentage)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var empty: MonthlySummary {
|
||||||
|
MonthlySummary(
|
||||||
|
periodLabel: "This Month",
|
||||||
|
startDate: Date(),
|
||||||
|
endDate: Date(),
|
||||||
|
startingValue: 0,
|
||||||
|
endingValue: 0,
|
||||||
|
contributions: 0,
|
||||||
|
netPerformance: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PortfolioChange {
|
||||||
|
let absolute: Decimal
|
||||||
|
let percentage: Double
|
||||||
|
let label: String
|
||||||
|
|
||||||
|
var formattedAbsolute: String {
|
||||||
|
let prefix = absolute >= 0 ? "+" : ""
|
||||||
|
let value = CurrencyFormatter.format(absolute, style: .currency, maximumFractionDigits: 0)
|
||||||
|
return prefix + value
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedPercentage: String {
|
||||||
|
let prefix = absolute >= 0 ? "+" : ""
|
||||||
|
return String(format: "\(prefix)%.2f%%", percentage)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var empty: PortfolioChange {
|
||||||
|
PortfolioChange(absolute: 0, percentage: 0, label: "since last update")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -44,11 +44,7 @@ extension PredictionAlgorithm: Codable {}
|
||||||
|
|
||||||
extension Prediction {
|
extension Prediction {
|
||||||
var formattedValue: String {
|
var formattedValue: String {
|
||||||
let formatter = NumberFormatter()
|
CurrencyFormatter.format(predictedValue, style: .currency, maximumFractionDigits: 0)
|
||||||
formatter.numberStyle = .currency
|
|
||||||
formatter.currencyCode = "EUR"
|
|
||||||
formatter.maximumFractionDigits = 0
|
|
||||||
return formatter.string(from: predictedValue as NSDecimalNumber) ?? "€0"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var formattedDate: String {
|
var formattedDate: String {
|
||||||
|
|
@ -58,13 +54,16 @@ extension Prediction {
|
||||||
}
|
}
|
||||||
|
|
||||||
var formattedConfidenceRange: String {
|
var formattedConfidenceRange: String {
|
||||||
let formatter = NumberFormatter()
|
let lower = CurrencyFormatter.format(
|
||||||
formatter.numberStyle = .currency
|
confidenceInterval.lower,
|
||||||
formatter.currencyCode = "EUR"
|
style: .currency,
|
||||||
formatter.maximumFractionDigits = 0
|
maximumFractionDigits: 0
|
||||||
|
)
|
||||||
let lower = formatter.string(from: confidenceInterval.lower as NSDecimalNumber) ?? "€0"
|
let upper = CurrencyFormatter.format(
|
||||||
let upper = formatter.string(from: confidenceInterval.upper as NSDecimalNumber) ?? "€0"
|
confidenceInterval.upper,
|
||||||
|
style: .currency,
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
)
|
||||||
|
|
||||||
return "\(lower) - \(upper)"
|
return "\(lower) - \(upper)"
|
||||||
}
|
}
|
||||||
|
|
@ -6,15 +6,17 @@
|
||||||
<string>development</string>
|
<string>development</string>
|
||||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||||
<array>
|
<array>
|
||||||
<string>iCloud.com.yourteam.investmenttracker</string>
|
<string>iCloud.com.alexandrevazquez.portfoliojournal</string>
|
||||||
</array>
|
</array>
|
||||||
<key>com.apple.developer.icloud-services</key>
|
<key>com.apple.developer.icloud-services</key>
|
||||||
<array>
|
<array>
|
||||||
<string>CloudKit</string>
|
<string>CloudKit</string>
|
||||||
</array>
|
</array>
|
||||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
<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>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array/>
|
<array>
|
||||||
|
<string>group.com.alexandrevazquez.portfoliojournal</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
|
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>iCloud.com.alexandrevazquez.portfoliojournal</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.developer.icloud-services</key>
|
||||||
|
<array>
|
||||||
|
<string>CloudKit</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||||
|
<string>$(TeamIdentifierPrefix)com.alexandrevazquez.portfoliojournal</string>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.com.alexandrevazquez.portfoliojournal</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
class AccountRepository: ObservableObject {
|
||||||
|
private let context: NSManagedObjectContext
|
||||||
|
|
||||||
|
@Published private(set) var accounts: [Account] = []
|
||||||
|
|
||||||
|
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
||||||
|
self.context = context
|
||||||
|
fetchAccounts()
|
||||||
|
setupNotificationObserver()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupNotificationObserver() {
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(contextDidChange),
|
||||||
|
name: .NSManagedObjectContextObjectsDidChange,
|
||||||
|
object: context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func contextDidChange(_ notification: Notification) {
|
||||||
|
fetchAccounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Fetch
|
||||||
|
|
||||||
|
func fetchAccounts() {
|
||||||
|
context.perform { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let request: NSFetchRequest<Account> = Account.fetchRequest()
|
||||||
|
request.sortDescriptors = [
|
||||||
|
NSSortDescriptor(keyPath: \Account.sortOrder, ascending: true),
|
||||||
|
NSSortDescriptor(keyPath: \Account.createdAt, ascending: true)
|
||||||
|
]
|
||||||
|
|
||||||
|
do {
|
||||||
|
let fetched = try self.context.fetch(request)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.accounts = fetched
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Failed to fetch accounts: \(error)")
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.accounts = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchAccount(by id: UUID) -> Account? {
|
||||||
|
let request: NSFetchRequest<Account> = Account.fetchRequest()
|
||||||
|
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
|
||||||
|
request.fetchLimit = 1
|
||||||
|
return try? context.fetch(request).first
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Create
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func createAccount(
|
||||||
|
name: String,
|
||||||
|
currency: String?,
|
||||||
|
inputMode: InputMode,
|
||||||
|
notificationFrequency: NotificationFrequency,
|
||||||
|
customFrequencyMonths: Int = 1
|
||||||
|
) -> Account {
|
||||||
|
let account = Account(context: context)
|
||||||
|
account.name = name
|
||||||
|
account.currency = currency
|
||||||
|
account.inputMode = inputMode.rawValue
|
||||||
|
account.notificationFrequency = notificationFrequency.rawValue
|
||||||
|
account.customFrequencyMonths = Int16(customFrequencyMonths)
|
||||||
|
account.sortOrder = Int16(accounts.count)
|
||||||
|
save()
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
func createDefaultAccountIfNeeded() -> Account {
|
||||||
|
if let existing = accounts.first {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
let defaultCurrency = AppSettings.getOrCreate(in: context).currency
|
||||||
|
let account = createAccount(
|
||||||
|
name: "Personal",
|
||||||
|
currency: defaultCurrency,
|
||||||
|
inputMode: .simple,
|
||||||
|
notificationFrequency: .monthly
|
||||||
|
)
|
||||||
|
|
||||||
|
// Attach existing sources to the default account
|
||||||
|
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||||
|
if let sources = try? context.fetch(request) {
|
||||||
|
for source in sources where source.account == nil {
|
||||||
|
source.account = account
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Update
|
||||||
|
|
||||||
|
func updateAccount(
|
||||||
|
_ account: Account,
|
||||||
|
name: String? = nil,
|
||||||
|
currency: String? = nil,
|
||||||
|
inputMode: InputMode? = nil,
|
||||||
|
notificationFrequency: NotificationFrequency? = nil,
|
||||||
|
customFrequencyMonths: Int? = nil
|
||||||
|
) {
|
||||||
|
if let name = name {
|
||||||
|
account.name = name
|
||||||
|
}
|
||||||
|
if let currency = currency {
|
||||||
|
account.currency = currency
|
||||||
|
}
|
||||||
|
if let inputMode = inputMode {
|
||||||
|
account.inputMode = inputMode.rawValue
|
||||||
|
}
|
||||||
|
if let notificationFrequency = notificationFrequency {
|
||||||
|
account.notificationFrequency = notificationFrequency.rawValue
|
||||||
|
}
|
||||||
|
if let customMonths = customFrequencyMonths {
|
||||||
|
account.customFrequencyMonths = Int16(customMonths)
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Delete
|
||||||
|
|
||||||
|
func deleteAccount(_ account: Account) {
|
||||||
|
context.delete(account)
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Save
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
guard context.hasChanges else { return }
|
||||||
|
do {
|
||||||
|
try context.save()
|
||||||
|
fetchAccounts()
|
||||||
|
} catch {
|
||||||
|
print("Failed to save accounts: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,23 +23,27 @@ class CategoryRepository: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func contextDidChange(_ notification: Notification) {
|
@objc private func contextDidChange(_ notification: Notification) {
|
||||||
|
guard isRelevantChange(notification) else { return }
|
||||||
fetchCategories()
|
fetchCategories()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Fetch
|
// MARK: - Fetch
|
||||||
|
|
||||||
func fetchCategories() {
|
func fetchCategories() {
|
||||||
let request: NSFetchRequest<Category> = Category.fetchRequest()
|
context.perform { [weak self] in
|
||||||
request.sortDescriptors = [
|
guard let self else { return }
|
||||||
NSSortDescriptor(keyPath: \Category.sortOrder, ascending: true),
|
let request: NSFetchRequest<Category> = Category.fetchRequest()
|
||||||
NSSortDescriptor(keyPath: \Category.name, ascending: true)
|
request.sortDescriptors = [
|
||||||
]
|
NSSortDescriptor(keyPath: \Category.sortOrder, ascending: true),
|
||||||
|
NSSortDescriptor(keyPath: \Category.name, ascending: true)
|
||||||
|
]
|
||||||
|
|
||||||
do {
|
do {
|
||||||
categories = try context.fetch(request)
|
self.categories = try self.context.fetch(request)
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to fetch categories: \(error)")
|
print("Failed to fetch categories: \(error)")
|
||||||
categories = []
|
self.categories = []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,8 +144,28 @@ class CategoryRepository: ObservableObject {
|
||||||
do {
|
do {
|
||||||
try context.save()
|
try context.save()
|
||||||
fetchCategories()
|
fetchCategories()
|
||||||
|
CoreDataStack.shared.refreshWidgetData()
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to save context: \(error)")
|
print("Failed to save context: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func isRelevantChange(_ notification: Notification) -> Bool {
|
||||||
|
guard let info = notification.userInfo else { return false }
|
||||||
|
let keys: [String] = [
|
||||||
|
NSInsertedObjectsKey,
|
||||||
|
NSUpdatedObjectsKey,
|
||||||
|
NSDeletedObjectsKey,
|
||||||
|
NSRefreshedObjectsKey
|
||||||
|
]
|
||||||
|
|
||||||
|
for key in keys {
|
||||||
|
if let objects = info[key] as? Set<NSManagedObject> {
|
||||||
|
if objects.contains(where: { $0 is Category }) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
class GoalRepository: ObservableObject {
|
||||||
|
private let context: NSManagedObjectContext
|
||||||
|
|
||||||
|
@Published private(set) var goals: [Goal] = []
|
||||||
|
|
||||||
|
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
||||||
|
self.context = context
|
||||||
|
fetchGoals()
|
||||||
|
setupNotificationObserver()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupNotificationObserver() {
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(contextDidChange),
|
||||||
|
name: .NSManagedObjectContextObjectsDidChange,
|
||||||
|
object: context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func contextDidChange(_ notification: Notification) {
|
||||||
|
fetchGoals()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Fetch
|
||||||
|
|
||||||
|
func fetchGoals(for account: Account? = nil) {
|
||||||
|
context.perform { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let request: NSFetchRequest<Goal> = Goal.fetchRequest()
|
||||||
|
if let account = account {
|
||||||
|
request.predicate = NSPredicate(format: "account == %@", account)
|
||||||
|
}
|
||||||
|
request.sortDescriptors = [
|
||||||
|
NSSortDescriptor(keyPath: \Goal.createdAt, ascending: true)
|
||||||
|
]
|
||||||
|
|
||||||
|
do {
|
||||||
|
self.goals = try self.context.fetch(request)
|
||||||
|
} catch {
|
||||||
|
print("Failed to fetch goals: \(error)")
|
||||||
|
self.goals = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Create
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func createGoal(
|
||||||
|
name: String,
|
||||||
|
targetAmount: Decimal,
|
||||||
|
targetDate: Date? = nil,
|
||||||
|
account: Account?
|
||||||
|
) -> Goal {
|
||||||
|
let goal = Goal(context: context)
|
||||||
|
goal.name = name
|
||||||
|
goal.targetAmount = NSDecimalNumber(decimal: targetAmount)
|
||||||
|
goal.targetDate = targetDate
|
||||||
|
goal.account = account
|
||||||
|
save()
|
||||||
|
return goal
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Update
|
||||||
|
|
||||||
|
func updateGoal(
|
||||||
|
_ goal: Goal,
|
||||||
|
name: String? = nil,
|
||||||
|
targetAmount: Decimal? = nil,
|
||||||
|
targetDate: Date? = nil,
|
||||||
|
clearTargetDate: Bool = false,
|
||||||
|
isActive: Bool? = nil
|
||||||
|
) {
|
||||||
|
if let name = name {
|
||||||
|
goal.name = name
|
||||||
|
}
|
||||||
|
if let targetAmount = targetAmount {
|
||||||
|
goal.targetAmount = NSDecimalNumber(decimal: targetAmount)
|
||||||
|
}
|
||||||
|
if clearTargetDate {
|
||||||
|
goal.targetDate = nil
|
||||||
|
} else if let targetDate = targetDate {
|
||||||
|
goal.targetDate = targetDate
|
||||||
|
}
|
||||||
|
if let isActive = isActive {
|
||||||
|
goal.isActive = isActive
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteGoal(_ goal: Goal) {
|
||||||
|
context.delete(goal)
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Save
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
guard context.hasChanges else { return }
|
||||||
|
do {
|
||||||
|
try context.save()
|
||||||
|
fetchGoals()
|
||||||
|
} catch {
|
||||||
|
print("Failed to save goals: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,22 +23,29 @@ class InvestmentSourceRepository: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func contextDidChange(_ notification: Notification) {
|
@objc private func contextDidChange(_ notification: Notification) {
|
||||||
|
guard isRelevantChange(notification) else { return }
|
||||||
fetchSources()
|
fetchSources()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Fetch
|
// MARK: - Fetch
|
||||||
|
|
||||||
func fetchSources() {
|
func fetchSources(account: Account? = nil) {
|
||||||
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
context.perform { [weak self] in
|
||||||
request.sortDescriptors = [
|
guard let self else { return }
|
||||||
NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)
|
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 {
|
do {
|
||||||
sources = try context.fetch(request)
|
self.sources = try self.context.fetch(request)
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to fetch sources: \(error)")
|
print("Failed to fetch sources: \(error)")
|
||||||
sources = []
|
self.sources = []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,8 +70,9 @@ class InvestmentSourceRepository: ObservableObject {
|
||||||
sources.filter { $0.isActive }
|
sources.filter { $0.isActive }
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchSourcesNeedingUpdate() -> [InvestmentSource] {
|
func fetchSourcesNeedingUpdate(for account: Account? = nil) -> [InvestmentSource] {
|
||||||
sources.filter { $0.needsUpdate }
|
let filtered = account == nil ? sources : sources.filter { $0.account?.id == account?.id }
|
||||||
|
return filtered.filter { $0.needsUpdate }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Create
|
// MARK: - Create
|
||||||
|
|
@ -74,13 +82,15 @@ class InvestmentSourceRepository: ObservableObject {
|
||||||
name: String,
|
name: String,
|
||||||
category: Category,
|
category: Category,
|
||||||
notificationFrequency: NotificationFrequency = .monthly,
|
notificationFrequency: NotificationFrequency = .monthly,
|
||||||
customFrequencyMonths: Int = 1
|
customFrequencyMonths: Int = 1,
|
||||||
|
account: Account? = nil
|
||||||
) -> InvestmentSource {
|
) -> InvestmentSource {
|
||||||
let source = InvestmentSource(context: context)
|
let source = InvestmentSource(context: context)
|
||||||
source.name = name
|
source.name = name
|
||||||
source.category = category
|
source.category = category
|
||||||
source.notificationFrequency = notificationFrequency.rawValue
|
source.notificationFrequency = notificationFrequency.rawValue
|
||||||
source.customFrequencyMonths = Int16(customFrequencyMonths)
|
source.customFrequencyMonths = Int16(customFrequencyMonths)
|
||||||
|
source.account = account
|
||||||
|
|
||||||
save()
|
save()
|
||||||
return source
|
return source
|
||||||
|
|
@ -94,7 +104,8 @@ class InvestmentSourceRepository: ObservableObject {
|
||||||
category: Category? = nil,
|
category: Category? = nil,
|
||||||
notificationFrequency: NotificationFrequency? = nil,
|
notificationFrequency: NotificationFrequency? = nil,
|
||||||
customFrequencyMonths: Int? = nil,
|
customFrequencyMonths: Int? = nil,
|
||||||
isActive: Bool? = nil
|
isActive: Bool? = nil,
|
||||||
|
account: Account? = nil
|
||||||
) {
|
) {
|
||||||
if let name = name {
|
if let name = name {
|
||||||
source.name = name
|
source.name = name
|
||||||
|
|
@ -111,6 +122,9 @@ class InvestmentSourceRepository: ObservableObject {
|
||||||
if let isActive = isActive {
|
if let isActive = isActive {
|
||||||
source.isActive = isActive
|
source.isActive = isActive
|
||||||
}
|
}
|
||||||
|
if let account = account {
|
||||||
|
source.account = account
|
||||||
|
}
|
||||||
|
|
||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
|
|
@ -170,8 +184,28 @@ class InvestmentSourceRepository: ObservableObject {
|
||||||
do {
|
do {
|
||||||
try context.save()
|
try context.save()
|
||||||
fetchSources()
|
fetchSources()
|
||||||
|
CoreDataStack.shared.refreshWidgetData()
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to save context: \(error)")
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4,11 +4,27 @@ import Combine
|
||||||
|
|
||||||
class SnapshotRepository: ObservableObject {
|
class SnapshotRepository: ObservableObject {
|
||||||
private let context: NSManagedObjectContext
|
private let context: NSManagedObjectContext
|
||||||
|
private let cache = NSCache<NSString, NSArray>()
|
||||||
|
@Published private(set) var cacheVersion: Int = 0
|
||||||
|
|
||||||
@Published private(set) var snapshots: [Snapshot] = []
|
@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) {
|
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
|
// Performance: Increase cache limits for better hit rate
|
||||||
|
cache.countLimit = 12
|
||||||
|
cache.totalCostLimit = 4_000_000 // 4 MB
|
||||||
|
setupNotificationObserver()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Fetch
|
// MARK: - Fetch
|
||||||
|
|
@ -19,6 +35,7 @@ class SnapshotRepository: ObservableObject {
|
||||||
request.sortDescriptors = [
|
request.sortDescriptors = [
|
||||||
NSSortDescriptor(keyPath: \Snapshot.date, ascending: false)
|
NSSortDescriptor(keyPath: \Snapshot.date, ascending: false)
|
||||||
]
|
]
|
||||||
|
request.fetchBatchSize = 200
|
||||||
|
|
||||||
return (try? context.fetch(request)) ?? []
|
return (try? context.fetch(request)) ?? []
|
||||||
}
|
}
|
||||||
|
|
@ -35,6 +52,18 @@ class SnapshotRepository: ObservableObject {
|
||||||
request.sortDescriptors = [
|
request.sortDescriptors = [
|
||||||
NSSortDescriptor(keyPath: \Snapshot.date, ascending: false)
|
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)) ?? []
|
return (try? context.fetch(request)) ?? []
|
||||||
}
|
}
|
||||||
|
|
@ -49,6 +78,7 @@ class SnapshotRepository: ObservableObject {
|
||||||
request.sortDescriptors = [
|
request.sortDescriptors = [
|
||||||
NSSortDescriptor(keyPath: \Snapshot.date, ascending: true)
|
NSSortDescriptor(keyPath: \Snapshot.date, ascending: true)
|
||||||
]
|
]
|
||||||
|
request.fetchBatchSize = 200
|
||||||
|
|
||||||
return (try? context.fetch(request)) ?? []
|
return (try? context.fetch(request)) ?? []
|
||||||
}
|
}
|
||||||
|
|
@ -91,7 +121,9 @@ class SnapshotRepository: ObservableObject {
|
||||||
date: Date? = nil,
|
date: Date? = nil,
|
||||||
value: Decimal? = nil,
|
value: Decimal? = nil,
|
||||||
contribution: Decimal? = nil,
|
contribution: Decimal? = nil,
|
||||||
notes: String? = nil
|
notes: String? = nil,
|
||||||
|
clearContribution: Bool = false,
|
||||||
|
clearNotes: Bool = false
|
||||||
) {
|
) {
|
||||||
if let date = date {
|
if let date = date {
|
||||||
snapshot.date = date
|
snapshot.date = date
|
||||||
|
|
@ -99,10 +131,14 @@ class SnapshotRepository: ObservableObject {
|
||||||
if let value = value {
|
if let value = value {
|
||||||
snapshot.value = NSDecimalNumber(decimal: 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)
|
snapshot.contribution = NSDecimalNumber(decimal: contribution)
|
||||||
}
|
}
|
||||||
if let notes = notes {
|
if clearNotes {
|
||||||
|
snapshot.notes = nil
|
||||||
|
} else if let notes = notes {
|
||||||
snapshot.notes = notes
|
snapshot.notes = notes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,7 +174,8 @@ class SnapshotRepository: ObservableObject {
|
||||||
var snapshots = fetchSnapshots(for: source)
|
var snapshots = fetchSnapshots(for: source)
|
||||||
|
|
||||||
if let months = months {
|
if let months = months {
|
||||||
let cutoffDate = Calendar.current.date(
|
// Performance: Use cached calendar
|
||||||
|
let cutoffDate = Self.calendar.date(
|
||||||
byAdding: .month,
|
byAdding: .month,
|
||||||
value: -months,
|
value: -months,
|
||||||
to: Date()
|
to: Date()
|
||||||
|
|
@ -150,16 +187,59 @@ class SnapshotRepository: ObservableObject {
|
||||||
return snapshots
|
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
|
// MARK: - Historical Data
|
||||||
|
|
||||||
func getMonthlyValues(for source: InvestmentSource) -> [(date: Date, value: Decimal)] {
|
func getMonthlyValues(for source: InvestmentSource) -> [(date: Date, value: Decimal)] {
|
||||||
let snapshots = fetchSnapshots(for: source).reversed()
|
let snapshots = fetchSnapshots(for: source).reversed()
|
||||||
|
|
||||||
var monthlyData: [(date: Date, value: Decimal)] = []
|
var monthlyData: [(date: Date, value: Decimal)] = []
|
||||||
|
monthlyData.reserveCapacity(min(snapshots.count, 60))
|
||||||
var processedMonths: Set<String> = []
|
var processedMonths: Set<String> = []
|
||||||
|
processedMonths.reserveCapacity(60)
|
||||||
|
|
||||||
let formatter = DateFormatter()
|
// Performance: Use shared formatter
|
||||||
formatter.dateFormat = "yyyy-MM"
|
let formatter = Self.monthYearFormatter
|
||||||
|
|
||||||
for snapshot in snapshots {
|
for snapshot in snapshots {
|
||||||
let monthKey = formatter.string(from: snapshot.date)
|
let monthKey = formatter.string(from: snapshot.date)
|
||||||
|
|
@ -175,11 +255,15 @@ class SnapshotRepository: ObservableObject {
|
||||||
func getPortfolioHistory() -> [(date: Date, totalValue: Decimal)] {
|
func getPortfolioHistory() -> [(date: Date, totalValue: Decimal)] {
|
||||||
let allSnapshots = fetchAllSnapshots()
|
let allSnapshots = fetchAllSnapshots()
|
||||||
|
|
||||||
// Group by date
|
// Performance: Pre-allocate dictionary
|
||||||
var dateValues: [Date: Decimal] = [:]
|
var dateValues: [Date: Decimal] = [:]
|
||||||
|
dateValues.reserveCapacity(min(allSnapshots.count, 365))
|
||||||
|
|
||||||
|
// Performance: Use cached calendar
|
||||||
|
let calendar = Self.calendar
|
||||||
|
|
||||||
for snapshot in allSnapshots {
|
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
|
dateValues[startOfDay, default: Decimal.zero] += snapshot.decimalValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,8 +278,39 @@ class SnapshotRepository: ObservableObject {
|
||||||
guard context.hasChanges else { return }
|
guard context.hasChanges else { return }
|
||||||
do {
|
do {
|
||||||
try context.save()
|
try context.save()
|
||||||
|
invalidateCache()
|
||||||
|
CoreDataStack.shared.refreshWidgetData()
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to save context: \(error)")
|
print("Failed to save context: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Cache
|
||||||
|
|
||||||
|
private func cacheKey(
|
||||||
|
sourceIds: [UUID],
|
||||||
|
months: Int?
|
||||||
|
) -> NSString {
|
||||||
|
let sortedIds = sourceIds.sorted().map { $0.uuidString }.joined(separator: ",")
|
||||||
|
let monthsPart = months.map(String.init) ?? "all"
|
||||||
|
return NSString(string: "\(cacheVersion)|\(monthsPart)|\(sortedIds)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func invalidateCache() {
|
||||||
|
cache.removeAllObjects()
|
||||||
|
cacheVersion &+= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupNotificationObserver() {
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(contextDidChange),
|
||||||
|
name: .NSManagedObjectContextObjectsDidChange,
|
||||||
|
object: context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func contextDidChange(_ notification: Notification) {
|
||||||
|
invalidateCache()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
class TransactionRepository {
|
||||||
|
private let context: NSManagedObjectContext
|
||||||
|
|
||||||
|
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchTransactions(for source: InvestmentSource) -> [Transaction] {
|
||||||
|
let request: NSFetchRequest<Transaction> = Transaction.fetchRequest()
|
||||||
|
request.predicate = NSPredicate(format: "source == %@", source)
|
||||||
|
request.sortDescriptors = [
|
||||||
|
NSSortDescriptor(keyPath: \Transaction.date, ascending: false)
|
||||||
|
]
|
||||||
|
return (try? context.fetch(request)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func createTransaction(
|
||||||
|
source: InvestmentSource,
|
||||||
|
type: TransactionType,
|
||||||
|
date: Date,
|
||||||
|
shares: Decimal?,
|
||||||
|
price: Decimal?,
|
||||||
|
amount: Decimal?,
|
||||||
|
notes: String?
|
||||||
|
) -> Transaction {
|
||||||
|
let transaction = Transaction(context: context)
|
||||||
|
transaction.source = source
|
||||||
|
transaction.type = type.rawValue
|
||||||
|
transaction.date = date
|
||||||
|
if let shares = shares {
|
||||||
|
transaction.shares = NSDecimalNumber(decimal: shares)
|
||||||
|
}
|
||||||
|
if let price = price {
|
||||||
|
transaction.price = NSDecimalNumber(decimal: price)
|
||||||
|
}
|
||||||
|
if let amount = amount {
|
||||||
|
transaction.amount = NSDecimalNumber(decimal: amount)
|
||||||
|
}
|
||||||
|
transaction.notes = notes
|
||||||
|
|
||||||
|
save()
|
||||||
|
return transaction
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteTransaction(_ transaction: Transaction) {
|
||||||
|
context.delete(transaction)
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
guard context.hasChanges else { return }
|
||||||
|
do {
|
||||||
|
try context.save()
|
||||||
|
} catch {
|
||||||
|
print("Failed to save transaction: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>API_KEY</key>
|
||||||
|
<string>AIzaSyAregLaXQ-WqRQTltTRyjQx3-lfxLl4Cng</string>
|
||||||
|
<key>GCM_SENDER_ID</key>
|
||||||
|
<string>334225114072</string>
|
||||||
|
<key>PLIST_VERSION</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>BUNDLE_ID</key>
|
||||||
|
<string>com.alexandrevazquez.portfoliojournal</string>
|
||||||
|
<key>PROJECT_ID</key>
|
||||||
|
<string>portfoliojournal-ef2d7</string>
|
||||||
|
<key>STORAGE_BUCKET</key>
|
||||||
|
<string>portfoliojournal-ef2d7.firebasestorage.app</string>
|
||||||
|
<key>IS_ADS_ENABLED</key>
|
||||||
|
<false></false>
|
||||||
|
<key>IS_ANALYTICS_ENABLED</key>
|
||||||
|
<false></false>
|
||||||
|
<key>IS_APPINVITE_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>IS_GCM_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>IS_SIGNIN_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>GOOGLE_APP_ID</key>
|
||||||
|
<string>1:334225114072:ios:81bad412ffe1c6df3d28ad</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Portfolio Journal</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>portfoliojournal</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>GADApplicationIdentifier</key>
|
||||||
|
<string>ca-app-pub-1549720748100858~9632507420</string>
|
||||||
|
<key>GADDelayAppMeasurementInit</key>
|
||||||
|
<true/>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSAllowsArbitraryLoadsForMedia</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
<key>NSCalendarsUsageDescription</key>
|
||||||
|
<string>Used to set investment update reminders.</string>
|
||||||
|
<key>NSFaceIDUsageDescription</key>
|
||||||
|
<string>Use Face ID to unlock your portfolio data.</string>
|
||||||
|
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSUserTrackingUsageDescription</key>
|
||||||
|
<string>This app uses tracking to provide personalized ads and improve your experience. Your data is not sold to third parties.</string>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>remote-notification</string>
|
||||||
|
</array>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict/>
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array>
|
||||||
|
<string>armv7</string>
|
||||||
|
</array>
|
||||||
|
<key>UIStatusBarStyle</key>
|
||||||
|
<string>UIStatusBarStyleDefault</string>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportsDocumentBrowser</key>
|
||||||
|
<false/>
|
||||||
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"identifier" : "Default Configuration",
|
||||||
|
"provider" : "appstore",
|
||||||
|
"products" : [
|
||||||
|
{
|
||||||
|
"id" : "com.portfoliojournal.premium",
|
||||||
|
"type" : "Non-Consumable",
|
||||||
|
"referenceName" : "Premium Unlock",
|
||||||
|
"state" : "approved",
|
||||||
|
"price" : {
|
||||||
|
"currency" : "EUR",
|
||||||
|
"value" : 4.69
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"subscriptionGroups" : [ ],
|
||||||
|
"localizations" : [ ],
|
||||||
|
"files" : [ ]
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
/*
|
/*
|
||||||
Localizable.strings
|
Localizable.strings
|
||||||
InvestmentTracker
|
PortfolioJournal
|
||||||
|
|
||||||
English (Base) localization
|
English (Base) localization
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// MARK: - General
|
// MARK: - General
|
||||||
"app_name" = "Investment Tracker";
|
"app_name" = "Portfolio Journal";
|
||||||
"ok" = "OK";
|
"ok" = "OK";
|
||||||
"cancel" = "Cancel";
|
"cancel" = "Cancel";
|
||||||
"save" = "Save";
|
"save" = "Save";
|
||||||
|
|
@ -22,13 +22,13 @@
|
||||||
"loading" = "Loading...";
|
"loading" = "Loading...";
|
||||||
|
|
||||||
// MARK: - Tab Bar
|
// MARK: - Tab Bar
|
||||||
"tab_dashboard" = "Dashboard";
|
"tab_dashboard" = "Home";
|
||||||
"tab_sources" = "Sources";
|
"tab_sources" = "Sources";
|
||||||
"tab_charts" = "Charts";
|
"tab_charts" = "Charts";
|
||||||
"tab_settings" = "Settings";
|
"tab_settings" = "Settings";
|
||||||
|
|
||||||
// MARK: - Dashboard
|
// MARK: - Dashboard
|
||||||
"dashboard_title" = "Dashboard";
|
"dashboard_title" = "Home";
|
||||||
"total_portfolio_value" = "Total Portfolio Value";
|
"total_portfolio_value" = "Total Portfolio Value";
|
||||||
"today" = "today";
|
"today" = "today";
|
||||||
"returns" = "Returns";
|
"returns" = "Returns";
|
||||||
|
|
@ -186,3 +186,51 @@
|
||||||
"placeholder_source_name" = "e.g., Vanguard 401k";
|
"placeholder_source_name" = "e.g., Vanguard 401k";
|
||||||
"placeholder_value" = "0.00";
|
"placeholder_value" = "0.00";
|
||||||
"placeholder_notes" = "Add notes...";
|
"placeholder_notes" = "Add notes...";
|
||||||
|
|
||||||
|
// MARK: - Monthly Check-in Moods
|
||||||
|
"mood_energized_title" = "On Fire";
|
||||||
|
"mood_confident_title" = "Confident";
|
||||||
|
"mood_balanced_title" = "Steady";
|
||||||
|
"mood_cautious_title" = "Cautious";
|
||||||
|
"mood_stressed_title" = "Stressed";
|
||||||
|
"mood_energized_detail" = "Feeling unbeatable";
|
||||||
|
"mood_confident_detail" = "On track and composed";
|
||||||
|
"mood_balanced_detail" = "Calm and patient";
|
||||||
|
"mood_cautious_detail" = "Watching the moves";
|
||||||
|
"mood_stressed_detail" = "Need a reset";
|
||||||
|
|
||||||
|
// MARK: - Monthly Check-in Achievements
|
||||||
|
"achievement_streak_3_title" = "3-Month Streak";
|
||||||
|
"achievement_streak_3_detail" = "Kept your check-ins on time for three months straight.";
|
||||||
|
"achievement_streak_6_title" = "Half-Year Hot Streak";
|
||||||
|
"achievement_streak_6_detail" = "Six consecutive on-time check-ins.";
|
||||||
|
"achievement_streak_12_title" = "Year of Momentum";
|
||||||
|
"achievement_streak_12_detail" = "A full year without missing the deadline.";
|
||||||
|
"achievement_perfect_on_time_title" = "Never Late";
|
||||||
|
"achievement_perfect_on_time_detail" = "Every check-in landed before the deadline.";
|
||||||
|
"achievement_clutch_finish_title" = "Clutch Finish";
|
||||||
|
"achievement_clutch_finish_detail" = "Submitted with hours to spare and still on time.";
|
||||||
|
"achievement_early_bird_title" = "Early Bird";
|
||||||
|
"achievement_early_bird_detail" = "On average you finish with plenty of time left.";
|
||||||
|
"achievements_title" = "Achievements";
|
||||||
|
"achievements_view_all" = "View all achievements";
|
||||||
|
"achievements_nav_title" = "Achievements";
|
||||||
|
"achievements_progress_title" = "Progress";
|
||||||
|
"achievements_unlocked_title" = "Unlocked";
|
||||||
|
"achievements_unlocked_empty" = "Complete check-ins to unlock achievements.";
|
||||||
|
"achievements_locked_title" = "Locked";
|
||||||
|
"achievements_locked_empty" = "All achievements unlocked. Nice work!";
|
||||||
|
|
||||||
|
// MARK: - Accessibility
|
||||||
|
"rating_accessibility" = "Rating %d of 5";
|
||||||
|
"achievements_unlocked_count" = "%d of %d unlocked";
|
||||||
|
"last_check_in" = "Last check-in: %@";
|
||||||
|
"next_check_in" = "Next check-in: %@";
|
||||||
|
"on_time_rate" = "%@ on-time";
|
||||||
|
"on_time_count" = "%d/%d on time";
|
||||||
|
"tightest_finish" = "Tightest finish: %@ before deadline.";
|
||||||
|
"date_today" = "Today";
|
||||||
|
"date_yesterday" = "Yesterday";
|
||||||
|
"date_never" = "Never";
|
||||||
|
"calendar_event_title" = "%@: Monthly Check-in";
|
||||||
|
"calendar_event_notes" = "Open %@ and complete your monthly check-in.";
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
/*
|
||||||
|
Localizable.strings
|
||||||
|
PortfolioJournal
|
||||||
|
|
||||||
|
Spanish (Spain) localization
|
||||||
|
*/
|
||||||
|
|
||||||
|
// MARK: - Journal
|
||||||
|
"Home" = "Inicio";
|
||||||
|
"Sources" = "Fuentes";
|
||||||
|
"Charts" = "Gráficos";
|
||||||
|
"Settings" = "Ajustes";
|
||||||
|
"Journal" = "Diario";
|
||||||
|
"Search monthly notes" = "Buscar notas mensuales";
|
||||||
|
"Monthly Check-ins" = "Chequeos mensuales";
|
||||||
|
"No monthly notes yet." = "Aún no hay notas mensuales.";
|
||||||
|
"No matching notes." = "No hay notas que coincidan.";
|
||||||
|
"Jump to month" = "Ir al mes";
|
||||||
|
"Today" = "Hoy";
|
||||||
|
"Mood not set" = "Estado de ánimo no establecido";
|
||||||
|
"No rating" = "Sin valoración";
|
||||||
|
"No note yet." = "Sin nota todavía.";
|
||||||
|
"Monthly Note" = "Nota mensual";
|
||||||
|
"Open Full Note" = "Ver nota completa";
|
||||||
|
"Duplicate Previous" = "Duplicar anterior";
|
||||||
|
"Save" = "Guardar";
|
||||||
|
|
||||||
|
// MARK: - Monthly Check-in
|
||||||
|
"Monthly Check-in" = "Chequeo mensual";
|
||||||
|
"This Month" = "Este mes";
|
||||||
|
"No check-in yet this month" = "Aún no hay chequeo este mes";
|
||||||
|
"Start your first check-in anytime." = "Empieza tu primer chequeo cuando quieras.";
|
||||||
|
"Mark Check-in Complete" = "Marcar chequeo completado";
|
||||||
|
"Editing stays open. New check-ins unlock after 70% of the month." = "La edición permanece abierta. Los nuevos chequeos se desbloquean tras el 70 % del mes.";
|
||||||
|
"Momentum & Streaks" = "Impulso y rachas";
|
||||||
|
"Log a check-in to start a streak" = "Registra un chequeo para iniciar una racha";
|
||||||
|
"Streak" = "Racha";
|
||||||
|
"On-time in a row" = "A tiempo seguidos";
|
||||||
|
"Best" = "Mejor";
|
||||||
|
"Personal best" = "Mejor personal";
|
||||||
|
"Avg early" = "Media de antelación";
|
||||||
|
"vs deadline" = "vs. fecha límite";
|
||||||
|
"On-time score" = "Puntuación a tiempo";
|
||||||
|
"Achievements" = "Logros";
|
||||||
|
"View all achievements" = "Ver todos los logros";
|
||||||
|
"Monthly Pulse" = "Pulso mensual";
|
||||||
|
"Optional" = "Opcional";
|
||||||
|
"Rate this month" = "Valora este mes";
|
||||||
|
"Skip" = "Omitir";
|
||||||
|
"How did it feel?" = "¿Cómo te sentiste?";
|
||||||
|
"Monthly Summary" = "Resumen mensual";
|
||||||
|
"Starting" = "Inicio";
|
||||||
|
"Ending" = "Final";
|
||||||
|
"Contributions" = "Aportaciones";
|
||||||
|
"Net Performance" = "Rendimiento neto";
|
||||||
|
"Update Sources" = "Actualizar fuentes";
|
||||||
|
"Add sources to start your monthly check-in." = "Añade fuentes para empezar tu chequeo mensual.";
|
||||||
|
"Updated this cycle" = "Actualizado en este ciclo";
|
||||||
|
"Needs update" = "Necesita actualización";
|
||||||
|
"Snapshot Notes" = "Notas de snapshots";
|
||||||
|
"No snapshot notes for this month." = "No hay notas de snapshots este mes.";
|
||||||
|
"Source" = "Fuente";
|
||||||
|
"last_check_in" = "Último chequeo: %@";
|
||||||
|
"next_check_in" = "Próximo chequeo: %@";
|
||||||
|
"on_time_rate" = "%@ a tiempo";
|
||||||
|
"on_time_count" = "%d/%d a tiempo";
|
||||||
|
"tightest_finish" = "Final más ajustado: %@ antes de la fecha límite.";
|
||||||
|
"date_today" = "Hoy";
|
||||||
|
"date_yesterday" = "Ayer";
|
||||||
|
"date_never" = "Nunca";
|
||||||
|
"calendar_event_title" = "%@: Chequeo mensual";
|
||||||
|
"calendar_event_notes" = "Abre %@ y completa tu chequeo mensual.";
|
||||||
|
|
||||||
|
// MARK: - Achievements View
|
||||||
|
"achievements_title" = "Logros";
|
||||||
|
"achievements_view_all" = "Ver todos los logros";
|
||||||
|
"achievements_nav_title" = "Logros";
|
||||||
|
"achievements_progress_title" = "Progreso";
|
||||||
|
"achievements_unlocked_title" = "Desbloqueados";
|
||||||
|
"achievements_unlocked_empty" = "Completa chequeos para desbloquear logros.";
|
||||||
|
"achievements_locked_title" = "Bloqueados";
|
||||||
|
"achievements_locked_empty" = "Todos los logros desbloqueados. ¡Buen trabajo!";
|
||||||
|
|
||||||
|
// MARK: - Monthly Check-in Moods
|
||||||
|
"mood_energized_title" = "En llamas";
|
||||||
|
"mood_confident_title" = "Confiado";
|
||||||
|
"mood_balanced_title" = "Estable";
|
||||||
|
"mood_cautious_title" = "Cauteloso";
|
||||||
|
"mood_stressed_title" = "Estresado";
|
||||||
|
"mood_energized_detail" = "Me siento imparable";
|
||||||
|
"mood_confident_detail" = "En camino y sereno";
|
||||||
|
"mood_balanced_detail" = "Calmado y paciente";
|
||||||
|
"mood_cautious_detail" = "Observando los movimientos";
|
||||||
|
"mood_stressed_detail" = "Necesito un respiro";
|
||||||
|
|
||||||
|
// MARK: - Categories
|
||||||
|
"category_stocks" = "Acciones";
|
||||||
|
"category_bonds" = "Bonos";
|
||||||
|
"category_real_estate" = "Inmobiliario";
|
||||||
|
"category_crypto" = "Cripto";
|
||||||
|
"category_cash" = "Efectivo";
|
||||||
|
"category_etfs" = "ETFs";
|
||||||
|
"category_retirement" = "Jubilación";
|
||||||
|
"category_other" = "Otros";
|
||||||
|
"uncategorized" = "Sin categoría";
|
||||||
|
|
||||||
|
// MARK: - Monthly Check-in Achievements
|
||||||
|
"achievement_streak_3_title" = "Racha de 3 meses";
|
||||||
|
"achievement_streak_3_detail" = "Has mantenido tus chequeos a tiempo durante tres meses seguidos.";
|
||||||
|
"achievement_streak_6_title" = "Racha de medio año";
|
||||||
|
"achievement_streak_6_detail" = "Seis chequeos a tiempo consecutivos.";
|
||||||
|
"achievement_streak_12_title" = "Un año de impulso";
|
||||||
|
"achievement_streak_12_detail" = "Un año completo sin perder la fecha límite.";
|
||||||
|
"achievement_perfect_on_time_title" = "Nunca tarde";
|
||||||
|
"achievement_perfect_on_time_detail" = "Todos los chequeos llegaron antes de la fecha límite.";
|
||||||
|
"achievement_clutch_finish_title" = "Final ajustado";
|
||||||
|
"achievement_clutch_finish_detail" = "Entregado con horas de margen y a tiempo.";
|
||||||
|
"achievement_early_bird_title" = "Madrugador";
|
||||||
|
"achievement_early_bird_detail" = "De media terminas con bastante margen.";
|
||||||
|
|
||||||
|
// MARK: - Accessibility
|
||||||
|
"rating_accessibility" = "Valoración %d de 5";
|
||||||
|
"achievements_unlocked_count" = "%d de %d desbloqueados";
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class AccountStore: ObservableObject {
|
||||||
|
@Published private(set) var accounts: [Account] = []
|
||||||
|
@Published var selectedAccount: Account?
|
||||||
|
@Published var showAllAccounts = true
|
||||||
|
|
||||||
|
private let accountRepository: AccountRepository
|
||||||
|
private let iapService: IAPService
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(
|
||||||
|
accountRepository: AccountRepository? = nil,
|
||||||
|
iapService: IAPService
|
||||||
|
) {
|
||||||
|
self.accountRepository = accountRepository ?? AccountRepository()
|
||||||
|
self.iapService = iapService
|
||||||
|
|
||||||
|
self.accountRepository.fetchAccounts()
|
||||||
|
let defaultAccount = self.accountRepository.createDefaultAccountIfNeeded()
|
||||||
|
accounts = self.accountRepository.accounts
|
||||||
|
selectedAccount = defaultAccount
|
||||||
|
|
||||||
|
loadSelection()
|
||||||
|
setupObservers()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupObservers() {
|
||||||
|
accountRepository.$accounts
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] accounts in
|
||||||
|
self?.accounts = accounts
|
||||||
|
self?.syncSelectedAccount()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadSelection() {
|
||||||
|
let context = CoreDataStack.shared.viewContext
|
||||||
|
let settings = AppSettings.getOrCreate(in: context)
|
||||||
|
showAllAccounts = settings.showAllAccounts
|
||||||
|
|
||||||
|
if let selectedId = settings.selectedAccountId,
|
||||||
|
let account = accountRepository.fetchAccount(by: selectedId) {
|
||||||
|
selectedAccount = account
|
||||||
|
} else {
|
||||||
|
selectedAccount = accounts.first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncSelectedAccount() {
|
||||||
|
if showAllAccounts { return }
|
||||||
|
if let selected = selectedAccount,
|
||||||
|
accounts.contains(where: { $0.id == selected.id }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedAccount = accounts.first
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectAllAccounts() {
|
||||||
|
showAllAccounts = true
|
||||||
|
persistSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectAccount(_ account: Account) {
|
||||||
|
showAllAccounts = false
|
||||||
|
selectedAccount = account
|
||||||
|
persistSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistSelection() {
|
||||||
|
let context = CoreDataStack.shared.viewContext
|
||||||
|
let settings = AppSettings.getOrCreate(in: context)
|
||||||
|
settings.showAllAccounts = showAllAccounts
|
||||||
|
settings.selectedAccountId = showAllAccounts ? nil : selectedAccount?.id
|
||||||
|
CoreDataStack.shared.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func canAddAccount() -> Bool {
|
||||||
|
iapService.isPremium || accounts.count < 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
import GoogleMobileAds
|
import GoogleMobileAds
|
||||||
import AppTrackingTransparency
|
import AppTrackingTransparency
|
||||||
import AdSupport
|
import AdSupport
|
||||||
|
|
@ -36,7 +38,6 @@ class AdMobService: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestConsent() async {
|
func requestConsent() async {
|
||||||
// Request App Tracking Transparency authorization
|
|
||||||
if #available(iOS 14.5, *) {
|
if #available(iOS 14.5, *) {
|
||||||
let status = await ATTrackingManager.requestTrackingAuthorization()
|
let status = await ATTrackingManager.requestTrackingAuthorization()
|
||||||
|
|
||||||
|
|
@ -109,48 +110,46 @@ class AdMobService: ObservableObject {
|
||||||
|
|
||||||
// MARK: - Banner Ad Coordinator
|
// MARK: - Banner Ad Coordinator
|
||||||
|
|
||||||
class BannerAdCoordinator: NSObject, GADBannerViewDelegate {
|
class BannerAdCoordinator: NSObject, BannerViewDelegate {
|
||||||
weak var adMobService: AdMobService?
|
weak var adMobService: AdMobService?
|
||||||
|
|
||||||
func bannerViewDidReceiveAd(_ bannerView: GADBannerView) {
|
func bannerViewDidReceiveAd(_ bannerView: BannerView) {
|
||||||
print("Banner ad received")
|
print("Banner ad received")
|
||||||
adMobService?.logAdImpression()
|
adMobService?.logAdImpression()
|
||||||
}
|
}
|
||||||
|
|
||||||
func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) {
|
func bannerView(_ bannerView: BannerView, didFailToReceiveAdWithError error: Error) {
|
||||||
print("Banner ad failed to load: \(error.localizedDescription)")
|
print("Banner ad failed to load: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func bannerViewDidRecordImpression(_ bannerView: GADBannerView) {
|
func bannerViewDidRecordImpression(_ bannerView: BannerView) {
|
||||||
// Impression recorded
|
// Impression recorded
|
||||||
}
|
}
|
||||||
|
|
||||||
func bannerViewDidRecordClick(_ bannerView: GADBannerView) {
|
func bannerViewDidRecordClick(_ bannerView: BannerView) {
|
||||||
adMobService?.logAdClick()
|
adMobService?.logAdClick()
|
||||||
}
|
}
|
||||||
|
|
||||||
func bannerViewWillPresentScreen(_ bannerView: GADBannerView) {
|
func bannerViewWillPresentScreen(_ bannerView: BannerView) {
|
||||||
// Ad will present full screen
|
// Ad will present full screen
|
||||||
}
|
}
|
||||||
|
|
||||||
func bannerViewWillDismissScreen(_ bannerView: GADBannerView) {
|
func bannerViewWillDismissScreen(_ bannerView: BannerView) {
|
||||||
// Ad will dismiss
|
// Ad will dismiss
|
||||||
}
|
}
|
||||||
|
|
||||||
func bannerViewDidDismissScreen(_ bannerView: GADBannerView) {
|
func bannerViewDidDismissScreen(_ bannerView: BannerView) {
|
||||||
// Ad dismissed
|
// Ad dismissed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - UIKit Banner View Wrapper
|
// MARK: - UIKit Banner View Wrapper
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct BannerAdView: UIViewRepresentable {
|
struct BannerAdView: UIViewRepresentable {
|
||||||
@EnvironmentObject var adMobService: AdMobService
|
@EnvironmentObject var adMobService: AdMobService
|
||||||
|
|
||||||
func makeUIView(context: Context) -> GADBannerView {
|
func makeUIView(context: Context) -> BannerView {
|
||||||
let bannerView = GADBannerView(adSize: GADAdSizeBanner)
|
let bannerView = BannerView(adSize: AdSizeBanner)
|
||||||
bannerView.adUnitID = AdMobService.bannerAdUnitID
|
bannerView.adUnitID = AdMobService.bannerAdUnitID
|
||||||
|
|
||||||
// Get root view controller
|
// Get root view controller
|
||||||
|
|
@ -160,12 +159,12 @@ struct BannerAdView: UIViewRepresentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
bannerView.delegate = context.coordinator
|
bannerView.delegate = context.coordinator
|
||||||
bannerView.load(GADRequest())
|
bannerView.load(Request())
|
||||||
|
|
||||||
return bannerView
|
return bannerView
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ uiView: GADBannerView, context: Context) {
|
func updateUIView(_ uiView: BannerView, context: Context) {
|
||||||
// Banner updates automatically
|
// Banner updates automatically
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3,8 +3,25 @@ import Foundation
|
||||||
class CalculationService {
|
class CalculationService {
|
||||||
static let shared = CalculationService()
|
static let shared = CalculationService()
|
||||||
|
|
||||||
|
// MARK: - Performance: Caching
|
||||||
|
private var cachedMonthlyReturns: [ObjectIdentifier: [InvestmentMetrics.MonthlyReturn]] = [:]
|
||||||
|
private var cacheVersion: Int = 0
|
||||||
|
|
||||||
private init() {}
|
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
|
// MARK: - Portfolio Summary
|
||||||
|
|
||||||
func calculatePortfolioSummary(
|
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)
|
// MARK: - CAGR (Compound Annual Growth Rate)
|
||||||
|
|
||||||
func calculateCAGR(
|
func calculateCAGR(
|
||||||
|
|
@ -254,13 +316,12 @@ class CalculationService {
|
||||||
func calculateMonthlyReturns(from snapshots: [Snapshot]) -> [InvestmentMetrics.MonthlyReturn] {
|
func calculateMonthlyReturns(from snapshots: [Snapshot]) -> [InvestmentMetrics.MonthlyReturn] {
|
||||||
guard snapshots.count >= 2 else { return [] }
|
guard snapshots.count >= 2 else { return [] }
|
||||||
|
|
||||||
var monthlyReturns: [InvestmentMetrics.MonthlyReturn] = []
|
// Performance: Use shared formatter instead of creating new one each call
|
||||||
let calendar = Calendar.current
|
let formatter = Self.monthYearFormatter
|
||||||
|
|
||||||
// Group snapshots by month
|
// Group snapshots by month - pre-allocate capacity
|
||||||
var monthlySnapshots: [String: [Snapshot]] = [:]
|
var monthlySnapshots: [String: [Snapshot]] = [:]
|
||||||
let formatter = DateFormatter()
|
monthlySnapshots.reserveCapacity(min(snapshots.count, 60)) // Reasonable max months
|
||||||
formatter.dateFormat = "yyyy-MM"
|
|
||||||
|
|
||||||
for snapshot in snapshots {
|
for snapshot in snapshots {
|
||||||
let key = formatter.string(from: snapshot.date)
|
let key = formatter.string(from: snapshot.date)
|
||||||
|
|
@ -269,6 +330,11 @@ class CalculationService {
|
||||||
|
|
||||||
// Sort months
|
// Sort months
|
||||||
let sortedMonths = monthlySnapshots.keys.sorted()
|
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 {
|
for i in 1..<sortedMonths.count {
|
||||||
let previousMonth = sortedMonths[i-1]
|
let previousMonth = sortedMonths[i-1]
|
||||||
|
|
@ -301,14 +367,17 @@ class CalculationService {
|
||||||
|
|
||||||
func calculateCategoryMetrics(
|
func calculateCategoryMetrics(
|
||||||
for categories: [Category],
|
for categories: [Category],
|
||||||
|
sources: [InvestmentSource],
|
||||||
totalPortfolioValue: Decimal
|
totalPortfolioValue: Decimal
|
||||||
) -> [CategoryMetrics] {
|
) -> [CategoryMetrics] {
|
||||||
categories.map { category in
|
categories.map { category in
|
||||||
let allSnapshots = category.sourcesArray.flatMap { $0.snapshotsArray }
|
let categorySources = sources.filter { $0.category?.id == category.id }
|
||||||
let metrics = calculateMetrics(for: allSnapshots)
|
let allSnapshots = categorySources.flatMap { $0.snapshotsArray }
|
||||||
|
let metrics = calculateCategoryMetrics(from: allSnapshots)
|
||||||
|
let categoryValue = categorySources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||||
|
|
||||||
let percentage = totalPortfolioValue > 0
|
let percentage = totalPortfolioValue > 0
|
||||||
? NSDecimalNumber(decimal: category.totalValue / totalPortfolioValue).doubleValue * 100
|
? NSDecimalNumber(decimal: categoryValue / totalPortfolioValue).doubleValue * 100
|
||||||
: 0
|
: 0
|
||||||
|
|
||||||
return CategoryMetrics(
|
return CategoryMetrics(
|
||||||
|
|
@ -316,12 +385,196 @@ class CalculationService {
|
||||||
categoryName: category.name,
|
categoryName: category.name,
|
||||||
colorHex: category.colorHex,
|
colorHex: category.colorHex,
|
||||||
icon: category.icon,
|
icon: category.icon,
|
||||||
totalValue: category.totalValue,
|
totalValue: categoryValue,
|
||||||
percentageOfPortfolio: percentage,
|
percentageOfPortfolio: percentage,
|
||||||
metrics: metrics
|
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
|
// MARK: - Array Extension
|
||||||
|
|
@ -35,9 +35,11 @@ class ExportService {
|
||||||
sources: [InvestmentSource],
|
sources: [InvestmentSource],
|
||||||
categories: [Category]
|
categories: [Category]
|
||||||
) -> String {
|
) -> 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 }) {
|
for source in sources.sorted(by: { $0.name < $1.name }) {
|
||||||
|
let accountName = source.account?.name ?? "Default"
|
||||||
let categoryName = source.category?.name ?? "Uncategorized"
|
let categoryName = source.category?.name ?? "Uncategorized"
|
||||||
|
|
||||||
for snapshot in source.snapshotsArray {
|
for snapshot in source.snapshotsArray {
|
||||||
|
|
@ -48,7 +50,7 @@ class ExportService {
|
||||||
: ""
|
: ""
|
||||||
let notes = escapeCSV(snapshot.notes ?? "")
|
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,59 +63,76 @@ class ExportService {
|
||||||
) -> String {
|
) -> String {
|
||||||
var exportData: [String: Any] = [:]
|
var exportData: [String: Any] = [:]
|
||||||
exportData["exportDate"] = ISO8601DateFormatter().string(from: Date())
|
exportData["exportDate"] = ISO8601DateFormatter().string(from: Date())
|
||||||
exportData["currency"] = "EUR"
|
exportData["version"] = 2
|
||||||
|
exportData["currency"] = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
|
||||||
|
|
||||||
// Export categories
|
let accounts = Dictionary(grouping: sources) { $0.account?.id.uuidString ?? "default" }
|
||||||
var categoriesArray: [[String: Any]] = []
|
var accountsArray: [[String: Any]] = []
|
||||||
for category in categories {
|
|
||||||
var categoryDict: [String: Any] = [
|
for (_, accountSources) in accounts {
|
||||||
"id": category.id.uuidString,
|
let account = accountSources.first?.account
|
||||||
"name": category.name,
|
var accountDict: [String: Any] = [
|
||||||
"color": category.colorHex,
|
"name": account?.name ?? "Default",
|
||||||
"icon": category.icon
|
"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 sources in this category
|
// Export categories for this account
|
||||||
var sourcesArray: [[String: Any]] = []
|
let categoriesById = Dictionary(uniqueKeysWithValues: categories.map { ($0.id, $0) })
|
||||||
for source in category.sourcesArray {
|
let sourcesByCategory = Dictionary(grouping: accountSources) { $0.category?.id ?? UUID() }
|
||||||
var sourceDict: [String: Any] = [
|
var categoriesArray: [[String: Any]] = []
|
||||||
"id": source.id.uuidString,
|
|
||||||
"name": source.name,
|
for (categoryId, categorySources) in sourcesByCategory {
|
||||||
"isActive": source.isActive,
|
let category = categoriesById[categoryId]
|
||||||
"notificationFrequency": source.notificationFrequency
|
var categoryDict: [String: Any] = [
|
||||||
|
"name": category?.name ?? "Uncategorized",
|
||||||
|
"color": category?.colorHex ?? "#3B82F6",
|
||||||
|
"icon": category?.icon ?? "chart.pie.fill"
|
||||||
]
|
]
|
||||||
|
|
||||||
// Export snapshots
|
var sourcesArray: [[String: Any]] = []
|
||||||
var snapshotsArray: [[String: Any]] = []
|
for source in categorySources {
|
||||||
for snapshot in source.snapshotsArray {
|
var sourceDict: [String: Any] = [
|
||||||
var snapshotDict: [String: Any] = [
|
"name": source.name,
|
||||||
"id": snapshot.id.uuidString,
|
"isActive": source.isActive,
|
||||||
"date": ISO8601DateFormatter().string(from: snapshot.date),
|
"notificationFrequency": source.notificationFrequency
|
||||||
"value": NSDecimalNumber(decimal: snapshot.decimalValue).doubleValue
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if snapshot.contribution != nil {
|
var snapshotsArray: [[String: Any]] = []
|
||||||
snapshotDict["contribution"] = NSDecimalNumber(
|
for snapshot in source.snapshotsArray {
|
||||||
decimal: snapshot.decimalContribution
|
var snapshotDict: [String: Any] = [
|
||||||
).doubleValue
|
"date": ISO8601DateFormatter().string(from: snapshot.date),
|
||||||
|
"value": NSDecimalNumber(decimal: snapshot.decimalValue).doubleValue
|
||||||
|
]
|
||||||
|
|
||||||
|
if snapshot.contribution != nil {
|
||||||
|
snapshotDict["contribution"] = NSDecimalNumber(
|
||||||
|
decimal: snapshot.decimalContribution
|
||||||
|
).doubleValue
|
||||||
|
}
|
||||||
|
|
||||||
|
if let notes = snapshot.notes, !notes.isEmpty {
|
||||||
|
snapshotDict["notes"] = notes
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshotsArray.append(snapshotDict)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let notes = snapshot.notes, !notes.isEmpty {
|
sourceDict["snapshots"] = snapshotsArray
|
||||||
snapshotDict["notes"] = notes
|
sourcesArray.append(sourceDict)
|
||||||
}
|
|
||||||
|
|
||||||
snapshotsArray.append(snapshotDict)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceDict["snapshots"] = snapshotsArray
|
categoryDict["sources"] = sourcesArray
|
||||||
sourcesArray.append(sourceDict)
|
categoriesArray.append(categoryDict)
|
||||||
}
|
}
|
||||||
|
|
||||||
categoryDict["sources"] = sourcesArray
|
accountDict["categories"] = categoriesArray
|
||||||
categoriesArray.append(categoryDict)
|
accountsArray.append(accountDict)
|
||||||
}
|
}
|
||||||
|
|
||||||
exportData["categories"] = categoriesArray
|
exportData["accounts"] = accountsArray
|
||||||
|
|
||||||
// Add summary
|
// Add summary
|
||||||
let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||||
|
|
@ -1,14 +1,20 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import FirebaseAnalytics
|
import FirebaseAnalytics
|
||||||
|
import FirebaseCore
|
||||||
|
|
||||||
class FirebaseService {
|
class FirebaseService {
|
||||||
static let shared = FirebaseService()
|
static let shared = FirebaseService()
|
||||||
|
|
||||||
|
private var isConfigured: Bool {
|
||||||
|
FirebaseApp.app() != nil
|
||||||
|
}
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
// MARK: - User Properties
|
// MARK: - User Properties
|
||||||
|
|
||||||
func setUserTier(_ tier: UserTier) {
|
func setUserTier(_ tier: UserTier) {
|
||||||
|
guard isConfigured else { return }
|
||||||
Analytics.setUserProperty(tier.rawValue, forName: "user_tier")
|
Analytics.setUserProperty(tier.rawValue, forName: "user_tier")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -20,6 +26,7 @@ class FirebaseService {
|
||||||
// MARK: - Screen Tracking
|
// MARK: - Screen Tracking
|
||||||
|
|
||||||
func logScreenView(screenName: String, screenClass: String? = nil) {
|
func logScreenView(screenName: String, screenClass: String? = nil) {
|
||||||
|
guard isConfigured else { return }
|
||||||
Analytics.logEvent(AnalyticsEventScreenView, parameters: [
|
Analytics.logEvent(AnalyticsEventScreenView, parameters: [
|
||||||
AnalyticsParameterScreenName: screenName,
|
AnalyticsParameterScreenName: screenName,
|
||||||
AnalyticsParameterScreenClass: screenClass ?? screenName
|
AnalyticsParameterScreenClass: screenClass ?? screenName
|
||||||
|
|
@ -29,6 +36,7 @@ class FirebaseService {
|
||||||
// MARK: - Investment Events
|
// MARK: - Investment Events
|
||||||
|
|
||||||
func logSourceAdded(categoryName: String, sourceCount: Int) {
|
func logSourceAdded(categoryName: String, sourceCount: Int) {
|
||||||
|
guard isConfigured else { return }
|
||||||
Analytics.logEvent("source_added", parameters: [
|
Analytics.logEvent("source_added", parameters: [
|
||||||
"category_name": categoryName,
|
"category_name": categoryName,
|
||||||
"total_sources": sourceCount
|
"total_sources": sourceCount
|
||||||
|
|
@ -36,12 +44,14 @@ class FirebaseService {
|
||||||
}
|
}
|
||||||
|
|
||||||
func logSourceDeleted(categoryName: String) {
|
func logSourceDeleted(categoryName: String) {
|
||||||
|
guard isConfigured else { return }
|
||||||
Analytics.logEvent("source_deleted", parameters: [
|
Analytics.logEvent("source_deleted", parameters: [
|
||||||
"category_name": categoryName
|
"category_name": categoryName
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func logSnapshotAdded(sourceName: String, value: Decimal) {
|
func logSnapshotAdded(sourceName: String, value: Decimal) {
|
||||||
|
guard isConfigured else { return }
|
||||||
Analytics.logEvent("snapshot_added", parameters: [
|
Analytics.logEvent("snapshot_added", parameters: [
|
||||||
"source_name": sourceName,
|
"source_name": sourceName,
|
||||||
"value": NSDecimalNumber(decimal: value).doubleValue
|
"value": NSDecimalNumber(decimal: value).doubleValue
|
||||||
|
|
@ -49,6 +59,7 @@ class FirebaseService {
|
||||||
}
|
}
|
||||||
|
|
||||||
func logCategoryCreated(name: String) {
|
func logCategoryCreated(name: String) {
|
||||||
|
guard isConfigured else { return }
|
||||||
Analytics.logEvent("category_created", parameters: [
|
Analytics.logEvent("category_created", parameters: [
|
||||||
"category_name": name
|
"category_name": name
|
||||||
])
|
])
|
||||||
|
|
@ -57,18 +68,21 @@ class FirebaseService {
|
||||||
// MARK: - Purchase Events
|
// MARK: - Purchase Events
|
||||||
|
|
||||||
func logPaywallShown(trigger: String) {
|
func logPaywallShown(trigger: String) {
|
||||||
|
guard isConfigured else { return }
|
||||||
Analytics.logEvent("paywall_shown", parameters: [
|
Analytics.logEvent("paywall_shown", parameters: [
|
||||||
"trigger": trigger
|
"trigger": trigger
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func logPurchaseAttempt(productId: String) {
|
func logPurchaseAttempt(productId: String) {
|
||||||
|
guard isConfigured else { return }
|
||||||
Analytics.logEvent("purchase_attempt", parameters: [
|
Analytics.logEvent("purchase_attempt", parameters: [
|
||||||
"product_id": productId
|
"product_id": productId
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func logPurchaseSuccess(productId: String, price: Decimal, isFamilyShared: Bool) {
|
func logPurchaseSuccess(productId: String, price: Decimal, isFamilyShared: Bool) {
|
||||||
|
guard isConfigured else { return }
|
||||||
Analytics.logEvent(AnalyticsEventPurchase, parameters: [
|
Analytics.logEvent(AnalyticsEventPurchase, parameters: [
|
||||||
AnalyticsParameterItemID: productId,
|
AnalyticsParameterItemID: productId,
|
||||||
AnalyticsParameterPrice: NSDecimalNumber(decimal: price).doubleValue,
|
AnalyticsParameterPrice: NSDecimalNumber(decimal: price).doubleValue,
|
||||||
|
|
@ -78,6 +92,7 @@ class FirebaseService {
|
||||||
}
|
}
|
||||||
|
|
||||||
func logPurchaseFailure(productId: String, error: String) {
|
func logPurchaseFailure(productId: String, error: String) {
|
||||||
|
guard isConfigured else { return }
|
||||||
Analytics.logEvent("purchase_failed", parameters: [
|
Analytics.logEvent("purchase_failed", parameters: [
|
||||||
"product_id": productId,
|
"product_id": productId,
|
||||||
"error": error
|
"error": error
|
||||||
|
|
@ -85,6 +100,7 @@ class FirebaseService {
|
||||||
}
|
}
|
||||||
|
|
||||||
func logRestorePurchases(success: Bool) {
|
func logRestorePurchases(success: Bool) {
|
||||||
|
guard isConfigured else { return }
|
||||||
Analytics.logEvent("restore_purchases", parameters: [
|
Analytics.logEvent("restore_purchases", parameters: [
|
||||||
"success": success
|
"success": success
|
||||||
])
|
])
|
||||||
|
|
@ -93,6 +109,7 @@ class FirebaseService {
|
||||||
// MARK: - Feature Usage Events
|
// MARK: - Feature Usage Events
|
||||||
|
|
||||||
func logChartViewed(chartType: String, isPremium: Bool) {
|
func logChartViewed(chartType: String, isPremium: Bool) {
|
||||||
|
guard isConfigured else { return }
|
||||||
Analytics.logEvent("chart_viewed", parameters: [
|
Analytics.logEvent("chart_viewed", parameters: [
|
||||||
"chart_type": chartType,
|
"chart_type": chartType,
|
||||||
"is_premium_chart": isPremium
|
"is_premium_chart": isPremium
|
||||||
|
|
@ -100,12 +117,14 @@ class FirebaseService {
|
||||||
}
|
}
|
||||||
|
|
||||||
func logPredictionViewed(algorithm: String) {
|
func logPredictionViewed(algorithm: String) {
|
||||||
|
guard isConfigured else { return }
|
||||||
Analytics.logEvent("prediction_viewed", parameters: [
|
Analytics.logEvent("prediction_viewed", parameters: [
|
||||||
"algorithm": algorithm
|
"algorithm": algorithm
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func logExportAttempt(format: String, success: Bool) {
|
func logExportAttempt(format: String, success: Bool) {
|
||||||
|
guard isConfigured else { return }
|
||||||
Analytics.logEvent("export_attempt", parameters: [
|
Analytics.logEvent("export_attempt", parameters: [
|
||||||
"format": format,
|
"format": format,
|
||||||
"success": success
|
"success": success
|
||||||
|
|
@ -113,6 +132,7 @@ class FirebaseService {
|
||||||
}
|
}
|
||||||
|
|
||||||
func logNotificationScheduled(frequency: String) {
|
func logNotificationScheduled(frequency: String) {
|
||||||
|
guard isConfigured else { return }
|
||||||
Analytics.logEvent("notification_scheduled", parameters: [
|
Analytics.logEvent("notification_scheduled", parameters: [
|
||||||
"frequency": frequency
|
"frequency": frequency
|
||||||
])
|
])
|
||||||
|
|
@ -121,12 +141,14 @@ class FirebaseService {
|
||||||
// MARK: - Ad Events
|
// MARK: - Ad Events
|
||||||
|
|
||||||
func logAdImpression(adType: String) {
|
func logAdImpression(adType: String) {
|
||||||
|
guard isConfigured else { return }
|
||||||
Analytics.logEvent("ad_impression", parameters: [
|
Analytics.logEvent("ad_impression", parameters: [
|
||||||
"ad_type": adType
|
"ad_type": adType
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func logAdClick(adType: String) {
|
func logAdClick(adType: String) {
|
||||||
|
guard isConfigured else { return }
|
||||||
Analytics.logEvent("ad_click", parameters: [
|
Analytics.logEvent("ad_click", parameters: [
|
||||||
"ad_type": adType
|
"ad_type": adType
|
||||||
])
|
])
|
||||||
|
|
@ -135,18 +157,21 @@ class FirebaseService {
|
||||||
// MARK: - Engagement Events
|
// MARK: - Engagement Events
|
||||||
|
|
||||||
func logOnboardingCompleted(stepCount: Int) {
|
func logOnboardingCompleted(stepCount: Int) {
|
||||||
|
guard isConfigured else { return }
|
||||||
Analytics.logEvent("onboarding_completed", parameters: [
|
Analytics.logEvent("onboarding_completed", parameters: [
|
||||||
"steps_completed": stepCount
|
"steps_completed": stepCount
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func logWidgetUsed(widgetType: String) {
|
func logWidgetUsed(widgetType: String) {
|
||||||
|
guard isConfigured else { return }
|
||||||
Analytics.logEvent("widget_used", parameters: [
|
Analytics.logEvent("widget_used", parameters: [
|
||||||
"widget_type": widgetType
|
"widget_type": widgetType
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func logAppOpened(fromWidget: Bool) {
|
func logAppOpened(fromWidget: Bool) {
|
||||||
|
guard isConfigured else { return }
|
||||||
Analytics.logEvent("app_opened", parameters: [
|
Analytics.logEvent("app_opened", parameters: [
|
||||||
"from_widget": fromWidget
|
"from_widget": fromWidget
|
||||||
])
|
])
|
||||||
|
|
@ -155,6 +180,7 @@ class FirebaseService {
|
||||||
// MARK: - Portfolio Events
|
// MARK: - Portfolio Events
|
||||||
|
|
||||||
func logPortfolioMilestone(totalValue: Decimal, milestone: String) {
|
func logPortfolioMilestone(totalValue: Decimal, milestone: String) {
|
||||||
|
guard isConfigured else { return }
|
||||||
Analytics.logEvent("portfolio_milestone", parameters: [
|
Analytics.logEvent("portfolio_milestone", parameters: [
|
||||||
"total_value": NSDecimalNumber(decimal: totalValue).doubleValue,
|
"total_value": NSDecimalNumber(decimal: totalValue).doubleValue,
|
||||||
"milestone": milestone
|
"milestone": milestone
|
||||||
|
|
@ -164,6 +190,7 @@ class FirebaseService {
|
||||||
// MARK: - Error Events
|
// MARK: - Error Events
|
||||||
|
|
||||||
func logError(type: String, message: String, context: String? = nil) {
|
func logError(type: String, message: String, context: String? = nil) {
|
||||||
|
guard isConfigured else { return }
|
||||||
Analytics.logEvent("app_error", parameters: [
|
Analytics.logEvent("app_error", parameters: [
|
||||||
"error_type": type,
|
"error_type": type,
|
||||||
"error_message": message,
|
"error_message": message,
|
||||||
|
|
@ -171,3 +198,4 @@ class FirebaseService {
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,16 +10,20 @@ class IAPService: ObservableObject {
|
||||||
@Published private(set) var products: [Product] = []
|
@Published private(set) var products: [Product] = []
|
||||||
@Published private(set) var purchaseState: PurchaseState = .idle
|
@Published private(set) var purchaseState: PurchaseState = .idle
|
||||||
@Published private(set) var isFamilyShared = false
|
@Published private(set) var isFamilyShared = false
|
||||||
|
#if DEBUG
|
||||||
|
@Published var debugOverrideEnabled = false
|
||||||
|
#endif
|
||||||
|
|
||||||
// MARK: - Constants
|
// MARK: - Constants
|
||||||
|
|
||||||
static let premiumProductID = "com.investmenttracker.premium"
|
static let premiumProductID = "com.portfoliojournal.premium"
|
||||||
static let premiumPrice = "€4.69"
|
static let premiumPrice = "€4.69"
|
||||||
|
|
||||||
// MARK: - Private Properties
|
// MARK: - Private Properties
|
||||||
|
|
||||||
private var updateListenerTask: Task<Void, Error>?
|
private var updateListenerTask: Task<Void, Error>?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private let sharedDefaults = UserDefaults(suiteName: AppConstants.appGroupIdentifier)
|
||||||
|
|
||||||
// MARK: - Purchase State
|
// MARK: - Purchase State
|
||||||
|
|
||||||
|
|
@ -34,6 +38,9 @@ class IAPService: ObservableObject {
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
#if DEBUG
|
||||||
|
debugOverrideEnabled = UserDefaults.standard.bool(forKey: "debugPremiumOverride")
|
||||||
|
#endif
|
||||||
updateListenerTask = listenForTransactions()
|
updateListenerTask = listenForTransactions()
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
|
|
@ -132,7 +139,15 @@ class IAPService: ObservableObject {
|
||||||
var isEntitled = false
|
var isEntitled = false
|
||||||
var familyShared = 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 case .verified(let transaction) = result {
|
||||||
if transaction.productID == Self.premiumProductID {
|
if transaction.productID == Self.premiumProductID {
|
||||||
isEntitled = true
|
isEntitled = true
|
||||||
|
|
@ -144,6 +159,7 @@ class IAPService: ObservableObject {
|
||||||
|
|
||||||
isPremium = isEntitled
|
isPremium = isEntitled
|
||||||
isFamilyShared = familyShared
|
isFamilyShared = familyShared
|
||||||
|
sharedDefaults?.set(isEntitled, forKey: "premiumUnlocked")
|
||||||
|
|
||||||
// Update Core Data
|
// Update Core Data
|
||||||
let context = CoreDataStack.shared.viewContext
|
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
|
// MARK: - Transaction Listener
|
||||||
|
|
||||||
private func listenForTransactions() -> Task<Void, Error> {
|
private func listenForTransactions() -> Task<Void, Error> {
|
||||||
return Task.detached { [weak self] in
|
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 {
|
if case .verified(let transaction) = result {
|
||||||
await transaction.finish()
|
await transaction.finish()
|
||||||
await self?.updatePremiumStatus()
|
await self?.updatePremiumStatus()
|
||||||
|
|
@ -214,6 +244,7 @@ enum IAPError: LocalizedError {
|
||||||
|
|
||||||
extension IAPService {
|
extension IAPService {
|
||||||
static let premiumFeatures: [(icon: String, title: String, description: String)] = [
|
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"),
|
("infinity", "Unlimited Sources", "Track as many investments as you want"),
|
||||||
("clock.arrow.circlepath", "Full History", "Access your complete investment history"),
|
("clock.arrow.circlepath", "Full History", "Access your complete investment history"),
|
||||||
("chart.bar.xaxis", "Advanced Charts", "5 types of detailed analytics charts"),
|
("chart.bar.xaxis", "Advanced Charts", "5 types of detailed analytics charts"),
|
||||||
|
|
@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Combine
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
|
@ -120,16 +121,12 @@ class NotificationService: ObservableObject {
|
||||||
let needsUpdate = repository.fetchSourcesNeedingUpdate()
|
let needsUpdate = repository.fetchSourcesNeedingUpdate()
|
||||||
pendingCount = needsUpdate.count
|
pendingCount = needsUpdate.count
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
center.setBadgeCount(pendingCount) { _ in }
|
||||||
UIApplication.shared.applicationIconBadgeNumber = self.pendingCount
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearBadge() {
|
func clearBadge() {
|
||||||
pendingCount = 0
|
pendingCount = 0
|
||||||
DispatchQueue.main.async {
|
center.setBadgeCount(0) { _ in }
|
||||||
UIApplication.shared.applicationIconBadgeNumber = 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Pending Notifications
|
// MARK: - Pending Notifications
|
||||||
|
|
@ -5,6 +5,9 @@ class PredictionEngine {
|
||||||
|
|
||||||
private let context = CoreDataStack.shared.viewContext
|
private let context = CoreDataStack.shared.viewContext
|
||||||
|
|
||||||
|
// MARK: - Performance: Cached Calendar reference
|
||||||
|
private static let calendar = Calendar.current
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
// MARK: - Main Prediction Interface
|
// MARK: - Main Prediction Interface
|
||||||
|
|
@ -46,6 +49,9 @@ class PredictionEngine {
|
||||||
case .movingAverage:
|
case .movingAverage:
|
||||||
predictions = predictMovingAverage(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
|
predictions = predictMovingAverage(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
|
||||||
accuracy = calculateMAAccuracy(snapshots: sortedSnapshots)
|
accuracy = calculateMAAccuracy(snapshots: sortedSnapshots)
|
||||||
|
case .holtTrend:
|
||||||
|
predictions = predictHoltTrend(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
|
||||||
|
accuracy = calculateHoltAccuracy(snapshots: sortedSnapshots)
|
||||||
}
|
}
|
||||||
|
|
||||||
return PredictionResult(
|
return PredictionResult(
|
||||||
|
|
@ -60,12 +66,12 @@ class PredictionEngine {
|
||||||
|
|
||||||
private func selectBestAlgorithm(volatility: Double) -> PredictionAlgorithm {
|
private func selectBestAlgorithm(volatility: Double) -> PredictionAlgorithm {
|
||||||
switch volatility {
|
switch volatility {
|
||||||
case 0..<10:
|
case 0..<8:
|
||||||
return .linear // Low volatility - linear works well
|
return .holtTrend
|
||||||
case 10..<25:
|
case 8..<20:
|
||||||
return .exponentialSmoothing // Medium volatility - weight recent data
|
return .exponentialSmoothing
|
||||||
default:
|
default:
|
||||||
return .movingAverage // High volatility - smooth out fluctuations
|
return .movingAverage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,7 +94,7 @@ class PredictionEngine {
|
||||||
let lastDate = snapshots.last!.date
|
let lastDate = snapshots.last!.date
|
||||||
|
|
||||||
for month in 1...monthsAhead {
|
for month in 1...monthsAhead {
|
||||||
guard let futureDate = Calendar.current.date(
|
guard let futureDate = Self.calendar.date(
|
||||||
byAdding: .month,
|
byAdding: .month,
|
||||||
value: month,
|
value: month,
|
||||||
to: lastDate
|
to: lastDate
|
||||||
|
|
@ -219,7 +225,7 @@ class PredictionEngine {
|
||||||
let lastDate = snapshots.last!.date
|
let lastDate = snapshots.last!.date
|
||||||
|
|
||||||
for month in 1...monthsAhead {
|
for month in 1...monthsAhead {
|
||||||
guard let futureDate = Calendar.current.date(
|
guard let futureDate = Self.calendar.date(
|
||||||
byAdding: .month,
|
byAdding: .month,
|
||||||
value: month,
|
value: month,
|
||||||
to: lastDate
|
to: lastDate
|
||||||
|
|
@ -297,7 +303,7 @@ class PredictionEngine {
|
||||||
let lastDate = snapshots.last!.date
|
let lastDate = snapshots.last!.date
|
||||||
|
|
||||||
for month in 1...monthsAhead {
|
for month in 1...monthsAhead {
|
||||||
guard let futureDate = Calendar.current.date(
|
guard let futureDate = Self.calendar.date(
|
||||||
byAdding: .month,
|
byAdding: .month,
|
||||||
value: month,
|
value: month,
|
||||||
to: lastDate
|
to: lastDate
|
||||||
|
|
@ -347,6 +353,90 @@ class PredictionEngine {
|
||||||
return max(0, min(1.0, 1 - (ssRes / ssTot)))
|
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
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func calculateVolatility(snapshots: [Snapshot]) -> Double {
|
private func calculateVolatility(snapshots: [Snapshot]) -> Double {
|
||||||
|
|
@ -373,11 +463,3 @@ class PredictionEngine {
|
||||||
return sqrt(variance)
|
return sqrt(variance)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Decimal Extension
|
|
||||||
|
|
||||||
private extension Decimal {
|
|
||||||
var doubleValue: Double {
|
|
||||||
NSDecimalNumber(decimal: self).doubleValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class SampleDataService {
|
||||||
|
static let shared = SampleDataService()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func seedSampleData() {
|
||||||
|
let context = CoreDataStack.shared.viewContext
|
||||||
|
let sourceRepository = InvestmentSourceRepository(context: context)
|
||||||
|
guard sourceRepository.sourceCount == 0 else { return }
|
||||||
|
|
||||||
|
let categoryRepository = CategoryRepository(context: context)
|
||||||
|
categoryRepository.createDefaultCategoriesIfNeeded()
|
||||||
|
|
||||||
|
let accountRepository = AccountRepository(context: context)
|
||||||
|
let account = accountRepository.createDefaultAccountIfNeeded()
|
||||||
|
|
||||||
|
let snapshotRepository = SnapshotRepository(context: context)
|
||||||
|
let goalRepository = GoalRepository(context: context)
|
||||||
|
let transactionRepository = TransactionRepository(context: context)
|
||||||
|
|
||||||
|
let categories = categoryRepository.categories
|
||||||
|
let stocksCategory = categories.first { $0.name == "Stocks" } ?? categories.first!
|
||||||
|
let cryptoCategory = categories.first { $0.name == "Crypto" } ?? categories.first!
|
||||||
|
let realEstateCategory = categories.first { $0.name == "Real Estate" } ?? categories.first!
|
||||||
|
|
||||||
|
let stocks = sourceRepository.createSource(
|
||||||
|
name: "Index Fund",
|
||||||
|
category: stocksCategory,
|
||||||
|
account: account
|
||||||
|
)
|
||||||
|
let crypto = sourceRepository.createSource(
|
||||||
|
name: "BTC",
|
||||||
|
category: cryptoCategory,
|
||||||
|
account: account
|
||||||
|
)
|
||||||
|
let realEstate = sourceRepository.createSource(
|
||||||
|
name: "Rental Property",
|
||||||
|
category: realEstateCategory,
|
||||||
|
account: account
|
||||||
|
)
|
||||||
|
|
||||||
|
seedSnapshots(for: stocks, baseValue: 12000, monthlyIncrease: 450, repository: snapshotRepository)
|
||||||
|
seedSnapshots(for: crypto, baseValue: 3000, monthlyIncrease: 250, repository: snapshotRepository)
|
||||||
|
seedSnapshots(for: realEstate, baseValue: 80000, monthlyIncrease: 600, repository: snapshotRepository)
|
||||||
|
|
||||||
|
seedMonthlyNotes()
|
||||||
|
|
||||||
|
transactionRepository.createTransaction(
|
||||||
|
source: stocks,
|
||||||
|
type: .buy,
|
||||||
|
date: Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date(),
|
||||||
|
shares: 10,
|
||||||
|
price: 400,
|
||||||
|
amount: nil,
|
||||||
|
notes: "Sample buy"
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = goalRepository.createGoal(
|
||||||
|
name: "1M Goal",
|
||||||
|
targetAmount: 1_000_000,
|
||||||
|
targetDate: nil,
|
||||||
|
account: account
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func seedSnapshots(
|
||||||
|
for source: InvestmentSource,
|
||||||
|
baseValue: Decimal,
|
||||||
|
monthlyIncrease: Decimal,
|
||||||
|
repository: SnapshotRepository
|
||||||
|
) {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
for monthOffset in (0..<6).reversed() {
|
||||||
|
let date = calendar.date(byAdding: .month, value: -monthOffset, to: Date()) ?? Date()
|
||||||
|
let value = baseValue + Decimal(monthOffset) * monthlyIncrease
|
||||||
|
repository.createSnapshot(
|
||||||
|
for: source,
|
||||||
|
date: date,
|
||||||
|
value: value,
|
||||||
|
contribution: monthOffset == 0 ? monthlyIncrease : nil,
|
||||||
|
notes: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func seedMonthlyNotes() {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let notes = [
|
||||||
|
"Rebalanced slightly toward equities. Stayed calm despite noise.",
|
||||||
|
"Focused on contributions. No major changes.",
|
||||||
|
"Reviewed allocation drift and decided to hold positions."
|
||||||
|
]
|
||||||
|
|
||||||
|
for (index, note) in notes.enumerated() {
|
||||||
|
let date = calendar.date(byAdding: .month, value: -index, to: Date()) ?? Date()
|
||||||
|
MonthlyCheckInStore.setNote(note, for: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class ShareService {
|
||||||
|
static let shared = ShareService()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func shareTextFile(content: String, fileName: String) {
|
||||||
|
guard let viewController = ShareService.topViewController() else { return }
|
||||||
|
|
||||||
|
let tempURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(fileName)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try content.write(to: tempURL, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
let activityVC = UIActivityViewController(
|
||||||
|
activityItems: [tempURL],
|
||||||
|
applicationActivities: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
if let popover = activityVC.popoverPresentationController {
|
||||||
|
popover.sourceView = viewController.view
|
||||||
|
popover.sourceRect = CGRect(
|
||||||
|
x: viewController.view.bounds.midX,
|
||||||
|
y: viewController.view.bounds.midY,
|
||||||
|
width: 0,
|
||||||
|
height: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
viewController.present(activityVC, animated: true)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Share file error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shareCalendarEvent(
|
||||||
|
title: String,
|
||||||
|
notes: String,
|
||||||
|
startDate: Date,
|
||||||
|
durationMinutes: Int = 60
|
||||||
|
) {
|
||||||
|
let endDate = startDate.addingTimeInterval(TimeInterval(durationMinutes * 60))
|
||||||
|
let icsContent = calendarICS(
|
||||||
|
title: title,
|
||||||
|
notes: notes,
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate
|
||||||
|
)
|
||||||
|
shareTextFile(content: icsContent, fileName: "PortfolioJournal-CheckIn.ics")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func topViewController(
|
||||||
|
base: UIViewController? = UIApplication.shared.connectedScenes
|
||||||
|
.compactMap { $0 as? UIWindowScene }
|
||||||
|
.flatMap { $0.windows }
|
||||||
|
.first(where: { $0.isKeyWindow })?.rootViewController
|
||||||
|
) -> UIViewController? {
|
||||||
|
if let nav = base as? UINavigationController {
|
||||||
|
return topViewController(base: nav.visibleViewController)
|
||||||
|
}
|
||||||
|
if let tab = base as? UITabBarController {
|
||||||
|
return topViewController(base: tab.selectedViewController)
|
||||||
|
}
|
||||||
|
if let presented = base?.presentedViewController {
|
||||||
|
return topViewController(base: presented)
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calendarICS(
|
||||||
|
title: String,
|
||||||
|
notes: String,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
) -> String {
|
||||||
|
let uid = UUID().uuidString
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
|
||||||
|
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||||
|
let stamp = formatter.string(from: Date())
|
||||||
|
let start = formatter.string(from: startDate)
|
||||||
|
let end = formatter.string(from: endDate)
|
||||||
|
|
||||||
|
return """
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//PortfolioJournal//MonthlyCheckIn//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:\(uid)
|
||||||
|
DTSTAMP:\(stamp)
|
||||||
|
DTSTART:\(start)
|
||||||
|
DTEND:\(end)
|
||||||
|
SUMMARY:\(escapeICS(title))
|
||||||
|
DESCRIPTION:\(escapeICS(notes))
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func escapeICS(_ value: String) -> String {
|
||||||
|
value
|
||||||
|
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||||
|
.replacingOccurrences(of: "\n", with: "\\n")
|
||||||
|
.replacingOccurrences(of: ";", with: "\\;")
|
||||||
|
.replacingOccurrences(of: ",", with: "\\,")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum AllocationTargetStore {
|
||||||
|
private static let targetsKey = "allocationTargets"
|
||||||
|
|
||||||
|
static func target(for categoryId: UUID) -> Double? {
|
||||||
|
loadTargets()[categoryId.uuidString]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setTarget(_ value: Double?, for categoryId: UUID) {
|
||||||
|
var targets = loadTargets()
|
||||||
|
let key = categoryId.uuidString
|
||||||
|
if let value, value > 0 {
|
||||||
|
targets[key] = value
|
||||||
|
} else {
|
||||||
|
targets.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
saveTargets(targets)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func totalTargetPercentage(for categoryIds: [UUID]) -> Double {
|
||||||
|
let targets = loadTargets()
|
||||||
|
return categoryIds.reduce(0) { total, id in
|
||||||
|
total + (targets[id.uuidString] ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func loadTargets() -> [String: Double] {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: targetsKey),
|
||||||
|
let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else {
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func saveTargets(_ targets: [String: Double]) {
|
||||||
|
if let data = try? JSONEncoder().encode(targets) {
|
||||||
|
UserDefaults.standard.set(data, forKey: targetsKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import Foundation
|
||||||
|
import LocalAuthentication
|
||||||
|
|
||||||
|
enum AppLockService {
|
||||||
|
static func canUseBiometrics() -> Bool {
|
||||||
|
let context = LAContext()
|
||||||
|
var error: NSError?
|
||||||
|
return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func authenticate(reason: String, completion: @escaping (Bool) -> Void) {
|
||||||
|
let context = LAContext()
|
||||||
|
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
completion(success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,19 +3,19 @@ import Foundation
|
||||||
enum AppConstants {
|
enum AppConstants {
|
||||||
// MARK: - App Info
|
// 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 appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
|
||||||
static let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
|
static let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
|
||||||
|
|
||||||
// MARK: - Bundle Identifiers
|
// MARK: - Bundle Identifiers
|
||||||
|
|
||||||
static let bundleIdentifier = "com.yourteam.investmenttracker"
|
static let bundleIdentifier = "com.alexandrevazquez.portfoliojournal"
|
||||||
static let appGroupIdentifier = "group.com.yourteam.investmenttracker"
|
static let appGroupIdentifier = "group.com.alexandrevazquez.portfoliojournal"
|
||||||
static let cloudKitContainerIdentifier = "iCloud.com.yourteam.investmenttracker"
|
static let cloudKitContainerIdentifier = "iCloud.com.alexandrevazquez.portfoliojournal"
|
||||||
|
|
||||||
// MARK: - StoreKit
|
// MARK: - StoreKit
|
||||||
|
|
||||||
static let premiumProductID = "com.investmenttracker.premium"
|
static let premiumProductID = "com.portfoliojournal.premium"
|
||||||
static let premiumPrice = "€4.69"
|
static let premiumPrice = "€4.69"
|
||||||
|
|
||||||
// MARK: - AdMob
|
// MARK: - AdMob
|
||||||
|
|
@ -98,7 +98,7 @@ enum AppConstants {
|
||||||
// MARK: - Deep Links
|
// MARK: - Deep Links
|
||||||
|
|
||||||
enum DeepLinks {
|
enum DeepLinks {
|
||||||
static let scheme = "investmenttracker"
|
static let scheme = "portfoliojournal"
|
||||||
static let sourceDetail = "source"
|
static let sourceDetail = "source"
|
||||||
static let addSnapshot = "addSnapshot"
|
static let addSnapshot = "addSnapshot"
|
||||||
static let premium = "premium"
|
static let premium = "premium"
|
||||||
|
|
@ -107,9 +107,9 @@ enum AppConstants {
|
||||||
// MARK: - URLs
|
// MARK: - URLs
|
||||||
|
|
||||||
enum URLs {
|
enum URLs {
|
||||||
static let privacyPolicy = "https://yourwebsite.com/privacy"
|
static let privacyPolicy = "https://portfoliojournal.app/privacy.html"
|
||||||
static let termsOfService = "https://yourwebsite.com/terms"
|
static let termsOfService = "https://portfoliojournal.app/terms.html"
|
||||||
static let support = "https://yourwebsite.com/support"
|
static let support = "https://portfoliojournal.app/support.html"
|
||||||
static let appStore = "https://apps.apple.com/app/idXXXXXXXXXX"
|
static let appStore = "https://apps.apple.com/app/idXXXXXXXXXX"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum CurrencyFormatter {
|
||||||
|
static func currentCurrencyCode() -> String {
|
||||||
|
let context = CoreDataStack.shared.viewContext
|
||||||
|
return AppSettings.getOrCreate(in: context).currency
|
||||||
|
}
|
||||||
|
|
||||||
|
static func format(_ decimal: Decimal, style: NumberFormatter.Style = .currency, maximumFractionDigits: Int = 2) -> String {
|
||||||
|
let formatter = NumberFormatter()
|
||||||
|
formatter.numberStyle = style
|
||||||
|
formatter.currencyCode = currentCurrencyCode()
|
||||||
|
formatter.maximumFractionDigits = maximumFractionDigits
|
||||||
|
return formatter.string(from: decimal as NSDecimalNumber) ?? "\(decimal)"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func symbol(for code: String) -> String {
|
||||||
|
let formatter = NumberFormatter()
|
||||||
|
formatter.numberStyle = .currency
|
||||||
|
formatter.currencyCode = code
|
||||||
|
return formatter.currencySymbol ?? code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum CurrencyPicker {
|
||||||
|
static let commonCodes: [String] = [
|
||||||
|
"EUR", "USD", "GBP", "CHF", "JPY",
|
||||||
|
"CAD", "AUD", "SEK", "NOK", "DKK"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct DashboardSectionConfig: Identifiable, Codable, Hashable {
|
||||||
|
let id: String
|
||||||
|
var isVisible: Bool
|
||||||
|
var isCollapsed: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DashboardSection: String, CaseIterable, Identifiable {
|
||||||
|
case totalValue
|
||||||
|
case monthlyCheckIn
|
||||||
|
case momentumStreaks
|
||||||
|
case monthlySummary
|
||||||
|
case evolution
|
||||||
|
case categoryBreakdown
|
||||||
|
case goals
|
||||||
|
case pendingUpdates
|
||||||
|
case periodReturns
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .totalValue:
|
||||||
|
return "Total Portfolio Value"
|
||||||
|
case .monthlyCheckIn:
|
||||||
|
return "Monthly Check-in"
|
||||||
|
case .momentumStreaks:
|
||||||
|
return "Momentum & Streaks"
|
||||||
|
case .monthlySummary:
|
||||||
|
return "Cashflow vs Growth"
|
||||||
|
case .evolution:
|
||||||
|
return "Portfolio Evolution"
|
||||||
|
case .categoryBreakdown:
|
||||||
|
return "By Category"
|
||||||
|
case .goals:
|
||||||
|
return "Goals"
|
||||||
|
case .pendingUpdates:
|
||||||
|
return "Pending Updates"
|
||||||
|
case .periodReturns:
|
||||||
|
return "Returns"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DashboardLayoutStore {
|
||||||
|
private static let storageKey = "dashboardLayoutConfig"
|
||||||
|
|
||||||
|
static func load() -> [DashboardSectionConfig] {
|
||||||
|
let defaults = defaultConfigs()
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: storageKey),
|
||||||
|
let decoded = try? JSONDecoder().decode([DashboardSectionConfig].self, from: data) else {
|
||||||
|
return defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
var merged: [DashboardSectionConfig] = []
|
||||||
|
for config in decoded {
|
||||||
|
if let section = DashboardSection(rawValue: config.id) {
|
||||||
|
merged.append(config)
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for section in DashboardSection.allCases {
|
||||||
|
if !merged.contains(where: { $0.id == section.id }) {
|
||||||
|
merged.append(defaults.first(where: { $0.id == section.id }) ?? DashboardSectionConfig(
|
||||||
|
id: section.id,
|
||||||
|
isVisible: true,
|
||||||
|
isCollapsed: false
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
static func save(_ configs: [DashboardSectionConfig]) {
|
||||||
|
guard let data = try? JSONEncoder().encode(configs) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: storageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func reset() {
|
||||||
|
UserDefaults.standard.removeObject(forKey: storageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func defaultConfigs() -> [DashboardSectionConfig] {
|
||||||
|
DashboardSection.allCases.map { section in
|
||||||
|
DashboardSectionConfig(
|
||||||
|
id: section.id,
|
||||||
|
isVisible: true,
|
||||||
|
isCollapsed: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -138,9 +138,9 @@ extension Date {
|
||||||
|
|
||||||
var friendlyDescription: String {
|
var friendlyDescription: String {
|
||||||
if isToday {
|
if isToday {
|
||||||
return "Today"
|
return String(localized: "date_today")
|
||||||
} else if isYesterday {
|
} else if isYesterday {
|
||||||
return "Yesterday"
|
return String(localized: "date_yesterday")
|
||||||
} else if isThisMonth {
|
} else if isThisMonth {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "EEEE, d"
|
formatter.dateFormat = "EEEE, d"
|
||||||
|
|
@ -183,6 +183,10 @@ struct DateRange {
|
||||||
return DateRange(start: lastYear.startOfYear, end: now.startOfYear.adding(days: -1))
|
return DateRange(start: lastYear.startOfYear, end: now.startOfYear.adding(days: -1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func month(containing date: Date) -> DateRange {
|
||||||
|
DateRange(start: date.startOfMonth, end: date.endOfMonth)
|
||||||
|
}
|
||||||
|
|
||||||
static func last(months: Int) -> DateRange {
|
static func last(months: Int) -> DateRange {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let start = now.adding(months: -months)
|
let start = now.adding(months: -months)
|
||||||
|
|
@ -1,43 +1,72 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
extension Decimal {
|
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
|
// MARK: - Formatting
|
||||||
|
|
||||||
var currencyString: String {
|
var currencyString: String {
|
||||||
let formatter = NumberFormatter()
|
CurrencyFormatter.format(self, style: .currency, maximumFractionDigits: 2)
|
||||||
formatter.numberStyle = .currency
|
|
||||||
formatter.currencyCode = "EUR"
|
|
||||||
formatter.maximumFractionDigits = 2
|
|
||||||
return formatter.string(from: self as NSDecimalNumber) ?? "€0.00"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var compactCurrencyString: String {
|
var compactCurrencyString: String {
|
||||||
let formatter = NumberFormatter()
|
CurrencyFormatter.format(self, style: .currency, maximumFractionDigits: 0)
|
||||||
formatter.numberStyle = .currency
|
|
||||||
formatter.currencyCode = "EUR"
|
|
||||||
formatter.maximumFractionDigits = 0
|
|
||||||
return formatter.string(from: self as NSDecimalNumber) ?? "€0"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var shortCurrencyString: String {
|
var shortCurrencyString: String {
|
||||||
let value = NSDecimalNumber(decimal: self).doubleValue
|
let value = NSDecimalNumber(decimal: self).doubleValue
|
||||||
|
let symbol = Self.cachedCurrencySymbol
|
||||||
|
|
||||||
switch abs(value) {
|
switch Swift.abs(value) {
|
||||||
case 1_000_000...:
|
case 1_000_000...:
|
||||||
return String(format: "€%.1fM", value / 1_000_000)
|
return String(format: "%@%.1fM", symbol, value / 1_000_000)
|
||||||
case 1_000...:
|
case 1_000...:
|
||||||
return String(format: "€%.1fK", value / 1_000)
|
return String(format: "%@%.1fK", symbol, value / 1_000)
|
||||||
default:
|
default:
|
||||||
return compactCurrencyString
|
return compactCurrencyString
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var percentageString: String {
|
var percentageString: String {
|
||||||
let formatter = NumberFormatter()
|
Self.percentFormatter.string(from: self as NSDecimalNumber) ?? "0%"
|
||||||
formatter.numberStyle = .percent
|
|
||||||
formatter.maximumFractionDigits = 2
|
|
||||||
formatter.multiplier = 1
|
|
||||||
return formatter.string(from: self as NSDecimalNumber) ?? "0%"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var signedPercentageString: String {
|
var signedPercentageString: String {
|
||||||
|
|
@ -46,10 +75,7 @@ extension Decimal {
|
||||||
}
|
}
|
||||||
|
|
||||||
var decimalString: String {
|
var decimalString: String {
|
||||||
let formatter = NumberFormatter()
|
Self.decimalFormatter.string(from: self as NSDecimalNumber) ?? "0"
|
||||||
formatter.numberStyle = .decimal
|
|
||||||
formatter.maximumFractionDigits = 2
|
|
||||||
return formatter.string(from: self as NSDecimalNumber) ?? "0"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Conversions
|
// MARK: - Conversions
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
enum FreemiumLimits {
|
enum FreemiumLimits {
|
||||||
static let maxSources = 5
|
static let maxSources = 5
|
||||||
|
|
@ -94,6 +95,8 @@ class FreemiumValidator: ObservableObject {
|
||||||
if iapService.isPremium { return true }
|
if iapService.isPremium { return true }
|
||||||
|
|
||||||
switch feature {
|
switch feature {
|
||||||
|
case .multipleAccounts:
|
||||||
|
return false
|
||||||
case .unlimitedSources:
|
case .unlimitedSources:
|
||||||
return false
|
return false
|
||||||
case .fullHistory:
|
case .fullHistory:
|
||||||
|
|
@ -112,6 +115,7 @@ class FreemiumValidator: ObservableObject {
|
||||||
// MARK: - Premium Features Enum
|
// MARK: - Premium Features Enum
|
||||||
|
|
||||||
enum PremiumFeature: String, CaseIterable, Identifiable {
|
enum PremiumFeature: String, CaseIterable, Identifiable {
|
||||||
|
case multipleAccounts = "multiple_accounts"
|
||||||
case unlimitedSources = "unlimited_sources"
|
case unlimitedSources = "unlimited_sources"
|
||||||
case fullHistory = "full_history"
|
case fullHistory = "full_history"
|
||||||
case advancedCharts = "advanced_charts"
|
case advancedCharts = "advanced_charts"
|
||||||
|
|
@ -123,6 +127,7 @@ class FreemiumValidator: ObservableObject {
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .multipleAccounts: return "Multiple Accounts"
|
||||||
case .unlimitedSources: return "Unlimited Sources"
|
case .unlimitedSources: return "Unlimited Sources"
|
||||||
case .fullHistory: return "Full History"
|
case .fullHistory: return "Full History"
|
||||||
case .advancedCharts: return "Advanced Charts"
|
case .advancedCharts: return "Advanced Charts"
|
||||||
|
|
@ -134,6 +139,7 @@ class FreemiumValidator: ObservableObject {
|
||||||
|
|
||||||
var icon: String {
|
var icon: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .multipleAccounts: return "person.2"
|
||||||
case .unlimitedSources: return "infinity"
|
case .unlimitedSources: return "infinity"
|
||||||
case .fullHistory: return "clock.arrow.circlepath"
|
case .fullHistory: return "clock.arrow.circlepath"
|
||||||
case .advancedCharts: return "chart.bar.xaxis"
|
case .advancedCharts: return "chart.bar.xaxis"
|
||||||
|
|
@ -145,6 +151,8 @@ class FreemiumValidator: ObservableObject {
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .multipleAccounts:
|
||||||
|
return "Track separate portfolios for family or business"
|
||||||
case .unlimitedSources:
|
case .unlimitedSources:
|
||||||
return "Track as many investment sources as you want"
|
return "Track as many investment sources as you want"
|
||||||
case .fullHistory:
|
case .fullHistory:
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
enum KeychainService {
|
||||||
|
private static let service = "PortfolioJournal"
|
||||||
|
private static let pinKey = "appLockPin"
|
||||||
|
|
||||||
|
static func savePin(_ pin: String) -> Bool {
|
||||||
|
guard let data = pin.data(using: .utf8) else { return false }
|
||||||
|
deletePin()
|
||||||
|
let query: [CFString: Any] = [
|
||||||
|
kSecClass: kSecClassGenericPassword,
|
||||||
|
kSecAttrService: service,
|
||||||
|
kSecAttrAccount: pinKey,
|
||||||
|
kSecValueData: data
|
||||||
|
]
|
||||||
|
return SecItemAdd(query as CFDictionary, nil) == errSecSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
static func readPin() -> String? {
|
||||||
|
let query: [CFString: Any] = [
|
||||||
|
kSecClass: kSecClassGenericPassword,
|
||||||
|
kSecAttrService: service,
|
||||||
|
kSecAttrAccount: pinKey,
|
||||||
|
kSecReturnData: true,
|
||||||
|
kSecMatchLimit: kSecMatchLimitOne
|
||||||
|
]
|
||||||
|
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
guard status == errSecSuccess,
|
||||||
|
let data = result as? Data,
|
||||||
|
let pin = String(data: data, encoding: .utf8) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return pin
|
||||||
|
}
|
||||||
|
|
||||||
|
static func deletePin() {
|
||||||
|
let query: [CFString: Any] = [
|
||||||
|
kSecClass: kSecClassGenericPassword,
|
||||||
|
kSecAttrService: service,
|
||||||
|
kSecAttrAccount: pinKey
|
||||||
|
]
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,455 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum MonthlyCheckInStore {
|
||||||
|
private static let notesKey = "monthlyCheckInNotes"
|
||||||
|
private static let completionsKey = "monthlyCheckInCompletions"
|
||||||
|
private static let legacyLastCheckInKey = "lastCheckInDate"
|
||||||
|
private static let entriesKey = "monthlyCheckInEntries"
|
||||||
|
|
||||||
|
// MARK: - Public Accessors
|
||||||
|
|
||||||
|
static func note(for date: Date) -> String {
|
||||||
|
entry(for: date)?.note ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setNote(_ note: String, for date: Date) {
|
||||||
|
updateEntry(for: date) { entry in
|
||||||
|
let trimmed = note.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
entry.note = trimmed.isEmpty ? nil : note
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func rating(for date: Date) -> Int? {
|
||||||
|
entry(for: date)?.rating
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setRating(_ rating: Int?, for date: Date) {
|
||||||
|
updateEntry(for: date) { entry in
|
||||||
|
if let rating, rating > 0 {
|
||||||
|
entry.rating = min(max(1, rating), 5)
|
||||||
|
} else {
|
||||||
|
entry.rating = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func mood(for date: Date) -> MonthlyCheckInMood? {
|
||||||
|
entry(for: date)?.mood
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setMood(_ mood: MonthlyCheckInMood?, for date: Date) {
|
||||||
|
updateEntry(for: date) { entry in
|
||||||
|
entry.mood = mood
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func monthKey(for date: Date) -> String {
|
||||||
|
Self.monthFormatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func allNotes() -> [(date: Date, note: String)] {
|
||||||
|
loadEntries()
|
||||||
|
.compactMap { key, entry in
|
||||||
|
guard let date = Self.monthFormatter.date(from: key) else { return nil }
|
||||||
|
return (date: date, note: entry.note ?? "")
|
||||||
|
}
|
||||||
|
.sorted { $0.date > $1.date }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func entry(for date: Date) -> MonthlyCheckInEntry? {
|
||||||
|
loadEntries()[monthKey(for: date)]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func allEntries() -> [(date: Date, entry: MonthlyCheckInEntry)] {
|
||||||
|
loadEntries()
|
||||||
|
.compactMap { key, entry in
|
||||||
|
guard let date = Self.monthFormatter.date(from: key) else { return nil }
|
||||||
|
return (date: date, entry: entry)
|
||||||
|
}
|
||||||
|
.sorted { $0.date > $1.date }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func completionDate(for date: Date) -> Date? {
|
||||||
|
entry(for: date)?.completionDate
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setCompletionDate(_ completionDate: Date, for month: Date) {
|
||||||
|
updateEntry(for: month) { entry in
|
||||||
|
entry.completionTime = completionDate.timeIntervalSince1970
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func latestCompletionDate() -> Date? {
|
||||||
|
let latestEntryDate = loadEntries().values
|
||||||
|
.compactMap { $0.completionDate }
|
||||||
|
.max()
|
||||||
|
|
||||||
|
if let latestEntryDate {
|
||||||
|
return latestEntryDate
|
||||||
|
}
|
||||||
|
|
||||||
|
let legacy = UserDefaults.standard.double(forKey: legacyLastCheckInKey)
|
||||||
|
guard legacy > 0 else { return nil }
|
||||||
|
return Date(timeIntervalSince1970: legacy)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func stats(referenceDate: Date = Date()) -> MonthlyCheckInStats {
|
||||||
|
let cutoff = referenceDate.endOfMonth
|
||||||
|
let entries = allEntries().filter { $0.date <= cutoff }
|
||||||
|
let completions: [(month: Date, completion: Date, mood: MonthlyCheckInMood?)] = entries.compactMap { entry in
|
||||||
|
guard let completion = entry.entry.completionDate else { return nil }
|
||||||
|
return (month: entry.date.startOfMonth, completion: completion, mood: entry.entry.mood)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !completions.isEmpty else { return .empty }
|
||||||
|
|
||||||
|
let deadlineDiffs = completions.map { item -> Double in
|
||||||
|
let deadline = item.month.endOfMonth
|
||||||
|
return deadline.timeIntervalSince(item.completion) / 86_400
|
||||||
|
}
|
||||||
|
|
||||||
|
let onTimeCompletions = completions.filter { item in
|
||||||
|
item.completion <= item.month.endOfMonth
|
||||||
|
}
|
||||||
|
let onTimeMonths = Set(onTimeCompletions.map { $0.month })
|
||||||
|
let totalCheckIns = completions.count
|
||||||
|
let onTimeCount = onTimeMonths.count
|
||||||
|
|
||||||
|
// Current streak counts consecutive on-time months up to the reference month.
|
||||||
|
var currentStreak = 0
|
||||||
|
var cursor = referenceDate.startOfMonth
|
||||||
|
while onTimeMonths.contains(cursor) {
|
||||||
|
currentStreak += 1
|
||||||
|
cursor = cursor.adding(months: -1).startOfMonth
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best streak across history.
|
||||||
|
let sortedMonths = onTimeMonths.sorted()
|
||||||
|
var bestStreak = 0
|
||||||
|
var running = 0
|
||||||
|
var previousMonth: Date?
|
||||||
|
for month in sortedMonths {
|
||||||
|
if let previousMonth, month == previousMonth.adding(months: 1).startOfMonth {
|
||||||
|
running += 1
|
||||||
|
} else {
|
||||||
|
running = 1
|
||||||
|
}
|
||||||
|
bestStreak = max(bestStreak, running)
|
||||||
|
previousMonth = month
|
||||||
|
}
|
||||||
|
|
||||||
|
let averageDaysBeforeDeadline = onTimeCount > 0
|
||||||
|
? deadlineDiffs
|
||||||
|
.filter { $0 >= 0 }
|
||||||
|
.average()
|
||||||
|
: nil
|
||||||
|
let closestCutoffDays = onTimeCount > 0
|
||||||
|
? deadlineDiffs.filter { $0 >= 0 }.min()
|
||||||
|
: nil
|
||||||
|
|
||||||
|
let recentMood = completions.sorted { $0.month > $1.month }.first?.mood
|
||||||
|
let achievements = buildAchievements(
|
||||||
|
currentStreak: currentStreak,
|
||||||
|
bestStreak: bestStreak,
|
||||||
|
onTimeCount: onTimeCount,
|
||||||
|
totalCheckIns: totalCheckIns,
|
||||||
|
closestCutoffDays: closestCutoffDays,
|
||||||
|
averageDaysBeforeDeadline: averageDaysBeforeDeadline
|
||||||
|
)
|
||||||
|
|
||||||
|
return MonthlyCheckInStats(
|
||||||
|
currentStreak: currentStreak,
|
||||||
|
bestStreak: bestStreak,
|
||||||
|
onTimeCount: onTimeCount,
|
||||||
|
totalCheckIns: totalCheckIns,
|
||||||
|
averageDaysBeforeDeadline: averageDaysBeforeDeadline,
|
||||||
|
closestCutoffDays: closestCutoffDays,
|
||||||
|
recentMood: recentMood,
|
||||||
|
achievements: achievements
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func achievementStatuses(referenceDate: Date = Date()) -> [MonthlyCheckInAchievementStatus] {
|
||||||
|
let stats = stats(referenceDate: referenceDate)
|
||||||
|
return achievementStatuses(for: stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
private static func updateEntry(for date: Date, mutate: (inout MonthlyCheckInEntry) -> Void) {
|
||||||
|
let key = monthKey(for: date)
|
||||||
|
var entries = loadEntries()
|
||||||
|
var entry = entries[key] ?? MonthlyCheckInEntry(
|
||||||
|
note: nil,
|
||||||
|
rating: nil,
|
||||||
|
mood: nil,
|
||||||
|
completionTime: legacyCompletion(for: key),
|
||||||
|
createdAt: Date().timeIntervalSince1970
|
||||||
|
)
|
||||||
|
|
||||||
|
mutate(&entry)
|
||||||
|
|
||||||
|
if entry.note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true {
|
||||||
|
entry.note = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let isEmpty = entry.note == nil && entry.rating == nil && entry.mood == nil && entry.completionTime == nil
|
||||||
|
if isEmpty {
|
||||||
|
entries.removeValue(forKey: key)
|
||||||
|
} else {
|
||||||
|
entries[key] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
saveEntries(entries)
|
||||||
|
persistLegacyMirrors(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func loadEntries() -> [String: MonthlyCheckInEntry] {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: entriesKey),
|
||||||
|
let decoded = try? JSONDecoder().decode([String: MonthlyCheckInEntry].self, from: data) else {
|
||||||
|
return migrateLegacyData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure legacy data is merged if it existed before this release.
|
||||||
|
return mergeLegacy(into: decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func saveEntries(_ entries: [String: MonthlyCheckInEntry]) {
|
||||||
|
guard let data = try? JSONEncoder().encode(entries) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: entriesKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func migrateLegacyData() -> [String: MonthlyCheckInEntry] {
|
||||||
|
let notes = loadNotes()
|
||||||
|
let completions = loadCompletions()
|
||||||
|
guard !notes.isEmpty || !completions.isEmpty else { return [:] }
|
||||||
|
|
||||||
|
var entries: [String: MonthlyCheckInEntry] = [:]
|
||||||
|
let now = Date().timeIntervalSince1970
|
||||||
|
|
||||||
|
for (key, note) in notes {
|
||||||
|
entries[key] = MonthlyCheckInEntry(
|
||||||
|
note: note,
|
||||||
|
rating: nil,
|
||||||
|
mood: nil,
|
||||||
|
completionTime: completions[key],
|
||||||
|
createdAt: now
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (key, completion) in completions where entries[key] == nil {
|
||||||
|
entries[key] = MonthlyCheckInEntry(
|
||||||
|
note: nil,
|
||||||
|
rating: nil,
|
||||||
|
mood: nil,
|
||||||
|
completionTime: completion,
|
||||||
|
createdAt: completion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
saveEntries(entries)
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mergeLegacy(into entries: [String: MonthlyCheckInEntry]) -> [String: MonthlyCheckInEntry] {
|
||||||
|
var merged = entries
|
||||||
|
let notes = loadNotes()
|
||||||
|
let completions = loadCompletions()
|
||||||
|
var shouldSave = false
|
||||||
|
|
||||||
|
for (key, note) in notes where merged[key]?.note == nil {
|
||||||
|
var entry = merged[key] ?? MonthlyCheckInEntry(
|
||||||
|
note: nil,
|
||||||
|
rating: nil,
|
||||||
|
mood: nil,
|
||||||
|
completionTime: completions[key],
|
||||||
|
createdAt: Date().timeIntervalSince1970
|
||||||
|
)
|
||||||
|
entry.note = note
|
||||||
|
merged[key] = entry
|
||||||
|
shouldSave = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for (key, completion) in completions where merged[key]?.completionTime == nil {
|
||||||
|
var entry = merged[key] ?? MonthlyCheckInEntry(
|
||||||
|
note: nil,
|
||||||
|
rating: nil,
|
||||||
|
mood: nil,
|
||||||
|
completionTime: nil,
|
||||||
|
createdAt: completion
|
||||||
|
)
|
||||||
|
entry.completionTime = completion
|
||||||
|
merged[key] = entry
|
||||||
|
shouldSave = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldSave {
|
||||||
|
saveEntries(merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func persistLegacyMirrors(_ entries: [String: MonthlyCheckInEntry]) {
|
||||||
|
var notes: [String: String] = [:]
|
||||||
|
var completions: [String: Double] = [:]
|
||||||
|
|
||||||
|
for (key, entry) in entries {
|
||||||
|
if let note = entry.note {
|
||||||
|
notes[key] = note
|
||||||
|
}
|
||||||
|
if let completion = entry.completionTime {
|
||||||
|
completions[key] = completion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveNotes(notes)
|
||||||
|
saveCompletions(completions)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func loadNotes() -> [String: String] {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: notesKey),
|
||||||
|
let decoded = try? JSONDecoder().decode([String: String].self, from: data) else {
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func saveNotes(_ notes: [String: String]) {
|
||||||
|
guard let data = try? JSONEncoder().encode(notes) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: notesKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func loadCompletions() -> [String: Double] {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: completionsKey),
|
||||||
|
let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else {
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func saveCompletions(_ completions: [String: Double]) {
|
||||||
|
guard let data = try? JSONEncoder().encode(completions) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: completionsKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func legacyCompletion(for key: String) -> Double? {
|
||||||
|
loadCompletions()[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MonthlyCheckInAchievementRule {
|
||||||
|
let achievement: MonthlyCheckInAchievement
|
||||||
|
let isUnlocked: (Int, Int, Int, Int, Double?, Double?) -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let achievementRules: [MonthlyCheckInAchievementRule] = [
|
||||||
|
MonthlyCheckInAchievementRule(
|
||||||
|
achievement: MonthlyCheckInAchievement(
|
||||||
|
key: "streak_3",
|
||||||
|
title: String(localized: "achievement_streak_3_title"),
|
||||||
|
detail: String(localized: "achievement_streak_3_detail"),
|
||||||
|
icon: "flame.fill"
|
||||||
|
),
|
||||||
|
isUnlocked: { currentStreak, _, _, _, _, _ in currentStreak >= 3 }
|
||||||
|
),
|
||||||
|
MonthlyCheckInAchievementRule(
|
||||||
|
achievement: MonthlyCheckInAchievement(
|
||||||
|
key: "streak_6",
|
||||||
|
title: String(localized: "achievement_streak_6_title"),
|
||||||
|
detail: String(localized: "achievement_streak_6_detail"),
|
||||||
|
icon: "bolt.heart.fill"
|
||||||
|
),
|
||||||
|
isUnlocked: { currentStreak, _, _, _, _, _ in currentStreak >= 6 }
|
||||||
|
),
|
||||||
|
MonthlyCheckInAchievementRule(
|
||||||
|
achievement: MonthlyCheckInAchievement(
|
||||||
|
key: "streak_12",
|
||||||
|
title: String(localized: "achievement_streak_12_title"),
|
||||||
|
detail: String(localized: "achievement_streak_12_detail"),
|
||||||
|
icon: "calendar.circle.fill"
|
||||||
|
),
|
||||||
|
isUnlocked: { _, bestStreak, _, _, _, _ in bestStreak >= 12 }
|
||||||
|
),
|
||||||
|
MonthlyCheckInAchievementRule(
|
||||||
|
achievement: MonthlyCheckInAchievement(
|
||||||
|
key: "perfect_on_time",
|
||||||
|
title: String(localized: "achievement_perfect_on_time_title"),
|
||||||
|
detail: String(localized: "achievement_perfect_on_time_detail"),
|
||||||
|
icon: "checkmark.seal.fill"
|
||||||
|
),
|
||||||
|
isUnlocked: { _, _, onTimeCount, totalCheckIns, _, _ in
|
||||||
|
onTimeCount == totalCheckIns && totalCheckIns >= 3
|
||||||
|
}
|
||||||
|
),
|
||||||
|
MonthlyCheckInAchievementRule(
|
||||||
|
achievement: MonthlyCheckInAchievement(
|
||||||
|
key: "clutch_finish",
|
||||||
|
title: String(localized: "achievement_clutch_finish_title"),
|
||||||
|
detail: String(localized: "achievement_clutch_finish_detail"),
|
||||||
|
icon: "hourglass"
|
||||||
|
),
|
||||||
|
isUnlocked: { _, _, _, _, closestCutoffDays, _ in
|
||||||
|
if let closestCutoffDays {
|
||||||
|
return closestCutoffDays <= 2
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
),
|
||||||
|
MonthlyCheckInAchievementRule(
|
||||||
|
achievement: MonthlyCheckInAchievement(
|
||||||
|
key: "early_bird",
|
||||||
|
title: String(localized: "achievement_early_bird_title"),
|
||||||
|
detail: String(localized: "achievement_early_bird_detail"),
|
||||||
|
icon: "sun.max.fill"
|
||||||
|
),
|
||||||
|
isUnlocked: { _, _, _, totalCheckIns, _, averageDaysBeforeDeadline in
|
||||||
|
if let averageDaysBeforeDeadline {
|
||||||
|
return averageDaysBeforeDeadline >= 10 && totalCheckIns >= 3
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
private static func achievementStatuses(for stats: MonthlyCheckInStats) -> [MonthlyCheckInAchievementStatus] {
|
||||||
|
achievementRules.map { rule in
|
||||||
|
MonthlyCheckInAchievementStatus(
|
||||||
|
achievement: rule.achievement,
|
||||||
|
isUnlocked: rule.isUnlocked(
|
||||||
|
stats.currentStreak,
|
||||||
|
stats.bestStreak,
|
||||||
|
stats.onTimeCount,
|
||||||
|
stats.totalCheckIns,
|
||||||
|
stats.closestCutoffDays,
|
||||||
|
stats.averageDaysBeforeDeadline
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func buildAchievements(
|
||||||
|
currentStreak: Int,
|
||||||
|
bestStreak: Int,
|
||||||
|
onTimeCount: Int,
|
||||||
|
totalCheckIns: Int,
|
||||||
|
closestCutoffDays: Double?,
|
||||||
|
averageDaysBeforeDeadline: Double?
|
||||||
|
) -> [MonthlyCheckInAchievement] {
|
||||||
|
achievementRules.compactMap { rule in
|
||||||
|
rule.isUnlocked(
|
||||||
|
currentStreak,
|
||||||
|
bestStreak,
|
||||||
|
onTimeCount,
|
||||||
|
totalCheckIns,
|
||||||
|
closestCutoffDays,
|
||||||
|
averageDaysBeforeDeadline
|
||||||
|
) ? rule.achievement : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static var monthFormatter: DateFormatter {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM"
|
||||||
|
return formatter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class TabSelectionStore: ObservableObject {
|
||||||
|
@Published var selectedTab = 0
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,705 @@
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class ChartsViewModel: ObservableObject {
|
||||||
|
// MARK: - Chart Types
|
||||||
|
|
||||||
|
enum ChartType: String, CaseIterable, Identifiable {
|
||||||
|
case evolution = "Evolution"
|
||||||
|
case allocation = "Allocation"
|
||||||
|
case performance = "Performance"
|
||||||
|
case contributions = "Contributions"
|
||||||
|
case rollingReturn = "Rolling 12M"
|
||||||
|
case riskReturn = "Risk vs Return"
|
||||||
|
case cashflow = "Net vs Contributions"
|
||||||
|
case drawdown = "Drawdown"
|
||||||
|
case volatility = "Volatility"
|
||||||
|
case prediction = "Prediction"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .evolution: return "chart.line.uptrend.xyaxis"
|
||||||
|
case .allocation: return "chart.pie.fill"
|
||||||
|
case .performance: return "chart.bar.fill"
|
||||||
|
case .contributions: return "tray.and.arrow.down.fill"
|
||||||
|
case .rollingReturn: return "arrow.triangle.2.circlepath"
|
||||||
|
case .riskReturn: return "dot.square"
|
||||||
|
case .cashflow: return "chart.bar.xaxis"
|
||||||
|
case .drawdown: return "arrow.down.right.circle"
|
||||||
|
case .volatility: return "waveform.path.ecg"
|
||||||
|
case .prediction: return "wand.and.stars"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPremium: Bool {
|
||||||
|
switch self {
|
||||||
|
case .evolution:
|
||||||
|
return false
|
||||||
|
case .allocation, .performance, .contributions, .rollingReturn, .riskReturn, .cashflow,
|
||||||
|
.drawdown, .volatility, .prediction:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .evolution:
|
||||||
|
return "Track your portfolio value over time"
|
||||||
|
case .allocation:
|
||||||
|
return "See how your investments are distributed"
|
||||||
|
case .performance:
|
||||||
|
return "Compare returns across categories"
|
||||||
|
case .contributions:
|
||||||
|
return "Review monthly inflows over time"
|
||||||
|
case .rollingReturn:
|
||||||
|
return "See rolling 12-month performance"
|
||||||
|
case .riskReturn:
|
||||||
|
return "Compare volatility vs return"
|
||||||
|
case .cashflow:
|
||||||
|
return "Compare growth vs contributions"
|
||||||
|
case .drawdown:
|
||||||
|
return "Analyze declines from peak values"
|
||||||
|
case .volatility:
|
||||||
|
return "Understand investment risk levels"
|
||||||
|
case .prediction:
|
||||||
|
return "View 12-month forecasts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func availableChartTypes(calmModeEnabled: Bool) -> [ChartType] {
|
||||||
|
let types: [ChartType] = calmModeEnabled
|
||||||
|
? [.evolution, .allocation, .performance, .contributions]
|
||||||
|
: ChartType.allCases
|
||||||
|
if !types.contains(selectedChartType) {
|
||||||
|
selectedChartType = .evolution
|
||||||
|
}
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Published Properties
|
||||||
|
|
||||||
|
@Published var selectedChartType: ChartType = .evolution
|
||||||
|
@Published var selectedCategory: Category?
|
||||||
|
@Published var selectedTimeRange: TimeRange = .year
|
||||||
|
@Published var selectedAccount: Account?
|
||||||
|
@Published var showAllAccounts = true
|
||||||
|
|
||||||
|
@Published var evolutionData: [(date: Date, value: Decimal)] = []
|
||||||
|
@Published var categoryEvolutionData: [CategoryEvolutionPoint] = []
|
||||||
|
@Published var allocationData: [(category: String, value: Decimal, color: String)] = []
|
||||||
|
@Published var performanceData: [(category: String, cagr: Double, color: String)] = []
|
||||||
|
@Published var contributionsData: [(date: Date, amount: Decimal)] = []
|
||||||
|
@Published var rollingReturnData: [(date: Date, value: Double)] = []
|
||||||
|
@Published var riskReturnData: [(category: String, cagr: Double, volatility: Double, color: String)] = []
|
||||||
|
@Published var cashflowData: [(date: Date, contributions: Decimal, netPerformance: Decimal)] = []
|
||||||
|
@Published var drawdownData: [(date: Date, drawdown: Double)] = []
|
||||||
|
@Published var volatilityData: [(date: Date, volatility: Double)] = []
|
||||||
|
@Published var predictionData: [Prediction] = []
|
||||||
|
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var showingPaywall = false
|
||||||
|
@Published private var predictionMonthsAhead = 12
|
||||||
|
|
||||||
|
// MARK: - Time Range
|
||||||
|
|
||||||
|
enum TimeRange: String, CaseIterable, Identifiable {
|
||||||
|
case month = "1M"
|
||||||
|
case quarter = "3M"
|
||||||
|
case halfYear = "6M"
|
||||||
|
case year = "1Y"
|
||||||
|
case all = "All"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var months: Int? {
|
||||||
|
switch self {
|
||||||
|
case .month: return 1
|
||||||
|
case .quarter: return 3
|
||||||
|
case .halfYear: return 6
|
||||||
|
case .year: return 12
|
||||||
|
case .all: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dependencies
|
||||||
|
|
||||||
|
private let sourceRepository: InvestmentSourceRepository
|
||||||
|
private let categoryRepository: CategoryRepository
|
||||||
|
private let snapshotRepository: SnapshotRepository
|
||||||
|
private let calculationService: CalculationService
|
||||||
|
private let predictionEngine: PredictionEngine
|
||||||
|
private let freemiumValidator: FreemiumValidator
|
||||||
|
private let maxHistoryMonths = 60
|
||||||
|
private let maxStackedCategories = 6
|
||||||
|
private let maxChartPoints = 500
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private var allCategories: [Category] {
|
||||||
|
categoryRepository.categories
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Performance: Caching and State
|
||||||
|
private var lastChartType: ChartType?
|
||||||
|
private var lastTimeRange: TimeRange?
|
||||||
|
private var lastCategoryId: UUID?
|
||||||
|
private var lastAccountId: UUID?
|
||||||
|
private var lastShowAllAccounts: Bool = true
|
||||||
|
private var cachedSnapshots: [Snapshot]?
|
||||||
|
private var cachedSnapshotsBySource: [UUID: [Snapshot]]?
|
||||||
|
private var isUpdateInProgress = false
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init(
|
||||||
|
sourceRepository: InvestmentSourceRepository? = nil,
|
||||||
|
categoryRepository: CategoryRepository? = nil,
|
||||||
|
snapshotRepository: SnapshotRepository? = nil,
|
||||||
|
calculationService: CalculationService? = nil,
|
||||||
|
predictionEngine: PredictionEngine? = nil,
|
||||||
|
iapService: IAPService
|
||||||
|
) {
|
||||||
|
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
|
||||||
|
self.categoryRepository = categoryRepository ?? CategoryRepository()
|
||||||
|
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
|
||||||
|
self.calculationService = calculationService ?? .shared
|
||||||
|
self.predictionEngine = predictionEngine ?? .shared
|
||||||
|
self.freemiumValidator = FreemiumValidator(iapService: iapService)
|
||||||
|
|
||||||
|
setupObservers()
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Setup
|
||||||
|
|
||||||
|
private func setupObservers() {
|
||||||
|
// Performance: Combine all selection changes into a single debounced stream
|
||||||
|
// This prevents multiple rapid updates when switching between views
|
||||||
|
Publishers.CombineLatest4($selectedChartType, $selectedCategory, $selectedTimeRange, $selectedAccount)
|
||||||
|
.combineLatest($showAllAccounts)
|
||||||
|
.debounce(for: .milliseconds(150), scheduler: DispatchQueue.main)
|
||||||
|
.sink { [weak self] combined, showAll in
|
||||||
|
guard let self else { return }
|
||||||
|
let (chartType, category, timeRange, _) = combined
|
||||||
|
|
||||||
|
// Performance: Skip update if nothing meaningful changed
|
||||||
|
let hasChanges = self.lastChartType != chartType ||
|
||||||
|
self.lastTimeRange != timeRange ||
|
||||||
|
self.lastCategoryId != category?.id ||
|
||||||
|
self.lastAccountId != self.selectedAccount?.id ||
|
||||||
|
self.lastShowAllAccounts != showAll
|
||||||
|
|
||||||
|
if hasChanges {
|
||||||
|
self.lastChartType = chartType
|
||||||
|
self.lastTimeRange = timeRange
|
||||||
|
self.lastCategoryId = category?.id
|
||||||
|
self.lastAccountId = self.selectedAccount?.id
|
||||||
|
self.lastShowAllAccounts = showAll
|
||||||
|
self.cachedSnapshots = nil // Invalidate cache on meaningful changes
|
||||||
|
self.cachedSnapshotsBySource = nil
|
||||||
|
self.updateChartData(chartType: chartType, category: category, timeRange: timeRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Loading
|
||||||
|
|
||||||
|
func loadData() {
|
||||||
|
updateChartData(
|
||||||
|
chartType: selectedChartType,
|
||||||
|
category: selectedCategory,
|
||||||
|
timeRange: selectedTimeRange
|
||||||
|
)
|
||||||
|
|
||||||
|
FirebaseService.shared.logScreenView(screenName: "Charts")
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectChart(_ chartType: ChartType) {
|
||||||
|
if chartType.isPremium && !freemiumValidator.isPremium {
|
||||||
|
showingPaywall = true
|
||||||
|
FirebaseService.shared.logPaywallShown(trigger: "advanced_charts")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedChartType = chartType
|
||||||
|
FirebaseService.shared.logChartViewed(
|
||||||
|
chartType: chartType.rawValue,
|
||||||
|
isPremium: chartType.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateChartData(chartType: ChartType, category: Category?, timeRange: TimeRange) {
|
||||||
|
// Performance: Prevent re-entrancy
|
||||||
|
guard !isUpdateInProgress else { return }
|
||||||
|
isUpdateInProgress = true
|
||||||
|
isLoading = true
|
||||||
|
|
||||||
|
defer {
|
||||||
|
isLoading = false
|
||||||
|
isUpdateInProgress = false
|
||||||
|
}
|
||||||
|
|
||||||
|
let sources: [InvestmentSource]
|
||||||
|
if let category = category {
|
||||||
|
sources = sourceRepository.fetchSources(for: category).filter { shouldIncludeSource($0) }
|
||||||
|
} else {
|
||||||
|
sources = sourceRepository.sources.filter { shouldIncludeSource($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
let monthsLimit = timeRange.months ?? maxHistoryMonths
|
||||||
|
let sourceIds = sources.compactMap { $0.id }
|
||||||
|
|
||||||
|
// Performance: Reuse cached snapshots when possible
|
||||||
|
var snapshots: [Snapshot]
|
||||||
|
if let cached = cachedSnapshots {
|
||||||
|
snapshots = cached
|
||||||
|
} else {
|
||||||
|
snapshots = snapshotRepository.fetchSnapshots(
|
||||||
|
for: sourceIds,
|
||||||
|
months: monthsLimit
|
||||||
|
)
|
||||||
|
snapshots = freemiumValidator.filterSnapshots(snapshots)
|
||||||
|
cachedSnapshots = snapshots
|
||||||
|
}
|
||||||
|
|
||||||
|
// Performance: Cache snapshotsBySource
|
||||||
|
let snapshotsBySource: [UUID: [Snapshot]]
|
||||||
|
if let cached = cachedSnapshotsBySource {
|
||||||
|
snapshotsBySource = cached
|
||||||
|
} else {
|
||||||
|
var grouped: [UUID: [Snapshot]] = [:]
|
||||||
|
grouped.reserveCapacity(sources.count)
|
||||||
|
for snapshot in snapshots {
|
||||||
|
guard let id = snapshot.source?.id else { continue }
|
||||||
|
grouped[id, default: []].append(snapshot)
|
||||||
|
}
|
||||||
|
snapshotsBySource = grouped
|
||||||
|
cachedSnapshotsBySource = grouped
|
||||||
|
}
|
||||||
|
|
||||||
|
// Performance: Only calculate data for the selected chart type
|
||||||
|
switch chartType {
|
||||||
|
case .evolution:
|
||||||
|
calculateEvolutionData(from: snapshots)
|
||||||
|
let categoriesForChart = categoriesForStackedChart(
|
||||||
|
sources: sources,
|
||||||
|
selectedCategory: selectedCategory
|
||||||
|
)
|
||||||
|
calculateCategoryEvolutionData(from: snapshots, categories: categoriesForChart)
|
||||||
|
case .allocation:
|
||||||
|
calculateAllocationData(for: sources)
|
||||||
|
case .performance:
|
||||||
|
calculatePerformanceData(for: sources, snapshotsBySource: snapshotsBySource)
|
||||||
|
case .contributions:
|
||||||
|
calculateContributionsData(from: snapshots)
|
||||||
|
case .rollingReturn:
|
||||||
|
calculateRollingReturnData(from: snapshots)
|
||||||
|
case .riskReturn:
|
||||||
|
calculateRiskReturnData(for: sources, snapshotsBySource: snapshotsBySource)
|
||||||
|
case .cashflow:
|
||||||
|
calculateCashflowData(from: snapshots)
|
||||||
|
case .drawdown:
|
||||||
|
calculateDrawdownData(from: snapshots)
|
||||||
|
case .volatility:
|
||||||
|
calculateVolatilityData(from: snapshots)
|
||||||
|
case .prediction:
|
||||||
|
calculatePredictionData(from: snapshots)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let selected = selectedCategory,
|
||||||
|
!availableCategories(for: chartType, sources: sources).contains(where: { $0.id == selected.id }) {
|
||||||
|
selectedCategory = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func availableCategories(
|
||||||
|
for chartType: ChartType,
|
||||||
|
sources: [InvestmentSource]? = nil
|
||||||
|
) -> [Category] {
|
||||||
|
let relevantSources = sources ?? sourceRepository.sources.filter { shouldIncludeSource($0) }
|
||||||
|
let categoriesWithData = Set(relevantSources.compactMap { $0.category?.id })
|
||||||
|
let filtered = allCategories.filter { categoriesWithData.contains($0.id) }
|
||||||
|
|
||||||
|
switch chartType {
|
||||||
|
case .evolution, .prediction:
|
||||||
|
return filtered
|
||||||
|
default:
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldIncludeSource(_ source: InvestmentSource) -> Bool {
|
||||||
|
if showAllAccounts || selectedAccount == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return source.account?.id == selectedAccount?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
private func categoriesForStackedChart(
|
||||||
|
sources: [InvestmentSource],
|
||||||
|
selectedCategory: Category?
|
||||||
|
) -> [Category] {
|
||||||
|
var totals: [UUID: Decimal] = [:]
|
||||||
|
|
||||||
|
for source in sources {
|
||||||
|
guard let categoryId = source.category?.id else { continue }
|
||||||
|
totals[categoryId, default: 0] += source.latestValue
|
||||||
|
}
|
||||||
|
|
||||||
|
var topCategoryIds = Set(
|
||||||
|
totals.sorted { $0.value > $1.value }
|
||||||
|
.prefix(maxStackedCategories)
|
||||||
|
.map { $0.key }
|
||||||
|
)
|
||||||
|
|
||||||
|
if let selectedCategory {
|
||||||
|
topCategoryIds.insert(selectedCategory.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return categoryRepository.categories.filter { topCategoryIds.contains($0.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func downsampleSeries(
|
||||||
|
_ data: [(date: Date, value: Decimal)],
|
||||||
|
maxPoints: Int
|
||||||
|
) -> [(date: Date, value: Decimal)] {
|
||||||
|
guard data.count > maxPoints, maxPoints > 0 else { return data }
|
||||||
|
let bucketSize = max(1, Int(ceil(Double(data.count) / Double(maxPoints))))
|
||||||
|
var sampled: [(date: Date, value: Decimal)] = []
|
||||||
|
sampled.reserveCapacity(maxPoints)
|
||||||
|
|
||||||
|
var index = 0
|
||||||
|
while index < data.count {
|
||||||
|
let end = min(index + bucketSize, data.count)
|
||||||
|
let bucket = data[index..<end]
|
||||||
|
if let last = bucket.last {
|
||||||
|
sampled.append(last)
|
||||||
|
}
|
||||||
|
index += bucketSize
|
||||||
|
}
|
||||||
|
return sampled
|
||||||
|
}
|
||||||
|
|
||||||
|
private func downsampleDates(_ dates: [Date], maxPoints: Int) -> [Date] {
|
||||||
|
guard dates.count > maxPoints, maxPoints > 0 else { return dates }
|
||||||
|
let bucketSize = max(1, Int(ceil(Double(dates.count) / Double(maxPoints))))
|
||||||
|
var sampled: [Date] = []
|
||||||
|
sampled.reserveCapacity(maxPoints)
|
||||||
|
|
||||||
|
var index = 0
|
||||||
|
while index < dates.count {
|
||||||
|
let end = min(index + bucketSize, dates.count)
|
||||||
|
let bucket = dates[index..<end]
|
||||||
|
if let last = bucket.last {
|
||||||
|
sampled.append(last)
|
||||||
|
}
|
||||||
|
index += bucketSize
|
||||||
|
}
|
||||||
|
return sampled
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chart Calculations
|
||||||
|
|
||||||
|
private func calculateEvolutionData(from snapshots: [Snapshot]) {
|
||||||
|
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||||
|
|
||||||
|
var dateValues: [Date: Decimal] = [:]
|
||||||
|
|
||||||
|
for snapshot in sortedSnapshots {
|
||||||
|
let day = Calendar.current.startOfDay(for: snapshot.date)
|
||||||
|
dateValues[day, default: 0] += snapshot.decimalValue
|
||||||
|
}
|
||||||
|
|
||||||
|
let series = dateValues
|
||||||
|
.map { (date: $0.key, value: $0.value) }
|
||||||
|
.sorted { $0.date < $1.date }
|
||||||
|
evolutionData = downsampleSeries(series, maxPoints: maxChartPoints)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateCategoryEvolutionData(from snapshots: [Snapshot], categories: [Category]) {
|
||||||
|
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||||
|
let categoriesWithData = Set(sortedSnapshots.compactMap { $0.source?.category?.id })
|
||||||
|
let filteredCategories = categories.filter { categoriesWithData.contains($0.id) }
|
||||||
|
let snapshotsByDay = Dictionary(grouping: sortedSnapshots) {
|
||||||
|
Calendar.current.startOfDay(for: $0.date)
|
||||||
|
}
|
||||||
|
let uniqueDates = downsampleDates(snapshotsByDay.keys.sorted(), maxPoints: maxChartPoints)
|
||||||
|
|
||||||
|
var latestBySource: [UUID: Snapshot] = [:]
|
||||||
|
var points: [CategoryEvolutionPoint] = []
|
||||||
|
|
||||||
|
for date in uniqueDates {
|
||||||
|
if let daySnapshots = snapshotsByDay[date] {
|
||||||
|
for snapshot in daySnapshots {
|
||||||
|
guard let sourceId = snapshot.source?.id else { continue }
|
||||||
|
latestBySource[sourceId] = snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var valuesByCategory: [UUID: Decimal] = [:]
|
||||||
|
for snapshot in latestBySource.values {
|
||||||
|
guard let category = snapshot.source?.category else { continue }
|
||||||
|
valuesByCategory[category.id, default: 0] += snapshot.decimalValue
|
||||||
|
}
|
||||||
|
|
||||||
|
for category in filteredCategories {
|
||||||
|
let value = valuesByCategory[category.id] ?? 0
|
||||||
|
points.append(CategoryEvolutionPoint(
|
||||||
|
date: date,
|
||||||
|
categoryName: category.name,
|
||||||
|
colorHex: category.colorHex,
|
||||||
|
value: value
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryEvolutionData = points
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateAllocationData(for sources: [InvestmentSource]) {
|
||||||
|
let categories = categoryRepository.categories
|
||||||
|
let valuesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
|
||||||
|
|
||||||
|
allocationData = categories.compactMap { category in
|
||||||
|
let categorySources = valuesByCategory[category.id] ?? []
|
||||||
|
let categoryValue = categorySources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||||
|
guard categoryValue > 0 else { return nil }
|
||||||
|
|
||||||
|
return (
|
||||||
|
category: category.name,
|
||||||
|
value: categoryValue,
|
||||||
|
color: category.colorHex
|
||||||
|
)
|
||||||
|
}.sorted { $0.value > $1.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculatePerformanceData(
|
||||||
|
for sources: [InvestmentSource],
|
||||||
|
snapshotsBySource: [UUID: [Snapshot]]
|
||||||
|
) {
|
||||||
|
let categories = categoryRepository.categories
|
||||||
|
let sourcesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
|
||||||
|
|
||||||
|
performanceData = categories.compactMap { category in
|
||||||
|
let categorySources = sourcesByCategory[category.id] ?? []
|
||||||
|
let snapshots = categorySources.compactMap { source -> [Snapshot]? in
|
||||||
|
let id = source.id
|
||||||
|
return snapshotsBySource[id]
|
||||||
|
}.flatMap { $0 }
|
||||||
|
guard snapshots.count >= 2 else { return nil }
|
||||||
|
|
||||||
|
let metrics = calculationService.calculateMetrics(for: snapshots)
|
||||||
|
|
||||||
|
return (
|
||||||
|
category: category.name,
|
||||||
|
cagr: metrics.cagr,
|
||||||
|
color: category.colorHex
|
||||||
|
)
|
||||||
|
}.sorted { $0.cagr > $1.cagr }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateContributionsData(from snapshots: [Snapshot]) {
|
||||||
|
let grouped = Dictionary(grouping: snapshots) { $0.date.startOfMonth }
|
||||||
|
contributionsData = grouped.map { date, items in
|
||||||
|
let total = items.reduce(Decimal.zero) { $0 + $1.decimalContribution }
|
||||||
|
return (date: date, amount: total)
|
||||||
|
}
|
||||||
|
.sorted { $0.date < $1.date }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateRollingReturnData(from snapshots: [Snapshot]) {
|
||||||
|
let monthlyTotals = monthlyTotals(from: snapshots)
|
||||||
|
guard monthlyTotals.count >= 13 else {
|
||||||
|
rollingReturnData = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var returns: [(date: Date, value: Double)] = []
|
||||||
|
for index in 12..<monthlyTotals.count {
|
||||||
|
let current = monthlyTotals[index]
|
||||||
|
let base = monthlyTotals[index - 12]
|
||||||
|
guard base.totalValue > 0 else { continue }
|
||||||
|
let change = current.totalValue - base.totalValue
|
||||||
|
let percent = NSDecimalNumber(decimal: change / base.totalValue).doubleValue * 100
|
||||||
|
returns.append((date: current.date, value: percent))
|
||||||
|
}
|
||||||
|
rollingReturnData = returns
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateRiskReturnData(
|
||||||
|
for sources: [InvestmentSource],
|
||||||
|
snapshotsBySource: [UUID: [Snapshot]]
|
||||||
|
) {
|
||||||
|
let categories = categoryRepository.categories
|
||||||
|
let sourcesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
|
||||||
|
|
||||||
|
riskReturnData = categories.compactMap { category in
|
||||||
|
let categorySources = sourcesByCategory[category.id] ?? []
|
||||||
|
let snapshots = categorySources.compactMap { source -> [Snapshot]? in
|
||||||
|
let id = source.id
|
||||||
|
return snapshotsBySource[id]
|
||||||
|
}.flatMap { $0 }
|
||||||
|
guard snapshots.count >= 3 else { return nil }
|
||||||
|
let metrics = calculationService.calculateMetrics(for: snapshots)
|
||||||
|
return (
|
||||||
|
category: category.name,
|
||||||
|
cagr: metrics.cagr,
|
||||||
|
volatility: metrics.volatility,
|
||||||
|
color: category.colorHex
|
||||||
|
)
|
||||||
|
}.sorted { $0.cagr > $1.cagr }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateCashflowData(from snapshots: [Snapshot]) {
|
||||||
|
let monthlyTotals = monthlyTotals(from: snapshots)
|
||||||
|
let contributionsByMonth = Dictionary(grouping: snapshots) { $0.date.startOfMonth }
|
||||||
|
.mapValues { items in
|
||||||
|
items.reduce(Decimal.zero) { $0 + $1.decimalContribution }
|
||||||
|
}
|
||||||
|
|
||||||
|
var data: [(date: Date, contributions: Decimal, netPerformance: Decimal)] = []
|
||||||
|
for index in 0..<monthlyTotals.count {
|
||||||
|
let current = monthlyTotals[index]
|
||||||
|
let previousTotal = index > 0 ? monthlyTotals[index - 1].totalValue : 0
|
||||||
|
let contributions = contributionsByMonth[current.date] ?? 0
|
||||||
|
let netPerformance = current.totalValue - previousTotal - contributions
|
||||||
|
data.append((date: current.date, contributions: contributions, netPerformance: netPerformance))
|
||||||
|
}
|
||||||
|
cashflowData = data
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateDrawdownData(from snapshots: [Snapshot]) {
|
||||||
|
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||||
|
guard !sortedSnapshots.isEmpty else {
|
||||||
|
drawdownData = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var peak = sortedSnapshots.first!.decimalValue
|
||||||
|
var data: [(date: Date, drawdown: Double)] = []
|
||||||
|
|
||||||
|
for snapshot in sortedSnapshots {
|
||||||
|
let value = snapshot.decimalValue
|
||||||
|
if value > peak {
|
||||||
|
peak = value
|
||||||
|
}
|
||||||
|
|
||||||
|
let drawdown = peak > 0
|
||||||
|
? NSDecimalNumber(decimal: (peak - value) / peak).doubleValue * 100
|
||||||
|
: 0
|
||||||
|
|
||||||
|
data.append((date: snapshot.date, drawdown: -drawdown))
|
||||||
|
}
|
||||||
|
|
||||||
|
drawdownData = data
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateVolatilityData(from snapshots: [Snapshot]) {
|
||||||
|
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||||
|
guard sortedSnapshots.count >= 3 else {
|
||||||
|
volatilityData = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var data: [(date: Date, volatility: Double)] = []
|
||||||
|
let windowSize = 3
|
||||||
|
|
||||||
|
for i in windowSize..<sortedSnapshots.count {
|
||||||
|
let window = Array(sortedSnapshots[(i - windowSize)..<i])
|
||||||
|
let values = window.map { NSDecimalNumber(decimal: $0.decimalValue).doubleValue }
|
||||||
|
|
||||||
|
let mean = values.reduce(0, +) / Double(values.count)
|
||||||
|
let variance = values.map { pow($0 - mean, 2) }.reduce(0, +) / Double(values.count)
|
||||||
|
let stdDev = sqrt(variance)
|
||||||
|
let volatility = mean > 0 ? (stdDev / mean) * 100 : 0
|
||||||
|
|
||||||
|
data.append((date: sortedSnapshots[i].date, volatility: volatility))
|
||||||
|
}
|
||||||
|
|
||||||
|
volatilityData = data
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculatePredictionData(from snapshots: [Snapshot]) {
|
||||||
|
guard freemiumValidator.canViewPredictions() else {
|
||||||
|
predictionData = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = predictionEngine.predict(snapshots: snapshots, monthsAhead: predictionMonthsAhead)
|
||||||
|
predictionData = result.predictions
|
||||||
|
}
|
||||||
|
|
||||||
|
private func monthlyTotals(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
|
||||||
|
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||||
|
let months = Array(Set(sortedSnapshots.map { $0.date.startOfMonth })).sorted()
|
||||||
|
guard !months.isEmpty else { return [] }
|
||||||
|
|
||||||
|
var snapshotsBySource: [UUID: [Snapshot]] = [:]
|
||||||
|
for snapshot in sortedSnapshots {
|
||||||
|
guard let sourceId = snapshot.source?.id else { continue }
|
||||||
|
snapshotsBySource[sourceId, default: []].append(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
var indices: [UUID: Int] = [:]
|
||||||
|
var totals: [(date: Date, totalValue: Decimal)] = []
|
||||||
|
|
||||||
|
for (index, month) in months.enumerated() {
|
||||||
|
let nextMonth = index + 1 < months.count ? months[index + 1] : Date.distantFuture
|
||||||
|
var total: Decimal = 0
|
||||||
|
|
||||||
|
for (sourceId, sourceSnapshots) in snapshotsBySource {
|
||||||
|
var currentIndex = indices[sourceId] ?? 0
|
||||||
|
var latest: Snapshot?
|
||||||
|
|
||||||
|
while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextMonth {
|
||||||
|
latest = sourceSnapshots[currentIndex]
|
||||||
|
currentIndex += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
indices[sourceId] = currentIndex
|
||||||
|
total += latest?.decimalValue ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
totals.append((date: month, totalValue: total))
|
||||||
|
}
|
||||||
|
|
||||||
|
return totals
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePredictionTargetDate(_ goals: [Goal]) {
|
||||||
|
let futureGoalDates = goals.compactMap { $0.targetDate }.filter { $0 > Date() }
|
||||||
|
guard let latestGoalDate = futureGoalDates.max(),
|
||||||
|
let lastSnapshotDate = evolutionData.last?.date else {
|
||||||
|
predictionMonthsAhead = 12
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let months = max(1, lastSnapshotDate.startOfMonth.monthsBetween(latestGoalDate.startOfMonth))
|
||||||
|
predictionMonthsAhead = max(12, months)
|
||||||
|
if selectedChartType == .prediction {
|
||||||
|
updateChartData(chartType: selectedChartType, category: selectedCategory, timeRange: selectedTimeRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
var categories: [Category] {
|
||||||
|
categoryRepository.categories
|
||||||
|
}
|
||||||
|
|
||||||
|
var availableChartTypes: [ChartType] {
|
||||||
|
ChartType.allCases
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPremium: Bool {
|
||||||
|
freemiumValidator.isPremium
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasData: Bool {
|
||||||
|
!sourceRepository.sources.filter { shouldIncludeSource($0) }.isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,443 @@
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class DashboardViewModel: ObservableObject {
|
||||||
|
// MARK: - Published Properties
|
||||||
|
|
||||||
|
@Published var portfolioSummary: PortfolioSummary = .empty
|
||||||
|
@Published var monthlySummary: MonthlySummary = .empty
|
||||||
|
@Published var categoryMetrics: [CategoryMetrics] = []
|
||||||
|
@Published var categoryEvolutionData: [CategoryEvolutionPoint] = []
|
||||||
|
@Published var recentSnapshots: [Snapshot] = []
|
||||||
|
@Published var sourcesNeedingUpdate: [InvestmentSource] = []
|
||||||
|
@Published var latestPortfolioChange: PortfolioChange = .empty
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
@Published var selectedAccount: Account?
|
||||||
|
@Published var showAllAccounts = true
|
||||||
|
|
||||||
|
// MARK: - Chart Data
|
||||||
|
|
||||||
|
@Published var evolutionData: [(date: Date, value: Decimal)] = []
|
||||||
|
|
||||||
|
// MARK: - Dependencies
|
||||||
|
|
||||||
|
private let categoryRepository: CategoryRepository
|
||||||
|
private let sourceRepository: InvestmentSourceRepository
|
||||||
|
private let snapshotRepository: SnapshotRepository
|
||||||
|
private let calculationService: CalculationService
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private var isRefreshing = false
|
||||||
|
private var refreshQueued = false
|
||||||
|
private let maxHistoryMonths = 60
|
||||||
|
|
||||||
|
// MARK: - Performance: Caching
|
||||||
|
private var cachedFilteredSources: [InvestmentSource]?
|
||||||
|
private var cachedSourcesHash: Int = 0
|
||||||
|
private var lastAccountId: UUID?
|
||||||
|
private var lastShowAllAccounts: Bool = true
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init(
|
||||||
|
categoryRepository: CategoryRepository? = nil,
|
||||||
|
sourceRepository: InvestmentSourceRepository? = nil,
|
||||||
|
snapshotRepository: SnapshotRepository? = nil,
|
||||||
|
calculationService: CalculationService? = nil
|
||||||
|
) {
|
||||||
|
self.categoryRepository = categoryRepository ?? CategoryRepository()
|
||||||
|
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
|
||||||
|
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
|
||||||
|
self.calculationService = calculationService ?? .shared
|
||||||
|
|
||||||
|
setupObservers()
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Setup
|
||||||
|
|
||||||
|
private func setupObservers() {
|
||||||
|
// Performance: Combine multiple publishers to reduce redundant refresh calls
|
||||||
|
// Use dropFirst to avoid initial trigger, and debounce to coalesce rapid changes
|
||||||
|
Publishers.Merge(
|
||||||
|
categoryRepository.$categories.map { _ in () },
|
||||||
|
sourceRepository.$sources.map { _ in () }
|
||||||
|
)
|
||||||
|
.dropFirst(2) // Skip initial values from both publishers
|
||||||
|
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.invalidateCache()
|
||||||
|
self?.refreshData()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// Observe Core Data changes with coalescing
|
||||||
|
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)
|
||||||
|
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
|
||||||
|
.compactMap { [weak self] notification -> Void? in
|
||||||
|
guard let self, self.isRelevantChange(notification) else { return nil }
|
||||||
|
return ()
|
||||||
|
}
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.invalidateCache()
|
||||||
|
self?.refreshData()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isRelevantChange(_ notification: Notification) -> Bool {
|
||||||
|
guard let info = notification.userInfo else { return false }
|
||||||
|
let keys: [String] = [
|
||||||
|
NSInsertedObjectsKey,
|
||||||
|
NSUpdatedObjectsKey,
|
||||||
|
NSDeletedObjectsKey,
|
||||||
|
NSRefreshedObjectsKey
|
||||||
|
]
|
||||||
|
|
||||||
|
for key in keys {
|
||||||
|
if let objects = info[key] as? Set<NSManagedObject> {
|
||||||
|
if objects.contains(where: { $0 is Snapshot || $0 is InvestmentSource || $0 is Category }) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Loading
|
||||||
|
|
||||||
|
func loadData() {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
queueRefresh(updateLoadingFlag: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshData() {
|
||||||
|
queueRefresh(updateLoadingFlag: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func queueRefresh(updateLoadingFlag: Bool) {
|
||||||
|
refreshQueued = true
|
||||||
|
if updateLoadingFlag {
|
||||||
|
isLoading = true
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !isRefreshing else { return }
|
||||||
|
|
||||||
|
isRefreshing = true
|
||||||
|
Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
while self.refreshQueued {
|
||||||
|
self.refreshQueued = false
|
||||||
|
await self.refreshAllData()
|
||||||
|
}
|
||||||
|
self.isRefreshing = false
|
||||||
|
if updateLoadingFlag {
|
||||||
|
self.isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshAllData() async {
|
||||||
|
let categories = categoryRepository.categories
|
||||||
|
let sources = filteredSources()
|
||||||
|
let allSnapshots = filteredSnapshots(for: sources)
|
||||||
|
|
||||||
|
// Calculate portfolio summary
|
||||||
|
portfolioSummary = calculationService.calculatePortfolioSummary(
|
||||||
|
from: sources,
|
||||||
|
snapshots: allSnapshots
|
||||||
|
)
|
||||||
|
|
||||||
|
monthlySummary = calculationService.calculateMonthlySummary(
|
||||||
|
sources: sources,
|
||||||
|
snapshots: allSnapshots
|
||||||
|
)
|
||||||
|
|
||||||
|
// Calculate category metrics
|
||||||
|
categoryMetrics = calculationService.calculateCategoryMetrics(
|
||||||
|
for: categories,
|
||||||
|
sources: sources,
|
||||||
|
totalPortfolioValue: portfolioSummary.totalValue
|
||||||
|
).sorted { $0.totalValue > $1.totalValue }
|
||||||
|
|
||||||
|
// Get recent snapshots
|
||||||
|
recentSnapshots = Array(allSnapshots.prefix(10))
|
||||||
|
|
||||||
|
// Get sources needing update
|
||||||
|
let accountFilter = showAllAccounts ? nil : selectedAccount
|
||||||
|
sourcesNeedingUpdate = sourceRepository.fetchSourcesNeedingUpdate(for: accountFilter)
|
||||||
|
|
||||||
|
// Calculate evolution data for chart
|
||||||
|
updateEvolutionData(from: allSnapshots, categories: categories)
|
||||||
|
latestPortfolioChange = calculateLatestChange(from: evolutionData)
|
||||||
|
|
||||||
|
// Log screen view
|
||||||
|
FirebaseService.shared.logScreenView(screenName: "Dashboard")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func filteredSources() -> [InvestmentSource] {
|
||||||
|
// Performance: Cache filtered sources to avoid repeated filtering
|
||||||
|
let currentHash = sourceRepository.sources.count
|
||||||
|
let accountChanged = lastAccountId != selectedAccount?.id || lastShowAllAccounts != showAllAccounts
|
||||||
|
|
||||||
|
if !accountChanged && cachedFilteredSources != nil && cachedSourcesHash == currentHash {
|
||||||
|
return cachedFilteredSources!
|
||||||
|
}
|
||||||
|
|
||||||
|
lastAccountId = selectedAccount?.id
|
||||||
|
lastShowAllAccounts = showAllAccounts
|
||||||
|
cachedSourcesHash = currentHash
|
||||||
|
|
||||||
|
if showAllAccounts || selectedAccount == nil {
|
||||||
|
cachedFilteredSources = sourceRepository.sources
|
||||||
|
} else {
|
||||||
|
cachedFilteredSources = sourceRepository.sources.filter { $0.account?.id == selectedAccount?.id }
|
||||||
|
}
|
||||||
|
return cachedFilteredSources!
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidates cached data when underlying data changes
|
||||||
|
private func invalidateCache() {
|
||||||
|
cachedFilteredSources = nil
|
||||||
|
cachedSourcesHash = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func filteredSnapshots(for sources: [InvestmentSource]) -> [Snapshot] {
|
||||||
|
let sourceIds = sources.compactMap { $0.id }
|
||||||
|
return snapshotRepository.fetchSnapshots(
|
||||||
|
for: sourceIds,
|
||||||
|
months: maxHistoryMonths
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateEvolutionData(from snapshots: [Snapshot], categories: [Category]) {
|
||||||
|
let summary = calculateEvolutionSummary(from: snapshots)
|
||||||
|
let categoriesWithData = Set(summary.categoryTotals.keys)
|
||||||
|
let categoryLookup = Dictionary(uniqueKeysWithValues: categories.map { ($0.id, $0) })
|
||||||
|
|
||||||
|
evolutionData = summary.evolutionData
|
||||||
|
categoryEvolutionData = summary.categorySeries.flatMap { entry in
|
||||||
|
entry.valuesByCategory.compactMap { categoryId, value in
|
||||||
|
guard categoriesWithData.contains(categoryId),
|
||||||
|
let category = categoryLookup[categoryId] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return CategoryEvolutionPoint(
|
||||||
|
date: entry.date,
|
||||||
|
categoryName: category.name,
|
||||||
|
colorHex: category.colorHex,
|
||||||
|
value: value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct EvolutionSummary {
|
||||||
|
let evolutionData: [(date: Date, value: Decimal)]
|
||||||
|
let categorySeries: [(date: Date, valuesByCategory: [UUID: Decimal])]
|
||||||
|
let categoryTotals: [UUID: Decimal]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateEvolutionSummary(from snapshots: [Snapshot]) -> EvolutionSummary {
|
||||||
|
guard !snapshots.isEmpty else {
|
||||||
|
return EvolutionSummary(evolutionData: [], categorySeries: [], categoryTotals: [:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Performance: Pre-allocate capacity and use more efficient data structures
|
||||||
|
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||||
|
|
||||||
|
// Use dictionary to deduplicate dates more efficiently
|
||||||
|
var uniqueDateSet = Set<Date>()
|
||||||
|
uniqueDateSet.reserveCapacity(sortedSnapshots.count)
|
||||||
|
for snapshot in sortedSnapshots {
|
||||||
|
uniqueDateSet.insert(Calendar.current.startOfDay(for: snapshot.date))
|
||||||
|
}
|
||||||
|
let uniqueDates = uniqueDateSet.sorted()
|
||||||
|
|
||||||
|
guard !uniqueDates.isEmpty else {
|
||||||
|
return EvolutionSummary(evolutionData: [], categorySeries: [], categoryTotals: [:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-allocate dictionaries with estimated capacity
|
||||||
|
var snapshotsBySource: [UUID: [(date: Date, value: Decimal, categoryId: UUID?)]] = [:]
|
||||||
|
snapshotsBySource.reserveCapacity(Set(sortedSnapshots.compactMap { $0.source?.id }).count)
|
||||||
|
var categoryTotals: [UUID: Decimal] = [:]
|
||||||
|
|
||||||
|
for snapshot in sortedSnapshots {
|
||||||
|
guard let sourceId = snapshot.source?.id else { continue }
|
||||||
|
let categoryId = snapshot.source?.category?.id
|
||||||
|
snapshotsBySource[sourceId, default: []].append(
|
||||||
|
(date: snapshot.date, value: snapshot.decimalValue, categoryId: categoryId)
|
||||||
|
)
|
||||||
|
if let categoryId {
|
||||||
|
categoryTotals[categoryId, default: 0] += snapshot.decimalValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-allocate result arrays
|
||||||
|
var indices: [UUID: Int] = [:]
|
||||||
|
indices.reserveCapacity(snapshotsBySource.count)
|
||||||
|
var evolution: [(date: Date, value: Decimal)] = []
|
||||||
|
evolution.reserveCapacity(uniqueDates.count)
|
||||||
|
var series: [(date: Date, valuesByCategory: [UUID: Decimal])] = []
|
||||||
|
series.reserveCapacity(uniqueDates.count)
|
||||||
|
|
||||||
|
// Track last known value per source for carry-forward optimization
|
||||||
|
var lastValues: [UUID: (value: Decimal, categoryId: UUID?)] = [:]
|
||||||
|
lastValues.reserveCapacity(snapshotsBySource.count)
|
||||||
|
|
||||||
|
for (index, date) in uniqueDates.enumerated() {
|
||||||
|
let nextDate = index + 1 < uniqueDates.count
|
||||||
|
? uniqueDates[index + 1]
|
||||||
|
: Date.distantFuture
|
||||||
|
var total: Decimal = 0
|
||||||
|
var valuesByCategory: [UUID: Decimal] = [:]
|
||||||
|
|
||||||
|
for (sourceId, sourceSnapshots) in snapshotsBySource {
|
||||||
|
var currentIndex = indices[sourceId] ?? 0
|
||||||
|
|
||||||
|
while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextDate {
|
||||||
|
let snap = sourceSnapshots[currentIndex]
|
||||||
|
lastValues[sourceId] = (value: snap.value, categoryId: snap.categoryId)
|
||||||
|
currentIndex += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
indices[sourceId] = currentIndex
|
||||||
|
|
||||||
|
// Use last known value (carry-forward)
|
||||||
|
if let lastValue = lastValues[sourceId] {
|
||||||
|
total += lastValue.value
|
||||||
|
if let categoryId = lastValue.categoryId {
|
||||||
|
valuesByCategory[categoryId, default: 0] += lastValue.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
evolution.append((date: date, value: total))
|
||||||
|
series.append((date: date, valuesByCategory: valuesByCategory))
|
||||||
|
}
|
||||||
|
|
||||||
|
return EvolutionSummary(
|
||||||
|
evolutionData: evolution,
|
||||||
|
categorySeries: series,
|
||||||
|
categoryTotals: categoryTotals
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateLatestChange(from data: [(date: Date, value: Decimal)]) -> PortfolioChange {
|
||||||
|
guard data.count >= 2 else {
|
||||||
|
return PortfolioChange(absolute: 0, percentage: 0, label: "since last update")
|
||||||
|
}
|
||||||
|
let last = data[data.count - 1]
|
||||||
|
let previous = data[data.count - 2]
|
||||||
|
let absolute = last.value - previous.value
|
||||||
|
let percentage = previous.value > 0
|
||||||
|
? NSDecimalNumber(decimal: absolute / previous.value).doubleValue * 100
|
||||||
|
: 0
|
||||||
|
return PortfolioChange(absolute: absolute, percentage: percentage, label: "since last update")
|
||||||
|
}
|
||||||
|
|
||||||
|
func goalEtaText(for goal: Goal, currentValue: Decimal) -> String? {
|
||||||
|
let target = goal.targetDecimal
|
||||||
|
let lastValue = evolutionData.last?.value ?? currentValue
|
||||||
|
|
||||||
|
if currentValue >= target {
|
||||||
|
return "Goal reached. Keep it steady."
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let months = estimateMonthsToGoal(target: target, lastValue: lastValue) else {
|
||||||
|
return "Keep going. Consistency pays off."
|
||||||
|
}
|
||||||
|
|
||||||
|
if months == 0 {
|
||||||
|
return "Almost there. One more check-in."
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseDate = evolutionData.last?.date ?? Date()
|
||||||
|
let estimatedDate = Calendar.current.date(byAdding: .month, value: months, to: baseDate)
|
||||||
|
if months >= 72 {
|
||||||
|
return "Long journey, strong discipline. You're building momentum."
|
||||||
|
}
|
||||||
|
|
||||||
|
if let estimatedDate = estimatedDate {
|
||||||
|
return "Estimated: \(estimatedDate.monthYearString)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Estimated: \(months) months"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func estimateMonthsToGoal(target: Decimal, lastValue: Decimal) -> Int? {
|
||||||
|
guard evolutionData.count >= 3 else { return nil }
|
||||||
|
let recent = Array(evolutionData.suffix(6))
|
||||||
|
guard let first = recent.first, let last = recent.last else { return nil }
|
||||||
|
let monthsBetween = max(1, first.date.monthsBetween(last.date))
|
||||||
|
let delta = last.value - first.value
|
||||||
|
guard delta > 0 else { return nil }
|
||||||
|
let monthlyGain = delta / Decimal(monthsBetween)
|
||||||
|
guard monthlyGain > 0 else { return nil }
|
||||||
|
let remaining = target - lastValue
|
||||||
|
guard remaining > 0 else { return 0 }
|
||||||
|
let months = NSDecimalNumber(decimal: remaining / monthlyGain).doubleValue
|
||||||
|
return Int(ceil(months))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
var hasData: Bool {
|
||||||
|
!filteredSources().isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalSourceCount: Int {
|
||||||
|
sourceRepository.sourceCount
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCategoryCount: Int {
|
||||||
|
categoryRepository.categories.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var pendingUpdatesCount: Int {
|
||||||
|
sourcesNeedingUpdate.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var topCategories: [CategoryMetrics] {
|
||||||
|
Array(categoryMetrics.prefix(5))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Formatting
|
||||||
|
|
||||||
|
var formattedTotalValue: String {
|
||||||
|
portfolioSummary.formattedTotalValue
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedDayChange: String {
|
||||||
|
portfolioSummary.formattedDayChange
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedMonthChange: String {
|
||||||
|
portfolioSummary.formattedMonthChange
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedYearChange: String {
|
||||||
|
portfolioSummary.formattedYearChange
|
||||||
|
}
|
||||||
|
|
||||||
|
var isDayChangePositive: Bool {
|
||||||
|
portfolioSummary.dayChange >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var isMonthChangePositive: Bool {
|
||||||
|
portfolioSummary.monthChange >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var isYearChangePositive: Bool {
|
||||||
|
portfolioSummary.yearChange >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedLastUpdate: String {
|
||||||
|
portfolioSummary.lastUpdated?.friendlyDescription ?? "Not yet"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,291 @@
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class GoalsViewModel: ObservableObject {
|
||||||
|
@Published var goals: [Goal] = []
|
||||||
|
@Published var totalValue: Decimal = Decimal.zero
|
||||||
|
@Published var selectedAccount: Account?
|
||||||
|
@Published var showAllAccounts = true
|
||||||
|
|
||||||
|
private let goalRepository: GoalRepository
|
||||||
|
private let sourceRepository: InvestmentSourceRepository
|
||||||
|
private let snapshotRepository: SnapshotRepository
|
||||||
|
private let maxHistoryMonths = 60
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
// MARK: - Performance: Caching
|
||||||
|
private var cachedEvolutionData: [UUID: [(date: Date, value: Decimal)]] = [:]
|
||||||
|
private var cachedCompletionDates: [UUID: Date?] = [:]
|
||||||
|
private var lastSourcesHash: Int = 0
|
||||||
|
|
||||||
|
init(
|
||||||
|
goalRepository: GoalRepository = GoalRepository(),
|
||||||
|
sourceRepository: InvestmentSourceRepository = InvestmentSourceRepository(),
|
||||||
|
snapshotRepository: SnapshotRepository = SnapshotRepository()
|
||||||
|
) {
|
||||||
|
self.goalRepository = goalRepository
|
||||||
|
self.sourceRepository = sourceRepository
|
||||||
|
self.snapshotRepository = snapshotRepository
|
||||||
|
|
||||||
|
setupObservers()
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupObservers() {
|
||||||
|
goalRepository.$goals
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] goals in
|
||||||
|
self?.updateGoals(using: goals)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
sourceRepository.$sources
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.updateTotalValue()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() {
|
||||||
|
// Performance: Invalidate caches when refreshing
|
||||||
|
let currentHash = sourceRepository.sources.count
|
||||||
|
if currentHash != lastSourcesHash {
|
||||||
|
cachedEvolutionData.removeAll()
|
||||||
|
cachedCompletionDates.removeAll()
|
||||||
|
lastSourcesHash = currentHash
|
||||||
|
}
|
||||||
|
loadGoals()
|
||||||
|
updateGoals(using: goalRepository.goals)
|
||||||
|
}
|
||||||
|
|
||||||
|
func progress(for goal: Goal) -> Double {
|
||||||
|
let currentTotal = totalValue(for: goal)
|
||||||
|
guard goal.targetDecimal > 0 else { return 0 }
|
||||||
|
let current = min(currentTotal, goal.targetDecimal)
|
||||||
|
return NSDecimalNumber(decimal: current / goal.targetDecimal).doubleValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func totalValue(for goal: Goal) -> Decimal {
|
||||||
|
if let account = goal.account {
|
||||||
|
return sourceRepository.sources
|
||||||
|
.filter { $0.account?.id == account.id }
|
||||||
|
.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||||
|
}
|
||||||
|
return totalValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func paceStatus(for goal: Goal) -> GoalPaceStatus? {
|
||||||
|
guard let targetDate = goal.targetDate else { return nil }
|
||||||
|
let targetDay = targetDate.startOfDay
|
||||||
|
let actualProgress = progress(for: goal)
|
||||||
|
let startDate = goal.createdAt.startOfDay
|
||||||
|
let totalDays = max(1, startDate.daysBetween(targetDay))
|
||||||
|
|
||||||
|
if actualProgress >= 1 {
|
||||||
|
return GoalPaceStatus(
|
||||||
|
expectedProgress: 1,
|
||||||
|
delta: 0,
|
||||||
|
isBehind: false,
|
||||||
|
statusText: "Goal reached"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let estimatedCompletionDate = estimateCompletionDate(for: goal) {
|
||||||
|
let estimatedDay = estimatedCompletionDate.startOfDay
|
||||||
|
let deltaDays = targetDay.daysBetween(estimatedDay)
|
||||||
|
let deltaPercent = min(abs(Double(deltaDays)) / Double(totalDays) * 100, 999)
|
||||||
|
let isBehind = estimatedDay > targetDay
|
||||||
|
let statusText = abs(deltaPercent) < 1
|
||||||
|
? "On track"
|
||||||
|
: isBehind
|
||||||
|
? String(format: "Behind by %.1f%%", deltaPercent)
|
||||||
|
: String(format: "Ahead by %.1f%%", deltaPercent)
|
||||||
|
|
||||||
|
return GoalPaceStatus(
|
||||||
|
expectedProgress: actualProgress,
|
||||||
|
delta: isBehind ? -deltaPercent / 100 : deltaPercent / 100,
|
||||||
|
isBehind: isBehind,
|
||||||
|
statusText: statusText
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let elapsedDays = max(0, startDate.daysBetween(Date()))
|
||||||
|
let expectedProgress = min(Double(elapsedDays) / Double(totalDays), 1)
|
||||||
|
let delta = actualProgress - expectedProgress
|
||||||
|
let isOverdue = Date() > targetDay && actualProgress < 1
|
||||||
|
let isBehind = isOverdue || delta < -0.03
|
||||||
|
let deltaPercent = abs(delta) * 100
|
||||||
|
let statusText = isOverdue
|
||||||
|
? "Behind schedule • target passed"
|
||||||
|
: delta >= 0
|
||||||
|
? String(format: "Ahead by %.1f%%", deltaPercent)
|
||||||
|
: String(format: "Behind by %.1f%%", deltaPercent)
|
||||||
|
|
||||||
|
return GoalPaceStatus(
|
||||||
|
expectedProgress: expectedProgress,
|
||||||
|
delta: delta,
|
||||||
|
isBehind: isBehind,
|
||||||
|
statusText: statusText
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteGoal(_ goal: Goal) {
|
||||||
|
goalRepository.deleteGoal(goal)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func estimateCompletionDate(for goal: Goal) -> Date? {
|
||||||
|
// Performance: Use cached completion date if available
|
||||||
|
if let cached = cachedCompletionDates[goal.id] {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
let sources: [InvestmentSource]
|
||||||
|
if let account = goal.account {
|
||||||
|
sources = sourceRepository.sources.filter { $0.account?.id == account.id }
|
||||||
|
} else {
|
||||||
|
sources = sourceRepository.sources
|
||||||
|
}
|
||||||
|
let sourceIds = sources.compactMap { $0.id }
|
||||||
|
guard !sourceIds.isEmpty else {
|
||||||
|
cachedCompletionDates[goal.id] = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Performance: Use cached evolution data if available
|
||||||
|
let cacheKey = goal.account?.id ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
|
||||||
|
let evolutionData: [(date: Date, value: Decimal)]
|
||||||
|
if let cached = cachedEvolutionData[cacheKey] {
|
||||||
|
evolutionData = cached
|
||||||
|
} else {
|
||||||
|
let snapshots = snapshotRepository.fetchSnapshots(
|
||||||
|
for: sourceIds,
|
||||||
|
months: maxHistoryMonths
|
||||||
|
)
|
||||||
|
evolutionData = calculateEvolutionData(from: snapshots)
|
||||||
|
cachedEvolutionData[cacheKey] = evolutionData
|
||||||
|
}
|
||||||
|
|
||||||
|
guard evolutionData.count >= 3,
|
||||||
|
let first = evolutionData.suffix(6).first,
|
||||||
|
let last = evolutionData.suffix(6).last else {
|
||||||
|
cachedCompletionDates[goal.id] = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let monthsBetween = max(1, first.date.monthsBetween(last.date))
|
||||||
|
let delta = last.value - first.value
|
||||||
|
guard delta > 0 else {
|
||||||
|
cachedCompletionDates[goal.id] = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let monthlyGain = delta / Decimal(monthsBetween)
|
||||||
|
guard monthlyGain > 0 else {
|
||||||
|
cachedCompletionDates[goal.id] = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentValue = totalValue(for: goal)
|
||||||
|
let remaining = goal.targetDecimal - currentValue
|
||||||
|
guard remaining > 0 else {
|
||||||
|
let result = Date()
|
||||||
|
cachedCompletionDates[goal.id] = result
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
let months = NSDecimalNumber(decimal: remaining / monthlyGain).doubleValue
|
||||||
|
let monthsRounded = Int(ceil(months))
|
||||||
|
let result = Calendar.current.date(byAdding: .month, value: monthsRounded, to: last.date)
|
||||||
|
cachedCompletionDates[goal.id] = result
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateEvolutionData(from snapshots: [Snapshot]) -> [(date: Date, value: Decimal)] {
|
||||||
|
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||||
|
let uniqueDates = Array(Set(sortedSnapshots.map { Calendar.current.startOfDay(for: $0.date) }))
|
||||||
|
.sorted()
|
||||||
|
guard !uniqueDates.isEmpty else { return [] }
|
||||||
|
|
||||||
|
var snapshotsBySource: [UUID: [(date: Date, value: Decimal)]] = [:]
|
||||||
|
for snapshot in sortedSnapshots {
|
||||||
|
guard let sourceId = snapshot.source?.id else { continue }
|
||||||
|
snapshotsBySource[sourceId, default: []].append(
|
||||||
|
(date: snapshot.date, value: snapshot.decimalValue)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var indices: [UUID: Int] = [:]
|
||||||
|
var evolution: [(date: Date, value: Decimal)] = []
|
||||||
|
|
||||||
|
for (index, date) in uniqueDates.enumerated() {
|
||||||
|
let nextDate = index + 1 < uniqueDates.count
|
||||||
|
? uniqueDates[index + 1]
|
||||||
|
: Date.distantFuture
|
||||||
|
var total: Decimal = 0
|
||||||
|
|
||||||
|
for (sourceId, sourceSnapshots) in snapshotsBySource {
|
||||||
|
var currentIndex = indices[sourceId] ?? 0
|
||||||
|
var latest: (date: Date, value: Decimal)?
|
||||||
|
|
||||||
|
while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextDate {
|
||||||
|
latest = sourceSnapshots[currentIndex]
|
||||||
|
currentIndex += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
indices[sourceId] = currentIndex
|
||||||
|
|
||||||
|
if let latest {
|
||||||
|
total += latest.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
evolution.append((date: date, value: total))
|
||||||
|
}
|
||||||
|
|
||||||
|
return evolution
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private helpers
|
||||||
|
|
||||||
|
private func loadGoals() {
|
||||||
|
if showAllAccounts || selectedAccount == nil {
|
||||||
|
goalRepository.fetchGoals()
|
||||||
|
} else if let account = selectedAccount {
|
||||||
|
goalRepository.fetchGoals(for: account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateGoals(using repositoryGoals: [Goal]) {
|
||||||
|
if showAllAccounts || selectedAccount == nil {
|
||||||
|
goals = repositoryGoals
|
||||||
|
} else if let account = selectedAccount {
|
||||||
|
goals = repositoryGoals.filter { $0.account?.id == account.id }
|
||||||
|
} else {
|
||||||
|
goals = repositoryGoals
|
||||||
|
}
|
||||||
|
updateTotalValue()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateTotalValue() {
|
||||||
|
if showAllAccounts || selectedAccount == nil {
|
||||||
|
totalValue = sourceRepository.sources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let account = selectedAccount {
|
||||||
|
totalValue = sourceRepository.sources
|
||||||
|
.filter { $0.account?.id == account.id }
|
||||||
|
.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GoalPaceStatus {
|
||||||
|
let expectedProgress: Double
|
||||||
|
let delta: Double
|
||||||
|
let isBehind: Bool
|
||||||
|
let statusText: String
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class JournalViewModel: ObservableObject {
|
||||||
|
@Published var snapshotNotes: [Snapshot] = []
|
||||||
|
@Published var monthlyNotes: [MonthlyNoteItem] = []
|
||||||
|
|
||||||
|
private let snapshotRepository: SnapshotRepository
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(snapshotRepository: SnapshotRepository? = nil) {
|
||||||
|
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
|
||||||
|
setupObservers()
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupObservers() {
|
||||||
|
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)
|
||||||
|
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() {
|
||||||
|
snapshotNotes = snapshotRepository.fetchAllSnapshots()
|
||||||
|
.sorted { $0.date > $1.date }
|
||||||
|
let snapshotMonths = Set(snapshotNotes.map { $0.date.startOfMonth })
|
||||||
|
let noteMonths = Set(MonthlyCheckInStore.allNotes().map { $0.date.startOfMonth })
|
||||||
|
let allMonths = snapshotMonths.union(noteMonths)
|
||||||
|
|
||||||
|
monthlyNotes = allMonths
|
||||||
|
.sorted(by: >)
|
||||||
|
.map { date -> MonthlyNoteItem in
|
||||||
|
let entry = MonthlyCheckInStore.entry(for: date)
|
||||||
|
return MonthlyNoteItem(
|
||||||
|
date: date,
|
||||||
|
note: entry?.note ?? "",
|
||||||
|
rating: entry?.rating,
|
||||||
|
mood: entry?.mood
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MonthlyNoteItem: Identifiable, Equatable {
|
||||||
|
var id: Date { date }
|
||||||
|
let date: Date
|
||||||
|
let note: String
|
||||||
|
let rating: Int?
|
||||||
|
let mood: MonthlyCheckInMood?
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class MonthlyCheckInViewModel: ObservableObject {
|
||||||
|
@Published var sourcesNeedingUpdate: [InvestmentSource] = []
|
||||||
|
@Published var sources: [InvestmentSource] = []
|
||||||
|
@Published var monthlySummary: MonthlySummary = .empty
|
||||||
|
@Published var recentNotes: [Snapshot] = []
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var selectedAccount: Account?
|
||||||
|
@Published var showAllAccounts = true
|
||||||
|
@Published var selectedRange: DateRange = .thisMonth
|
||||||
|
@Published var checkInStats: MonthlyCheckInStats = .empty
|
||||||
|
|
||||||
|
private let sourceRepository: InvestmentSourceRepository
|
||||||
|
private let snapshotRepository: SnapshotRepository
|
||||||
|
private let calculationService: CalculationService
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
init(
|
||||||
|
sourceRepository: InvestmentSourceRepository? = nil,
|
||||||
|
snapshotRepository: SnapshotRepository? = nil,
|
||||||
|
calculationService: CalculationService? = nil
|
||||||
|
) {
|
||||||
|
let context = CoreDataStack.shared.viewContext
|
||||||
|
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository(context: context)
|
||||||
|
self.snapshotRepository = snapshotRepository ?? SnapshotRepository(context: context)
|
||||||
|
self.calculationService = calculationService ?? .shared
|
||||||
|
|
||||||
|
setupObservers()
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupObservers() {
|
||||||
|
sourceRepository.$sources
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)
|
||||||
|
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() {
|
||||||
|
isLoading = true
|
||||||
|
let filtered = filteredSources()
|
||||||
|
let snapshots = filteredSnapshots(for: filtered)
|
||||||
|
|
||||||
|
let accountFilter = showAllAccounts ? nil : selectedAccount
|
||||||
|
sourcesNeedingUpdate = sourceRepository.fetchSourcesNeedingUpdate(for: accountFilter)
|
||||||
|
sources = filtered
|
||||||
|
|
||||||
|
monthlySummary = calculationService.calculateMonthlySummary(
|
||||||
|
sources: filtered,
|
||||||
|
snapshots: snapshots,
|
||||||
|
range: selectedRange
|
||||||
|
)
|
||||||
|
|
||||||
|
checkInStats = MonthlyCheckInStore.stats(referenceDate: selectedRange.end)
|
||||||
|
|
||||||
|
recentNotes = snapshots
|
||||||
|
.filter { ($0.notes?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||||
|
&& selectedRange.contains($0.date) }
|
||||||
|
.sorted { $0.date > $1.date }
|
||||||
|
.prefix(5)
|
||||||
|
.map { $0 }
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func duplicatePreviousMonthSnapshots(referenceDate: Date) {
|
||||||
|
let targetRange = DateRange.month(containing: referenceDate)
|
||||||
|
let previousRange = DateRange.month(containing: referenceDate.adding(months: -1))
|
||||||
|
let sources = filteredSources()
|
||||||
|
guard !sources.isEmpty else { return }
|
||||||
|
|
||||||
|
let targetSnapshots = snapshotRepository.fetchSnapshots(
|
||||||
|
from: targetRange.start,
|
||||||
|
to: targetRange.end
|
||||||
|
)
|
||||||
|
let targetSourceIds = Set(targetSnapshots.compactMap { $0.source?.id })
|
||||||
|
|
||||||
|
let now = Date()
|
||||||
|
let targetDate = targetRange.contains(now) ? now : targetRange.end
|
||||||
|
|
||||||
|
for source in sources {
|
||||||
|
let sourceId = source.id
|
||||||
|
if targetSourceIds.contains(sourceId) { continue }
|
||||||
|
|
||||||
|
let previousSnapshots = snapshotRepository.fetchSnapshots(for: source)
|
||||||
|
guard let previousSnapshot = previousSnapshots.first(where: { previousRange.contains($0.date) }) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshotRepository.createSnapshot(
|
||||||
|
for: source,
|
||||||
|
date: targetDate,
|
||||||
|
value: previousSnapshot.decimalValue,
|
||||||
|
contribution: previousSnapshot.contribution?.decimalValue,
|
||||||
|
notes: previousSnapshot.notes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func filteredSources() -> [InvestmentSource] {
|
||||||
|
if showAllAccounts || selectedAccount == nil {
|
||||||
|
return sourceRepository.sources
|
||||||
|
}
|
||||||
|
return sourceRepository.sources.filter { $0.account?.id == selectedAccount?.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func filteredSnapshots(for sources: [InvestmentSource]) -> [Snapshot] {
|
||||||
|
let sourceIds = Set(sources.compactMap { $0.id })
|
||||||
|
return snapshotRepository.fetchAllSnapshots().filter { snapshot in
|
||||||
|
let sourceId = snapshot.source?.id
|
||||||
|
return sourceId.map(sourceIds.contains) ?? false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue