initial version
This commit is contained in:
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,52 @@
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
import FirebaseCore
|
||||
import FirebaseAnalytics
|
||||
import GoogleMobileAds
|
||||
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
// Initialize Firebase (only if GoogleService-Info.plist exists)
|
||||
if Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist") != nil {
|
||||
FirebaseApp.configure()
|
||||
} else {
|
||||
print("Warning: GoogleService-Info.plist not found. Firebase disabled.")
|
||||
}
|
||||
|
||||
// Initialize Google Mobile Ads
|
||||
MobileAds.shared.start()
|
||||
|
||||
// Request notification permissions
|
||||
requestNotificationPermissions()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func requestNotificationPermissions() {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in
|
||||
if let error = error {
|
||||
print("Notification permission error: \(error)")
|
||||
}
|
||||
print("Notification permission granted: \(granted)")
|
||||
}
|
||||
}
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
|
||||
) {
|
||||
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
|
||||
print("Device token: \(token)")
|
||||
}
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFailToRegisterForRemoteNotificationsWithError error: Error
|
||||
) {
|
||||
print("Failed to register for remote notifications: \(error)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var iapService: IAPService
|
||||
@EnvironmentObject var adMobService: AdMobService
|
||||
@EnvironmentObject var tabSelection: TabSelectionStore
|
||||
@AppStorage("onboardingCompleted") private var onboardingCompleted = false
|
||||
@AppStorage("faceIdEnabled") private var faceIdEnabled = false
|
||||
@AppStorage("pinEnabled") private var pinEnabled = false
|
||||
@AppStorage("lockOnLaunch") private var lockOnLaunch = true
|
||||
@AppStorage("lockOnBackground") private var lockOnBackground = false
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var isUnlocked = false
|
||||
|
||||
private var lockEnabled: Bool {
|
||||
faceIdEnabled || pinEnabled
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Group {
|
||||
if !onboardingCompleted {
|
||||
OnboardingView(onboardingCompleted: $onboardingCompleted)
|
||||
} else {
|
||||
mainContent
|
||||
}
|
||||
}
|
||||
|
||||
if onboardingCompleted && lockEnabled && !isUnlocked {
|
||||
AppLockView(isUnlocked: $isUnlocked)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if !lockEnabled {
|
||||
isUnlocked = true
|
||||
} else {
|
||||
isUnlocked = !lockOnLaunch
|
||||
}
|
||||
}
|
||||
.onChange(of: lockEnabled) { _, enabled in
|
||||
if !enabled {
|
||||
isUnlocked = true
|
||||
} else {
|
||||
isUnlocked = !lockOnLaunch
|
||||
}
|
||||
}
|
||||
.onChange(of: onboardingCompleted) { _, completed in
|
||||
if completed && lockEnabled {
|
||||
isUnlocked = !lockOnLaunch
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
guard onboardingCompleted, lockEnabled else { return }
|
||||
if phase == .background && lockOnBackground {
|
||||
isUnlocked = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var mainContent: some View {
|
||||
ZStack {
|
||||
TabView(selection: $tabSelection.selectedTab) {
|
||||
DashboardView()
|
||||
.tabItem {
|
||||
Label("Home", systemImage: "house.fill")
|
||||
}
|
||||
.tag(0)
|
||||
|
||||
SourceListView(iapService: iapService)
|
||||
.tabItem {
|
||||
Label("Sources", systemImage: "list.bullet")
|
||||
}
|
||||
.tag(1)
|
||||
|
||||
ChartsContainerView(iapService: iapService)
|
||||
.tabItem {
|
||||
Label("Charts", systemImage: "chart.xyaxis.line")
|
||||
}
|
||||
.tag(2)
|
||||
|
||||
JournalView()
|
||||
.tabItem {
|
||||
Label("Journal", systemImage: "book.closed")
|
||||
}
|
||||
.tag(3)
|
||||
|
||||
SettingsView()
|
||||
.tabItem {
|
||||
Label("Settings", systemImage: "gearshape.fill")
|
||||
}
|
||||
.tag(4)
|
||||
}
|
||||
}
|
||||
// Banner ad at bottom for free users
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
if !iapService.isPremium {
|
||||
BannerAdView()
|
||||
.frame(height: AppConstants.UI.bannerAdHeight)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color(.systemBackground))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
.environmentObject(IAPService())
|
||||
.environmentObject(AdMobService())
|
||||
.environmentObject(AccountStore(iapService: IAPService()))
|
||||
.environmentObject(TabSelectionStore())
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct PortfolioJournalApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
@StateObject private var iapService: IAPService
|
||||
@StateObject private var adMobService: AdMobService
|
||||
@StateObject private var accountStore: AccountStore
|
||||
@StateObject private var tabSelection = TabSelectionStore()
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
let coreDataStack = CoreDataStack.shared
|
||||
|
||||
init() {
|
||||
let iap = IAPService()
|
||||
_iapService = StateObject(wrappedValue: iap)
|
||||
_adMobService = StateObject(wrappedValue: AdMobService())
|
||||
_accountStore = StateObject(wrappedValue: AccountStore(iapService: iap))
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(\.managedObjectContext, coreDataStack.viewContext)
|
||||
.environmentObject(iapService)
|
||||
.environmentObject(adMobService)
|
||||
.environmentObject(accountStore)
|
||||
.environmentObject(tabSelection)
|
||||
}
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
if newPhase == .active {
|
||||
coreDataStack.refreshWidgetData()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "AppIcon-dark.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"filename" : "AppIcon-tinted.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "BrandMark@1x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "BrandMark@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "BrandMark@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
struct CategoryEvolutionPoint: Identifiable {
|
||||
let date: Date
|
||||
let categoryName: String
|
||||
let colorHex: String
|
||||
let value: Decimal
|
||||
|
||||
var id: String {
|
||||
"\(categoryName)-\(date.timeIntervalSince1970)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(Account)
|
||||
public class Account: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Account> {
|
||||
return NSFetchRequest<Account>(entityName: "Account")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var currency: String?
|
||||
@NSManaged public var inputMode: String
|
||||
@NSManaged public var notificationFrequency: String
|
||||
@NSManaged public var customFrequencyMonths: Int16
|
||||
@NSManaged public var sortOrder: Int16
|
||||
@NSManaged public var sources: NSSet?
|
||||
@NSManaged public var goals: NSSet?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
createdAt = Date()
|
||||
name = "Account"
|
||||
inputMode = InputMode.simple.rawValue
|
||||
notificationFrequency = NotificationFrequency.monthly.rawValue
|
||||
customFrequencyMonths = 1
|
||||
sortOrder = 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension Account {
|
||||
var sourcesArray: [InvestmentSource] {
|
||||
let set = sources as? Set<InvestmentSource> ?? []
|
||||
return set.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
var goalsArray: [Goal] {
|
||||
let set = goals as? Set<Goal> ?? []
|
||||
return set.sorted { $0.createdAt < $1.createdAt }
|
||||
}
|
||||
|
||||
var frequency: NotificationFrequency {
|
||||
NotificationFrequency(rawValue: notificationFrequency) ?? .monthly
|
||||
}
|
||||
|
||||
var currencyCode: String? {
|
||||
currency?.isEmpty == false ? currency : nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(AppSettings)
|
||||
public class AppSettings: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<AppSettings> {
|
||||
return NSFetchRequest<AppSettings>(entityName: "AppSettings")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var currency: String
|
||||
@NSManaged public var defaultNotificationTime: Date?
|
||||
@NSManaged public var enableAnalytics: Bool
|
||||
@NSManaged public var onboardingCompleted: Bool
|
||||
@NSManaged public var lastSyncDate: Date?
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var inputMode: String
|
||||
@NSManaged public var selectedAccountId: UUID?
|
||||
@NSManaged public var showAllAccounts: Bool
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
let localeCode = Locale.current.currency?.identifier
|
||||
currency = CurrencyPicker.commonCodes.contains(localeCode ?? "")
|
||||
? (localeCode ?? "EUR")
|
||||
: "EUR"
|
||||
enableAnalytics = true
|
||||
onboardingCompleted = false
|
||||
inputMode = InputMode.simple.rawValue
|
||||
showAllAccounts = true
|
||||
createdAt = Date()
|
||||
|
||||
// Default notification time: 9:00 AM
|
||||
var components = DateComponents()
|
||||
components.hour = 9
|
||||
components.minute = 0
|
||||
defaultNotificationTime = Calendar.current.date(from: components)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension AppSettings {
|
||||
var currencySymbol: String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
formatter.currencyCode = currency
|
||||
return formatter.currencySymbol ?? "€"
|
||||
}
|
||||
|
||||
var notificationTimeString: String {
|
||||
guard let time = defaultNotificationTime else { return "9:00 AM" }
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: time)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Static Helpers
|
||||
|
||||
extension AppSettings {
|
||||
static func getOrCreate(in context: NSManagedObjectContext) -> AppSettings {
|
||||
let request: NSFetchRequest<AppSettings> = AppSettings.fetchRequest()
|
||||
request.fetchLimit = 1
|
||||
|
||||
if let existing = try? context.fetch(request).first {
|
||||
return existing
|
||||
}
|
||||
|
||||
let new = AppSettings(context: context)
|
||||
try? context.save()
|
||||
return new
|
||||
}
|
||||
|
||||
static func markOnboardingComplete(in context: NSManagedObjectContext) {
|
||||
let settings = getOrCreate(in: context)
|
||||
settings.onboardingCompleted = true
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(Asset)
|
||||
public class Asset: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Asset> {
|
||||
return NSFetchRequest<Asset>(entityName: "Asset")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var symbol: String?
|
||||
@NSManaged public var type: String
|
||||
@NSManaged public var currency: String?
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var sources: NSSet?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
createdAt = Date()
|
||||
name = ""
|
||||
type = AssetType.stock.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
enum AssetType: String, CaseIterable, Identifiable {
|
||||
case stock
|
||||
case etf
|
||||
case crypto
|
||||
case fund
|
||||
case cash
|
||||
case realEstate
|
||||
case other
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .stock: return "Stock"
|
||||
case .etf: return "ETF"
|
||||
case .crypto: return "Crypto"
|
||||
case .fund: return "Fund"
|
||||
case .cash: return "Cash"
|
||||
case .realEstate: return "Real Estate"
|
||||
case .other: return "Other"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
import SwiftUI
|
||||
|
||||
@objc(Category)
|
||||
public class Category: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Category> {
|
||||
return NSFetchRequest<Category>(entityName: "Category")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var colorHex: String
|
||||
@NSManaged public var icon: String
|
||||
@NSManaged public var sortOrder: Int16
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var sources: NSSet?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
createdAt = Date()
|
||||
sortOrder = 0
|
||||
colorHex = "#3B82F6"
|
||||
icon = "chart.pie.fill"
|
||||
name = ""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension Category {
|
||||
var color: Color {
|
||||
Color(hex: colorHex) ?? .blue
|
||||
}
|
||||
|
||||
var sourcesArray: [InvestmentSource] {
|
||||
let set = sources as? Set<InvestmentSource> ?? []
|
||||
return set.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
var sourceCount: Int {
|
||||
sources?.count ?? 0
|
||||
}
|
||||
|
||||
var totalValue: Decimal {
|
||||
sourcesArray.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Generated accessors for sources
|
||||
|
||||
extension Category {
|
||||
@objc(addSourcesObject:)
|
||||
@NSManaged public func addToSources(_ value: InvestmentSource)
|
||||
|
||||
@objc(removeSourcesObject:)
|
||||
@NSManaged public func removeFromSources(_ value: InvestmentSource)
|
||||
|
||||
@objc(addSources:)
|
||||
@NSManaged public func addToSources(_ values: NSSet)
|
||||
|
||||
@objc(removeSources:)
|
||||
@NSManaged public func removeFromSources(_ values: NSSet)
|
||||
}
|
||||
|
||||
// MARK: - Default Categories
|
||||
|
||||
extension Category {
|
||||
static let defaultCategories: [(name: String, colorHex: String, icon: String)] = [
|
||||
("Stocks", "#10B981", "chart.line.uptrend.xyaxis"),
|
||||
("Bonds", "#3B82F6", "building.columns.fill"),
|
||||
("Real Estate", "#F59E0B", "house.fill"),
|
||||
("Crypto", "#8B5CF6", "bitcoinsign.circle.fill"),
|
||||
("Cash", "#6B7280", "banknote.fill"),
|
||||
("ETFs", "#EC4899", "chart.bar.fill"),
|
||||
("Retirement", "#14B8A6", "person.fill"),
|
||||
("Other", "#64748B", "ellipsis.circle.fill")
|
||||
]
|
||||
|
||||
static func createDefaultCategories(in context: NSManagedObjectContext) {
|
||||
for (index, categoryData) in defaultCategories.enumerated() {
|
||||
let category = Category(context: context)
|
||||
category.name = categoryData.name
|
||||
category.colorHex = categoryData.colorHex
|
||||
category.icon = categoryData.icon
|
||||
category.sortOrder = Int16(index)
|
||||
}
|
||||
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(Goal)
|
||||
public class Goal: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Goal> {
|
||||
return NSFetchRequest<Goal>(entityName: "Goal")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var targetAmount: NSDecimalNumber?
|
||||
@NSManaged public var targetDate: Date?
|
||||
@NSManaged public var isActive: Bool
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var account: Account?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
createdAt = Date()
|
||||
name = ""
|
||||
isActive = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension Goal {
|
||||
var targetDecimal: Decimal {
|
||||
targetAmount?.decimalValue ?? Decimal.zero
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(InvestmentSource)
|
||||
public class InvestmentSource: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<InvestmentSource> {
|
||||
return NSFetchRequest<InvestmentSource>(entityName: "InvestmentSource")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var notificationFrequency: String
|
||||
@NSManaged public var customFrequencyMonths: Int16
|
||||
@NSManaged public var isActive: Bool
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var category: Category?
|
||||
@NSManaged public var account: Account?
|
||||
@NSManaged public var snapshots: NSSet?
|
||||
@NSManaged public var transactions: NSSet?
|
||||
@NSManaged public var asset: Asset?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
createdAt = Date()
|
||||
isActive = true
|
||||
notificationFrequency = NotificationFrequency.monthly.rawValue
|
||||
customFrequencyMonths = 1
|
||||
name = ""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification Frequency
|
||||
|
||||
enum NotificationFrequency: String, CaseIterable, Identifiable {
|
||||
case monthly = "monthly"
|
||||
case quarterly = "quarterly"
|
||||
case semiannual = "semiannual"
|
||||
case annual = "annual"
|
||||
case custom = "custom"
|
||||
case never = "never"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .monthly: return "Monthly"
|
||||
case .quarterly: return "Quarterly"
|
||||
case .semiannual: return "Semi-Annual"
|
||||
case .annual: return "Annual"
|
||||
case .custom: return "Custom"
|
||||
case .never: return "Never"
|
||||
}
|
||||
}
|
||||
|
||||
var months: Int {
|
||||
switch self {
|
||||
case .monthly: return 1
|
||||
case .quarterly: return 3
|
||||
case .semiannual: return 6
|
||||
case .annual: return 12
|
||||
case .custom, .never: return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension InvestmentSource {
|
||||
/// Returns snapshots sorted by date descending (most recent first)
|
||||
/// Performance note: This sorts on every call. For repeated access, cache the result.
|
||||
var snapshotsArray: [Snapshot] {
|
||||
let set = snapshots as? Set<Snapshot> ?? []
|
||||
return set.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
/// Returns snapshots sorted by date ascending (oldest first)
|
||||
/// Performance note: This sorts on every call. For repeated access, cache the result.
|
||||
var sortedSnapshotsByDateAscending: [Snapshot] {
|
||||
let set = snapshots as? Set<Snapshot> ?? []
|
||||
return set.sorted { $0.date < $1.date }
|
||||
}
|
||||
|
||||
/// Returns the most recent snapshot without sorting all snapshots
|
||||
/// Performance: O(n) instead of O(n log n) for sorting
|
||||
var latestSnapshot: Snapshot? {
|
||||
guard let set = snapshots as? Set<Snapshot>, !set.isEmpty else { return nil }
|
||||
return set.max(by: { $0.date < $1.date })
|
||||
}
|
||||
|
||||
var latestValue: Decimal {
|
||||
latestSnapshot?.value?.decimalValue ?? Decimal.zero
|
||||
}
|
||||
|
||||
var snapshotCount: Int {
|
||||
snapshots?.count ?? 0
|
||||
}
|
||||
|
||||
/// Returns transactions sorted by date descending
|
||||
/// Performance note: This sorts on every call. For repeated access, cache the result.
|
||||
var transactionsArray: [Transaction] {
|
||||
let set = transactions as? Set<Transaction> ?? []
|
||||
return set.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
var frequency: NotificationFrequency {
|
||||
NotificationFrequency(rawValue: notificationFrequency) ?? .monthly
|
||||
}
|
||||
|
||||
var nextReminderDate: Date? {
|
||||
if let account = account {
|
||||
return account.nextReminderDate
|
||||
}
|
||||
|
||||
guard frequency != .never else { return nil }
|
||||
|
||||
let months = frequency == .custom ? Int(customFrequencyMonths) : frequency.months
|
||||
guard let lastSnapshot = latestSnapshot else {
|
||||
return Date() // Remind now if no snapshots
|
||||
}
|
||||
|
||||
return Calendar.current.date(byAdding: .month, value: months, to: lastSnapshot.date)
|
||||
}
|
||||
|
||||
var needsUpdate: Bool {
|
||||
if let account = account {
|
||||
guard let nextDate = account.nextReminderDate else { return false }
|
||||
return Date() >= nextDate
|
||||
}
|
||||
|
||||
guard let nextDate = nextReminderDate else { return false }
|
||||
return Date() >= nextDate
|
||||
}
|
||||
|
||||
// MARK: - Performance Metrics
|
||||
|
||||
/// Calculates total return percentage. Performance: Uses O(n) min/max instead of sorting.
|
||||
var totalReturn: Decimal {
|
||||
guard let set = snapshots as? Set<Snapshot>, set.count >= 2 else {
|
||||
return Decimal.zero
|
||||
}
|
||||
|
||||
// Performance: Find min and max by date in one pass instead of sorting twice
|
||||
guard let first = set.min(by: { $0.date < $1.date }),
|
||||
let last = set.max(by: { $0.date < $1.date }),
|
||||
let firstValue = first.value?.decimalValue,
|
||||
firstValue != Decimal.zero else {
|
||||
return Decimal.zero
|
||||
}
|
||||
|
||||
let lastValue = last.value?.decimalValue ?? Decimal.zero
|
||||
return ((lastValue - firstValue) / firstValue) * 100
|
||||
}
|
||||
|
||||
/// Performance: Iterates snapshots directly without sorting
|
||||
var totalContributions: Decimal {
|
||||
guard let set = snapshots as? Set<Snapshot> else { return Decimal.zero }
|
||||
return set.reduce(Decimal.zero) { result, snapshot in
|
||||
result + (snapshot.contribution?.decimalValue ?? Decimal.zero)
|
||||
}
|
||||
}
|
||||
|
||||
/// Performance: Iterates transactions directly without sorting
|
||||
var totalInvested: Decimal {
|
||||
guard let set = transactions as? Set<Transaction> else { return Decimal.zero }
|
||||
return set.reduce(Decimal.zero) { result, transaction in
|
||||
let amount = transaction.decimalAmount
|
||||
switch transaction.transactionType {
|
||||
case .buy:
|
||||
return result + amount
|
||||
case .sell:
|
||||
return result - amount
|
||||
default:
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Performance: Iterates transactions directly without sorting
|
||||
var totalDividends: Decimal {
|
||||
guard let set = transactions as? Set<Transaction> else { return Decimal.zero }
|
||||
return set.reduce(Decimal.zero) { result, transaction in
|
||||
transaction.transactionType == .dividend ? result + transaction.decimalAmount : result
|
||||
}
|
||||
}
|
||||
|
||||
/// Performance: Iterates transactions directly without sorting
|
||||
var totalFees: Decimal {
|
||||
guard let set = transactions as? Set<Transaction> else { return Decimal.zero }
|
||||
return set.reduce(Decimal.zero) { result, transaction in
|
||||
transaction.transactionType == .fee ? result + transaction.decimalAmount : result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Account Scheduling
|
||||
|
||||
extension Account {
|
||||
var nextReminderDate: Date? {
|
||||
guard frequency != .never else { return nil }
|
||||
|
||||
let months = frequency == .custom ? Int(customFrequencyMonths) : frequency.months
|
||||
guard let latestSnapshotDate = sourcesArray
|
||||
.compactMap({ $0.latestSnapshot?.date })
|
||||
.max() else {
|
||||
return Date()
|
||||
}
|
||||
|
||||
return Calendar.current.date(byAdding: .month, value: months, to: latestSnapshotDate)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Generated accessors for snapshots
|
||||
|
||||
extension InvestmentSource {
|
||||
@objc(addSnapshotsObject:)
|
||||
@NSManaged public func addToSnapshots(_ value: Snapshot)
|
||||
|
||||
@objc(removeSnapshotsObject:)
|
||||
@NSManaged public func removeFromSnapshots(_ value: Snapshot)
|
||||
|
||||
@objc(addSnapshots:)
|
||||
@NSManaged public func addToSnapshots(_ values: NSSet)
|
||||
|
||||
@objc(removeSnapshots:)
|
||||
@NSManaged public func removeFromSnapshots(_ values: NSSet)
|
||||
|
||||
@objc(addTransactionsObject:)
|
||||
@NSManaged public func addToTransactions(_ value: Transaction)
|
||||
|
||||
@objc(removeTransactionsObject:)
|
||||
@NSManaged public func removeFromTransactions(_ value: Transaction)
|
||||
|
||||
@objc(addTransactions:)
|
||||
@NSManaged public func addToTransactions(_ values: NSSet)
|
||||
|
||||
@objc(removeTransactions:)
|
||||
@NSManaged public func removeFromTransactions(_ values: NSSet)
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier="">
|
||||
<entity name="AppSettings" representedClassName="AppSettings" syncable="YES">
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="currency" attributeType="String" defaultValueString="EUR"/>
|
||||
<attribute name="defaultNotificationTime" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="enableAnalytics" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="inputMode" attributeType="String" defaultValueString="simple"/>
|
||||
<attribute name="lastSyncDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="onboardingCompleted" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="selectedAccountId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="showAllAccounts" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
|
||||
</entity>
|
||||
<entity name="Account" representedClassName="Account" syncable="YES">
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="currency" optional="YES" attributeType="String"/>
|
||||
<attribute name="customFrequencyMonths" attributeType="Integer 16" defaultValueString="1" usesScalarValueType="YES"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="inputMode" attributeType="String" defaultValueString="simple"/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="notificationFrequency" attributeType="String" defaultValueString="monthly"/>
|
||||
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="goals" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Goal" inverseName="account" inverseEntity="Goal"/>
|
||||
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="InvestmentSource" inverseName="account" inverseEntity="InvestmentSource"/>
|
||||
</entity>
|
||||
<entity name="Category" representedClassName="Category" syncable="YES">
|
||||
<attribute name="colorHex" attributeType="String" defaultValueString="#3B82F6"/>
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="icon" attributeType="String" defaultValueString="chart.pie.fill"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="InvestmentSource" inverseName="category" inverseEntity="InvestmentSource"/>
|
||||
</entity>
|
||||
<entity name="InvestmentSource" representedClassName="InvestmentSource" syncable="YES">
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="customFrequencyMonths" attributeType="Integer 16" defaultValueString="1" usesScalarValueType="YES"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="isActive" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="notificationFrequency" attributeType="String" defaultValueString="monthly"/>
|
||||
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="sources" inverseEntity="Account"/>
|
||||
<relationship name="asset" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Asset" inverseName="sources" inverseEntity="Asset"/>
|
||||
<relationship name="category" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Category" inverseName="sources" inverseEntity="Category"/>
|
||||
<relationship name="snapshots" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Snapshot" inverseName="source" inverseEntity="Snapshot"/>
|
||||
<relationship name="transactions" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Transaction" inverseName="source" inverseEntity="Transaction"/>
|
||||
</entity>
|
||||
<entity name="Asset" representedClassName="Asset" syncable="YES">
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="currency" optional="YES" attributeType="String"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="symbol" optional="YES" attributeType="String"/>
|
||||
<attribute name="type" attributeType="String" defaultValueString="stock"/>
|
||||
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="asset" inverseEntity="InvestmentSource"/>
|
||||
</entity>
|
||||
<entity name="Transaction" representedClassName="Transaction" syncable="YES">
|
||||
<attribute name="amount" optional="YES" attributeType="Decimal"/>
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="notes" optional="YES" attributeType="String"/>
|
||||
<attribute name="price" optional="YES" attributeType="Decimal"/>
|
||||
<attribute name="shares" optional="YES" attributeType="Decimal"/>
|
||||
<attribute name="type" attributeType="String" defaultValueString="buy"/>
|
||||
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="transactions" inverseEntity="InvestmentSource"/>
|
||||
</entity>
|
||||
<entity name="Goal" representedClassName="Goal" syncable="YES">
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="isActive" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="targetAmount" optional="YES" attributeType="Decimal"/>
|
||||
<attribute name="targetDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="goals" inverseEntity="Account"/>
|
||||
</entity>
|
||||
<entity name="PredictionCache" representedClassName="PredictionCache" syncable="YES">
|
||||
<attribute name="algorithm" attributeType="String" defaultValueString="linear"/>
|
||||
<attribute name="calculatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="predictionData" optional="YES" attributeType="Binary"/>
|
||||
<attribute name="sourceId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="validUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
</entity>
|
||||
<entity name="PremiumStatus" representedClassName="PremiumStatus" syncable="YES">
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="isFamilyShared" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="isPremium" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="lastVerificationDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="originalPurchaseDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="productIdentifier" optional="YES" attributeType="String"/>
|
||||
<attribute name="purchaseDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="transactionId" optional="YES" attributeType="String"/>
|
||||
</entity>
|
||||
<entity name="Snapshot" representedClassName="Snapshot" syncable="YES">
|
||||
<attribute name="contribution" optional="YES" attributeType="Decimal"/>
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="notes" optional="YES" attributeType="String"/>
|
||||
<attribute name="value" optional="YES" attributeType="Decimal"/>
|
||||
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="snapshots" inverseEntity="InvestmentSource"/>
|
||||
</entity>
|
||||
</model>
|
||||
@@ -0,0 +1,151 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(PredictionCache)
|
||||
public class PredictionCache: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<PredictionCache> {
|
||||
return NSFetchRequest<PredictionCache>(entityName: "PredictionCache")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var sourceId: UUID?
|
||||
@NSManaged public var algorithm: String
|
||||
@NSManaged public var predictionData: Data?
|
||||
@NSManaged public var calculatedAt: Date
|
||||
@NSManaged public var validUntil: Date
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
calculatedAt = Date()
|
||||
// Cache valid for 24 hours
|
||||
validUntil = Calendar.current.date(byAdding: .hour, value: 24, to: Date()) ?? Date()
|
||||
algorithm = PredictionAlgorithm.linear.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Prediction Algorithm
|
||||
|
||||
enum PredictionAlgorithm: String, CaseIterable, Identifiable {
|
||||
case linear = "linear"
|
||||
case exponentialSmoothing = "exponential_smoothing"
|
||||
case movingAverage = "moving_average"
|
||||
case holtTrend = "holt_trend"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .linear: return "Linear Regression"
|
||||
case .exponentialSmoothing: return "Exponential Smoothing"
|
||||
case .movingAverage: return "Moving Average"
|
||||
case .holtTrend: return "Holt Trend"
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .linear:
|
||||
return "Projects future values based on historical trend line"
|
||||
case .exponentialSmoothing:
|
||||
return "Gives more weight to recent data points"
|
||||
case .movingAverage:
|
||||
return "Smooths out short-term fluctuations"
|
||||
case .holtTrend:
|
||||
return "Captures level and trend changes over time"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension PredictionCache {
|
||||
var isValid: Bool {
|
||||
Date() < validUntil
|
||||
}
|
||||
|
||||
var algorithmType: PredictionAlgorithm {
|
||||
PredictionAlgorithm(rawValue: algorithm) ?? .linear
|
||||
}
|
||||
|
||||
var predictions: [Prediction]? {
|
||||
guard let data = predictionData else { return nil }
|
||||
return try? JSONDecoder().decode([Prediction].self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Static Helpers
|
||||
|
||||
extension PredictionCache {
|
||||
static func getCache(
|
||||
for sourceId: UUID?,
|
||||
algorithm: PredictionAlgorithm,
|
||||
in context: NSManagedObjectContext
|
||||
) -> PredictionCache? {
|
||||
let request: NSFetchRequest<PredictionCache> = PredictionCache.fetchRequest()
|
||||
|
||||
if let sourceId = sourceId {
|
||||
request.predicate = NSPredicate(
|
||||
format: "sourceId == %@ AND algorithm == %@",
|
||||
sourceId as CVarArg,
|
||||
algorithm.rawValue
|
||||
)
|
||||
} else {
|
||||
request.predicate = NSPredicate(
|
||||
format: "sourceId == nil AND algorithm == %@",
|
||||
algorithm.rawValue
|
||||
)
|
||||
}
|
||||
|
||||
request.fetchLimit = 1
|
||||
|
||||
guard let cache = try? context.fetch(request).first,
|
||||
cache.isValid else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return cache
|
||||
}
|
||||
|
||||
static func saveCache(
|
||||
for sourceId: UUID?,
|
||||
algorithm: PredictionAlgorithm,
|
||||
predictions: [Prediction],
|
||||
in context: NSManagedObjectContext
|
||||
) {
|
||||
// Remove old cache
|
||||
let request: NSFetchRequest<PredictionCache> = PredictionCache.fetchRequest()
|
||||
if let sourceId = sourceId {
|
||||
request.predicate = NSPredicate(
|
||||
format: "sourceId == %@ AND algorithm == %@",
|
||||
sourceId as CVarArg,
|
||||
algorithm.rawValue
|
||||
)
|
||||
} else {
|
||||
request.predicate = NSPredicate(
|
||||
format: "sourceId == nil AND algorithm == %@",
|
||||
algorithm.rawValue
|
||||
)
|
||||
}
|
||||
|
||||
if let oldCache = try? context.fetch(request) {
|
||||
oldCache.forEach { context.delete($0) }
|
||||
}
|
||||
|
||||
// Create new cache
|
||||
let cache = PredictionCache(context: context)
|
||||
cache.sourceId = sourceId
|
||||
cache.algorithm = algorithm.rawValue
|
||||
cache.predictionData = try? JSONEncoder().encode(predictions)
|
||||
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
static func invalidateAll(in context: NSManagedObjectContext) {
|
||||
let request: NSFetchRequest<PredictionCache> = PredictionCache.fetchRequest()
|
||||
if let caches = try? context.fetch(request) {
|
||||
caches.forEach { context.delete($0) }
|
||||
}
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(PremiumStatus)
|
||||
public class PremiumStatus: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<PremiumStatus> {
|
||||
return NSFetchRequest<PremiumStatus>(entityName: "PremiumStatus")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var isPremium: Bool
|
||||
@NSManaged public var purchaseDate: Date?
|
||||
@NSManaged public var productIdentifier: String?
|
||||
@NSManaged public var transactionId: String?
|
||||
@NSManaged public var isFamilyShared: Bool
|
||||
@NSManaged public var lastVerificationDate: Date?
|
||||
@NSManaged public var originalPurchaseDate: Date?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
isPremium = false
|
||||
isFamilyShared = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension PremiumStatus {
|
||||
var daysSincePurchase: Int? {
|
||||
guard let purchaseDate = purchaseDate else { return nil }
|
||||
return Calendar.current.dateComponents([.day], from: purchaseDate, to: Date()).day
|
||||
}
|
||||
|
||||
var needsVerification: Bool {
|
||||
guard let lastVerification = lastVerificationDate else { return true }
|
||||
let hoursSinceVerification = Calendar.current.dateComponents(
|
||||
[.hour],
|
||||
from: lastVerification,
|
||||
to: Date()
|
||||
).hour ?? 0
|
||||
return hoursSinceVerification >= 24
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Static Helpers
|
||||
|
||||
extension PremiumStatus {
|
||||
static func getOrCreate(in context: NSManagedObjectContext) -> PremiumStatus {
|
||||
let request: NSFetchRequest<PremiumStatus> = PremiumStatus.fetchRequest()
|
||||
request.fetchLimit = 1
|
||||
|
||||
if let existing = try? context.fetch(request).first {
|
||||
return existing
|
||||
}
|
||||
|
||||
let new = PremiumStatus(context: context)
|
||||
try? context.save()
|
||||
return new
|
||||
}
|
||||
|
||||
static func updateStatus(
|
||||
isPremium: Bool,
|
||||
productIdentifier: String?,
|
||||
transactionId: String?,
|
||||
isFamilyShared: Bool,
|
||||
in context: NSManagedObjectContext
|
||||
) {
|
||||
let status = getOrCreate(in: context)
|
||||
status.isPremium = isPremium
|
||||
status.productIdentifier = productIdentifier
|
||||
status.transactionId = transactionId
|
||||
status.isFamilyShared = isFamilyShared
|
||||
status.lastVerificationDate = Date()
|
||||
|
||||
if isPremium && status.purchaseDate == nil {
|
||||
status.purchaseDate = Date()
|
||||
}
|
||||
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(Snapshot)
|
||||
public class Snapshot: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Snapshot> {
|
||||
return NSFetchRequest<Snapshot>(entityName: "Snapshot")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var date: Date
|
||||
@NSManaged public var value: NSDecimalNumber?
|
||||
@NSManaged public var contribution: NSDecimalNumber?
|
||||
@NSManaged public var notes: String?
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var source: InvestmentSource?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
date = Date()
|
||||
createdAt = Date()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension Snapshot {
|
||||
var decimalValue: Decimal {
|
||||
value?.decimalValue ?? Decimal.zero
|
||||
}
|
||||
|
||||
var decimalContribution: Decimal {
|
||||
contribution?.decimalValue ?? Decimal.zero
|
||||
}
|
||||
|
||||
var formattedValue: String {
|
||||
CurrencyFormatter.format(value?.decimalValue ?? Decimal.zero, style: .currency, maximumFractionDigits: 2)
|
||||
}
|
||||
|
||||
var formattedDate: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .none
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
var monthYearString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM yyyy"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Comparison with Previous
|
||||
|
||||
extension Snapshot {
|
||||
func change(from previousSnapshot: Snapshot?) -> Decimal {
|
||||
guard let previous = previousSnapshot,
|
||||
let previousValue = previous.value?.decimalValue,
|
||||
previousValue != Decimal.zero else {
|
||||
return Decimal.zero
|
||||
}
|
||||
|
||||
let currentValue = value?.decimalValue ?? Decimal.zero
|
||||
return currentValue - previousValue
|
||||
}
|
||||
|
||||
func percentageChange(from previousSnapshot: Snapshot?) -> Decimal {
|
||||
guard let previous = previousSnapshot,
|
||||
let previousValue = previous.value?.decimalValue,
|
||||
previousValue != Decimal.zero else {
|
||||
return Decimal.zero
|
||||
}
|
||||
|
||||
let currentValue = value?.decimalValue ?? Decimal.zero
|
||||
return ((currentValue - previousValue) / previousValue) * 100
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(Transaction)
|
||||
public class Transaction: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Transaction> {
|
||||
return NSFetchRequest<Transaction>(entityName: "Transaction")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var date: Date
|
||||
@NSManaged public var type: String
|
||||
@NSManaged public var shares: NSDecimalNumber?
|
||||
@NSManaged public var price: NSDecimalNumber?
|
||||
@NSManaged public var amount: NSDecimalNumber?
|
||||
@NSManaged public var notes: String?
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var source: InvestmentSource?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
createdAt = Date()
|
||||
date = Date()
|
||||
type = TransactionType.buy.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
enum TransactionType: String, CaseIterable, Identifiable {
|
||||
case buy
|
||||
case sell
|
||||
case dividend
|
||||
case fee
|
||||
case transfer
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .buy: return "Buy"
|
||||
case .sell: return "Sell"
|
||||
case .dividend: return "Dividend"
|
||||
case .fee: return "Fee"
|
||||
case .transfer: return "Transfer"
|
||||
}
|
||||
}
|
||||
|
||||
var isInvestmentFlow: Bool {
|
||||
self == .buy || self == .sell
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension Transaction {
|
||||
var decimalShares: Decimal {
|
||||
shares?.decimalValue ?? Decimal.zero
|
||||
}
|
||||
|
||||
var decimalPrice: Decimal {
|
||||
price?.decimalValue ?? Decimal.zero
|
||||
}
|
||||
|
||||
var decimalAmount: Decimal {
|
||||
if let amount = amount?.decimalValue, amount != 0 {
|
||||
return amount
|
||||
}
|
||||
return decimalShares * decimalPrice
|
||||
}
|
||||
|
||||
var transactionType: TransactionType {
|
||||
TransactionType(rawValue: type) ?? .buy
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import Foundation
|
||||
|
||||
struct InvestmentMetrics {
|
||||
// Basic Metrics
|
||||
let totalValue: Decimal
|
||||
let totalContributions: Decimal
|
||||
let absoluteReturn: Decimal
|
||||
let percentageReturn: Decimal
|
||||
|
||||
// Advanced Metrics
|
||||
let cagr: Double // Compound Annual Growth Rate
|
||||
let twr: Double // Time-Weighted Return
|
||||
let volatility: Double // Standard deviation annualized
|
||||
let maxDrawdown: Double // Maximum peak-to-trough decline
|
||||
let sharpeRatio: Double // Risk-adjusted return
|
||||
let bestMonth: MonthlyReturn?
|
||||
let worstMonth: MonthlyReturn?
|
||||
let winRate: Double // Percentage of positive months
|
||||
let averageMonthlyReturn: Double
|
||||
|
||||
// Time period
|
||||
let startDate: Date?
|
||||
let endDate: Date?
|
||||
let totalMonths: Int
|
||||
|
||||
struct MonthlyReturn: Identifiable {
|
||||
let id = UUID()
|
||||
let date: Date
|
||||
let returnPercentage: Double
|
||||
|
||||
var formattedDate: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM yyyy"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
var formattedReturn: String {
|
||||
String(format: "%.1f%%", returnPercentage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Formatting Helpers
|
||||
|
||||
extension InvestmentMetrics {
|
||||
var formattedTotalValue: String {
|
||||
formatCurrency(totalValue)
|
||||
}
|
||||
|
||||
var formattedAbsoluteReturn: String {
|
||||
let prefix = absoluteReturn >= 0 ? "+" : ""
|
||||
return prefix + formatCurrency(absoluteReturn)
|
||||
}
|
||||
|
||||
var formattedPercentageReturn: String {
|
||||
let prefix = percentageReturn >= 0 ? "+" : ""
|
||||
return String(format: "\(prefix)%.2f%%", NSDecimalNumber(decimal: percentageReturn).doubleValue)
|
||||
}
|
||||
|
||||
var formattedCAGR: String {
|
||||
String(format: "%.2f%%", cagr)
|
||||
}
|
||||
|
||||
var formattedTWR: String {
|
||||
String(format: "%.2f%%", twr)
|
||||
}
|
||||
|
||||
var formattedVolatility: String {
|
||||
String(format: "%.2f%%", volatility)
|
||||
}
|
||||
|
||||
var formattedMaxDrawdown: String {
|
||||
String(format: "%.2f%%", maxDrawdown)
|
||||
}
|
||||
|
||||
var formattedSharpeRatio: String {
|
||||
String(format: "%.2f", sharpeRatio)
|
||||
}
|
||||
|
||||
var formattedWinRate: String {
|
||||
String(format: "%.0f%%", winRate)
|
||||
}
|
||||
|
||||
var formattedAverageMonthlyReturn: String {
|
||||
let prefix = averageMonthlyReturn >= 0 ? "+" : ""
|
||||
return String(format: "\(prefix)%.2f%%", averageMonthlyReturn)
|
||||
}
|
||||
|
||||
private func formatCurrency(_ value: Decimal) -> String {
|
||||
CurrencyFormatter.format(value, style: .currency, maximumFractionDigits: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty State
|
||||
|
||||
extension InvestmentMetrics {
|
||||
static var empty: InvestmentMetrics {
|
||||
InvestmentMetrics(
|
||||
totalValue: 0,
|
||||
totalContributions: 0,
|
||||
absoluteReturn: 0,
|
||||
percentageReturn: 0,
|
||||
cagr: 0,
|
||||
twr: 0,
|
||||
volatility: 0,
|
||||
maxDrawdown: 0,
|
||||
sharpeRatio: 0,
|
||||
bestMonth: nil,
|
||||
worstMonth: nil,
|
||||
winRate: 0,
|
||||
averageMonthlyReturn: 0,
|
||||
startDate: nil,
|
||||
endDate: nil,
|
||||
totalMonths: 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Metrics
|
||||
|
||||
struct CategoryMetrics: Identifiable {
|
||||
let id: UUID
|
||||
let categoryName: String
|
||||
let colorHex: String
|
||||
let icon: String
|
||||
let totalValue: Decimal
|
||||
let percentageOfPortfolio: Double
|
||||
let metrics: InvestmentMetrics
|
||||
|
||||
var formattedTotalValue: String {
|
||||
CurrencyFormatter.format(totalValue, style: .currency, maximumFractionDigits: 0)
|
||||
}
|
||||
|
||||
var formattedPercentage: String {
|
||||
String(format: "%.1f%%", percentageOfPortfolio)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Portfolio Summary
|
||||
|
||||
struct PortfolioSummary {
|
||||
let totalValue: Decimal
|
||||
let totalContributions: Decimal
|
||||
let dayChange: Decimal
|
||||
let dayChangePercentage: Double
|
||||
let weekChange: Decimal
|
||||
let weekChangePercentage: Double
|
||||
let monthChange: Decimal
|
||||
let monthChangePercentage: Double
|
||||
let yearChange: Decimal
|
||||
let yearChangePercentage: Double
|
||||
let allTimeReturn: Decimal
|
||||
let allTimeReturnPercentage: Double
|
||||
let sourceCount: Int
|
||||
let lastUpdated: Date?
|
||||
|
||||
var formattedTotalValue: String {
|
||||
CurrencyFormatter.format(totalValue, style: .currency, maximumFractionDigits: 0)
|
||||
}
|
||||
|
||||
var formattedDayChange: String {
|
||||
formatChange(dayChange, dayChangePercentage)
|
||||
}
|
||||
|
||||
var formattedMonthChange: String {
|
||||
formatChange(monthChange, monthChangePercentage)
|
||||
}
|
||||
|
||||
var formattedYearChange: String {
|
||||
formatChange(yearChange, yearChangePercentage)
|
||||
}
|
||||
|
||||
var formattedAllTimeReturn: String {
|
||||
formatChange(allTimeReturn, allTimeReturnPercentage)
|
||||
}
|
||||
|
||||
private func formatChange(_ absolute: Decimal, _ percentage: Double) -> String {
|
||||
let prefix = absolute >= 0 ? "+" : ""
|
||||
let absString = CurrencyFormatter.format(absolute, style: .currency, maximumFractionDigits: 0)
|
||||
let pctString = String(format: "%.2f%%", percentage)
|
||||
|
||||
return "\(prefix)\(absString) (\(prefix)\(pctString))"
|
||||
}
|
||||
|
||||
static var empty: PortfolioSummary {
|
||||
PortfolioSummary(
|
||||
totalValue: 0,
|
||||
totalContributions: 0,
|
||||
dayChange: 0,
|
||||
dayChangePercentage: 0,
|
||||
weekChange: 0,
|
||||
weekChangePercentage: 0,
|
||||
monthChange: 0,
|
||||
monthChangePercentage: 0,
|
||||
yearChange: 0,
|
||||
yearChangePercentage: 0,
|
||||
allTimeReturn: 0,
|
||||
allTimeReturnPercentage: 0,
|
||||
sourceCount: 0,
|
||||
lastUpdated: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import Foundation
|
||||
|
||||
struct MonthlyCheckInEntry: Codable, Equatable {
|
||||
var note: String?
|
||||
var rating: Int?
|
||||
var mood: MonthlyCheckInMood?
|
||||
var completionTime: Double?
|
||||
var createdAt: Double
|
||||
|
||||
var completionDate: Date? {
|
||||
completionTime.map { Date(timeIntervalSince1970: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
enum MonthlyCheckInMood: String, Codable, CaseIterable, Identifiable {
|
||||
case energized
|
||||
case confident
|
||||
case balanced
|
||||
case cautious
|
||||
case stressed
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .energized: return String(localized: "mood_energized_title")
|
||||
case .confident: return String(localized: "mood_confident_title")
|
||||
case .balanced: return String(localized: "mood_balanced_title")
|
||||
case .cautious: return String(localized: "mood_cautious_title")
|
||||
case .stressed: return String(localized: "mood_stressed_title")
|
||||
}
|
||||
}
|
||||
|
||||
var iconName: String {
|
||||
switch self {
|
||||
case .energized: return "flame.fill"
|
||||
case .confident: return "hand.thumbsup.fill"
|
||||
case .balanced: return "leaf.fill"
|
||||
case .cautious: return "exclamationmark.triangle.fill"
|
||||
case .stressed: return "exclamationmark.octagon.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var detail: String {
|
||||
switch self {
|
||||
case .energized: return String(localized: "mood_energized_detail")
|
||||
case .confident: return String(localized: "mood_confident_detail")
|
||||
case .balanced: return String(localized: "mood_balanced_detail")
|
||||
case .cautious: return String(localized: "mood_cautious_detail")
|
||||
case .stressed: return String(localized: "mood_stressed_detail")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MonthlyCheckInAchievement: Identifiable, Equatable {
|
||||
let key: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let icon: String
|
||||
|
||||
var id: String { key }
|
||||
}
|
||||
|
||||
struct MonthlyCheckInAchievementStatus: Identifiable, Equatable {
|
||||
let achievement: MonthlyCheckInAchievement
|
||||
let isUnlocked: Bool
|
||||
|
||||
var id: String { achievement.key }
|
||||
}
|
||||
|
||||
struct MonthlyCheckInStats: Equatable {
|
||||
let currentStreak: Int
|
||||
let bestStreak: Int
|
||||
let onTimeCount: Int
|
||||
let totalCheckIns: Int
|
||||
let averageDaysBeforeDeadline: Double?
|
||||
let closestCutoffDays: Double?
|
||||
let recentMood: MonthlyCheckInMood?
|
||||
let achievements: [MonthlyCheckInAchievement]
|
||||
|
||||
static var empty: MonthlyCheckInStats {
|
||||
MonthlyCheckInStats(
|
||||
currentStreak: 0,
|
||||
bestStreak: 0,
|
||||
onTimeCount: 0,
|
||||
totalCheckIns: 0,
|
||||
averageDaysBeforeDeadline: nil,
|
||||
closestCutoffDays: nil,
|
||||
recentMood: nil,
|
||||
achievements: []
|
||||
)
|
||||
}
|
||||
|
||||
var onTimeRate: Double {
|
||||
guard totalCheckIns > 0 else { return 0 }
|
||||
return Double(onTimeCount) / Double(totalCheckIns)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
|
||||
struct MonthlySummary {
|
||||
let periodLabel: String
|
||||
let startDate: Date
|
||||
let endDate: Date
|
||||
let startingValue: Decimal
|
||||
let endingValue: Decimal
|
||||
let contributions: Decimal
|
||||
let netPerformance: Decimal
|
||||
|
||||
var netPerformancePercentage: Double {
|
||||
let base = startingValue + contributions
|
||||
guard base > 0 else { return 0 }
|
||||
return NSDecimalNumber(decimal: netPerformance / base).doubleValue * 100
|
||||
}
|
||||
|
||||
var formattedStartingValue: String {
|
||||
CurrencyFormatter.format(startingValue, style: .currency, maximumFractionDigits: 0)
|
||||
}
|
||||
|
||||
var formattedEndingValue: String {
|
||||
CurrencyFormatter.format(endingValue, style: .currency, maximumFractionDigits: 0)
|
||||
}
|
||||
|
||||
var formattedContributions: String {
|
||||
CurrencyFormatter.format(contributions, style: .currency, maximumFractionDigits: 0)
|
||||
}
|
||||
|
||||
var formattedNetPerformance: String {
|
||||
let prefix = netPerformance >= 0 ? "+" : ""
|
||||
let value = CurrencyFormatter.format(netPerformance, style: .currency, maximumFractionDigits: 0)
|
||||
return prefix + value
|
||||
}
|
||||
|
||||
var formattedNetPerformancePercentage: String {
|
||||
let prefix = netPerformance >= 0 ? "+" : ""
|
||||
return String(format: "\(prefix)%.2f%%", netPerformancePercentage)
|
||||
}
|
||||
|
||||
static var empty: MonthlySummary {
|
||||
MonthlySummary(
|
||||
periodLabel: "This Month",
|
||||
startDate: Date(),
|
||||
endDate: Date(),
|
||||
startingValue: 0,
|
||||
endingValue: 0,
|
||||
contributions: 0,
|
||||
netPerformance: 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct PortfolioChange {
|
||||
let absolute: Decimal
|
||||
let percentage: Double
|
||||
let label: String
|
||||
|
||||
var formattedAbsolute: String {
|
||||
let prefix = absolute >= 0 ? "+" : ""
|
||||
let value = CurrencyFormatter.format(absolute, style: .currency, maximumFractionDigits: 0)
|
||||
return prefix + value
|
||||
}
|
||||
|
||||
var formattedPercentage: String {
|
||||
let prefix = absolute >= 0 ? "+" : ""
|
||||
return String(format: "\(prefix)%.2f%%", percentage)
|
||||
}
|
||||
|
||||
static var empty: PortfolioChange {
|
||||
PortfolioChange(absolute: 0, percentage: 0, label: "since last update")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import Foundation
|
||||
|
||||
struct Prediction: Codable, Identifiable {
|
||||
let id: UUID
|
||||
let date: Date
|
||||
let predictedValue: Decimal
|
||||
let algorithm: PredictionAlgorithm
|
||||
let confidenceInterval: ConfidenceInterval
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
date: Date,
|
||||
predictedValue: Decimal,
|
||||
algorithm: PredictionAlgorithm,
|
||||
confidenceInterval: ConfidenceInterval
|
||||
) {
|
||||
self.id = id
|
||||
self.date = date
|
||||
self.predictedValue = predictedValue
|
||||
self.algorithm = algorithm
|
||||
self.confidenceInterval = confidenceInterval
|
||||
}
|
||||
|
||||
struct ConfidenceInterval: Codable {
|
||||
let lower: Decimal
|
||||
let upper: Decimal
|
||||
|
||||
var range: Decimal {
|
||||
upper - lower
|
||||
}
|
||||
|
||||
var percentageWidth: Decimal {
|
||||
guard upper != 0 else { return 0 }
|
||||
return (range / upper) * 100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Prediction Algorithm Codable
|
||||
|
||||
extension PredictionAlgorithm: Codable {}
|
||||
|
||||
// MARK: - Prediction Helpers
|
||||
|
||||
extension Prediction {
|
||||
var formattedValue: String {
|
||||
CurrencyFormatter.format(predictedValue, style: .currency, maximumFractionDigits: 0)
|
||||
}
|
||||
|
||||
var formattedDate: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM yyyy"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
var formattedConfidenceRange: String {
|
||||
let lower = CurrencyFormatter.format(
|
||||
confidenceInterval.lower,
|
||||
style: .currency,
|
||||
maximumFractionDigits: 0
|
||||
)
|
||||
let upper = CurrencyFormatter.format(
|
||||
confidenceInterval.upper,
|
||||
style: .currency,
|
||||
maximumFractionDigits: 0
|
||||
)
|
||||
|
||||
return "\(lower) - \(upper)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Prediction Result
|
||||
|
||||
struct PredictionResult {
|
||||
let predictions: [Prediction]
|
||||
let algorithm: PredictionAlgorithm
|
||||
let accuracy: Double // R-squared or similar metric
|
||||
let volatility: Double
|
||||
|
||||
var isHighConfidence: Bool {
|
||||
accuracy >= 0.7
|
||||
}
|
||||
|
||||
var confidenceLevel: ConfidenceLevel {
|
||||
switch accuracy {
|
||||
case 0.8...: return .high
|
||||
case 0.5..<0.8: return .medium
|
||||
default: return .low
|
||||
}
|
||||
}
|
||||
|
||||
enum ConfidenceLevel: String {
|
||||
case high = "High"
|
||||
case medium = "Medium"
|
||||
case low = "Low"
|
||||
|
||||
var color: String {
|
||||
switch self {
|
||||
case .high: return "#10B981"
|
||||
case .medium: return "#F59E0B"
|
||||
case .low: return "#EF4444"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.com.alexandrevazquez.portfoliojournal</string>
|
||||
</array>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudKit</string>
|
||||
</array>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)com.alexandrevazquez.portfoliojournal</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.alexandrevazquez.portfoliojournal</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.com.alexandrevazquez.portfoliojournal</string>
|
||||
</array>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudKit</string>
|
||||
</array>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)com.alexandrevazquez.portfoliojournal</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.alexandrevazquez.portfoliojournal</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,153 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
import Combine
|
||||
|
||||
class AccountRepository: ObservableObject {
|
||||
private let context: NSManagedObjectContext
|
||||
|
||||
@Published private(set) var accounts: [Account] = []
|
||||
|
||||
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
||||
self.context = context
|
||||
fetchAccounts()
|
||||
setupNotificationObserver()
|
||||
}
|
||||
|
||||
private func setupNotificationObserver() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(contextDidChange),
|
||||
name: .NSManagedObjectContextObjectsDidChange,
|
||||
object: context
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func contextDidChange(_ notification: Notification) {
|
||||
fetchAccounts()
|
||||
}
|
||||
|
||||
// MARK: - Fetch
|
||||
|
||||
func fetchAccounts() {
|
||||
context.perform { [weak self] in
|
||||
guard let self else { return }
|
||||
let request: NSFetchRequest<Account> = Account.fetchRequest()
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \Account.sortOrder, ascending: true),
|
||||
NSSortDescriptor(keyPath: \Account.createdAt, ascending: true)
|
||||
]
|
||||
|
||||
do {
|
||||
let fetched = try self.context.fetch(request)
|
||||
DispatchQueue.main.async {
|
||||
self.accounts = fetched
|
||||
}
|
||||
} catch {
|
||||
print("Failed to fetch accounts: \(error)")
|
||||
DispatchQueue.main.async {
|
||||
self.accounts = []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchAccount(by id: UUID) -> Account? {
|
||||
let request: NSFetchRequest<Account> = Account.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
|
||||
request.fetchLimit = 1
|
||||
return try? context.fetch(request).first
|
||||
}
|
||||
|
||||
// MARK: - Create
|
||||
|
||||
@discardableResult
|
||||
func createAccount(
|
||||
name: String,
|
||||
currency: String?,
|
||||
inputMode: InputMode,
|
||||
notificationFrequency: NotificationFrequency,
|
||||
customFrequencyMonths: Int = 1
|
||||
) -> Account {
|
||||
let account = Account(context: context)
|
||||
account.name = name
|
||||
account.currency = currency
|
||||
account.inputMode = inputMode.rawValue
|
||||
account.notificationFrequency = notificationFrequency.rawValue
|
||||
account.customFrequencyMonths = Int16(customFrequencyMonths)
|
||||
account.sortOrder = Int16(accounts.count)
|
||||
save()
|
||||
return account
|
||||
}
|
||||
|
||||
func createDefaultAccountIfNeeded() -> Account {
|
||||
if let existing = accounts.first {
|
||||
return existing
|
||||
}
|
||||
|
||||
let defaultCurrency = AppSettings.getOrCreate(in: context).currency
|
||||
let account = createAccount(
|
||||
name: "Personal",
|
||||
currency: defaultCurrency,
|
||||
inputMode: .simple,
|
||||
notificationFrequency: .monthly
|
||||
)
|
||||
|
||||
// Attach existing sources to the default account
|
||||
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||
if let sources = try? context.fetch(request) {
|
||||
for source in sources where source.account == nil {
|
||||
source.account = account
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
return account
|
||||
}
|
||||
|
||||
// MARK: - Update
|
||||
|
||||
func updateAccount(
|
||||
_ account: Account,
|
||||
name: String? = nil,
|
||||
currency: String? = nil,
|
||||
inputMode: InputMode? = nil,
|
||||
notificationFrequency: NotificationFrequency? = nil,
|
||||
customFrequencyMonths: Int? = nil
|
||||
) {
|
||||
if let name = name {
|
||||
account.name = name
|
||||
}
|
||||
if let currency = currency {
|
||||
account.currency = currency
|
||||
}
|
||||
if let inputMode = inputMode {
|
||||
account.inputMode = inputMode.rawValue
|
||||
}
|
||||
if let notificationFrequency = notificationFrequency {
|
||||
account.notificationFrequency = notificationFrequency.rawValue
|
||||
}
|
||||
if let customMonths = customFrequencyMonths {
|
||||
account.customFrequencyMonths = Int16(customMonths)
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Delete
|
||||
|
||||
func deleteAccount(_ account: Account) {
|
||||
context.delete(account)
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Save
|
||||
|
||||
private func save() {
|
||||
guard context.hasChanges else { return }
|
||||
do {
|
||||
try context.save()
|
||||
fetchAccounts()
|
||||
} catch {
|
||||
print("Failed to save accounts: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
import Combine
|
||||
|
||||
class CategoryRepository: ObservableObject {
|
||||
private let context: NSManagedObjectContext
|
||||
|
||||
@Published private(set) var categories: [Category] = []
|
||||
|
||||
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
||||
self.context = context
|
||||
fetchCategories()
|
||||
setupNotificationObserver()
|
||||
}
|
||||
|
||||
private func setupNotificationObserver() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(contextDidChange),
|
||||
name: .NSManagedObjectContextObjectsDidChange,
|
||||
object: context
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func contextDidChange(_ notification: Notification) {
|
||||
guard isRelevantChange(notification) else { return }
|
||||
fetchCategories()
|
||||
}
|
||||
|
||||
// MARK: - Fetch
|
||||
|
||||
func fetchCategories() {
|
||||
context.perform { [weak self] in
|
||||
guard let self else { return }
|
||||
let request: NSFetchRequest<Category> = Category.fetchRequest()
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \Category.sortOrder, ascending: true),
|
||||
NSSortDescriptor(keyPath: \Category.name, ascending: true)
|
||||
]
|
||||
|
||||
do {
|
||||
self.categories = try self.context.fetch(request)
|
||||
} catch {
|
||||
print("Failed to fetch categories: \(error)")
|
||||
self.categories = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchCategory(by id: UUID) -> Category? {
|
||||
let request: NSFetchRequest<Category> = Category.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
|
||||
request.fetchLimit = 1
|
||||
return try? context.fetch(request).first
|
||||
}
|
||||
|
||||
// MARK: - Create
|
||||
|
||||
@discardableResult
|
||||
func createCategory(
|
||||
name: String,
|
||||
colorHex: String,
|
||||
icon: String
|
||||
) -> Category {
|
||||
let category = Category(context: context)
|
||||
category.name = name
|
||||
category.colorHex = colorHex
|
||||
category.icon = icon
|
||||
category.sortOrder = Int16(categories.count)
|
||||
|
||||
save()
|
||||
return category
|
||||
}
|
||||
|
||||
func createDefaultCategoriesIfNeeded() {
|
||||
guard categories.isEmpty else { return }
|
||||
Category.createDefaultCategories(in: context)
|
||||
fetchCategories()
|
||||
}
|
||||
|
||||
// MARK: - Update
|
||||
|
||||
func updateCategory(
|
||||
_ category: Category,
|
||||
name: String? = nil,
|
||||
colorHex: String? = nil,
|
||||
icon: String? = nil
|
||||
) {
|
||||
if let name = name {
|
||||
category.name = name
|
||||
}
|
||||
if let colorHex = colorHex {
|
||||
category.colorHex = colorHex
|
||||
}
|
||||
if let icon = icon {
|
||||
category.icon = icon
|
||||
}
|
||||
|
||||
save()
|
||||
}
|
||||
|
||||
func updateSortOrder(_ categories: [Category]) {
|
||||
for (index, category) in categories.enumerated() {
|
||||
category.sortOrder = Int16(index)
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Delete
|
||||
|
||||
func deleteCategory(_ category: Category) {
|
||||
context.delete(category)
|
||||
save()
|
||||
}
|
||||
|
||||
func deleteCategory(at offsets: IndexSet) {
|
||||
for index in offsets {
|
||||
guard index < categories.count else { continue }
|
||||
context.delete(categories[index])
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Statistics
|
||||
|
||||
var totalValue: Decimal {
|
||||
categories.reduce(Decimal.zero) { $0 + $1.totalValue }
|
||||
}
|
||||
|
||||
func categoryAllocation() -> [(category: Category, percentage: Double)] {
|
||||
let total = totalValue
|
||||
guard total > 0 else { return [] }
|
||||
|
||||
return categories.map { category in
|
||||
let percentage = NSDecimalNumber(decimal: category.totalValue / total).doubleValue * 100
|
||||
return (category: category, percentage: percentage)
|
||||
}.sorted { $0.percentage > $1.percentage }
|
||||
}
|
||||
|
||||
// MARK: - Save
|
||||
|
||||
private func save() {
|
||||
guard context.hasChanges else { return }
|
||||
do {
|
||||
try context.save()
|
||||
fetchCategories()
|
||||
CoreDataStack.shared.refreshWidgetData()
|
||||
} catch {
|
||||
print("Failed to save context: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func isRelevantChange(_ notification: Notification) -> Bool {
|
||||
guard let info = notification.userInfo else { return false }
|
||||
let keys: [String] = [
|
||||
NSInsertedObjectsKey,
|
||||
NSUpdatedObjectsKey,
|
||||
NSDeletedObjectsKey,
|
||||
NSRefreshedObjectsKey
|
||||
]
|
||||
|
||||
for key in keys {
|
||||
if let objects = info[key] as? Set<NSManagedObject> {
|
||||
if objects.contains(where: { $0 is Category }) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
import Combine
|
||||
|
||||
class GoalRepository: ObservableObject {
|
||||
private let context: NSManagedObjectContext
|
||||
|
||||
@Published private(set) var goals: [Goal] = []
|
||||
|
||||
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
||||
self.context = context
|
||||
fetchGoals()
|
||||
setupNotificationObserver()
|
||||
}
|
||||
|
||||
private func setupNotificationObserver() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(contextDidChange),
|
||||
name: .NSManagedObjectContextObjectsDidChange,
|
||||
object: context
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func contextDidChange(_ notification: Notification) {
|
||||
fetchGoals()
|
||||
}
|
||||
|
||||
// MARK: - Fetch
|
||||
|
||||
func fetchGoals(for account: Account? = nil) {
|
||||
context.perform { [weak self] in
|
||||
guard let self else { return }
|
||||
let request: NSFetchRequest<Goal> = Goal.fetchRequest()
|
||||
if let account = account {
|
||||
request.predicate = NSPredicate(format: "account == %@", account)
|
||||
}
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \Goal.createdAt, ascending: true)
|
||||
]
|
||||
|
||||
do {
|
||||
self.goals = try self.context.fetch(request)
|
||||
} catch {
|
||||
print("Failed to fetch goals: \(error)")
|
||||
self.goals = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Create
|
||||
|
||||
@discardableResult
|
||||
func createGoal(
|
||||
name: String,
|
||||
targetAmount: Decimal,
|
||||
targetDate: Date? = nil,
|
||||
account: Account?
|
||||
) -> Goal {
|
||||
let goal = Goal(context: context)
|
||||
goal.name = name
|
||||
goal.targetAmount = NSDecimalNumber(decimal: targetAmount)
|
||||
goal.targetDate = targetDate
|
||||
goal.account = account
|
||||
save()
|
||||
return goal
|
||||
}
|
||||
|
||||
// MARK: - Update
|
||||
|
||||
func updateGoal(
|
||||
_ goal: Goal,
|
||||
name: String? = nil,
|
||||
targetAmount: Decimal? = nil,
|
||||
targetDate: Date? = nil,
|
||||
clearTargetDate: Bool = false,
|
||||
isActive: Bool? = nil
|
||||
) {
|
||||
if let name = name {
|
||||
goal.name = name
|
||||
}
|
||||
if let targetAmount = targetAmount {
|
||||
goal.targetAmount = NSDecimalNumber(decimal: targetAmount)
|
||||
}
|
||||
if clearTargetDate {
|
||||
goal.targetDate = nil
|
||||
} else if let targetDate = targetDate {
|
||||
goal.targetDate = targetDate
|
||||
}
|
||||
if let isActive = isActive {
|
||||
goal.isActive = isActive
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
func deleteGoal(_ goal: Goal) {
|
||||
context.delete(goal)
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Save
|
||||
|
||||
private func save() {
|
||||
guard context.hasChanges else { return }
|
||||
do {
|
||||
try context.save()
|
||||
fetchGoals()
|
||||
} catch {
|
||||
print("Failed to save goals: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
import Combine
|
||||
|
||||
class InvestmentSourceRepository: ObservableObject {
|
||||
private let context: NSManagedObjectContext
|
||||
|
||||
@Published private(set) var sources: [InvestmentSource] = []
|
||||
|
||||
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
||||
self.context = context
|
||||
fetchSources()
|
||||
setupNotificationObserver()
|
||||
}
|
||||
|
||||
private func setupNotificationObserver() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(contextDidChange),
|
||||
name: .NSManagedObjectContextObjectsDidChange,
|
||||
object: context
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func contextDidChange(_ notification: Notification) {
|
||||
guard isRelevantChange(notification) else { return }
|
||||
fetchSources()
|
||||
}
|
||||
|
||||
// MARK: - Fetch
|
||||
|
||||
func fetchSources(account: Account? = nil) {
|
||||
context.perform { [weak self] in
|
||||
guard let self else { return }
|
||||
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||
if let account = account {
|
||||
request.predicate = NSPredicate(format: "account == %@", account)
|
||||
}
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)
|
||||
]
|
||||
|
||||
do {
|
||||
self.sources = try self.context.fetch(request)
|
||||
} catch {
|
||||
print("Failed to fetch sources: \(error)")
|
||||
self.sources = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchSource(by id: UUID) -> InvestmentSource? {
|
||||
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
|
||||
request.fetchLimit = 1
|
||||
return try? context.fetch(request).first
|
||||
}
|
||||
|
||||
func fetchSources(for category: Category) -> [InvestmentSource] {
|
||||
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "category == %@", category)
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)
|
||||
]
|
||||
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
func fetchActiveSources() -> [InvestmentSource] {
|
||||
sources.filter { $0.isActive }
|
||||
}
|
||||
|
||||
func fetchSourcesNeedingUpdate(for account: Account? = nil) -> [InvestmentSource] {
|
||||
let filtered = account == nil ? sources : sources.filter { $0.account?.id == account?.id }
|
||||
return filtered.filter { $0.needsUpdate }
|
||||
}
|
||||
|
||||
// MARK: - Create
|
||||
|
||||
@discardableResult
|
||||
func createSource(
|
||||
name: String,
|
||||
category: Category,
|
||||
notificationFrequency: NotificationFrequency = .monthly,
|
||||
customFrequencyMonths: Int = 1,
|
||||
account: Account? = nil
|
||||
) -> InvestmentSource {
|
||||
let source = InvestmentSource(context: context)
|
||||
source.name = name
|
||||
source.category = category
|
||||
source.notificationFrequency = notificationFrequency.rawValue
|
||||
source.customFrequencyMonths = Int16(customFrequencyMonths)
|
||||
source.account = account
|
||||
|
||||
save()
|
||||
return source
|
||||
}
|
||||
|
||||
// MARK: - Update
|
||||
|
||||
func updateSource(
|
||||
_ source: InvestmentSource,
|
||||
name: String? = nil,
|
||||
category: Category? = nil,
|
||||
notificationFrequency: NotificationFrequency? = nil,
|
||||
customFrequencyMonths: Int? = nil,
|
||||
isActive: Bool? = nil,
|
||||
account: Account? = nil
|
||||
) {
|
||||
if let name = name {
|
||||
source.name = name
|
||||
}
|
||||
if let category = category {
|
||||
source.category = category
|
||||
}
|
||||
if let frequency = notificationFrequency {
|
||||
source.notificationFrequency = frequency.rawValue
|
||||
}
|
||||
if let customMonths = customFrequencyMonths {
|
||||
source.customFrequencyMonths = Int16(customMonths)
|
||||
}
|
||||
if let isActive = isActive {
|
||||
source.isActive = isActive
|
||||
}
|
||||
if let account = account {
|
||||
source.account = account
|
||||
}
|
||||
|
||||
save()
|
||||
}
|
||||
|
||||
func toggleActive(_ source: InvestmentSource) {
|
||||
source.isActive.toggle()
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Delete
|
||||
|
||||
func deleteSource(_ source: InvestmentSource) {
|
||||
context.delete(source)
|
||||
save()
|
||||
}
|
||||
|
||||
func deleteSource(at offsets: IndexSet) {
|
||||
for index in offsets {
|
||||
guard index < sources.count else { continue }
|
||||
context.delete(sources[index])
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Statistics
|
||||
|
||||
var sourceCount: Int {
|
||||
sources.count
|
||||
}
|
||||
|
||||
var activeSourceCount: Int {
|
||||
sources.filter { $0.isActive }.count
|
||||
}
|
||||
|
||||
var totalValue: Decimal {
|
||||
sources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
}
|
||||
|
||||
func topSources(limit: Int = 5) -> [InvestmentSource] {
|
||||
sources
|
||||
.sorted { $0.latestValue > $1.latestValue }
|
||||
.prefix(limit)
|
||||
.map { $0 }
|
||||
}
|
||||
|
||||
// MARK: - Freemium Check
|
||||
|
||||
var canAddMoreSources: Bool {
|
||||
// This will be checked against FreemiumValidator
|
||||
true
|
||||
}
|
||||
|
||||
// MARK: - Save
|
||||
|
||||
private func save() {
|
||||
guard context.hasChanges else { return }
|
||||
do {
|
||||
try context.save()
|
||||
fetchSources()
|
||||
CoreDataStack.shared.refreshWidgetData()
|
||||
} catch {
|
||||
print("Failed to save context: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func isRelevantChange(_ notification: Notification) -> Bool {
|
||||
guard let info = notification.userInfo else { return false }
|
||||
let keys: [String] = [
|
||||
NSInsertedObjectsKey,
|
||||
NSUpdatedObjectsKey,
|
||||
NSDeletedObjectsKey,
|
||||
NSRefreshedObjectsKey
|
||||
]
|
||||
|
||||
for key in keys {
|
||||
if let objects = info[key] as? Set<NSManagedObject> {
|
||||
if objects.contains(where: { $0 is InvestmentSource }) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
import Combine
|
||||
|
||||
class SnapshotRepository: ObservableObject {
|
||||
private let context: NSManagedObjectContext
|
||||
private let cache = NSCache<NSString, NSArray>()
|
||||
@Published private(set) var cacheVersion: Int = 0
|
||||
|
||||
@Published private(set) var snapshots: [Snapshot] = []
|
||||
|
||||
// MARK: - Performance: Shared DateFormatter
|
||||
private static let monthYearFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
// MARK: - Performance: Cached Calendar
|
||||
private static let calendar = Calendar.current
|
||||
|
||||
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
||||
self.context = context
|
||||
// Performance: Increase cache limits for better hit rate
|
||||
cache.countLimit = 12
|
||||
cache.totalCostLimit = 4_000_000 // 4 MB
|
||||
setupNotificationObserver()
|
||||
}
|
||||
|
||||
// MARK: - Fetch
|
||||
|
||||
func fetchSnapshots(for source: InvestmentSource) -> [Snapshot] {
|
||||
let request: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "source == %@", source)
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \Snapshot.date, ascending: false)
|
||||
]
|
||||
request.fetchBatchSize = 200
|
||||
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
func fetchSnapshot(by id: UUID) -> Snapshot? {
|
||||
let request: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
|
||||
request.fetchLimit = 1
|
||||
return try? context.fetch(request).first
|
||||
}
|
||||
|
||||
func fetchAllSnapshots() -> [Snapshot] {
|
||||
let request: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \Snapshot.date, ascending: false)
|
||||
]
|
||||
request.fetchBatchSize = 200
|
||||
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
func fetchSnapshots(for account: Account) -> [Snapshot] {
|
||||
let request: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "source.account == %@", account)
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \Snapshot.date, ascending: false)
|
||||
]
|
||||
request.fetchBatchSize = 200
|
||||
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
func fetchSnapshots(from startDate: Date, to endDate: Date) -> [Snapshot] {
|
||||
let request: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
|
||||
request.predicate = NSPredicate(
|
||||
format: "date >= %@ AND date <= %@",
|
||||
startDate as NSDate,
|
||||
endDate as NSDate
|
||||
)
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \Snapshot.date, ascending: true)
|
||||
]
|
||||
request.fetchBatchSize = 200
|
||||
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
func fetchLatestSnapshots() -> [Snapshot] {
|
||||
// Get the latest snapshot for each source
|
||||
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||
let sources = (try? context.fetch(request)) ?? []
|
||||
|
||||
return sources.compactMap { $0.latestSnapshot }
|
||||
}
|
||||
|
||||
// MARK: - Create
|
||||
|
||||
@discardableResult
|
||||
func createSnapshot(
|
||||
for source: InvestmentSource,
|
||||
date: Date = Date(),
|
||||
value: Decimal,
|
||||
contribution: Decimal? = nil,
|
||||
notes: String? = nil
|
||||
) -> Snapshot {
|
||||
let snapshot = Snapshot(context: context)
|
||||
snapshot.source = source
|
||||
snapshot.date = date
|
||||
snapshot.value = NSDecimalNumber(decimal: value)
|
||||
if let contribution = contribution {
|
||||
snapshot.contribution = NSDecimalNumber(decimal: contribution)
|
||||
}
|
||||
snapshot.notes = notes
|
||||
|
||||
save()
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// MARK: - Update
|
||||
|
||||
func updateSnapshot(
|
||||
_ snapshot: Snapshot,
|
||||
date: Date? = nil,
|
||||
value: Decimal? = nil,
|
||||
contribution: Decimal? = nil,
|
||||
notes: String? = nil,
|
||||
clearContribution: Bool = false,
|
||||
clearNotes: Bool = false
|
||||
) {
|
||||
if let date = date {
|
||||
snapshot.date = date
|
||||
}
|
||||
if let value = value {
|
||||
snapshot.value = NSDecimalNumber(decimal: value)
|
||||
}
|
||||
if clearContribution {
|
||||
snapshot.contribution = nil
|
||||
} else if let contribution = contribution {
|
||||
snapshot.contribution = NSDecimalNumber(decimal: contribution)
|
||||
}
|
||||
if clearNotes {
|
||||
snapshot.notes = nil
|
||||
} else if let notes = notes {
|
||||
snapshot.notes = notes
|
||||
}
|
||||
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Delete
|
||||
|
||||
func deleteSnapshot(_ snapshot: Snapshot) {
|
||||
context.delete(snapshot)
|
||||
save()
|
||||
}
|
||||
|
||||
func deleteSnapshots(_ snapshots: [Snapshot]) {
|
||||
snapshots.forEach { context.delete($0) }
|
||||
save()
|
||||
}
|
||||
|
||||
func deleteSnapshot(at offsets: IndexSet, from snapshots: [Snapshot]) {
|
||||
for index in offsets {
|
||||
guard index < snapshots.count else { continue }
|
||||
context.delete(snapshots[index])
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Filtered Fetch (Freemium)
|
||||
|
||||
func fetchSnapshots(
|
||||
for source: InvestmentSource,
|
||||
limitedToMonths months: Int?
|
||||
) -> [Snapshot] {
|
||||
var snapshots = fetchSnapshots(for: source)
|
||||
|
||||
if let months = months {
|
||||
// Performance: Use cached calendar
|
||||
let cutoffDate = Self.calendar.date(
|
||||
byAdding: .month,
|
||||
value: -months,
|
||||
to: Date()
|
||||
) ?? Date()
|
||||
|
||||
snapshots = snapshots.filter { $0.date >= cutoffDate }
|
||||
}
|
||||
|
||||
return snapshots
|
||||
}
|
||||
|
||||
func fetchSnapshots(
|
||||
for sourceIds: [UUID],
|
||||
months: Int? = nil
|
||||
) -> [Snapshot] {
|
||||
guard !sourceIds.isEmpty else { return [] }
|
||||
|
||||
// Performance: Use cached calendar
|
||||
let cutoffDate: Date? = {
|
||||
guard let months = months else { return nil }
|
||||
return Self.calendar.date(byAdding: .month, value: -months, to: Date())
|
||||
}()
|
||||
|
||||
let key = cacheKey(
|
||||
sourceIds: sourceIds,
|
||||
months: months
|
||||
)
|
||||
|
||||
if let cached = cache.object(forKey: key) as? [Snapshot] {
|
||||
return cached
|
||||
}
|
||||
|
||||
let request: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
|
||||
var predicates: [NSPredicate] = [
|
||||
NSPredicate(format: "source.id IN %@", sourceIds)
|
||||
]
|
||||
|
||||
if let cutoffDate {
|
||||
predicates.append(NSPredicate(format: "date >= %@", cutoffDate as NSDate))
|
||||
}
|
||||
|
||||
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \Snapshot.date, ascending: false)
|
||||
]
|
||||
request.fetchBatchSize = 300
|
||||
|
||||
let fetched = (try? context.fetch(request)) ?? []
|
||||
cache.setObject(fetched as NSArray, forKey: key, cost: fetched.count)
|
||||
return fetched
|
||||
}
|
||||
|
||||
// MARK: - Historical Data
|
||||
|
||||
func getMonthlyValues(for source: InvestmentSource) -> [(date: Date, value: Decimal)] {
|
||||
let snapshots = fetchSnapshots(for: source).reversed()
|
||||
|
||||
var monthlyData: [(date: Date, value: Decimal)] = []
|
||||
monthlyData.reserveCapacity(min(snapshots.count, 60))
|
||||
var processedMonths: Set<String> = []
|
||||
processedMonths.reserveCapacity(60)
|
||||
|
||||
// Performance: Use shared formatter
|
||||
let formatter = Self.monthYearFormatter
|
||||
|
||||
for snapshot in snapshots {
|
||||
let monthKey = formatter.string(from: snapshot.date)
|
||||
if !processedMonths.contains(monthKey) {
|
||||
processedMonths.insert(monthKey)
|
||||
monthlyData.append((date: snapshot.date, value: snapshot.decimalValue))
|
||||
}
|
||||
}
|
||||
|
||||
return monthlyData
|
||||
}
|
||||
|
||||
func getPortfolioHistory() -> [(date: Date, totalValue: Decimal)] {
|
||||
let allSnapshots = fetchAllSnapshots()
|
||||
|
||||
// Performance: Pre-allocate dictionary
|
||||
var dateValues: [Date: Decimal] = [:]
|
||||
dateValues.reserveCapacity(min(allSnapshots.count, 365))
|
||||
|
||||
// Performance: Use cached calendar
|
||||
let calendar = Self.calendar
|
||||
|
||||
for snapshot in allSnapshots {
|
||||
let startOfDay = calendar.startOfDay(for: snapshot.date)
|
||||
dateValues[startOfDay, default: Decimal.zero] += snapshot.decimalValue
|
||||
}
|
||||
|
||||
return dateValues
|
||||
.map { (date: $0.key, totalValue: $0.value) }
|
||||
.sorted { $0.date < $1.date }
|
||||
}
|
||||
|
||||
// MARK: - Save
|
||||
|
||||
private func save() {
|
||||
guard context.hasChanges else { return }
|
||||
do {
|
||||
try context.save()
|
||||
invalidateCache()
|
||||
CoreDataStack.shared.refreshWidgetData()
|
||||
} catch {
|
||||
print("Failed to save context: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cache
|
||||
|
||||
private func cacheKey(
|
||||
sourceIds: [UUID],
|
||||
months: Int?
|
||||
) -> NSString {
|
||||
let sortedIds = sourceIds.sorted().map { $0.uuidString }.joined(separator: ",")
|
||||
let monthsPart = months.map(String.init) ?? "all"
|
||||
return NSString(string: "\(cacheVersion)|\(monthsPart)|\(sortedIds)")
|
||||
}
|
||||
|
||||
private func invalidateCache() {
|
||||
cache.removeAllObjects()
|
||||
cacheVersion &+= 1
|
||||
}
|
||||
|
||||
private func setupNotificationObserver() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(contextDidChange),
|
||||
name: .NSManagedObjectContextObjectsDidChange,
|
||||
object: context
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func contextDidChange(_ notification: Notification) {
|
||||
invalidateCache()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
class TransactionRepository {
|
||||
private let context: NSManagedObjectContext
|
||||
|
||||
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func fetchTransactions(for source: InvestmentSource) -> [Transaction] {
|
||||
let request: NSFetchRequest<Transaction> = Transaction.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "source == %@", source)
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \Transaction.date, ascending: false)
|
||||
]
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func createTransaction(
|
||||
source: InvestmentSource,
|
||||
type: TransactionType,
|
||||
date: Date,
|
||||
shares: Decimal?,
|
||||
price: Decimal?,
|
||||
amount: Decimal?,
|
||||
notes: String?
|
||||
) -> Transaction {
|
||||
let transaction = Transaction(context: context)
|
||||
transaction.source = source
|
||||
transaction.type = type.rawValue
|
||||
transaction.date = date
|
||||
if let shares = shares {
|
||||
transaction.shares = NSDecimalNumber(decimal: shares)
|
||||
}
|
||||
if let price = price {
|
||||
transaction.price = NSDecimalNumber(decimal: price)
|
||||
}
|
||||
if let amount = amount {
|
||||
transaction.amount = NSDecimalNumber(decimal: amount)
|
||||
}
|
||||
transaction.notes = notes
|
||||
|
||||
save()
|
||||
return transaction
|
||||
}
|
||||
|
||||
func deleteTransaction(_ transaction: Transaction) {
|
||||
context.delete(transaction)
|
||||
save()
|
||||
}
|
||||
|
||||
private func save() {
|
||||
guard context.hasChanges else { return }
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print("Failed to save transaction: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>API_KEY</key>
|
||||
<string>AIzaSyAregLaXQ-WqRQTltTRyjQx3-lfxLl4Cng</string>
|
||||
<key>GCM_SENDER_ID</key>
|
||||
<string>334225114072</string>
|
||||
<key>PLIST_VERSION</key>
|
||||
<string>1</string>
|
||||
<key>BUNDLE_ID</key>
|
||||
<string>com.alexandrevazquez.portfoliojournal</string>
|
||||
<key>PROJECT_ID</key>
|
||||
<string>portfoliojournal-ef2d7</string>
|
||||
<key>STORAGE_BUCKET</key>
|
||||
<string>portfoliojournal-ef2d7.firebasestorage.app</string>
|
||||
<key>IS_ADS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_ANALYTICS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_APPINVITE_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_GCM_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_SIGNIN_ENABLED</key>
|
||||
<true></true>
|
||||
<key>GOOGLE_APP_ID</key>
|
||||
<string>1:334225114072:ios:81bad412ffe1c6df3d28ad</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,92 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Portfolio Journal</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>portfoliojournal</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>GADApplicationIdentifier</key>
|
||||
<string>ca-app-pub-1549720748100858~9632507420</string>
|
||||
<key>GADDelayAppMeasurementInit</key>
|
||||
<true/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<false/>
|
||||
<key>NSAllowsArbitraryLoadsForMedia</key>
|
||||
<false/>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>NSCalendarsUsageDescription</key>
|
||||
<string>Used to set investment update reminders.</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Use Face ID to unlock your portfolio data.</string>
|
||||
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
|
||||
<true/>
|
||||
<key>NSUserTrackingUsageDescription</key>
|
||||
<string>This app uses tracking to provide personalized ads and improve your experience. Your data is not sold to third parties.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UIStatusBarStyle</key>
|
||||
<string>UIStatusBarStyleDefault</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportsDocumentBrowser</key>
|
||||
<false/>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"identifier" : "Default Configuration",
|
||||
"provider" : "appstore",
|
||||
"products" : [
|
||||
{
|
||||
"id" : "com.portfoliojournal.premium",
|
||||
"type" : "Non-Consumable",
|
||||
"referenceName" : "Premium Unlock",
|
||||
"state" : "approved",
|
||||
"price" : {
|
||||
"currency" : "EUR",
|
||||
"value" : 4.69
|
||||
}
|
||||
}
|
||||
],
|
||||
"subscriptionGroups" : [ ],
|
||||
"localizations" : [ ],
|
||||
"files" : [ ]
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
PortfolioJournal
|
||||
|
||||
English (Base) localization
|
||||
*/
|
||||
|
||||
// MARK: - General
|
||||
"app_name" = "Portfolio Journal";
|
||||
"ok" = "OK";
|
||||
"cancel" = "Cancel";
|
||||
"save" = "Save";
|
||||
"delete" = "Delete";
|
||||
"edit" = "Edit";
|
||||
"add" = "Add";
|
||||
"done" = "Done";
|
||||
"close" = "Close";
|
||||
"continue" = "Continue";
|
||||
"skip" = "Skip";
|
||||
"error" = "Error";
|
||||
"success" = "Success";
|
||||
"loading" = "Loading...";
|
||||
|
||||
// MARK: - Tab Bar
|
||||
"tab_dashboard" = "Home";
|
||||
"tab_sources" = "Sources";
|
||||
"tab_charts" = "Charts";
|
||||
"tab_settings" = "Settings";
|
||||
|
||||
// MARK: - Dashboard
|
||||
"dashboard_title" = "Home";
|
||||
"total_portfolio_value" = "Total Portfolio Value";
|
||||
"today" = "today";
|
||||
"returns" = "Returns";
|
||||
"by_category" = "By Category";
|
||||
"pending_updates" = "Pending Updates";
|
||||
"see_all" = "See All";
|
||||
|
||||
// MARK: - Sources
|
||||
"sources_title" = "Sources";
|
||||
"add_source" = "Add Source";
|
||||
"source_name" = "Source Name";
|
||||
"select_category" = "Select Category";
|
||||
"initial_value" = "Initial Value";
|
||||
"initial_value_optional" = "Initial Value (Optional)";
|
||||
"reminder_frequency" = "Reminder Frequency";
|
||||
"source_limit_warning" = "Source limit reached. Upgrade to Premium for unlimited sources.";
|
||||
"no_sources" = "No Investment Sources";
|
||||
"no_sources_message" = "Add your first investment source to start tracking your portfolio.";
|
||||
|
||||
// MARK: - Snapshots
|
||||
"add_snapshot" = "Add Snapshot";
|
||||
"edit_snapshot" = "Edit Snapshot";
|
||||
"snapshot_date" = "Date";
|
||||
"snapshot_value" = "Value";
|
||||
"snapshot_contribution" = "Contribution";
|
||||
"contribution_optional" = "Contribution (Optional)";
|
||||
"notes" = "Notes";
|
||||
"notes_optional" = "Notes (Optional)";
|
||||
"previous_value" = "Previous: %@";
|
||||
"change_from_previous" = "Change from previous";
|
||||
|
||||
// MARK: - Charts
|
||||
"charts_title" = "Charts";
|
||||
"evolution" = "Evolution";
|
||||
"allocation" = "Allocation";
|
||||
"performance" = "Performance";
|
||||
"drawdown" = "Drawdown";
|
||||
"volatility" = "Volatility";
|
||||
"prediction" = "Prediction";
|
||||
"portfolio_evolution" = "Portfolio Evolution";
|
||||
"asset_allocation" = "Asset Allocation";
|
||||
"performance_by_category" = "Performance by Category";
|
||||
"drawdown_analysis" = "Drawdown Analysis";
|
||||
"prediction_12_month" = "12-Month Prediction";
|
||||
"not_enough_data" = "Not enough data";
|
||||
|
||||
// MARK: - Metrics
|
||||
"cagr" = "CAGR";
|
||||
"twr" = "TWR";
|
||||
"max_drawdown" = "Max Drawdown";
|
||||
"sharpe_ratio" = "Sharpe Ratio";
|
||||
"win_rate" = "Win Rate";
|
||||
"avg_monthly" = "Avg Monthly";
|
||||
"best_month" = "Best Month";
|
||||
"worst_month" = "Worst Month";
|
||||
|
||||
// MARK: - Premium
|
||||
"premium" = "Premium";
|
||||
"upgrade_to_premium" = "Upgrade to Premium";
|
||||
"unlock_full_potential" = "Unlock Full Potential";
|
||||
"one_time_purchase" = "One-time purchase";
|
||||
"includes_family_sharing" = "Includes Family Sharing";
|
||||
"upgrade_now" = "Upgrade Now";
|
||||
"restore_purchases" = "Restore Purchases";
|
||||
"premium_active" = "Premium Active";
|
||||
"premium_feature" = "Premium Feature";
|
||||
"unlock" = "Unlock";
|
||||
|
||||
// Premium Features
|
||||
"feature_unlimited_sources" = "Unlimited Sources";
|
||||
"feature_unlimited_sources_desc" = "Track as many investments as you want";
|
||||
"feature_full_history" = "Full History";
|
||||
"feature_full_history_desc" = "Access your complete investment history";
|
||||
"feature_advanced_charts" = "Advanced Charts";
|
||||
"feature_advanced_charts_desc" = "5 types of detailed analytics charts";
|
||||
"feature_predictions" = "Predictions";
|
||||
"feature_predictions_desc" = "AI-powered 12-month forecasts";
|
||||
"feature_export" = "Export Data";
|
||||
"feature_export_desc" = "Export to CSV and JSON formats";
|
||||
"feature_no_ads" = "No Ads";
|
||||
"feature_no_ads_desc" = "Ad-free experience forever";
|
||||
|
||||
// MARK: - Settings
|
||||
"settings_title" = "Settings";
|
||||
"subscription" = "Subscription";
|
||||
"notifications" = "Notifications";
|
||||
"default_reminder_time" = "Default Reminder Time";
|
||||
"data" = "Data";
|
||||
"export_data" = "Export Data";
|
||||
"total_sources" = "Total Sources";
|
||||
"total_snapshots" = "Total Snapshots";
|
||||
"storage_used" = "Storage Used";
|
||||
"about" = "About";
|
||||
"version" = "Version";
|
||||
"privacy_policy" = "Privacy Policy";
|
||||
"terms_of_service" = "Terms of Service";
|
||||
"support" = "Support";
|
||||
"rate_app" = "Rate App";
|
||||
"danger_zone" = "Danger Zone";
|
||||
"reset_all_data" = "Reset All Data";
|
||||
"reset_confirmation" = "This will permanently delete all your investment data. This action cannot be undone.";
|
||||
|
||||
// MARK: - Notification Frequencies
|
||||
"frequency_monthly" = "Monthly";
|
||||
"frequency_quarterly" = "Quarterly";
|
||||
"frequency_semiannual" = "Semi-Annual";
|
||||
"frequency_annual" = "Annual";
|
||||
"frequency_custom" = "Custom";
|
||||
"frequency_never" = "Never";
|
||||
"every_n_months" = "Every %d month(s)";
|
||||
|
||||
// MARK: - Categories
|
||||
"category_stocks" = "Stocks";
|
||||
"category_bonds" = "Bonds";
|
||||
"category_real_estate" = "Real Estate";
|
||||
"category_crypto" = "Crypto";
|
||||
"category_cash" = "Cash";
|
||||
"category_etfs" = "ETFs";
|
||||
"category_retirement" = "Retirement";
|
||||
"category_other" = "Other";
|
||||
"uncategorized" = "Uncategorized";
|
||||
|
||||
// MARK: - Time Ranges
|
||||
"time_1m" = "1M";
|
||||
"time_3m" = "3M";
|
||||
"time_6m" = "6M";
|
||||
"time_1y" = "1Y";
|
||||
"time_all" = "All";
|
||||
|
||||
// MARK: - Export
|
||||
"export_format" = "Select Format";
|
||||
"export_csv" = "CSV";
|
||||
"export_csv_desc" = "Compatible with Excel, Google Sheets";
|
||||
"export_json" = "JSON";
|
||||
"export_json_desc" = "Full data structure for backup";
|
||||
|
||||
// MARK: - Onboarding
|
||||
"onboarding_track_title" = "Track Your Investments";
|
||||
"onboarding_track_desc" = "Monitor all your investment sources in one place. Stocks, bonds, real estate, crypto, and more.";
|
||||
"onboarding_visualize_title" = "Visualize Your Growth";
|
||||
"onboarding_visualize_desc" = "Beautiful charts show your portfolio evolution, allocation, and performance over time.";
|
||||
"onboarding_reminders_title" = "Never Miss an Update";
|
||||
"onboarding_reminders_desc" = "Set reminders to track your investments regularly. Monthly, quarterly, or custom schedules.";
|
||||
"onboarding_sync_title" = "Sync Everywhere";
|
||||
"onboarding_sync_desc" = "Your data syncs automatically via iCloud across all your Apple devices.";
|
||||
"get_started" = "Get Started";
|
||||
|
||||
// MARK: - Errors
|
||||
"error_generic" = "An error occurred. Please try again.";
|
||||
"error_no_purchases" = "No purchases found to restore";
|
||||
"error_purchase_failed" = "Purchase failed: %@";
|
||||
"error_export_failed" = "Export failed. Please try again.";
|
||||
|
||||
// MARK: - Placeholders
|
||||
"placeholder_source_name" = "e.g., Vanguard 401k";
|
||||
"placeholder_value" = "0.00";
|
||||
"placeholder_notes" = "Add notes...";
|
||||
|
||||
// MARK: - Monthly Check-in Moods
|
||||
"mood_energized_title" = "On Fire";
|
||||
"mood_confident_title" = "Confident";
|
||||
"mood_balanced_title" = "Steady";
|
||||
"mood_cautious_title" = "Cautious";
|
||||
"mood_stressed_title" = "Stressed";
|
||||
"mood_energized_detail" = "Feeling unbeatable";
|
||||
"mood_confident_detail" = "On track and composed";
|
||||
"mood_balanced_detail" = "Calm and patient";
|
||||
"mood_cautious_detail" = "Watching the moves";
|
||||
"mood_stressed_detail" = "Need a reset";
|
||||
|
||||
// MARK: - Monthly Check-in Achievements
|
||||
"achievement_streak_3_title" = "3-Month Streak";
|
||||
"achievement_streak_3_detail" = "Kept your check-ins on time for three months straight.";
|
||||
"achievement_streak_6_title" = "Half-Year Hot Streak";
|
||||
"achievement_streak_6_detail" = "Six consecutive on-time check-ins.";
|
||||
"achievement_streak_12_title" = "Year of Momentum";
|
||||
"achievement_streak_12_detail" = "A full year without missing the deadline.";
|
||||
"achievement_perfect_on_time_title" = "Never Late";
|
||||
"achievement_perfect_on_time_detail" = "Every check-in landed before the deadline.";
|
||||
"achievement_clutch_finish_title" = "Clutch Finish";
|
||||
"achievement_clutch_finish_detail" = "Submitted with hours to spare and still on time.";
|
||||
"achievement_early_bird_title" = "Early Bird";
|
||||
"achievement_early_bird_detail" = "On average you finish with plenty of time left.";
|
||||
"achievements_title" = "Achievements";
|
||||
"achievements_view_all" = "View all achievements";
|
||||
"achievements_nav_title" = "Achievements";
|
||||
"achievements_progress_title" = "Progress";
|
||||
"achievements_unlocked_title" = "Unlocked";
|
||||
"achievements_unlocked_empty" = "Complete check-ins to unlock achievements.";
|
||||
"achievements_locked_title" = "Locked";
|
||||
"achievements_locked_empty" = "All achievements unlocked. Nice work!";
|
||||
|
||||
// MARK: - Accessibility
|
||||
"rating_accessibility" = "Rating %d of 5";
|
||||
"achievements_unlocked_count" = "%d of %d unlocked";
|
||||
"last_check_in" = "Last check-in: %@";
|
||||
"next_check_in" = "Next check-in: %@";
|
||||
"on_time_rate" = "%@ on-time";
|
||||
"on_time_count" = "%d/%d on time";
|
||||
"tightest_finish" = "Tightest finish: %@ before deadline.";
|
||||
"date_today" = "Today";
|
||||
"date_yesterday" = "Yesterday";
|
||||
"date_never" = "Never";
|
||||
"calendar_event_title" = "%@: Monthly Check-in";
|
||||
"calendar_event_notes" = "Open %@ and complete your monthly check-in.";
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
PortfolioJournal
|
||||
|
||||
Spanish (Spain) localization
|
||||
*/
|
||||
|
||||
// MARK: - Journal
|
||||
"Home" = "Inicio";
|
||||
"Sources" = "Fuentes";
|
||||
"Charts" = "Gráficos";
|
||||
"Settings" = "Ajustes";
|
||||
"Journal" = "Diario";
|
||||
"Search monthly notes" = "Buscar notas mensuales";
|
||||
"Monthly Check-ins" = "Chequeos mensuales";
|
||||
"No monthly notes yet." = "Aún no hay notas mensuales.";
|
||||
"No matching notes." = "No hay notas que coincidan.";
|
||||
"Jump to month" = "Ir al mes";
|
||||
"Today" = "Hoy";
|
||||
"Mood not set" = "Estado de ánimo no establecido";
|
||||
"No rating" = "Sin valoración";
|
||||
"No note yet." = "Sin nota todavía.";
|
||||
"Monthly Note" = "Nota mensual";
|
||||
"Open Full Note" = "Ver nota completa";
|
||||
"Duplicate Previous" = "Duplicar anterior";
|
||||
"Save" = "Guardar";
|
||||
|
||||
// MARK: - Monthly Check-in
|
||||
"Monthly Check-in" = "Chequeo mensual";
|
||||
"This Month" = "Este mes";
|
||||
"No check-in yet this month" = "Aún no hay chequeo este mes";
|
||||
"Start your first check-in anytime." = "Empieza tu primer chequeo cuando quieras.";
|
||||
"Mark Check-in Complete" = "Marcar chequeo completado";
|
||||
"Editing stays open. New check-ins unlock after 70% of the month." = "La edición permanece abierta. Los nuevos chequeos se desbloquean tras el 70 % del mes.";
|
||||
"Momentum & Streaks" = "Impulso y rachas";
|
||||
"Log a check-in to start a streak" = "Registra un chequeo para iniciar una racha";
|
||||
"Streak" = "Racha";
|
||||
"On-time in a row" = "A tiempo seguidos";
|
||||
"Best" = "Mejor";
|
||||
"Personal best" = "Mejor personal";
|
||||
"Avg early" = "Media de antelación";
|
||||
"vs deadline" = "vs. fecha límite";
|
||||
"On-time score" = "Puntuación a tiempo";
|
||||
"Achievements" = "Logros";
|
||||
"View all achievements" = "Ver todos los logros";
|
||||
"Monthly Pulse" = "Pulso mensual";
|
||||
"Optional" = "Opcional";
|
||||
"Rate this month" = "Valora este mes";
|
||||
"Skip" = "Omitir";
|
||||
"How did it feel?" = "¿Cómo te sentiste?";
|
||||
"Monthly Summary" = "Resumen mensual";
|
||||
"Starting" = "Inicio";
|
||||
"Ending" = "Final";
|
||||
"Contributions" = "Aportaciones";
|
||||
"Net Performance" = "Rendimiento neto";
|
||||
"Update Sources" = "Actualizar fuentes";
|
||||
"Add sources to start your monthly check-in." = "Añade fuentes para empezar tu chequeo mensual.";
|
||||
"Updated this cycle" = "Actualizado en este ciclo";
|
||||
"Needs update" = "Necesita actualización";
|
||||
"Snapshot Notes" = "Notas de snapshots";
|
||||
"No snapshot notes for this month." = "No hay notas de snapshots este mes.";
|
||||
"Source" = "Fuente";
|
||||
"last_check_in" = "Último chequeo: %@";
|
||||
"next_check_in" = "Próximo chequeo: %@";
|
||||
"on_time_rate" = "%@ a tiempo";
|
||||
"on_time_count" = "%d/%d a tiempo";
|
||||
"tightest_finish" = "Final más ajustado: %@ antes de la fecha límite.";
|
||||
"date_today" = "Hoy";
|
||||
"date_yesterday" = "Ayer";
|
||||
"date_never" = "Nunca";
|
||||
"calendar_event_title" = "%@: Chequeo mensual";
|
||||
"calendar_event_notes" = "Abre %@ y completa tu chequeo mensual.";
|
||||
|
||||
// MARK: - Achievements View
|
||||
"achievements_title" = "Logros";
|
||||
"achievements_view_all" = "Ver todos los logros";
|
||||
"achievements_nav_title" = "Logros";
|
||||
"achievements_progress_title" = "Progreso";
|
||||
"achievements_unlocked_title" = "Desbloqueados";
|
||||
"achievements_unlocked_empty" = "Completa chequeos para desbloquear logros.";
|
||||
"achievements_locked_title" = "Bloqueados";
|
||||
"achievements_locked_empty" = "Todos los logros desbloqueados. ¡Buen trabajo!";
|
||||
|
||||
// MARK: - Monthly Check-in Moods
|
||||
"mood_energized_title" = "En llamas";
|
||||
"mood_confident_title" = "Confiado";
|
||||
"mood_balanced_title" = "Estable";
|
||||
"mood_cautious_title" = "Cauteloso";
|
||||
"mood_stressed_title" = "Estresado";
|
||||
"mood_energized_detail" = "Me siento imparable";
|
||||
"mood_confident_detail" = "En camino y sereno";
|
||||
"mood_balanced_detail" = "Calmado y paciente";
|
||||
"mood_cautious_detail" = "Observando los movimientos";
|
||||
"mood_stressed_detail" = "Necesito un respiro";
|
||||
|
||||
// MARK: - Categories
|
||||
"category_stocks" = "Acciones";
|
||||
"category_bonds" = "Bonos";
|
||||
"category_real_estate" = "Inmobiliario";
|
||||
"category_crypto" = "Cripto";
|
||||
"category_cash" = "Efectivo";
|
||||
"category_etfs" = "ETFs";
|
||||
"category_retirement" = "Jubilación";
|
||||
"category_other" = "Otros";
|
||||
"uncategorized" = "Sin categoría";
|
||||
|
||||
// MARK: - Monthly Check-in Achievements
|
||||
"achievement_streak_3_title" = "Racha de 3 meses";
|
||||
"achievement_streak_3_detail" = "Has mantenido tus chequeos a tiempo durante tres meses seguidos.";
|
||||
"achievement_streak_6_title" = "Racha de medio año";
|
||||
"achievement_streak_6_detail" = "Seis chequeos a tiempo consecutivos.";
|
||||
"achievement_streak_12_title" = "Un año de impulso";
|
||||
"achievement_streak_12_detail" = "Un año completo sin perder la fecha límite.";
|
||||
"achievement_perfect_on_time_title" = "Nunca tarde";
|
||||
"achievement_perfect_on_time_detail" = "Todos los chequeos llegaron antes de la fecha límite.";
|
||||
"achievement_clutch_finish_title" = "Final ajustado";
|
||||
"achievement_clutch_finish_detail" = "Entregado con horas de margen y a tiempo.";
|
||||
"achievement_early_bird_title" = "Madrugador";
|
||||
"achievement_early_bird_detail" = "De media terminas con bastante margen.";
|
||||
|
||||
// MARK: - Accessibility
|
||||
"rating_accessibility" = "Valoración %d de 5";
|
||||
"achievements_unlocked_count" = "%d de %d desbloqueados";
|
||||
@@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
|
||||
@MainActor
|
||||
class AccountStore: ObservableObject {
|
||||
@Published private(set) var accounts: [Account] = []
|
||||
@Published var selectedAccount: Account?
|
||||
@Published var showAllAccounts = true
|
||||
|
||||
private let accountRepository: AccountRepository
|
||||
private let iapService: IAPService
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
accountRepository: AccountRepository? = nil,
|
||||
iapService: IAPService
|
||||
) {
|
||||
self.accountRepository = accountRepository ?? AccountRepository()
|
||||
self.iapService = iapService
|
||||
|
||||
self.accountRepository.fetchAccounts()
|
||||
let defaultAccount = self.accountRepository.createDefaultAccountIfNeeded()
|
||||
accounts = self.accountRepository.accounts
|
||||
selectedAccount = defaultAccount
|
||||
|
||||
loadSelection()
|
||||
setupObservers()
|
||||
}
|
||||
|
||||
private func setupObservers() {
|
||||
accountRepository.$accounts
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] accounts in
|
||||
self?.accounts = accounts
|
||||
self?.syncSelectedAccount()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func loadSelection() {
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
let settings = AppSettings.getOrCreate(in: context)
|
||||
showAllAccounts = settings.showAllAccounts
|
||||
|
||||
if let selectedId = settings.selectedAccountId,
|
||||
let account = accountRepository.fetchAccount(by: selectedId) {
|
||||
selectedAccount = account
|
||||
} else {
|
||||
selectedAccount = accounts.first
|
||||
}
|
||||
}
|
||||
|
||||
private func syncSelectedAccount() {
|
||||
if showAllAccounts { return }
|
||||
if let selected = selectedAccount,
|
||||
accounts.contains(where: { $0.id == selected.id }) {
|
||||
return
|
||||
}
|
||||
selectedAccount = accounts.first
|
||||
}
|
||||
|
||||
func selectAllAccounts() {
|
||||
showAllAccounts = true
|
||||
persistSelection()
|
||||
}
|
||||
|
||||
func selectAccount(_ account: Account) {
|
||||
showAllAccounts = false
|
||||
selectedAccount = account
|
||||
persistSelection()
|
||||
}
|
||||
|
||||
func persistSelection() {
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
let settings = AppSettings.getOrCreate(in: context)
|
||||
settings.showAllAccounts = showAllAccounts
|
||||
settings.selectedAccountId = showAllAccounts ? nil : selectedAccount?.id
|
||||
CoreDataStack.shared.save()
|
||||
}
|
||||
|
||||
func canAddAccount() -> Bool {
|
||||
iapService.isPremium || accounts.count < 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import GoogleMobileAds
|
||||
import AppTrackingTransparency
|
||||
import AdSupport
|
||||
|
||||
@MainActor
|
||||
class AdMobService: ObservableObject {
|
||||
// MARK: - Published Properties
|
||||
|
||||
@Published var isConsentObtained = false
|
||||
@Published var canShowAds = false
|
||||
@Published var isLoading = false
|
||||
|
||||
// MARK: - Ad Unit IDs
|
||||
|
||||
// Test Ad Unit IDs - Replace with production IDs before release
|
||||
#if DEBUG
|
||||
static let bannerAdUnitID = "ca-app-pub-3940256099942544/2934735716"
|
||||
#else
|
||||
static let bannerAdUnitID = "ca-app-pub-XXXXXXXXXXXXXXXX/YYYYYYYYYY" // Replace with your Ad Unit ID
|
||||
#endif
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
checkConsentStatus()
|
||||
}
|
||||
|
||||
// MARK: - Consent Management
|
||||
|
||||
func checkConsentStatus() {
|
||||
// Check if we already have consent
|
||||
let consentStatus = UserDefaults.standard.bool(forKey: "adConsentObtained")
|
||||
isConsentObtained = consentStatus
|
||||
canShowAds = consentStatus
|
||||
}
|
||||
|
||||
func requestConsent() async {
|
||||
if #available(iOS 14.5, *) {
|
||||
let status = await ATTrackingManager.requestTrackingAuthorization()
|
||||
|
||||
switch status {
|
||||
case .authorized:
|
||||
isConsentObtained = true
|
||||
canShowAds = true
|
||||
case .denied, .restricted:
|
||||
// Can still show non-personalized ads
|
||||
isConsentObtained = true
|
||||
canShowAds = true
|
||||
case .notDetermined:
|
||||
// Will be asked again later
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// iOS 14.4 and earlier - consent assumed
|
||||
isConsentObtained = true
|
||||
canShowAds = true
|
||||
}
|
||||
|
||||
UserDefaults.standard.set(isConsentObtained, forKey: "adConsentObtained")
|
||||
}
|
||||
|
||||
// MARK: - GDPR Consent (UMP SDK)
|
||||
|
||||
func requestGDPRConsent() async {
|
||||
// Implement UMP SDK consent flow if targeting EU users
|
||||
// This is a simplified version - full implementation requires UMP SDK
|
||||
|
||||
let isEUUser = isUserInEU()
|
||||
|
||||
if isEUUser {
|
||||
// Show GDPR consent dialog
|
||||
// For now, assume consent if user continues
|
||||
isConsentObtained = true
|
||||
canShowAds = true
|
||||
} else {
|
||||
isConsentObtained = true
|
||||
canShowAds = true
|
||||
}
|
||||
|
||||
UserDefaults.standard.set(isConsentObtained, forKey: "adConsentObtained")
|
||||
}
|
||||
|
||||
private func isUserInEU() -> Bool {
|
||||
let euCountries = [
|
||||
"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
|
||||
"DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
|
||||
"PL", "PT", "RO", "SK", "SI", "ES", "SE", "GB", "IS", "LI",
|
||||
"NO", "CH"
|
||||
]
|
||||
|
||||
let countryCode = Locale.current.region?.identifier ?? ""
|
||||
return euCountries.contains(countryCode)
|
||||
}
|
||||
|
||||
// MARK: - Analytics
|
||||
|
||||
func logAdImpression() {
|
||||
FirebaseService.shared.logAdImpression(adType: "banner")
|
||||
}
|
||||
|
||||
func logAdClick() {
|
||||
FirebaseService.shared.logAdClick(adType: "banner")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Banner Ad Coordinator
|
||||
|
||||
class BannerAdCoordinator: NSObject, BannerViewDelegate {
|
||||
weak var adMobService: AdMobService?
|
||||
|
||||
func bannerViewDidReceiveAd(_ bannerView: BannerView) {
|
||||
print("Banner ad received")
|
||||
adMobService?.logAdImpression()
|
||||
}
|
||||
|
||||
func bannerView(_ bannerView: BannerView, didFailToReceiveAdWithError error: Error) {
|
||||
print("Banner ad failed to load: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
func bannerViewDidRecordImpression(_ bannerView: BannerView) {
|
||||
// Impression recorded
|
||||
}
|
||||
|
||||
func bannerViewDidRecordClick(_ bannerView: BannerView) {
|
||||
adMobService?.logAdClick()
|
||||
}
|
||||
|
||||
func bannerViewWillPresentScreen(_ bannerView: BannerView) {
|
||||
// Ad will present full screen
|
||||
}
|
||||
|
||||
func bannerViewWillDismissScreen(_ bannerView: BannerView) {
|
||||
// Ad will dismiss
|
||||
}
|
||||
|
||||
func bannerViewDidDismissScreen(_ bannerView: BannerView) {
|
||||
// Ad dismissed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIKit Banner View Wrapper
|
||||
|
||||
struct BannerAdView: UIViewRepresentable {
|
||||
@EnvironmentObject var adMobService: AdMobService
|
||||
|
||||
func makeUIView(context: Context) -> BannerView {
|
||||
let bannerView = BannerView(adSize: AdSizeBanner)
|
||||
bannerView.adUnitID = AdMobService.bannerAdUnitID
|
||||
|
||||
// Get root view controller
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let rootViewController = windowScene.windows.first?.rootViewController {
|
||||
bannerView.rootViewController = rootViewController
|
||||
}
|
||||
|
||||
bannerView.delegate = context.coordinator
|
||||
bannerView.load(Request())
|
||||
|
||||
return bannerView
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: BannerView, context: Context) {
|
||||
// Banner updates automatically
|
||||
}
|
||||
|
||||
func makeCoordinator() -> BannerAdCoordinator {
|
||||
let coordinator = BannerAdCoordinator()
|
||||
coordinator.adMobService = adMobService
|
||||
return coordinator
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
import Foundation
|
||||
|
||||
class CalculationService {
|
||||
static let shared = CalculationService()
|
||||
|
||||
// MARK: - Performance: Caching
|
||||
private var cachedMonthlyReturns: [ObjectIdentifier: [InvestmentMetrics.MonthlyReturn]] = [:]
|
||||
private var cacheVersion: Int = 0
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Call this when underlying data changes to invalidate caches
|
||||
func invalidateCache() {
|
||||
cachedMonthlyReturns.removeAll()
|
||||
cacheVersion += 1
|
||||
}
|
||||
|
||||
// MARK: - Shared DateFormatter (avoid repeated allocations)
|
||||
private static let monthYearFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
// MARK: - Portfolio Summary
|
||||
|
||||
func calculatePortfolioSummary(
|
||||
from sources: [InvestmentSource],
|
||||
snapshots: [Snapshot]
|
||||
) -> PortfolioSummary {
|
||||
let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
let totalContributions = sources.reduce(Decimal.zero) { $0 + $1.totalContributions }
|
||||
|
||||
// Calculate period changes
|
||||
let now = Date()
|
||||
let dayAgo = Calendar.current.date(byAdding: .day, value: -1, to: now) ?? now
|
||||
let weekAgo = Calendar.current.date(byAdding: .weekOfYear, value: -1, to: now) ?? now
|
||||
let monthAgo = Calendar.current.date(byAdding: .month, value: -1, to: now) ?? now
|
||||
let yearAgo = Calendar.current.date(byAdding: .year, value: -1, to: now) ?? now
|
||||
|
||||
let dayChange = calculatePeriodChange(sources: sources, from: dayAgo)
|
||||
let weekChange = calculatePeriodChange(sources: sources, from: weekAgo)
|
||||
let monthChange = calculatePeriodChange(sources: sources, from: monthAgo)
|
||||
let yearChange = calculatePeriodChange(sources: sources, from: yearAgo)
|
||||
|
||||
let allTimeReturn = totalValue - totalContributions
|
||||
let allTimeReturnPercentage = totalContributions > 0
|
||||
? NSDecimalNumber(decimal: allTimeReturn / totalContributions).doubleValue * 100
|
||||
: 0
|
||||
|
||||
let lastUpdated = snapshots.map { $0.date }.max()
|
||||
|
||||
return PortfolioSummary(
|
||||
totalValue: totalValue,
|
||||
totalContributions: totalContributions,
|
||||
dayChange: dayChange.absolute,
|
||||
dayChangePercentage: dayChange.percentage,
|
||||
weekChange: weekChange.absolute,
|
||||
weekChangePercentage: weekChange.percentage,
|
||||
monthChange: monthChange.absolute,
|
||||
monthChangePercentage: monthChange.percentage,
|
||||
yearChange: yearChange.absolute,
|
||||
yearChangePercentage: yearChange.percentage,
|
||||
allTimeReturn: allTimeReturn,
|
||||
allTimeReturnPercentage: allTimeReturnPercentage,
|
||||
sourceCount: sources.count,
|
||||
lastUpdated: lastUpdated
|
||||
)
|
||||
}
|
||||
|
||||
private func calculatePeriodChange(
|
||||
sources: [InvestmentSource],
|
||||
from startDate: Date
|
||||
) -> (absolute: Decimal, percentage: Double) {
|
||||
var previousTotal: Decimal = 0
|
||||
var currentTotal: Decimal = 0
|
||||
|
||||
for source in sources {
|
||||
currentTotal += source.latestValue
|
||||
|
||||
// Find snapshot closest to start date
|
||||
let snapshots = source.sortedSnapshotsByDateAscending
|
||||
let previousSnapshot = snapshots.last { $0.date <= startDate } ?? snapshots.first
|
||||
previousTotal += previousSnapshot?.decimalValue ?? 0
|
||||
}
|
||||
|
||||
let absolute = currentTotal - previousTotal
|
||||
let percentage = previousTotal > 0
|
||||
? NSDecimalNumber(decimal: absolute / previousTotal).doubleValue * 100
|
||||
: 0
|
||||
|
||||
return (absolute, percentage)
|
||||
}
|
||||
|
||||
// MARK: - Investment Metrics
|
||||
|
||||
func calculateMetrics(for snapshots: [Snapshot]) -> InvestmentMetrics {
|
||||
guard !snapshots.isEmpty else { return .empty }
|
||||
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
let values = sortedSnapshots.map { $0.decimalValue }
|
||||
|
||||
guard let firstValue = values.first,
|
||||
let lastValue = values.last,
|
||||
firstValue != 0 else {
|
||||
return .empty
|
||||
}
|
||||
|
||||
let totalValue = lastValue
|
||||
let totalContributions = sortedSnapshots.reduce(Decimal.zero) { $0 + $1.decimalContribution }
|
||||
let absoluteReturn = lastValue - firstValue
|
||||
let percentageReturn = (absoluteReturn / firstValue) * 100
|
||||
|
||||
// Calculate monthly returns for advanced metrics
|
||||
let monthlyReturns = calculateMonthlyReturns(from: sortedSnapshots)
|
||||
|
||||
let cagr = calculateCAGR(
|
||||
startValue: firstValue,
|
||||
endValue: lastValue,
|
||||
startDate: sortedSnapshots.first?.date ?? Date(),
|
||||
endDate: sortedSnapshots.last?.date ?? Date()
|
||||
)
|
||||
|
||||
let twr = calculateTWR(snapshots: sortedSnapshots)
|
||||
let volatility = calculateVolatility(monthlyReturns: monthlyReturns)
|
||||
let maxDrawdown = calculateMaxDrawdown(values: values)
|
||||
let sharpeRatio = calculateSharpeRatio(
|
||||
averageReturn: monthlyReturns.map { $0.returnPercentage }.average(),
|
||||
volatility: volatility
|
||||
)
|
||||
|
||||
let winRate = calculateWinRate(monthlyReturns: monthlyReturns)
|
||||
let averageMonthlyReturn = monthlyReturns.map { $0.returnPercentage }.average()
|
||||
|
||||
return InvestmentMetrics(
|
||||
totalValue: totalValue,
|
||||
totalContributions: totalContributions,
|
||||
absoluteReturn: absoluteReturn,
|
||||
percentageReturn: percentageReturn,
|
||||
cagr: cagr,
|
||||
twr: twr,
|
||||
volatility: volatility,
|
||||
maxDrawdown: maxDrawdown,
|
||||
sharpeRatio: sharpeRatio,
|
||||
bestMonth: monthlyReturns.max(by: { $0.returnPercentage < $1.returnPercentage }),
|
||||
worstMonth: monthlyReturns.min(by: { $0.returnPercentage < $1.returnPercentage }),
|
||||
winRate: winRate,
|
||||
averageMonthlyReturn: averageMonthlyReturn,
|
||||
startDate: sortedSnapshots.first?.date,
|
||||
endDate: sortedSnapshots.last?.date,
|
||||
totalMonths: monthlyReturns.count
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Monthly Summary
|
||||
|
||||
func calculateMonthlySummary(
|
||||
sources: [InvestmentSource],
|
||||
snapshots: [Snapshot],
|
||||
range: DateRange = .thisMonth
|
||||
) -> MonthlySummary {
|
||||
let startDate = range.start
|
||||
let endDate = range.end
|
||||
|
||||
var startingValue: Decimal = 0
|
||||
var endingValue: Decimal = 0
|
||||
var contributions: Decimal = 0
|
||||
|
||||
let snapshotsBySource = Dictionary(grouping: snapshots) { $0.source?.id }
|
||||
|
||||
for source in sources {
|
||||
let sourceSnapshots = snapshotsBySource[source.id] ?? []
|
||||
let sorted = sourceSnapshots.sorted { $0.date < $1.date }
|
||||
|
||||
let startSnapshot = sorted.last { $0.date <= startDate }
|
||||
let endSnapshot = sorted.last { $0.date <= endDate }
|
||||
|
||||
startingValue += startSnapshot?.decimalValue ?? 0
|
||||
endingValue += endSnapshot?.decimalValue ?? 0
|
||||
|
||||
let rangeContributions = sorted
|
||||
.filter { $0.date >= startDate && $0.date <= endDate }
|
||||
.reduce(Decimal.zero) { $0 + $1.decimalContribution }
|
||||
contributions += rangeContributions
|
||||
}
|
||||
|
||||
let netPerformance = endingValue - startingValue - contributions
|
||||
|
||||
return MonthlySummary(
|
||||
periodLabel: "This Month",
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
startingValue: startingValue,
|
||||
endingValue: endingValue,
|
||||
contributions: contributions,
|
||||
netPerformance: netPerformance
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - CAGR (Compound Annual Growth Rate)
|
||||
|
||||
func calculateCAGR(
|
||||
startValue: Decimal,
|
||||
endValue: Decimal,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
) -> Double {
|
||||
guard startValue > 0 else { return 0 }
|
||||
|
||||
let years = Calendar.current.dateComponents(
|
||||
[.day],
|
||||
from: startDate,
|
||||
to: endDate
|
||||
).day.map { Double($0) / 365.25 } ?? 0
|
||||
|
||||
guard years > 0 else { return 0 }
|
||||
|
||||
let ratio = NSDecimalNumber(decimal: endValue / startValue).doubleValue
|
||||
let cagr = pow(ratio, 1 / years) - 1
|
||||
|
||||
return cagr * 100
|
||||
}
|
||||
|
||||
// MARK: - TWR (Time-Weighted Return)
|
||||
|
||||
func calculateTWR(snapshots: [Snapshot]) -> Double {
|
||||
guard snapshots.count >= 2 else { return 0 }
|
||||
|
||||
var twr: Double = 1.0
|
||||
|
||||
for i in 1..<snapshots.count {
|
||||
let previousValue = snapshots[i-1].decimalValue
|
||||
let currentValue = snapshots[i].decimalValue
|
||||
let contribution = snapshots[i].decimalContribution
|
||||
|
||||
guard previousValue > 0 else { continue }
|
||||
|
||||
// Adjust for contributions
|
||||
let adjustedPreviousValue = previousValue + contribution
|
||||
let periodReturn = NSDecimalNumber(
|
||||
decimal: currentValue / adjustedPreviousValue
|
||||
).doubleValue
|
||||
|
||||
twr *= periodReturn
|
||||
}
|
||||
|
||||
return (twr - 1) * 100
|
||||
}
|
||||
|
||||
// MARK: - Volatility (Annualized Standard Deviation)
|
||||
|
||||
func calculateVolatility(monthlyReturns: [InvestmentMetrics.MonthlyReturn]) -> Double {
|
||||
let returns = monthlyReturns.map { $0.returnPercentage }
|
||||
guard returns.count >= 2 else { return 0 }
|
||||
|
||||
let mean = returns.average()
|
||||
let squaredDifferences = returns.map { pow($0 - mean, 2) }
|
||||
let variance = squaredDifferences.reduce(0, +) / Double(returns.count - 1)
|
||||
let stdDev = sqrt(variance)
|
||||
|
||||
// Annualize (multiply by sqrt(12) for monthly data)
|
||||
return stdDev * sqrt(12)
|
||||
}
|
||||
|
||||
// MARK: - Max Drawdown
|
||||
|
||||
func calculateMaxDrawdown(values: [Decimal]) -> Double {
|
||||
guard !values.isEmpty else { return 0 }
|
||||
|
||||
var maxDrawdown: Double = 0
|
||||
var peak = values[0]
|
||||
|
||||
for value in values {
|
||||
if value > peak {
|
||||
peak = value
|
||||
}
|
||||
|
||||
guard peak > 0 else { continue }
|
||||
|
||||
let drawdown = NSDecimalNumber(
|
||||
decimal: (peak - value) / peak
|
||||
).doubleValue * 100
|
||||
|
||||
maxDrawdown = max(maxDrawdown, drawdown)
|
||||
}
|
||||
|
||||
return maxDrawdown
|
||||
}
|
||||
|
||||
// MARK: - Sharpe Ratio
|
||||
|
||||
func calculateSharpeRatio(
|
||||
averageReturn: Double,
|
||||
volatility: Double,
|
||||
riskFreeRate: Double = 2.0 // Assume 2% annual risk-free rate
|
||||
) -> Double {
|
||||
guard volatility > 0 else { return 0 }
|
||||
|
||||
// Convert to monthly risk-free rate
|
||||
let monthlyRiskFree = riskFreeRate / 12
|
||||
|
||||
return (averageReturn - monthlyRiskFree) / volatility * sqrt(12)
|
||||
}
|
||||
|
||||
// MARK: - Win Rate
|
||||
|
||||
func calculateWinRate(monthlyReturns: [InvestmentMetrics.MonthlyReturn]) -> Double {
|
||||
guard !monthlyReturns.isEmpty else { return 0 }
|
||||
|
||||
let positiveMonths = monthlyReturns.filter { $0.returnPercentage > 0 }.count
|
||||
return Double(positiveMonths) / Double(monthlyReturns.count) * 100
|
||||
}
|
||||
|
||||
// MARK: - Monthly Returns
|
||||
|
||||
func calculateMonthlyReturns(from snapshots: [Snapshot]) -> [InvestmentMetrics.MonthlyReturn] {
|
||||
guard snapshots.count >= 2 else { return [] }
|
||||
|
||||
// Performance: Use shared formatter instead of creating new one each call
|
||||
let formatter = Self.monthYearFormatter
|
||||
|
||||
// Group snapshots by month - pre-allocate capacity
|
||||
var monthlySnapshots: [String: [Snapshot]] = [:]
|
||||
monthlySnapshots.reserveCapacity(min(snapshots.count, 60)) // Reasonable max months
|
||||
|
||||
for snapshot in snapshots {
|
||||
let key = formatter.string(from: snapshot.date)
|
||||
monthlySnapshots[key, default: []].append(snapshot)
|
||||
}
|
||||
|
||||
// Sort months
|
||||
let sortedMonths = monthlySnapshots.keys.sorted()
|
||||
guard sortedMonths.count >= 2 else { return [] }
|
||||
|
||||
// Pre-allocate result array
|
||||
var monthlyReturns: [InvestmentMetrics.MonthlyReturn] = []
|
||||
monthlyReturns.reserveCapacity(sortedMonths.count - 1)
|
||||
|
||||
for i in 1..<sortedMonths.count {
|
||||
let previousMonth = sortedMonths[i-1]
|
||||
let currentMonth = sortedMonths[i]
|
||||
|
||||
guard let previousSnapshots = monthlySnapshots[previousMonth],
|
||||
let currentSnapshots = monthlySnapshots[currentMonth],
|
||||
let previousValue = previousSnapshots.last?.decimalValue,
|
||||
let currentValue = currentSnapshots.last?.decimalValue,
|
||||
previousValue > 0 else {
|
||||
continue
|
||||
}
|
||||
|
||||
let returnPercentage = NSDecimalNumber(
|
||||
decimal: (currentValue - previousValue) / previousValue
|
||||
).doubleValue * 100
|
||||
|
||||
if let date = formatter.date(from: currentMonth) {
|
||||
monthlyReturns.append(InvestmentMetrics.MonthlyReturn(
|
||||
date: date,
|
||||
returnPercentage: returnPercentage
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return monthlyReturns
|
||||
}
|
||||
|
||||
// MARK: - Category Metrics
|
||||
|
||||
func calculateCategoryMetrics(
|
||||
for categories: [Category],
|
||||
sources: [InvestmentSource],
|
||||
totalPortfolioValue: Decimal
|
||||
) -> [CategoryMetrics] {
|
||||
categories.map { category in
|
||||
let categorySources = sources.filter { $0.category?.id == category.id }
|
||||
let allSnapshots = categorySources.flatMap { $0.snapshotsArray }
|
||||
let metrics = calculateCategoryMetrics(from: allSnapshots)
|
||||
let categoryValue = categorySources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
|
||||
let percentage = totalPortfolioValue > 0
|
||||
? NSDecimalNumber(decimal: categoryValue / totalPortfolioValue).doubleValue * 100
|
||||
: 0
|
||||
|
||||
return CategoryMetrics(
|
||||
id: category.id,
|
||||
categoryName: category.name,
|
||||
colorHex: category.colorHex,
|
||||
icon: category.icon,
|
||||
totalValue: categoryValue,
|
||||
percentageOfPortfolio: percentage,
|
||||
metrics: metrics
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SeriesPoint {
|
||||
let date: Date
|
||||
let value: Decimal
|
||||
let contribution: Decimal
|
||||
}
|
||||
|
||||
private func calculateCategoryMetrics(from snapshots: [Snapshot]) -> InvestmentMetrics {
|
||||
let series = buildCategorySeries(from: snapshots)
|
||||
guard !series.isEmpty else { return .empty }
|
||||
|
||||
let sortedSeries = series.sorted { $0.date < $1.date }
|
||||
let values = sortedSeries.map { $0.value }
|
||||
|
||||
guard let firstValue = values.first,
|
||||
let lastValue = values.last,
|
||||
firstValue != 0 else {
|
||||
return .empty
|
||||
}
|
||||
|
||||
let totalValue = lastValue
|
||||
let totalContributions = sortedSeries.reduce(Decimal.zero) { $0 + $1.contribution }
|
||||
let absoluteReturn = lastValue - firstValue
|
||||
let percentageReturn = (absoluteReturn / firstValue) * 100
|
||||
|
||||
let monthlyReturns = calculateMonthlyReturns(from: sortedSeries)
|
||||
|
||||
let cagr = calculateCAGR(
|
||||
startValue: firstValue,
|
||||
endValue: lastValue,
|
||||
startDate: sortedSeries.first?.date ?? Date(),
|
||||
endDate: sortedSeries.last?.date ?? Date()
|
||||
)
|
||||
|
||||
let twr = calculateTWR(series: sortedSeries)
|
||||
let volatility = calculateVolatility(monthlyReturns: monthlyReturns)
|
||||
let maxDrawdown = calculateMaxDrawdown(values: values)
|
||||
let sharpeRatio = calculateSharpeRatio(
|
||||
averageReturn: monthlyReturns.map { $0.returnPercentage }.average(),
|
||||
volatility: volatility
|
||||
)
|
||||
|
||||
let winRate = calculateWinRate(monthlyReturns: monthlyReturns)
|
||||
let averageMonthlyReturn = monthlyReturns.map { $0.returnPercentage }.average()
|
||||
|
||||
return InvestmentMetrics(
|
||||
totalValue: totalValue,
|
||||
totalContributions: totalContributions,
|
||||
absoluteReturn: absoluteReturn,
|
||||
percentageReturn: percentageReturn,
|
||||
cagr: cagr,
|
||||
twr: twr,
|
||||
volatility: volatility,
|
||||
maxDrawdown: maxDrawdown,
|
||||
sharpeRatio: sharpeRatio,
|
||||
bestMonth: monthlyReturns.max(by: { $0.returnPercentage < $1.returnPercentage }),
|
||||
worstMonth: monthlyReturns.min(by: { $0.returnPercentage < $1.returnPercentage }),
|
||||
winRate: winRate,
|
||||
averageMonthlyReturn: averageMonthlyReturn,
|
||||
startDate: sortedSeries.first?.date,
|
||||
endDate: sortedSeries.last?.date,
|
||||
totalMonths: monthlyReturns.count
|
||||
)
|
||||
}
|
||||
|
||||
private func buildCategorySeries(from snapshots: [Snapshot]) -> [SeriesPoint] {
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
let uniqueDates = Array(Set(sortedSnapshots.map { Calendar.current.startOfDay(for: $0.date) }))
|
||||
.sorted()
|
||||
guard !uniqueDates.isEmpty else { return [] }
|
||||
|
||||
var snapshotsBySource: [UUID: [(date: Date, value: Decimal)]] = [:]
|
||||
var contributionsByDate: [Date: Decimal] = [:]
|
||||
|
||||
for snapshot in sortedSnapshots {
|
||||
guard let sourceId = snapshot.source?.id else { continue }
|
||||
snapshotsBySource[sourceId, default: []].append(
|
||||
(date: snapshot.date, value: snapshot.decimalValue)
|
||||
)
|
||||
let day = Calendar.current.startOfDay(for: snapshot.date)
|
||||
contributionsByDate[day, default: 0] += snapshot.decimalContribution
|
||||
}
|
||||
|
||||
var indices: [UUID: Int] = [:]
|
||||
var series: [SeriesPoint] = []
|
||||
|
||||
for (index, date) in uniqueDates.enumerated() {
|
||||
let nextDate = index + 1 < uniqueDates.count
|
||||
? uniqueDates[index + 1]
|
||||
: Date.distantFuture
|
||||
var total: Decimal = 0
|
||||
|
||||
for (sourceId, sourceSnapshots) in snapshotsBySource {
|
||||
var currentIndex = indices[sourceId] ?? 0
|
||||
var latest: (date: Date, value: Decimal)?
|
||||
|
||||
while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextDate {
|
||||
latest = sourceSnapshots[currentIndex]
|
||||
currentIndex += 1
|
||||
}
|
||||
|
||||
indices[sourceId] = currentIndex
|
||||
|
||||
if let latest {
|
||||
total += latest.value
|
||||
}
|
||||
}
|
||||
|
||||
series.append(
|
||||
SeriesPoint(
|
||||
date: date,
|
||||
value: total,
|
||||
contribution: contributionsByDate[date] ?? 0
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return series
|
||||
}
|
||||
|
||||
private func calculateMonthlyReturns(from series: [SeriesPoint]) -> [InvestmentMetrics.MonthlyReturn] {
|
||||
guard series.count >= 2 else { return [] }
|
||||
|
||||
// Performance: Use shared formatter
|
||||
let formatter = Self.monthYearFormatter
|
||||
|
||||
var monthlySeries: [String: [SeriesPoint]] = [:]
|
||||
monthlySeries.reserveCapacity(min(series.count, 60))
|
||||
|
||||
for point in series {
|
||||
let key = formatter.string(from: point.date)
|
||||
monthlySeries[key, default: []].append(point)
|
||||
}
|
||||
|
||||
let sortedMonths = monthlySeries.keys.sorted()
|
||||
guard sortedMonths.count >= 2 else { return [] }
|
||||
|
||||
var monthlyReturns: [InvestmentMetrics.MonthlyReturn] = []
|
||||
monthlyReturns.reserveCapacity(sortedMonths.count - 1)
|
||||
|
||||
for i in 1..<sortedMonths.count {
|
||||
let previousMonth = sortedMonths[i - 1]
|
||||
let currentMonth = sortedMonths[i]
|
||||
|
||||
guard let previousPoints = monthlySeries[previousMonth],
|
||||
let currentPoints = monthlySeries[currentMonth],
|
||||
let previousValue = previousPoints.last?.value,
|
||||
let currentValue = currentPoints.last?.value,
|
||||
previousValue > 0 else {
|
||||
continue
|
||||
}
|
||||
|
||||
let returnPercentage = NSDecimalNumber(
|
||||
decimal: (currentValue - previousValue) / previousValue
|
||||
).doubleValue * 100
|
||||
|
||||
if let date = formatter.date(from: currentMonth) {
|
||||
monthlyReturns.append(InvestmentMetrics.MonthlyReturn(
|
||||
date: date,
|
||||
returnPercentage: returnPercentage
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return monthlyReturns
|
||||
}
|
||||
|
||||
private func calculateTWR(series: [SeriesPoint]) -> Double {
|
||||
guard series.count >= 2 else { return 0 }
|
||||
|
||||
var twr: Double = 1.0
|
||||
for i in 1..<series.count {
|
||||
let previousValue = series[i - 1].value
|
||||
let currentValue = series[i].value
|
||||
let contribution = series[i].contribution
|
||||
|
||||
guard previousValue > 0 else { continue }
|
||||
|
||||
let periodReturn = (currentValue - contribution) / previousValue
|
||||
twr *= NSDecimalNumber(decimal: periodReturn).doubleValue
|
||||
}
|
||||
|
||||
return (twr - 1) * 100
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Array Extension
|
||||
|
||||
extension Array where Element == Double {
|
||||
func average() -> Double {
|
||||
guard !isEmpty else { return 0 }
|
||||
return reduce(0, +) / Double(count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
class ExportService {
|
||||
static let shared = ExportService()
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Export Formats
|
||||
|
||||
enum ExportFormat: String, CaseIterable, Identifiable {
|
||||
case csv = "CSV"
|
||||
case json = "JSON"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var fileExtension: String {
|
||||
switch self {
|
||||
case .csv: return "csv"
|
||||
case .json: return "json"
|
||||
}
|
||||
}
|
||||
|
||||
var mimeType: String {
|
||||
switch self {
|
||||
case .csv: return "text/csv"
|
||||
case .json: return "application/json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Export Data
|
||||
|
||||
func exportToCSV(
|
||||
sources: [InvestmentSource],
|
||||
categories: [Category]
|
||||
) -> String {
|
||||
let currencyCode = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
|
||||
var csv = "Account,Category,Source,Date,Value (\(currencyCode)),Contribution (\(currencyCode)),Notes\n"
|
||||
|
||||
for source in sources.sorted(by: { $0.name < $1.name }) {
|
||||
let accountName = source.account?.name ?? "Default"
|
||||
let categoryName = source.category?.name ?? "Uncategorized"
|
||||
|
||||
for snapshot in source.snapshotsArray {
|
||||
let date = formatDate(snapshot.date)
|
||||
let value = formatDecimal(snapshot.decimalValue)
|
||||
let contribution = snapshot.contribution != nil
|
||||
? formatDecimal(snapshot.decimalContribution)
|
||||
: ""
|
||||
let notes = escapeCSV(snapshot.notes ?? "")
|
||||
|
||||
csv += "\(escapeCSV(accountName)),\(escapeCSV(categoryName)),\(escapeCSV(source.name)),\(date),\(value),\(contribution),\(notes)\n"
|
||||
}
|
||||
}
|
||||
|
||||
return csv
|
||||
}
|
||||
|
||||
func exportToJSON(
|
||||
sources: [InvestmentSource],
|
||||
categories: [Category]
|
||||
) -> String {
|
||||
var exportData: [String: Any] = [:]
|
||||
exportData["exportDate"] = ISO8601DateFormatter().string(from: Date())
|
||||
exportData["version"] = 2
|
||||
exportData["currency"] = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
|
||||
|
||||
let accounts = Dictionary(grouping: sources) { $0.account?.id.uuidString ?? "default" }
|
||||
var accountsArray: [[String: Any]] = []
|
||||
|
||||
for (_, accountSources) in accounts {
|
||||
let account = accountSources.first?.account
|
||||
var accountDict: [String: Any] = [
|
||||
"name": account?.name ?? "Default",
|
||||
"currency": account?.currency ?? exportData["currency"] as? String ?? "EUR",
|
||||
"inputMode": account?.inputMode ?? InputMode.simple.rawValue,
|
||||
"notificationFrequency": account?.notificationFrequency ?? NotificationFrequency.monthly.rawValue,
|
||||
"customFrequencyMonths": account?.customFrequencyMonths ?? 1
|
||||
]
|
||||
|
||||
// Export categories for this account
|
||||
let categoriesById = Dictionary(uniqueKeysWithValues: categories.map { ($0.id, $0) })
|
||||
let sourcesByCategory = Dictionary(grouping: accountSources) { $0.category?.id ?? UUID() }
|
||||
var categoriesArray: [[String: Any]] = []
|
||||
|
||||
for (categoryId, categorySources) in sourcesByCategory {
|
||||
let category = categoriesById[categoryId]
|
||||
var categoryDict: [String: Any] = [
|
||||
"name": category?.name ?? "Uncategorized",
|
||||
"color": category?.colorHex ?? "#3B82F6",
|
||||
"icon": category?.icon ?? "chart.pie.fill"
|
||||
]
|
||||
|
||||
var sourcesArray: [[String: Any]] = []
|
||||
for source in categorySources {
|
||||
var sourceDict: [String: Any] = [
|
||||
"name": source.name,
|
||||
"isActive": source.isActive,
|
||||
"notificationFrequency": source.notificationFrequency
|
||||
]
|
||||
|
||||
var snapshotsArray: [[String: Any]] = []
|
||||
for snapshot in source.snapshotsArray {
|
||||
var snapshotDict: [String: Any] = [
|
||||
"date": ISO8601DateFormatter().string(from: snapshot.date),
|
||||
"value": NSDecimalNumber(decimal: snapshot.decimalValue).doubleValue
|
||||
]
|
||||
|
||||
if snapshot.contribution != nil {
|
||||
snapshotDict["contribution"] = NSDecimalNumber(
|
||||
decimal: snapshot.decimalContribution
|
||||
).doubleValue
|
||||
}
|
||||
|
||||
if let notes = snapshot.notes, !notes.isEmpty {
|
||||
snapshotDict["notes"] = notes
|
||||
}
|
||||
|
||||
snapshotsArray.append(snapshotDict)
|
||||
}
|
||||
|
||||
sourceDict["snapshots"] = snapshotsArray
|
||||
sourcesArray.append(sourceDict)
|
||||
}
|
||||
|
||||
categoryDict["sources"] = sourcesArray
|
||||
categoriesArray.append(categoryDict)
|
||||
}
|
||||
|
||||
accountDict["categories"] = categoriesArray
|
||||
accountsArray.append(accountDict)
|
||||
}
|
||||
|
||||
exportData["accounts"] = accountsArray
|
||||
|
||||
// Add summary
|
||||
let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
exportData["summary"] = [
|
||||
"totalSources": sources.count,
|
||||
"totalCategories": categories.count,
|
||||
"totalValue": NSDecimalNumber(decimal: totalValue).doubleValue,
|
||||
"totalSnapshots": sources.reduce(0) { $0 + $1.snapshotCount }
|
||||
]
|
||||
|
||||
// Convert to JSON
|
||||
do {
|
||||
let jsonData = try JSONSerialization.data(
|
||||
withJSONObject: exportData,
|
||||
options: [.prettyPrinted, .sortedKeys]
|
||||
)
|
||||
return String(data: jsonData, encoding: .utf8) ?? "{}"
|
||||
} catch {
|
||||
print("JSON export error: \(error)")
|
||||
return "{}"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Share
|
||||
|
||||
func share(
|
||||
format: ExportFormat,
|
||||
sources: [InvestmentSource],
|
||||
categories: [Category],
|
||||
from viewController: UIViewController
|
||||
) {
|
||||
let content: String
|
||||
let fileName: String
|
||||
|
||||
switch format {
|
||||
case .csv:
|
||||
content = exportToCSV(sources: sources, categories: categories)
|
||||
fileName = "investment_tracker_export.csv"
|
||||
case .json:
|
||||
content = exportToJSON(sources: sources, categories: categories)
|
||||
fileName = "investment_tracker_export.json"
|
||||
}
|
||||
|
||||
// Create temporary file
|
||||
let tempURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(fileName)
|
||||
|
||||
do {
|
||||
try content.write(to: tempURL, atomically: true, encoding: .utf8)
|
||||
|
||||
let activityVC = UIActivityViewController(
|
||||
activityItems: [tempURL],
|
||||
applicationActivities: nil
|
||||
)
|
||||
|
||||
// iPad support
|
||||
if let popover = activityVC.popoverPresentationController {
|
||||
popover.sourceView = viewController.view
|
||||
popover.sourceRect = CGRect(
|
||||
x: viewController.view.bounds.midX,
|
||||
y: viewController.view.bounds.midY,
|
||||
width: 0,
|
||||
height: 0
|
||||
)
|
||||
}
|
||||
|
||||
viewController.present(activityVC, animated: true) {
|
||||
FirebaseService.shared.logExportAttempt(
|
||||
format: format.rawValue,
|
||||
success: true
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
print("Export error: \(error)")
|
||||
FirebaseService.shared.logExportAttempt(
|
||||
format: format.rawValue,
|
||||
success: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private func formatDecimal(_ decimal: Decimal) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.minimumFractionDigits = 2
|
||||
formatter.maximumFractionDigits = 2
|
||||
formatter.decimalSeparator = "."
|
||||
formatter.groupingSeparator = ""
|
||||
return formatter.string(from: decimal as NSDecimalNumber) ?? "0.00"
|
||||
}
|
||||
|
||||
private func escapeCSV(_ value: String) -> String {
|
||||
var escaped = value
|
||||
if escaped.contains("\"") || escaped.contains(",") || escaped.contains("\n") {
|
||||
escaped = escaped.replacingOccurrences(of: "\"", with: "\"\"")
|
||||
escaped = "\"\(escaped)\""
|
||||
}
|
||||
return escaped
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Import Service (Future)
|
||||
|
||||
extension ExportService {
|
||||
func importFromCSV(_ content: String) -> (sources: [ImportedSource], errors: [String]) {
|
||||
// Future implementation for importing data
|
||||
return ([], ["Import not yet implemented"])
|
||||
}
|
||||
|
||||
struct ImportedSource {
|
||||
let name: String
|
||||
let categoryName: String
|
||||
let snapshots: [(date: Date, value: Decimal, contribution: Decimal?, notes: String?)]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import Foundation
|
||||
import FirebaseAnalytics
|
||||
import FirebaseCore
|
||||
|
||||
class FirebaseService {
|
||||
static let shared = FirebaseService()
|
||||
|
||||
private var isConfigured: Bool {
|
||||
FirebaseApp.app() != nil
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - User Properties
|
||||
|
||||
func setUserTier(_ tier: UserTier) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.setUserProperty(tier.rawValue, forName: "user_tier")
|
||||
}
|
||||
|
||||
enum UserTier: String {
|
||||
case free = "free"
|
||||
case premium = "premium"
|
||||
}
|
||||
|
||||
// MARK: - Screen Tracking
|
||||
|
||||
func logScreenView(screenName: String, screenClass: String? = nil) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent(AnalyticsEventScreenView, parameters: [
|
||||
AnalyticsParameterScreenName: screenName,
|
||||
AnalyticsParameterScreenClass: screenClass ?? screenName
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Investment Events
|
||||
|
||||
func logSourceAdded(categoryName: String, sourceCount: Int) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("source_added", parameters: [
|
||||
"category_name": categoryName,
|
||||
"total_sources": sourceCount
|
||||
])
|
||||
}
|
||||
|
||||
func logSourceDeleted(categoryName: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("source_deleted", parameters: [
|
||||
"category_name": categoryName
|
||||
])
|
||||
}
|
||||
|
||||
func logSnapshotAdded(sourceName: String, value: Decimal) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("snapshot_added", parameters: [
|
||||
"source_name": sourceName,
|
||||
"value": NSDecimalNumber(decimal: value).doubleValue
|
||||
])
|
||||
}
|
||||
|
||||
func logCategoryCreated(name: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("category_created", parameters: [
|
||||
"category_name": name
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Purchase Events
|
||||
|
||||
func logPaywallShown(trigger: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("paywall_shown", parameters: [
|
||||
"trigger": trigger
|
||||
])
|
||||
}
|
||||
|
||||
func logPurchaseAttempt(productId: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("purchase_attempt", parameters: [
|
||||
"product_id": productId
|
||||
])
|
||||
}
|
||||
|
||||
func logPurchaseSuccess(productId: String, price: Decimal, isFamilyShared: Bool) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent(AnalyticsEventPurchase, parameters: [
|
||||
AnalyticsParameterItemID: productId,
|
||||
AnalyticsParameterPrice: NSDecimalNumber(decimal: price).doubleValue,
|
||||
AnalyticsParameterCurrency: "EUR",
|
||||
"is_family_shared": isFamilyShared
|
||||
])
|
||||
}
|
||||
|
||||
func logPurchaseFailure(productId: String, error: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("purchase_failed", parameters: [
|
||||
"product_id": productId,
|
||||
"error": error
|
||||
])
|
||||
}
|
||||
|
||||
func logRestorePurchases(success: Bool) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("restore_purchases", parameters: [
|
||||
"success": success
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Feature Usage Events
|
||||
|
||||
func logChartViewed(chartType: String, isPremium: Bool) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("chart_viewed", parameters: [
|
||||
"chart_type": chartType,
|
||||
"is_premium_chart": isPremium
|
||||
])
|
||||
}
|
||||
|
||||
func logPredictionViewed(algorithm: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("prediction_viewed", parameters: [
|
||||
"algorithm": algorithm
|
||||
])
|
||||
}
|
||||
|
||||
func logExportAttempt(format: String, success: Bool) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("export_attempt", parameters: [
|
||||
"format": format,
|
||||
"success": success
|
||||
])
|
||||
}
|
||||
|
||||
func logNotificationScheduled(frequency: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("notification_scheduled", parameters: [
|
||||
"frequency": frequency
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Ad Events
|
||||
|
||||
func logAdImpression(adType: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("ad_impression", parameters: [
|
||||
"ad_type": adType
|
||||
])
|
||||
}
|
||||
|
||||
func logAdClick(adType: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("ad_click", parameters: [
|
||||
"ad_type": adType
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Engagement Events
|
||||
|
||||
func logOnboardingCompleted(stepCount: Int) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("onboarding_completed", parameters: [
|
||||
"steps_completed": stepCount
|
||||
])
|
||||
}
|
||||
|
||||
func logWidgetUsed(widgetType: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("widget_used", parameters: [
|
||||
"widget_type": widgetType
|
||||
])
|
||||
}
|
||||
|
||||
func logAppOpened(fromWidget: Bool) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("app_opened", parameters: [
|
||||
"from_widget": fromWidget
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Portfolio Events
|
||||
|
||||
func logPortfolioMilestone(totalValue: Decimal, milestone: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("portfolio_milestone", parameters: [
|
||||
"total_value": NSDecimalNumber(decimal: totalValue).doubleValue,
|
||||
"milestone": milestone
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Error Events
|
||||
|
||||
func logError(type: String, message: String, context: String? = nil) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("app_error", parameters: [
|
||||
"error_type": type,
|
||||
"error_message": message,
|
||||
"context": context ?? ""
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
class GoalShareService {
|
||||
static let shared = GoalShareService()
|
||||
|
||||
private init() {}
|
||||
|
||||
@MainActor
|
||||
func shareGoal(
|
||||
name: String,
|
||||
progress: Double,
|
||||
currentValue: Decimal,
|
||||
targetValue: Decimal
|
||||
) {
|
||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let viewController = windowScene.windows.first?.rootViewController else {
|
||||
return
|
||||
}
|
||||
|
||||
let card = GoalShareCardView(
|
||||
name: name,
|
||||
progress: progress,
|
||||
currentValue: currentValue,
|
||||
targetValue: targetValue
|
||||
)
|
||||
|
||||
if #available(iOS 16.0, *) {
|
||||
let renderer = ImageRenderer(content: card)
|
||||
let scale = viewController.view.window?.windowScene?.screen.scale
|
||||
?? viewController.traitCollection.displayScale
|
||||
renderer.scale = scale
|
||||
if let image = renderer.uiImage {
|
||||
let activityVC = UIActivityViewController(activityItems: [image], applicationActivities: nil)
|
||||
viewController.present(activityVC, animated: true)
|
||||
}
|
||||
} else {
|
||||
let text = "I am \(Int(progress * 100))% towards \(name) on Portfolio Journal!"
|
||||
let activityVC = UIActivityViewController(activityItems: [text], applicationActivities: nil)
|
||||
viewController.present(activityVC, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import Foundation
|
||||
import StoreKit
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
class IAPService: ObservableObject {
|
||||
// MARK: - Published Properties
|
||||
|
||||
@Published private(set) var isPremium = false
|
||||
@Published private(set) var products: [Product] = []
|
||||
@Published private(set) var purchaseState: PurchaseState = .idle
|
||||
@Published private(set) var isFamilyShared = false
|
||||
#if DEBUG
|
||||
@Published var debugOverrideEnabled = false
|
||||
#endif
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
static let premiumProductID = "com.portfoliojournal.premium"
|
||||
static let premiumPrice = "€4.69"
|
||||
|
||||
// MARK: - Private Properties
|
||||
|
||||
private var updateListenerTask: Task<Void, Error>?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private let sharedDefaults = UserDefaults(suiteName: AppConstants.appGroupIdentifier)
|
||||
|
||||
// MARK: - Purchase State
|
||||
|
||||
enum PurchaseState: Equatable {
|
||||
case idle
|
||||
case purchasing
|
||||
case purchased
|
||||
case failed(String)
|
||||
case restored
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
#if DEBUG
|
||||
debugOverrideEnabled = UserDefaults.standard.bool(forKey: "debugPremiumOverride")
|
||||
#endif
|
||||
updateListenerTask = listenForTransactions()
|
||||
|
||||
Task {
|
||||
await loadProducts()
|
||||
await updatePremiumStatus()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
updateListenerTask?.cancel()
|
||||
}
|
||||
|
||||
// MARK: - Load Products
|
||||
|
||||
func loadProducts() async {
|
||||
do {
|
||||
products = try await Product.products(for: [Self.premiumProductID])
|
||||
print("Loaded \(products.count) products")
|
||||
} catch {
|
||||
print("Failed to load products: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Purchase
|
||||
|
||||
func purchase() async throws {
|
||||
guard let product = products.first else {
|
||||
throw IAPError.productNotFound
|
||||
}
|
||||
|
||||
purchaseState = .purchasing
|
||||
|
||||
do {
|
||||
let result = try await product.purchase()
|
||||
|
||||
switch result {
|
||||
case .success(let verification):
|
||||
let transaction = try checkVerified(verification)
|
||||
|
||||
// Check if family shared
|
||||
isFamilyShared = transaction.ownershipType == .familyShared
|
||||
|
||||
await transaction.finish()
|
||||
await updatePremiumStatus()
|
||||
|
||||
purchaseState = .purchased
|
||||
|
||||
// Track analytics
|
||||
FirebaseService.shared.logPurchaseSuccess(
|
||||
productId: product.id,
|
||||
price: product.price,
|
||||
isFamilyShared: isFamilyShared
|
||||
)
|
||||
|
||||
case .userCancelled:
|
||||
purchaseState = .idle
|
||||
|
||||
case .pending:
|
||||
purchaseState = .idle
|
||||
|
||||
@unknown default:
|
||||
purchaseState = .idle
|
||||
}
|
||||
} catch {
|
||||
purchaseState = .failed(error.localizedDescription)
|
||||
FirebaseService.shared.logPurchaseFailure(
|
||||
productId: product.id,
|
||||
error: error.localizedDescription
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Restore Purchases
|
||||
|
||||
func restorePurchases() async {
|
||||
purchaseState = .purchasing
|
||||
|
||||
do {
|
||||
try await AppStore.sync()
|
||||
await updatePremiumStatus()
|
||||
|
||||
if isPremium {
|
||||
purchaseState = .restored
|
||||
} else {
|
||||
purchaseState = .failed("No purchases to restore")
|
||||
}
|
||||
} catch {
|
||||
purchaseState = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Update Premium Status
|
||||
|
||||
func updatePremiumStatus() async {
|
||||
var isEntitled = false
|
||||
var familyShared = false
|
||||
|
||||
#if DEBUG
|
||||
if debugOverrideEnabled {
|
||||
isPremium = true
|
||||
isFamilyShared = false
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
for await result in StoreKit.Transaction.currentEntitlements {
|
||||
if case .verified(let transaction) = result {
|
||||
if transaction.productID == Self.premiumProductID {
|
||||
isEntitled = true
|
||||
familyShared = transaction.ownershipType == .familyShared
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isPremium = isEntitled
|
||||
isFamilyShared = familyShared
|
||||
sharedDefaults?.set(isEntitled, forKey: "premiumUnlocked")
|
||||
|
||||
// Update Core Data
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
PremiumStatus.updateStatus(
|
||||
isPremium: isEntitled,
|
||||
productIdentifier: Self.premiumProductID,
|
||||
transactionId: nil,
|
||||
isFamilyShared: familyShared,
|
||||
in: context
|
||||
)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func setDebugPremiumOverride(_ enabled: Bool) {
|
||||
debugOverrideEnabled = enabled
|
||||
UserDefaults.standard.set(enabled, forKey: "debugPremiumOverride")
|
||||
if enabled {
|
||||
isPremium = true
|
||||
isFamilyShared = false
|
||||
sharedDefaults?.set(true, forKey: "premiumUnlocked")
|
||||
} else {
|
||||
Task { await updatePremiumStatus() }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Transaction Listener
|
||||
|
||||
private func listenForTransactions() -> Task<Void, Error> {
|
||||
return Task.detached { [weak self] in
|
||||
for await result in StoreKit.Transaction.updates {
|
||||
if case .verified(let transaction) = result {
|
||||
await transaction.finish()
|
||||
await self?.updatePremiumStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Verification
|
||||
|
||||
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
|
||||
switch result {
|
||||
case .unverified(_, let error):
|
||||
throw IAPError.verificationFailed(error.localizedDescription)
|
||||
case .verified(let safe):
|
||||
return safe
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Product Info
|
||||
|
||||
var premiumProduct: Product? {
|
||||
products.first { $0.id == Self.premiumProductID }
|
||||
}
|
||||
|
||||
var formattedPrice: String {
|
||||
premiumProduct?.displayPrice ?? Self.premiumPrice
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - IAP Error
|
||||
|
||||
enum IAPError: LocalizedError {
|
||||
case productNotFound
|
||||
case verificationFailed(String)
|
||||
case purchaseFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .productNotFound:
|
||||
return "Product not found. Please try again later."
|
||||
case .verificationFailed(let message):
|
||||
return "Verification failed: \(message)"
|
||||
case .purchaseFailed(let message):
|
||||
return "Purchase failed: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Premium Features
|
||||
|
||||
extension IAPService {
|
||||
static let premiumFeatures: [(icon: String, title: String, description: String)] = [
|
||||
("person.2", "Multiple Accounts", "Separate portfolios for business or family"),
|
||||
("infinity", "Unlimited Sources", "Track as many investments as you want"),
|
||||
("clock.arrow.circlepath", "Full History", "Access your complete investment history"),
|
||||
("chart.bar.xaxis", "Advanced Charts", "5 types of detailed analytics charts"),
|
||||
("wand.and.stars", "Predictions", "AI-powered 12-month forecasts"),
|
||||
("square.and.arrow.up", "Export Data", "Export to CSV and JSON formats"),
|
||||
("xmark.circle", "No Ads", "Ad-free experience forever"),
|
||||
("person.2", "Family Sharing", "Share with up to 5 family members")
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
import Combine
|
||||
|
||||
class ImportService {
|
||||
static let shared = ImportService()
|
||||
|
||||
private init() {}
|
||||
|
||||
struct ImportResult {
|
||||
let accountsCreated: Int
|
||||
let sourcesCreated: Int
|
||||
let snapshotsCreated: Int
|
||||
let errors: [String]
|
||||
}
|
||||
|
||||
struct ImportProgress {
|
||||
let completed: Int
|
||||
let total: Int
|
||||
let message: String
|
||||
|
||||
var fraction: Double {
|
||||
guard total > 0 else { return 0 }
|
||||
return min(max(Double(completed) / Double(total), 0), 1)
|
||||
}
|
||||
}
|
||||
|
||||
enum ImportFormat {
|
||||
case csv
|
||||
case json
|
||||
}
|
||||
|
||||
struct ImportedAccount {
|
||||
let name: String
|
||||
let currency: String?
|
||||
let inputMode: InputMode
|
||||
let notificationFrequency: NotificationFrequency
|
||||
let customFrequencyMonths: Int
|
||||
let categories: [ImportedCategory]
|
||||
}
|
||||
|
||||
struct ImportedCategory {
|
||||
let name: String
|
||||
let colorHex: String?
|
||||
let icon: String?
|
||||
let sources: [ImportedSource]
|
||||
}
|
||||
|
||||
struct ImportedSource {
|
||||
let name: String
|
||||
let snapshots: [ImportedSnapshot]
|
||||
}
|
||||
|
||||
struct ImportedSnapshot {
|
||||
let date: Date
|
||||
let value: Decimal
|
||||
let contribution: Decimal?
|
||||
let notes: String?
|
||||
}
|
||||
|
||||
func importData(
|
||||
content: String,
|
||||
format: ImportFormat,
|
||||
allowMultipleAccounts: Bool,
|
||||
defaultAccountName: String? = nil
|
||||
) -> ImportResult {
|
||||
switch format {
|
||||
case .csv:
|
||||
let parsed = parseCSV(
|
||||
content,
|
||||
allowMultipleAccounts: allowMultipleAccounts,
|
||||
defaultAccountName: defaultAccountName
|
||||
)
|
||||
return applyImport(parsed, context: CoreDataStack.shared.viewContext)
|
||||
case .json:
|
||||
let parsed = parseJSON(content, allowMultipleAccounts: allowMultipleAccounts)
|
||||
return applyImport(parsed, context: CoreDataStack.shared.viewContext)
|
||||
}
|
||||
}
|
||||
|
||||
func importDataAsync(
|
||||
content: String,
|
||||
format: ImportFormat,
|
||||
allowMultipleAccounts: Bool,
|
||||
defaultAccountName: String? = nil,
|
||||
progress: @escaping (ImportProgress) -> Void
|
||||
) async -> ImportResult {
|
||||
await withCheckedContinuation { continuation in
|
||||
CoreDataStack.shared.performBackgroundTask { context in
|
||||
let parsed: [ImportedAccount]
|
||||
switch format {
|
||||
case .csv:
|
||||
parsed = self.parseCSV(
|
||||
content,
|
||||
allowMultipleAccounts: allowMultipleAccounts,
|
||||
defaultAccountName: defaultAccountName
|
||||
)
|
||||
case .json:
|
||||
parsed = self.parseJSON(content, allowMultipleAccounts: allowMultipleAccounts)
|
||||
}
|
||||
|
||||
let totalSnapshots = parsed.reduce(0) { total, account in
|
||||
total + account.categories.reduce(0) { subtotal, category in
|
||||
subtotal + category.sources.reduce(0) { sourceTotal, source in
|
||||
sourceTotal + source.snapshots.count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
progress(ImportProgress(completed: 0, total: totalSnapshots, message: "Importing data"))
|
||||
}
|
||||
|
||||
let result = self.applyImport(parsed, context: context) { completed in
|
||||
DispatchQueue.main.async {
|
||||
progress(ImportProgress(
|
||||
completed: completed,
|
||||
total: totalSnapshots,
|
||||
message: "Imported \(completed) of \(totalSnapshots) snapshots"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func sampleCSV() -> String {
|
||||
return """
|
||||
Account,Category,Source,Date,Value,Contribution,Notes
|
||||
Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
|
||||
Personal,Crypto,BTC,2024-01-01,3200,,Cold storage
|
||||
Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
"""
|
||||
}
|
||||
|
||||
static func sampleJSON() -> String {
|
||||
return """
|
||||
{
|
||||
"version": 2,
|
||||
"currency": "EUR",
|
||||
"accounts": [{
|
||||
"name": "Personal",
|
||||
"inputMode": "simple",
|
||||
"notificationFrequency": "monthly",
|
||||
"categories": [{
|
||||
"name": "Stocks",
|
||||
"color": "#3B82F6",
|
||||
"icon": "chart.line.uptrend.xyaxis",
|
||||
"sources": [{
|
||||
"name": "Index Fund",
|
||||
"snapshots": [{
|
||||
"date": "2024-01-01T00:00:00Z",
|
||||
"value": 15000,
|
||||
"contribution": 12000
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
// MARK: - Parsing
|
||||
|
||||
private func parseCSV(
|
||||
_ content: String,
|
||||
allowMultipleAccounts: Bool,
|
||||
defaultAccountName: String?
|
||||
) -> [ImportedAccount] {
|
||||
let rows = parseCSVRows(content)
|
||||
guard rows.count > 1 else { return [] }
|
||||
|
||||
let headers = rows[0].map { $0.lowercased().trimmingCharacters(in: .whitespaces) }
|
||||
let indexOfAccount = headers.firstIndex(of: "account")
|
||||
let indexOfCategory = headers.firstIndex(of: "category")
|
||||
let indexOfSource = headers.firstIndex(of: "source")
|
||||
let indexOfDate = headers.firstIndex(of: "date")
|
||||
let indexOfValue = headers.firstIndex(where: { $0.hasPrefix("value") })
|
||||
let indexOfContribution = headers.firstIndex(where: { $0.hasPrefix("contribution") })
|
||||
let indexOfNotes = headers.firstIndex(of: "notes")
|
||||
|
||||
var grouped: [String: [String: [String: [ImportedSnapshot]]]] = [:]
|
||||
|
||||
for row in rows.dropFirst() {
|
||||
let providedAccount = indexOfAccount.flatMap { row.safeValue(at: $0) }
|
||||
let fallbackAccount = defaultAccountName ?? "Personal"
|
||||
let normalizedAccount = (providedAccount ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let rawAccountName = normalizedAccount.isEmpty ? fallbackAccount : normalizedAccount
|
||||
let accountName = allowMultipleAccounts ? rawAccountName : "Personal"
|
||||
|
||||
guard let categoryName = indexOfCategory.flatMap({ row.safeValue(at: $0) }), !categoryName.isEmpty,
|
||||
let sourceName = indexOfSource.flatMap({ row.safeValue(at: $0) }), !sourceName.isEmpty,
|
||||
let dateString = indexOfDate.flatMap({ row.safeValue(at: $0) }),
|
||||
let valueString = indexOfValue.flatMap({ row.safeValue(at: $0) }) else {
|
||||
continue
|
||||
}
|
||||
|
||||
guard let date = parseDate(dateString),
|
||||
let value = parseDecimal(valueString) else { continue }
|
||||
|
||||
let contribution = indexOfContribution
|
||||
.flatMap { row.safeValue(at: $0) }
|
||||
.flatMap(parseDecimal)
|
||||
let notes = indexOfNotes
|
||||
.flatMap { row.safeValue(at: $0) }
|
||||
.flatMap { $0.isEmpty ? nil : $0 }
|
||||
|
||||
let snapshot = ImportedSnapshot(
|
||||
date: date,
|
||||
value: value,
|
||||
contribution: contribution,
|
||||
notes: notes
|
||||
)
|
||||
|
||||
grouped[accountName, default: [:]][categoryName, default: [:]][sourceName, default: []].append(snapshot)
|
||||
}
|
||||
|
||||
return grouped.map { accountName, categories in
|
||||
let importedCategories = categories.map { categoryName, sources in
|
||||
let importedSources = sources.map { sourceName, snapshots in
|
||||
ImportedSource(name: sourceName, snapshots: snapshots)
|
||||
}
|
||||
return ImportedCategory(name: categoryName, colorHex: nil, icon: nil, sources: importedSources)
|
||||
}
|
||||
|
||||
return ImportedAccount(
|
||||
name: accountName,
|
||||
currency: nil,
|
||||
inputMode: .simple,
|
||||
notificationFrequency: .monthly,
|
||||
customFrequencyMonths: 1,
|
||||
categories: importedCategories
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func parseJSON(_ content: String, allowMultipleAccounts: Bool) -> [ImportedAccount] {
|
||||
guard let data = content.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return []
|
||||
}
|
||||
|
||||
if let accountsArray = json["accounts"] as? [[String: Any]] {
|
||||
return accountsArray.compactMap { accountDict in
|
||||
let rawName = accountDict["name"] as? String ?? "Personal"
|
||||
let name = allowMultipleAccounts ? rawName : "Personal"
|
||||
|
||||
let currency = accountDict["currency"] as? String
|
||||
let inputMode = InputMode(rawValue: accountDict["inputMode"] as? String ?? "") ?? .simple
|
||||
let notificationFrequency = NotificationFrequency(
|
||||
rawValue: accountDict["notificationFrequency"] as? String ?? ""
|
||||
) ?? .monthly
|
||||
let customFrequencyMonths = accountDict["customFrequencyMonths"] as? Int ?? 1
|
||||
|
||||
let categoriesArray = accountDict["categories"] as? [[String: Any]] ?? []
|
||||
let categories = categoriesArray.map { categoryDict in
|
||||
let categoryName = categoryDict["name"] as? String ?? "Uncategorized"
|
||||
let colorHex = categoryDict["color"] as? String
|
||||
let icon = categoryDict["icon"] as? String
|
||||
let sourcesArray = categoryDict["sources"] as? [[String: Any]] ?? []
|
||||
let sources = sourcesArray.map { sourceDict in
|
||||
let sourceName = sourceDict["name"] as? String ?? "Source"
|
||||
let snapshotsArray = sourceDict["snapshots"] as? [[String: Any]] ?? []
|
||||
let snapshots = snapshotsArray.compactMap { snapshotDict -> ImportedSnapshot? in
|
||||
guard let dateString = snapshotDict["date"] as? String,
|
||||
let date = ISO8601DateFormatter().date(from: dateString),
|
||||
let value = snapshotDict["value"] as? Double else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let contribution = (snapshotDict["contribution"] as? Double).map { Decimal($0) }
|
||||
let notes = snapshotDict["notes"] as? String
|
||||
return ImportedSnapshot(
|
||||
date: date,
|
||||
value: Decimal(value),
|
||||
contribution: contribution,
|
||||
notes: notes
|
||||
)
|
||||
}
|
||||
|
||||
return ImportedSource(name: sourceName, snapshots: snapshots)
|
||||
}
|
||||
|
||||
return ImportedCategory(
|
||||
name: categoryName,
|
||||
colorHex: colorHex,
|
||||
icon: icon,
|
||||
sources: sources
|
||||
)
|
||||
}
|
||||
|
||||
return ImportedAccount(
|
||||
name: name,
|
||||
currency: currency,
|
||||
inputMode: inputMode,
|
||||
notificationFrequency: notificationFrequency,
|
||||
customFrequencyMonths: customFrequencyMonths,
|
||||
categories: categories
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy JSON: categories only
|
||||
if let categoriesArray = json["categories"] as? [[String: Any]] {
|
||||
let categories = categoriesArray.map { categoryDict in
|
||||
let categoryName = categoryDict["name"] as? String ?? "Uncategorized"
|
||||
let colorHex = categoryDict["color"] as? String
|
||||
let icon = categoryDict["icon"] as? String
|
||||
let sourcesArray = categoryDict["sources"] as? [[String: Any]] ?? []
|
||||
let sources = sourcesArray.map { sourceDict in
|
||||
let sourceName = sourceDict["name"] as? String ?? "Source"
|
||||
let snapshotsArray = sourceDict["snapshots"] as? [[String: Any]] ?? []
|
||||
let snapshots = snapshotsArray.compactMap { snapshotDict -> ImportedSnapshot? in
|
||||
guard let dateString = snapshotDict["date"] as? String,
|
||||
let date = ISO8601DateFormatter().date(from: dateString),
|
||||
let value = snapshotDict["value"] as? Double else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let contribution = (snapshotDict["contribution"] as? Double).map { Decimal($0) }
|
||||
let notes = snapshotDict["notes"] as? String
|
||||
return ImportedSnapshot(
|
||||
date: date,
|
||||
value: Decimal(value),
|
||||
contribution: contribution,
|
||||
notes: notes
|
||||
)
|
||||
}
|
||||
|
||||
return ImportedSource(name: sourceName, snapshots: snapshots)
|
||||
}
|
||||
|
||||
return ImportedCategory(
|
||||
name: categoryName,
|
||||
colorHex: colorHex,
|
||||
icon: icon,
|
||||
sources: sources
|
||||
)
|
||||
}
|
||||
|
||||
return [
|
||||
ImportedAccount(
|
||||
name: "Personal",
|
||||
currency: json["currency"] as? String,
|
||||
inputMode: .simple,
|
||||
notificationFrequency: .monthly,
|
||||
customFrequencyMonths: 1,
|
||||
categories: categories
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
// MARK: - Apply Import
|
||||
|
||||
private func applyImport(
|
||||
_ accounts: [ImportedAccount],
|
||||
context: NSManagedObjectContext,
|
||||
snapshotProgress: ((Int) -> Void)? = nil
|
||||
) -> ImportResult {
|
||||
let accountRepository = AccountRepository(context: context)
|
||||
let categoryRepository = CategoryRepository(context: context)
|
||||
let sourceRepository = InvestmentSourceRepository(context: context)
|
||||
let snapshotRepository = SnapshotRepository(context: context)
|
||||
|
||||
var accountsCreated = 0
|
||||
var sourcesCreated = 0
|
||||
var snapshotsCreated = 0
|
||||
var errors: [String] = []
|
||||
|
||||
var categoryLookup = buildCategoryLookup(from: categoryRepository.categories)
|
||||
let otherCategory = resolveExistingCategory(named: "Other", lookup: categoryLookup) ??
|
||||
categoryRepository.createCategory(
|
||||
name: "Other",
|
||||
colorHex: "#64748B",
|
||||
icon: "ellipsis.circle.fill"
|
||||
)
|
||||
categoryLookup[normalizedCategoryName(otherCategory.name)] = otherCategory
|
||||
|
||||
var completionDatesByMonth: [String: Date] = [:]
|
||||
|
||||
for importedAccount in accounts {
|
||||
let existingAccount = accountRepository.accounts.first(where: { $0.name == importedAccount.name })
|
||||
let account = existingAccount ?? accountRepository.createAccount(
|
||||
name: importedAccount.name,
|
||||
currency: importedAccount.currency,
|
||||
inputMode: importedAccount.inputMode,
|
||||
notificationFrequency: importedAccount.notificationFrequency,
|
||||
customFrequencyMonths: importedAccount.customFrequencyMonths
|
||||
)
|
||||
|
||||
if existingAccount == nil {
|
||||
accountsCreated += 1
|
||||
}
|
||||
|
||||
for importedCategory in importedAccount.categories {
|
||||
let existingCategory = resolveExistingCategory(
|
||||
named: importedCategory.name,
|
||||
lookup: categoryLookup
|
||||
)
|
||||
let shouldUseOther = existingCategory == nil &&
|
||||
importedCategory.colorHex == nil &&
|
||||
importedCategory.icon == nil
|
||||
let resolvedName = canonicalCategoryName(for: importedCategory.name) ?? importedCategory.name
|
||||
let category = existingCategory ?? (shouldUseOther
|
||||
? otherCategory
|
||||
: categoryRepository.createCategory(
|
||||
name: resolvedName,
|
||||
colorHex: importedCategory.colorHex ?? "#3B82F6",
|
||||
icon: importedCategory.icon ?? "chart.pie.fill"
|
||||
))
|
||||
categoryLookup[normalizedCategoryName(category.name)] = category
|
||||
|
||||
for importedSource in importedCategory.sources {
|
||||
let existingSource = sourceRepository.sources.first(where: {
|
||||
$0.name == importedSource.name && $0.account?.id == account.id
|
||||
})
|
||||
let source = existingSource ?? sourceRepository.createSource(
|
||||
name: importedSource.name,
|
||||
category: category,
|
||||
notificationFrequency: importedAccount.notificationFrequency,
|
||||
customFrequencyMonths: importedAccount.customFrequencyMonths
|
||||
)
|
||||
source.account = account
|
||||
if existingSource == nil {
|
||||
sourcesCreated += 1
|
||||
}
|
||||
|
||||
for snapshot in importedSource.snapshots {
|
||||
snapshotRepository.createSnapshot(
|
||||
for: source,
|
||||
date: snapshot.date,
|
||||
value: snapshot.value,
|
||||
contribution: snapshot.contribution,
|
||||
notes: snapshot.notes
|
||||
)
|
||||
snapshotsCreated += 1
|
||||
snapshotProgress?(snapshotsCreated)
|
||||
|
||||
let monthKey = MonthlyCheckInStore.monthKey(for: snapshot.date)
|
||||
if let existingDate = completionDatesByMonth[monthKey] {
|
||||
if snapshot.date > existingDate {
|
||||
completionDatesByMonth[monthKey] = snapshot.date
|
||||
}
|
||||
} else {
|
||||
completionDatesByMonth[monthKey] = snapshot.date
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if context.hasChanges {
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
errors.append("Failed to save imported data.")
|
||||
}
|
||||
}
|
||||
|
||||
if !completionDatesByMonth.isEmpty {
|
||||
for (monthKey, completionDate) in completionDatesByMonth {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM"
|
||||
if let monthDate = formatter.date(from: monthKey) {
|
||||
MonthlyCheckInStore.setCompletionDate(completionDate, for: monthDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ImportResult(
|
||||
accountsCreated: accountsCreated,
|
||||
sourcesCreated: sourcesCreated,
|
||||
snapshotsCreated: snapshotsCreated,
|
||||
errors: errors
|
||||
)
|
||||
}
|
||||
|
||||
private func resolveExistingCategory(
|
||||
named rawName: String,
|
||||
lookup: [String: Category]
|
||||
) -> Category? {
|
||||
if let canonical = canonicalCategoryName(for: rawName) {
|
||||
let canonicalKey = normalizedCategoryName(canonical)
|
||||
if let match = lookup[canonicalKey] {
|
||||
return match
|
||||
}
|
||||
}
|
||||
return lookup[normalizedCategoryName(rawName)]
|
||||
}
|
||||
|
||||
private func buildCategoryLookup(from categories: [Category]) -> [String: Category] {
|
||||
var lookup: [String: Category] = [:]
|
||||
for category in categories {
|
||||
lookup[normalizedCategoryName(category.name)] = category
|
||||
}
|
||||
return lookup
|
||||
}
|
||||
|
||||
private func canonicalCategoryName(for rawName: String) -> String? {
|
||||
let normalized = normalizedCategoryName(rawName)
|
||||
for mapping in categoryAliasMappings {
|
||||
if mapping.aliases.contains(where: { normalizedCategoryName($0) == normalized }) {
|
||||
return mapping.canonical
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func normalizedCategoryName(_ value: String) -> String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalized = trimmed.folding(
|
||||
options: [.diacriticInsensitive, .caseInsensitive],
|
||||
locale: .current
|
||||
)
|
||||
return normalized.replacingOccurrences(
|
||||
of: "\\s+",
|
||||
with: " ",
|
||||
options: .regularExpression
|
||||
)
|
||||
}
|
||||
|
||||
private var categoryAliasMappings: [(canonical: String, aliases: [String])] {
|
||||
[
|
||||
(
|
||||
canonical: "Stocks",
|
||||
aliases: ["Stocks", "category_stocks", String(localized: "category_stocks"), "Acciones"]
|
||||
),
|
||||
(
|
||||
canonical: "Bonds",
|
||||
aliases: ["Bonds", "category_bonds", String(localized: "category_bonds"), "Bonos"]
|
||||
),
|
||||
(
|
||||
canonical: "Real Estate",
|
||||
aliases: ["Real Estate", "category_real_estate", String(localized: "category_real_estate"), "Inmobiliario"]
|
||||
),
|
||||
(
|
||||
canonical: "Crypto",
|
||||
aliases: ["Crypto", "category_crypto", String(localized: "category_crypto"), "Cripto"]
|
||||
),
|
||||
(
|
||||
canonical: "Cash",
|
||||
aliases: ["Cash", "category_cash", String(localized: "category_cash"), "Efectivo"]
|
||||
),
|
||||
(
|
||||
canonical: "ETFs",
|
||||
aliases: ["ETFs", "category_etfs", String(localized: "category_etfs"), "ETF"]
|
||||
),
|
||||
(
|
||||
canonical: "Retirement",
|
||||
aliases: ["Retirement", "category_retirement", String(localized: "category_retirement"), "Jubilación"]
|
||||
),
|
||||
(
|
||||
canonical: "Other",
|
||||
aliases: [
|
||||
"Other",
|
||||
"category_other",
|
||||
String(localized: "category_other"),
|
||||
"Uncategorized",
|
||||
"uncategorized",
|
||||
String(localized: "uncategorized"),
|
||||
"Otros",
|
||||
"Sin categoría"
|
||||
]
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - CSV Helpers
|
||||
|
||||
private func parseCSVRows(_ content: String) -> [[String]] {
|
||||
var rows: [[String]] = []
|
||||
var currentRow: [String] = []
|
||||
var currentField = ""
|
||||
var insideQuotes = false
|
||||
|
||||
for char in content {
|
||||
if char == "\"" {
|
||||
insideQuotes.toggle()
|
||||
continue
|
||||
}
|
||||
|
||||
if char == "," && !insideQuotes {
|
||||
currentRow.append(currentField)
|
||||
currentField = ""
|
||||
continue
|
||||
}
|
||||
|
||||
if char == "\n" && !insideQuotes {
|
||||
currentRow.append(currentField)
|
||||
rows.append(currentRow.map { $0.trimmingCharacters(in: .whitespaces) })
|
||||
currentRow = []
|
||||
currentField = ""
|
||||
continue
|
||||
}
|
||||
|
||||
currentField.append(char)
|
||||
}
|
||||
|
||||
if !currentField.isEmpty || !currentRow.isEmpty {
|
||||
currentRow.append(currentField)
|
||||
rows.append(currentRow.map { $0.trimmingCharacters(in: .whitespaces) })
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
private func parseDate(_ value: String) -> Date? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
|
||||
if let iso = ISO8601DateFormatter().date(from: trimmed) {
|
||||
return iso
|
||||
}
|
||||
|
||||
let formats = [
|
||||
"yyyy-MM-dd",
|
||||
"yyyy/MM/dd",
|
||||
"dd/MM/yyyy",
|
||||
"MM/dd/yyyy",
|
||||
"dd-MM-yyyy",
|
||||
"MM-dd-yyyy",
|
||||
"yyyy-MM-dd HH:mm",
|
||||
"yyyy-MM-dd HH:mm:ss",
|
||||
"yyyy/MM/dd HH:mm",
|
||||
"yyyy/MM/dd HH:mm:ss",
|
||||
"dd/MM/yyyy HH:mm",
|
||||
"dd/MM/yyyy HH:mm:ss",
|
||||
"MM/dd/yyyy HH:mm",
|
||||
"MM/dd/yyyy HH:mm:ss",
|
||||
"dd-MM-yyyy HH:mm",
|
||||
"dd-MM-yyyy HH:mm:ss",
|
||||
"MM-dd-yyyy HH:mm",
|
||||
"MM-dd-yyyy HH:mm:ss",
|
||||
"dd/MM/yyyy h:mm a",
|
||||
"dd/MM/yyyy h:mm:ss a",
|
||||
"MM/dd/yyyy h:mm a",
|
||||
"MM/dd/yyyy h:mm:ss a",
|
||||
"dd-MM-yyyy h:mm a",
|
||||
"dd-MM-yyyy h:mm:ss a",
|
||||
"MM-dd-yyyy h:mm a",
|
||||
"MM-dd-yyyy h:mm:ss a"
|
||||
]
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = .current
|
||||
|
||||
for format in formats {
|
||||
formatter.dateFormat = format
|
||||
if let date = formatter.date(from: trimmed) {
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func parseDecimal(_ value: String) -> Decimal? {
|
||||
let cleaned = value
|
||||
.replacingOccurrences(of: ",", with: ".")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
return Decimal(string: cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array where Element == String {
|
||||
func safeValue(at index: Int) -> String? {
|
||||
guard index >= 0, index < count else { return nil }
|
||||
return self[index]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import UserNotifications
|
||||
import UIKit
|
||||
|
||||
class NotificationService: ObservableObject {
|
||||
static let shared = NotificationService()
|
||||
|
||||
@Published var isAuthorized = false
|
||||
@Published var pendingCount = 0
|
||||
|
||||
private let center = UNUserNotificationCenter.current()
|
||||
|
||||
private init() {
|
||||
checkAuthorizationStatus()
|
||||
}
|
||||
|
||||
// MARK: - Authorization
|
||||
|
||||
func checkAuthorizationStatus() {
|
||||
center.getNotificationSettings { [weak self] settings in
|
||||
DispatchQueue.main.async {
|
||||
self?.isAuthorized = settings.authorizationStatus == .authorized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requestAuthorization() async -> Bool {
|
||||
do {
|
||||
let granted = try await center.requestAuthorization(options: [.alert, .badge, .sound])
|
||||
await MainActor.run {
|
||||
self.isAuthorized = granted
|
||||
}
|
||||
return granted
|
||||
} catch {
|
||||
print("Notification authorization error: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Schedule Notifications
|
||||
|
||||
func scheduleReminder(for source: InvestmentSource) {
|
||||
guard let nextDate = source.nextReminderDate else { return }
|
||||
guard source.frequency != .never else { return }
|
||||
|
||||
// Remove existing notification for this source
|
||||
cancelReminder(for: source)
|
||||
|
||||
// Get notification time from settings
|
||||
let settings = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext)
|
||||
let notificationTime = settings.defaultNotificationTime ?? defaultNotificationTime()
|
||||
|
||||
// Combine date and time
|
||||
let calendar = Calendar.current
|
||||
var components = calendar.dateComponents([.year, .month, .day], from: nextDate)
|
||||
let timeComponents = calendar.dateComponents([.hour, .minute], from: notificationTime)
|
||||
components.hour = timeComponents.hour
|
||||
components.minute = timeComponents.minute
|
||||
|
||||
guard let triggerDate = calendar.date(from: components) else { return }
|
||||
|
||||
// Create notification content
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Investment Update Reminder"
|
||||
content.body = "Time to update \(source.name). Tap to add a new snapshot."
|
||||
content.sound = .default
|
||||
content.badge = NSNumber(value: pendingCount + 1)
|
||||
content.userInfo = [
|
||||
"sourceId": source.id.uuidString,
|
||||
"sourceName": source.name
|
||||
]
|
||||
|
||||
// Create trigger
|
||||
let triggerComponents = calendar.dateComponents(
|
||||
[.year, .month, .day, .hour, .minute],
|
||||
from: triggerDate
|
||||
)
|
||||
let trigger = UNCalendarNotificationTrigger(dateMatching: triggerComponents, repeats: false)
|
||||
|
||||
// Create request
|
||||
let request = UNNotificationRequest(
|
||||
identifier: notificationIdentifier(for: source),
|
||||
content: content,
|
||||
trigger: trigger
|
||||
)
|
||||
|
||||
// Schedule
|
||||
center.add(request) { error in
|
||||
if let error = error {
|
||||
print("Failed to schedule notification: \(error)")
|
||||
} else {
|
||||
print("Scheduled reminder for \(source.name) on \(triggerDate)")
|
||||
FirebaseService.shared.logNotificationScheduled(frequency: source.notificationFrequency)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func scheduleAllReminders(for sources: [InvestmentSource]) {
|
||||
for source in sources {
|
||||
scheduleReminder(for: source)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cancel Notifications
|
||||
|
||||
func cancelReminder(for source: InvestmentSource) {
|
||||
center.removePendingNotificationRequests(
|
||||
withIdentifiers: [notificationIdentifier(for: source)]
|
||||
)
|
||||
}
|
||||
|
||||
func cancelAllReminders() {
|
||||
center.removeAllPendingNotificationRequests()
|
||||
}
|
||||
|
||||
// MARK: - Badge Management
|
||||
|
||||
func updateBadgeCount() {
|
||||
let repository = InvestmentSourceRepository()
|
||||
let needsUpdate = repository.fetchSourcesNeedingUpdate()
|
||||
pendingCount = needsUpdate.count
|
||||
|
||||
center.setBadgeCount(pendingCount) { _ in }
|
||||
}
|
||||
|
||||
func clearBadge() {
|
||||
pendingCount = 0
|
||||
center.setBadgeCount(0) { _ in }
|
||||
}
|
||||
|
||||
// MARK: - Pending Notifications
|
||||
|
||||
func getPendingNotifications() async -> [UNNotificationRequest] {
|
||||
await center.pendingNotificationRequests()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func notificationIdentifier(for source: InvestmentSource) -> String {
|
||||
"investment_reminder_\(source.id.uuidString)"
|
||||
}
|
||||
|
||||
private func defaultNotificationTime() -> Date {
|
||||
var components = DateComponents()
|
||||
components.hour = 9
|
||||
components.minute = 0
|
||||
return Calendar.current.date(from: components) ?? Date()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Deep Link Handler
|
||||
|
||||
extension NotificationService {
|
||||
func handleNotificationResponse(_ response: UNNotificationResponse) {
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
|
||||
guard let sourceIdString = userInfo["sourceId"] as? String,
|
||||
let sourceId = UUID(uuidString: sourceIdString) else {
|
||||
return
|
||||
}
|
||||
|
||||
// Post notification for deep linking
|
||||
NotificationCenter.default.post(
|
||||
name: .openSourceDetail,
|
||||
object: nil,
|
||||
userInfo: ["sourceId": sourceId]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification Names
|
||||
|
||||
extension Notification.Name {
|
||||
static let openSourceDetail = Notification.Name("openSourceDetail")
|
||||
}
|
||||
|
||||
// MARK: - Background Refresh
|
||||
|
||||
extension NotificationService {
|
||||
func performBackgroundRefresh() {
|
||||
updateBadgeCount()
|
||||
|
||||
// Reschedule any missed notifications
|
||||
let repository = InvestmentSourceRepository()
|
||||
let sources = repository.fetchActiveSources()
|
||||
scheduleAllReminders(for: sources)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import Foundation
|
||||
|
||||
class PredictionEngine {
|
||||
static let shared = PredictionEngine()
|
||||
|
||||
private let context = CoreDataStack.shared.viewContext
|
||||
|
||||
// MARK: - Performance: Cached Calendar reference
|
||||
private static let calendar = Calendar.current
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Main Prediction Interface
|
||||
|
||||
func predict(
|
||||
snapshots: [Snapshot],
|
||||
monthsAhead: Int = 12,
|
||||
algorithm: PredictionAlgorithm? = nil
|
||||
) -> PredictionResult {
|
||||
guard snapshots.count >= 3 else {
|
||||
return PredictionResult(
|
||||
predictions: [],
|
||||
algorithm: .linear,
|
||||
accuracy: 0,
|
||||
volatility: 0
|
||||
)
|
||||
}
|
||||
|
||||
// Sort snapshots by date
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
|
||||
// Calculate volatility for algorithm selection
|
||||
let volatility = calculateVolatility(snapshots: sortedSnapshots)
|
||||
|
||||
// Select algorithm if not specified
|
||||
let selectedAlgorithm = algorithm ?? selectBestAlgorithm(volatility: volatility)
|
||||
|
||||
// Generate predictions
|
||||
let predictions: [Prediction]
|
||||
let accuracy: Double
|
||||
|
||||
switch selectedAlgorithm {
|
||||
case .linear:
|
||||
predictions = predictLinear(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
|
||||
accuracy = calculateLinearAccuracy(snapshots: sortedSnapshots)
|
||||
case .exponentialSmoothing:
|
||||
predictions = predictExponentialSmoothing(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
|
||||
accuracy = calculateESAccuracy(snapshots: sortedSnapshots)
|
||||
case .movingAverage:
|
||||
predictions = predictMovingAverage(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
|
||||
accuracy = calculateMAAccuracy(snapshots: sortedSnapshots)
|
||||
case .holtTrend:
|
||||
predictions = predictHoltTrend(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
|
||||
accuracy = calculateHoltAccuracy(snapshots: sortedSnapshots)
|
||||
}
|
||||
|
||||
return PredictionResult(
|
||||
predictions: predictions,
|
||||
algorithm: selectedAlgorithm,
|
||||
accuracy: accuracy,
|
||||
volatility: volatility
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Algorithm Selection
|
||||
|
||||
private func selectBestAlgorithm(volatility: Double) -> PredictionAlgorithm {
|
||||
switch volatility {
|
||||
case 0..<8:
|
||||
return .holtTrend
|
||||
case 8..<20:
|
||||
return .exponentialSmoothing
|
||||
default:
|
||||
return .movingAverage
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Linear Regression
|
||||
|
||||
func predictLinear(snapshots: [Snapshot], monthsAhead: Int = 12) -> [Prediction] {
|
||||
guard snapshots.count >= 3 else { return [] }
|
||||
|
||||
guard let firstDate = snapshots.first?.date else { return [] }
|
||||
|
||||
let dataPoints: [(x: Double, y: Double)] = snapshots.map { snapshot in
|
||||
let daysSinceStart = snapshot.date.timeIntervalSince(firstDate) / 86400
|
||||
return (x: daysSinceStart, y: snapshot.decimalValue.doubleValue)
|
||||
}
|
||||
|
||||
let (slope, intercept) = calculateLinearRegression(dataPoints: dataPoints)
|
||||
let residualStdDev = calculateResidualStdDev(dataPoints: dataPoints, slope: slope, intercept: intercept)
|
||||
|
||||
var predictions: [Prediction] = []
|
||||
let lastDate = snapshots.last!.date
|
||||
|
||||
for month in 1...monthsAhead {
|
||||
guard let futureDate = Self.calendar.date(
|
||||
byAdding: .month,
|
||||
value: month,
|
||||
to: lastDate
|
||||
) else { continue }
|
||||
|
||||
let daysFromStart = futureDate.timeIntervalSince(firstDate) / 86400
|
||||
let predictedValue = max(0, slope * daysFromStart + intercept)
|
||||
|
||||
// Widen confidence interval for further predictions
|
||||
let confidenceMultiplier = 1.0 + (Double(month) * 0.02)
|
||||
let intervalWidth = residualStdDev * 1.96 * confidenceMultiplier
|
||||
|
||||
predictions.append(Prediction(
|
||||
date: futureDate,
|
||||
predictedValue: Decimal(predictedValue),
|
||||
algorithm: .linear,
|
||||
confidenceInterval: Prediction.ConfidenceInterval(
|
||||
lower: Decimal(max(0, predictedValue - intervalWidth)),
|
||||
upper: Decimal(predictedValue + intervalWidth)
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
return predictions
|
||||
}
|
||||
|
||||
private func calculateLinearRegression(
|
||||
dataPoints: [(x: Double, y: Double)]
|
||||
) -> (slope: Double, intercept: Double) {
|
||||
let n = Double(dataPoints.count)
|
||||
let sumX = dataPoints.reduce(0) { $0 + $1.x }
|
||||
let sumY = dataPoints.reduce(0) { $0 + $1.y }
|
||||
let sumXY = dataPoints.reduce(0) { $0 + ($1.x * $1.y) }
|
||||
let sumX2 = dataPoints.reduce(0) { $0 + ($1.x * $1.x) }
|
||||
|
||||
let denominator = n * sumX2 - sumX * sumX
|
||||
guard denominator != 0 else { return (0, sumY / n) }
|
||||
|
||||
let slope = (n * sumXY - sumX * sumY) / denominator
|
||||
let intercept = (sumY - slope * sumX) / n
|
||||
|
||||
return (slope, intercept)
|
||||
}
|
||||
|
||||
private func calculateResidualStdDev(
|
||||
dataPoints: [(x: Double, y: Double)],
|
||||
slope: Double,
|
||||
intercept: Double
|
||||
) -> Double {
|
||||
guard dataPoints.count > 2 else { return 0 }
|
||||
|
||||
let residuals = dataPoints.map { point in
|
||||
let predicted = slope * point.x + intercept
|
||||
return pow(point.y - predicted, 2)
|
||||
}
|
||||
|
||||
let meanSquaredError = residuals.reduce(0, +) / Double(dataPoints.count - 2)
|
||||
return sqrt(meanSquaredError)
|
||||
}
|
||||
|
||||
private func calculateLinearAccuracy(snapshots: [Snapshot]) -> Double {
|
||||
guard snapshots.count >= 5 else { return 0.5 }
|
||||
|
||||
// Use last 20% of data for validation
|
||||
let splitIndex = Int(Double(snapshots.count) * 0.8)
|
||||
let trainingData = Array(snapshots.prefix(splitIndex))
|
||||
let validationData = Array(snapshots.suffix(from: splitIndex))
|
||||
|
||||
guard let firstDate = trainingData.first?.date else { return 0.5 }
|
||||
|
||||
let trainPoints = trainingData.map { snapshot in
|
||||
(x: snapshot.date.timeIntervalSince(firstDate) / 86400, y: snapshot.decimalValue.doubleValue)
|
||||
}
|
||||
|
||||
let (slope, intercept) = calculateLinearRegression(dataPoints: trainPoints)
|
||||
|
||||
// Calculate R-squared on validation data
|
||||
let validationValues = validationData.map { $0.decimalValue.doubleValue }
|
||||
let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count)
|
||||
|
||||
var ssRes: Double = 0
|
||||
var ssTot: Double = 0
|
||||
|
||||
for snapshot in validationData {
|
||||
let x = snapshot.date.timeIntervalSince(firstDate) / 86400
|
||||
let actual = snapshot.decimalValue.doubleValue
|
||||
let predicted = slope * x + intercept
|
||||
|
||||
ssRes += pow(actual - predicted, 2)
|
||||
ssTot += pow(actual - meanValidation, 2)
|
||||
}
|
||||
|
||||
guard ssTot != 0 else { return 0.5 }
|
||||
let rSquared = max(0, 1 - (ssRes / ssTot))
|
||||
|
||||
return min(1.0, rSquared)
|
||||
}
|
||||
|
||||
// MARK: - Exponential Smoothing
|
||||
|
||||
func predictExponentialSmoothing(
|
||||
snapshots: [Snapshot],
|
||||
monthsAhead: Int = 12,
|
||||
alpha: Double = 0.3
|
||||
) -> [Prediction] {
|
||||
guard snapshots.count >= 3 else { return [] }
|
||||
|
||||
let values = snapshots.map { $0.decimalValue.doubleValue }
|
||||
|
||||
// Calculate smoothed values
|
||||
var smoothed = values[0]
|
||||
for i in 1..<values.count {
|
||||
smoothed = alpha * values[i] + (1 - alpha) * smoothed
|
||||
}
|
||||
|
||||
// Calculate trend
|
||||
var trend: Double = 0
|
||||
if values.count >= 2 {
|
||||
let recentChange = values.suffix(3).reduce(0) { $0 + $1 } / 3.0 -
|
||||
values.prefix(3).reduce(0) { $0 + $1 } / 3.0
|
||||
trend = recentChange / Double(values.count)
|
||||
}
|
||||
|
||||
// Calculate standard deviation for confidence interval
|
||||
let stdDev = calculateStdDev(values: values)
|
||||
|
||||
var predictions: [Prediction] = []
|
||||
let lastDate = snapshots.last!.date
|
||||
|
||||
for month in 1...monthsAhead {
|
||||
guard let futureDate = Self.calendar.date(
|
||||
byAdding: .month,
|
||||
value: month,
|
||||
to: lastDate
|
||||
) else { continue }
|
||||
|
||||
let predictedValue = max(0, smoothed + trend * Double(month))
|
||||
let intervalWidth = stdDev * 1.96 * (1.0 + Double(month) * 0.05)
|
||||
|
||||
predictions.append(Prediction(
|
||||
date: futureDate,
|
||||
predictedValue: Decimal(predictedValue),
|
||||
algorithm: .exponentialSmoothing,
|
||||
confidenceInterval: Prediction.ConfidenceInterval(
|
||||
lower: Decimal(max(0, predictedValue - intervalWidth)),
|
||||
upper: Decimal(predictedValue + intervalWidth)
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
return predictions
|
||||
}
|
||||
|
||||
private func calculateESAccuracy(snapshots: [Snapshot]) -> Double {
|
||||
guard snapshots.count >= 5 else { return 0.5 }
|
||||
|
||||
let values = snapshots.map { $0.decimalValue.doubleValue }
|
||||
let splitIndex = Int(Double(values.count) * 0.8)
|
||||
|
||||
var smoothed = values[0]
|
||||
for i in 1..<splitIndex {
|
||||
smoothed = 0.3 * values[i] + 0.7 * smoothed
|
||||
}
|
||||
|
||||
let validationValues = Array(values.suffix(from: splitIndex))
|
||||
let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count)
|
||||
|
||||
var ssRes: Double = 0
|
||||
var ssTot: Double = 0
|
||||
|
||||
for (i, actual) in validationValues.enumerated() {
|
||||
let predicted = smoothed + (smoothed - values[splitIndex - 1]) * Double(i + 1) / Double(splitIndex)
|
||||
ssRes += pow(actual - predicted, 2)
|
||||
ssTot += pow(actual - meanValidation, 2)
|
||||
}
|
||||
|
||||
guard ssTot != 0 else { return 0.5 }
|
||||
return max(0, min(1.0, 1 - (ssRes / ssTot)))
|
||||
}
|
||||
|
||||
// MARK: - Moving Average
|
||||
|
||||
func predictMovingAverage(
|
||||
snapshots: [Snapshot],
|
||||
monthsAhead: Int = 12,
|
||||
windowSize: Int = 3
|
||||
) -> [Prediction] {
|
||||
guard snapshots.count >= windowSize else { return [] }
|
||||
|
||||
let values = snapshots.map { $0.decimalValue.doubleValue }
|
||||
|
||||
// Calculate moving average of last window
|
||||
let recentValues = Array(values.suffix(windowSize))
|
||||
let movingAverage = recentValues.reduce(0, +) / Double(windowSize)
|
||||
|
||||
// Calculate average monthly change
|
||||
var changes: [Double] = []
|
||||
for i in 1..<values.count {
|
||||
changes.append(values[i] - values[i - 1])
|
||||
}
|
||||
let avgChange = changes.isEmpty ? 0 : changes.reduce(0, +) / Double(changes.count)
|
||||
|
||||
let stdDev = calculateStdDev(values: values)
|
||||
|
||||
var predictions: [Prediction] = []
|
||||
let lastDate = snapshots.last!.date
|
||||
|
||||
for month in 1...monthsAhead {
|
||||
guard let futureDate = Self.calendar.date(
|
||||
byAdding: .month,
|
||||
value: month,
|
||||
to: lastDate
|
||||
) else { continue }
|
||||
|
||||
let predictedValue = max(0, movingAverage + avgChange * Double(month))
|
||||
let intervalWidth = stdDev * 1.96 * (1.0 + Double(month) * 0.03)
|
||||
|
||||
predictions.append(Prediction(
|
||||
date: futureDate,
|
||||
predictedValue: Decimal(predictedValue),
|
||||
algorithm: .movingAverage,
|
||||
confidenceInterval: Prediction.ConfidenceInterval(
|
||||
lower: Decimal(max(0, predictedValue - intervalWidth)),
|
||||
upper: Decimal(predictedValue + intervalWidth)
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
return predictions
|
||||
}
|
||||
|
||||
private func calculateMAAccuracy(snapshots: [Snapshot]) -> Double {
|
||||
guard snapshots.count >= 5 else { return 0.5 }
|
||||
|
||||
let values = snapshots.map { $0.decimalValue.doubleValue }
|
||||
let windowSize = 3
|
||||
let splitIndex = Int(Double(values.count) * 0.8)
|
||||
|
||||
guard splitIndex > windowSize else { return 0.5 }
|
||||
|
||||
let recentWindow = Array(values[(splitIndex - windowSize)..<splitIndex])
|
||||
let movingAvg = recentWindow.reduce(0, +) / Double(windowSize)
|
||||
|
||||
let validationValues = Array(values.suffix(from: splitIndex))
|
||||
let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count)
|
||||
|
||||
var ssRes: Double = 0
|
||||
var ssTot: Double = 0
|
||||
|
||||
for actual in validationValues {
|
||||
ssRes += pow(actual - movingAvg, 2)
|
||||
ssTot += pow(actual - meanValidation, 2)
|
||||
}
|
||||
|
||||
guard ssTot != 0 else { return 0.5 }
|
||||
return max(0, min(1.0, 1 - (ssRes / ssTot)))
|
||||
}
|
||||
|
||||
// MARK: - Holt Trend (Double Exponential Smoothing)
|
||||
|
||||
func predictHoltTrend(
|
||||
snapshots: [Snapshot],
|
||||
monthsAhead: Int = 12,
|
||||
alpha: Double = 0.4,
|
||||
beta: Double = 0.3
|
||||
) -> [Prediction] {
|
||||
guard snapshots.count >= 3 else { return [] }
|
||||
|
||||
let values = snapshots.map { $0.decimalValue.doubleValue }
|
||||
var level = values[0]
|
||||
var trend = values[1] - values[0]
|
||||
|
||||
var fitted: [Double] = []
|
||||
for value in values {
|
||||
let lastLevel = level
|
||||
level = alpha * value + (1 - alpha) * (level + trend)
|
||||
trend = beta * (level - lastLevel) + (1 - beta) * trend
|
||||
fitted.append(level + trend)
|
||||
}
|
||||
|
||||
let residuals = zip(values, fitted).map { $0 - $1 }
|
||||
let stdDev = calculateStdDev(values: residuals)
|
||||
|
||||
var predictions: [Prediction] = []
|
||||
let lastDate = snapshots.last!.date
|
||||
|
||||
for month in 1...monthsAhead {
|
||||
guard let futureDate = Self.calendar.date(
|
||||
byAdding: .month,
|
||||
value: month,
|
||||
to: lastDate
|
||||
) else { continue }
|
||||
|
||||
let predictedValue = max(0, level + Double(month) * trend)
|
||||
let intervalWidth = stdDev * 1.96 * (1.0 + Double(month) * 0.04)
|
||||
|
||||
predictions.append(Prediction(
|
||||
date: futureDate,
|
||||
predictedValue: Decimal(predictedValue),
|
||||
algorithm: .holtTrend,
|
||||
confidenceInterval: Prediction.ConfidenceInterval(
|
||||
lower: Decimal(max(0, predictedValue - intervalWidth)),
|
||||
upper: Decimal(predictedValue + intervalWidth)
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
return predictions
|
||||
}
|
||||
|
||||
private func calculateHoltAccuracy(snapshots: [Snapshot]) -> Double {
|
||||
guard snapshots.count >= 5 else { return 0.5 }
|
||||
|
||||
let values = snapshots.map { $0.decimalValue.doubleValue }
|
||||
let splitIndex = Int(Double(values.count) * 0.8)
|
||||
guard splitIndex >= 2 else { return 0.5 }
|
||||
|
||||
var level = values[0]
|
||||
var trend = values[1] - values[0]
|
||||
|
||||
for value in values.prefix(splitIndex) {
|
||||
let lastLevel = level
|
||||
level = 0.4 * value + 0.6 * (level + trend)
|
||||
trend = 0.3 * (level - lastLevel) + 0.7 * trend
|
||||
}
|
||||
|
||||
let validationValues = Array(values.suffix(from: splitIndex))
|
||||
let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count)
|
||||
|
||||
var ssRes: Double = 0
|
||||
var ssTot: Double = 0
|
||||
|
||||
for (i, actual) in validationValues.enumerated() {
|
||||
let predicted = level + Double(i + 1) * trend
|
||||
ssRes += pow(actual - predicted, 2)
|
||||
ssTot += pow(actual - meanValidation, 2)
|
||||
}
|
||||
|
||||
guard ssTot != 0 else { return 0.5 }
|
||||
return max(0, min(1.0, 1 - (ssRes / ssTot)))
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func calculateVolatility(snapshots: [Snapshot]) -> Double {
|
||||
let values = snapshots.map { $0.decimalValue.doubleValue }
|
||||
guard values.count >= 2 else { return 0 }
|
||||
|
||||
var returns: [Double] = []
|
||||
for i in 1..<values.count {
|
||||
guard values[i - 1] != 0 else { continue }
|
||||
let periodReturn = (values[i] - values[i - 1]) / values[i - 1] * 100
|
||||
returns.append(periodReturn)
|
||||
}
|
||||
|
||||
return calculateStdDev(values: returns)
|
||||
}
|
||||
|
||||
private func calculateStdDev(values: [Double]) -> Double {
|
||||
guard values.count >= 2 else { return 0 }
|
||||
|
||||
let mean = values.reduce(0, +) / Double(values.count)
|
||||
let squaredDifferences = values.map { pow($0 - mean, 2) }
|
||||
let variance = squaredDifferences.reduce(0, +) / Double(values.count - 1)
|
||||
|
||||
return sqrt(variance)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import Foundation
|
||||
|
||||
class SampleDataService {
|
||||
static let shared = SampleDataService()
|
||||
|
||||
private init() {}
|
||||
|
||||
func seedSampleData() {
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
let sourceRepository = InvestmentSourceRepository(context: context)
|
||||
guard sourceRepository.sourceCount == 0 else { return }
|
||||
|
||||
let categoryRepository = CategoryRepository(context: context)
|
||||
categoryRepository.createDefaultCategoriesIfNeeded()
|
||||
|
||||
let accountRepository = AccountRepository(context: context)
|
||||
let account = accountRepository.createDefaultAccountIfNeeded()
|
||||
|
||||
let snapshotRepository = SnapshotRepository(context: context)
|
||||
let goalRepository = GoalRepository(context: context)
|
||||
let transactionRepository = TransactionRepository(context: context)
|
||||
|
||||
let categories = categoryRepository.categories
|
||||
let stocksCategory = categories.first { $0.name == "Stocks" } ?? categories.first!
|
||||
let cryptoCategory = categories.first { $0.name == "Crypto" } ?? categories.first!
|
||||
let realEstateCategory = categories.first { $0.name == "Real Estate" } ?? categories.first!
|
||||
|
||||
let stocks = sourceRepository.createSource(
|
||||
name: "Index Fund",
|
||||
category: stocksCategory,
|
||||
account: account
|
||||
)
|
||||
let crypto = sourceRepository.createSource(
|
||||
name: "BTC",
|
||||
category: cryptoCategory,
|
||||
account: account
|
||||
)
|
||||
let realEstate = sourceRepository.createSource(
|
||||
name: "Rental Property",
|
||||
category: realEstateCategory,
|
||||
account: account
|
||||
)
|
||||
|
||||
seedSnapshots(for: stocks, baseValue: 12000, monthlyIncrease: 450, repository: snapshotRepository)
|
||||
seedSnapshots(for: crypto, baseValue: 3000, monthlyIncrease: 250, repository: snapshotRepository)
|
||||
seedSnapshots(for: realEstate, baseValue: 80000, monthlyIncrease: 600, repository: snapshotRepository)
|
||||
|
||||
seedMonthlyNotes()
|
||||
|
||||
transactionRepository.createTransaction(
|
||||
source: stocks,
|
||||
type: .buy,
|
||||
date: Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date(),
|
||||
shares: 10,
|
||||
price: 400,
|
||||
amount: nil,
|
||||
notes: "Sample buy"
|
||||
)
|
||||
|
||||
_ = goalRepository.createGoal(
|
||||
name: "1M Goal",
|
||||
targetAmount: 1_000_000,
|
||||
targetDate: nil,
|
||||
account: account
|
||||
)
|
||||
}
|
||||
|
||||
private func seedSnapshots(
|
||||
for source: InvestmentSource,
|
||||
baseValue: Decimal,
|
||||
monthlyIncrease: Decimal,
|
||||
repository: SnapshotRepository
|
||||
) {
|
||||
let calendar = Calendar.current
|
||||
for monthOffset in (0..<6).reversed() {
|
||||
let date = calendar.date(byAdding: .month, value: -monthOffset, to: Date()) ?? Date()
|
||||
let value = baseValue + Decimal(monthOffset) * monthlyIncrease
|
||||
repository.createSnapshot(
|
||||
for: source,
|
||||
date: date,
|
||||
value: value,
|
||||
contribution: monthOffset == 0 ? monthlyIncrease : nil,
|
||||
notes: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func seedMonthlyNotes() {
|
||||
let calendar = Calendar.current
|
||||
let notes = [
|
||||
"Rebalanced slightly toward equities. Stayed calm despite noise.",
|
||||
"Focused on contributions. No major changes.",
|
||||
"Reviewed allocation drift and decided to hold positions."
|
||||
]
|
||||
|
||||
for (index, note) in notes.enumerated() {
|
||||
let date = calendar.date(byAdding: .month, value: -index, to: Date()) ?? Date()
|
||||
MonthlyCheckInStore.setNote(note, for: date)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
class ShareService {
|
||||
static let shared = ShareService()
|
||||
|
||||
private init() {}
|
||||
|
||||
func shareTextFile(content: String, fileName: String) {
|
||||
guard let viewController = ShareService.topViewController() else { return }
|
||||
|
||||
let tempURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(fileName)
|
||||
|
||||
do {
|
||||
try content.write(to: tempURL, atomically: true, encoding: .utf8)
|
||||
|
||||
let activityVC = UIActivityViewController(
|
||||
activityItems: [tempURL],
|
||||
applicationActivities: nil
|
||||
)
|
||||
|
||||
if let popover = activityVC.popoverPresentationController {
|
||||
popover.sourceView = viewController.view
|
||||
popover.sourceRect = CGRect(
|
||||
x: viewController.view.bounds.midX,
|
||||
y: viewController.view.bounds.midY,
|
||||
width: 0,
|
||||
height: 0
|
||||
)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
viewController.present(activityVC, animated: true)
|
||||
}
|
||||
} catch {
|
||||
print("Share file error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func shareCalendarEvent(
|
||||
title: String,
|
||||
notes: String,
|
||||
startDate: Date,
|
||||
durationMinutes: Int = 60
|
||||
) {
|
||||
let endDate = startDate.addingTimeInterval(TimeInterval(durationMinutes * 60))
|
||||
let icsContent = calendarICS(
|
||||
title: title,
|
||||
notes: notes,
|
||||
startDate: startDate,
|
||||
endDate: endDate
|
||||
)
|
||||
shareTextFile(content: icsContent, fileName: "PortfolioJournal-CheckIn.ics")
|
||||
}
|
||||
|
||||
private static func topViewController(
|
||||
base: UIViewController? = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap { $0.windows }
|
||||
.first(where: { $0.isKeyWindow })?.rootViewController
|
||||
) -> UIViewController? {
|
||||
if let nav = base as? UINavigationController {
|
||||
return topViewController(base: nav.visibleViewController)
|
||||
}
|
||||
if let tab = base as? UITabBarController {
|
||||
return topViewController(base: tab.selectedViewController)
|
||||
}
|
||||
if let presented = base?.presentedViewController {
|
||||
return topViewController(base: presented)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
private func calendarICS(
|
||||
title: String,
|
||||
notes: String,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
) -> String {
|
||||
let uid = UUID().uuidString
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
let stamp = formatter.string(from: Date())
|
||||
let start = formatter.string(from: startDate)
|
||||
let end = formatter.string(from: endDate)
|
||||
|
||||
return """
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//PortfolioJournal//MonthlyCheckIn//EN
|
||||
BEGIN:VEVENT
|
||||
UID:\(uid)
|
||||
DTSTAMP:\(stamp)
|
||||
DTSTART:\(start)
|
||||
DTEND:\(end)
|
||||
SUMMARY:\(escapeICS(title))
|
||||
DESCRIPTION:\(escapeICS(notes))
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
"""
|
||||
}
|
||||
|
||||
private func escapeICS(_ value: String) -> String {
|
||||
value
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "\n", with: "\\n")
|
||||
.replacingOccurrences(of: ";", with: "\\;")
|
||||
.replacingOccurrences(of: ",", with: "\\,")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
enum AllocationTargetStore {
|
||||
private static let targetsKey = "allocationTargets"
|
||||
|
||||
static func target(for categoryId: UUID) -> Double? {
|
||||
loadTargets()[categoryId.uuidString]
|
||||
}
|
||||
|
||||
static func setTarget(_ value: Double?, for categoryId: UUID) {
|
||||
var targets = loadTargets()
|
||||
let key = categoryId.uuidString
|
||||
if let value, value > 0 {
|
||||
targets[key] = value
|
||||
} else {
|
||||
targets.removeValue(forKey: key)
|
||||
}
|
||||
saveTargets(targets)
|
||||
}
|
||||
|
||||
static func totalTargetPercentage(for categoryIds: [UUID]) -> Double {
|
||||
let targets = loadTargets()
|
||||
return categoryIds.reduce(0) { total, id in
|
||||
total + (targets[id.uuidString] ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadTargets() -> [String: Double] {
|
||||
guard let data = UserDefaults.standard.data(forKey: targetsKey),
|
||||
let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else {
|
||||
return [:]
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
private static func saveTargets(_ targets: [String: Double]) {
|
||||
if let data = try? JSONEncoder().encode(targets) {
|
||||
UserDefaults.standard.set(data, forKey: targetsKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
import LocalAuthentication
|
||||
|
||||
enum AppLockService {
|
||||
static func canUseBiometrics() -> Bool {
|
||||
let context = LAContext()
|
||||
var error: NSError?
|
||||
return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
||||
}
|
||||
|
||||
static func authenticate(reason: String, completion: @escaping (Bool) -> Void) {
|
||||
let context = LAContext()
|
||||
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in
|
||||
DispatchQueue.main.async {
|
||||
completion(success)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import Foundation
|
||||
|
||||
enum AppConstants {
|
||||
// MARK: - App Info
|
||||
|
||||
static let appName = "Portfolio Journal"
|
||||
static let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
|
||||
static let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
|
||||
|
||||
// MARK: - Bundle Identifiers
|
||||
|
||||
static let bundleIdentifier = "com.alexandrevazquez.portfoliojournal"
|
||||
static let appGroupIdentifier = "group.com.alexandrevazquez.portfoliojournal"
|
||||
static let cloudKitContainerIdentifier = "iCloud.com.alexandrevazquez.portfoliojournal"
|
||||
|
||||
// MARK: - StoreKit
|
||||
|
||||
static let premiumProductID = "com.portfoliojournal.premium"
|
||||
static let premiumPrice = "€4.69"
|
||||
|
||||
// MARK: - AdMob
|
||||
|
||||
#if DEBUG
|
||||
static let adMobAppID = "ca-app-pub-3940256099942544~1458002511" // Test App ID
|
||||
static let bannerAdUnitID = "ca-app-pub-3940256099942544/2934735716" // Test Banner
|
||||
#else
|
||||
static let adMobAppID = "ca-app-pub-XXXXXXXXXXXXXXXX~YYYYYYYYYY" // Replace with real App ID
|
||||
static let bannerAdUnitID = "ca-app-pub-XXXXXXXXXXXXXXXX/YYYYYYYYYY" // Replace with real Ad Unit ID
|
||||
#endif
|
||||
|
||||
// MARK: - Currency
|
||||
|
||||
static let defaultCurrency = "EUR"
|
||||
static let currencySymbol = "€"
|
||||
|
||||
// MARK: - Freemium Limits
|
||||
|
||||
static let maxFreeSources = 5
|
||||
static let maxFreeHistoricalMonths = 12
|
||||
|
||||
// MARK: - UI Constants
|
||||
|
||||
enum UI {
|
||||
static let cornerRadius: CGFloat = 12
|
||||
static let smallCornerRadius: CGFloat = 8
|
||||
static let largeCornerRadius: CGFloat = 16
|
||||
|
||||
static let padding: CGFloat = 16
|
||||
static let smallPadding: CGFloat = 8
|
||||
static let largePadding: CGFloat = 24
|
||||
|
||||
static let iconSize: CGFloat = 24
|
||||
static let smallIconSize: CGFloat = 16
|
||||
static let largeIconSize: CGFloat = 32
|
||||
|
||||
static let bannerAdHeight: CGFloat = 50
|
||||
static let tabBarHeight: CGFloat = 49
|
||||
|
||||
static let cardShadowRadius: CGFloat = 4
|
||||
static let cardShadowOpacity: CGFloat = 0.1
|
||||
}
|
||||
|
||||
// MARK: - Animation
|
||||
|
||||
enum Animation {
|
||||
static let defaultDuration: Double = 0.3
|
||||
static let shortDuration: Double = 0.15
|
||||
static let longDuration: Double = 0.5
|
||||
}
|
||||
|
||||
// MARK: - Charts
|
||||
|
||||
enum Charts {
|
||||
static let defaultMonthsToShow = 12
|
||||
static let predictionMonths = 12
|
||||
static let minDataPointsForPrediction = 3
|
||||
static let confidenceIntervalPercentage = 0.15
|
||||
}
|
||||
|
||||
// MARK: - Notifications
|
||||
|
||||
enum Notifications {
|
||||
static let defaultHour = 9
|
||||
static let defaultMinute = 0
|
||||
static let categoryIdentifier = "INVESTMENT_REMINDER"
|
||||
}
|
||||
|
||||
// MARK: - Storage Keys
|
||||
|
||||
enum StorageKeys {
|
||||
static let onboardingCompleted = "onboardingCompleted"
|
||||
static let adConsentObtained = "adConsentObtained"
|
||||
static let lastSyncDate = "lastSyncDate"
|
||||
static let selectedCategoryFilter = "selectedCategoryFilter"
|
||||
static let preferredChartType = "preferredChartType"
|
||||
}
|
||||
|
||||
// MARK: - Deep Links
|
||||
|
||||
enum DeepLinks {
|
||||
static let scheme = "portfoliojournal"
|
||||
static let sourceDetail = "source"
|
||||
static let addSnapshot = "addSnapshot"
|
||||
static let premium = "premium"
|
||||
}
|
||||
|
||||
// MARK: - URLs
|
||||
|
||||
enum URLs {
|
||||
static let privacyPolicy = "https://portfoliojournal.app/privacy.html"
|
||||
static let termsOfService = "https://portfoliojournal.app/terms.html"
|
||||
static let support = "https://portfoliojournal.app/support.html"
|
||||
static let appStore = "https://apps.apple.com/app/idXXXXXXXXXX"
|
||||
}
|
||||
|
||||
// MARK: - Feature Flags
|
||||
|
||||
enum Features {
|
||||
static let enablePredictions = true
|
||||
static let enableExport = true
|
||||
static let enableWidgets = true
|
||||
static let enableNotifications = true
|
||||
static let enableAnalytics = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SF Symbols
|
||||
|
||||
enum SFSymbol {
|
||||
// Navigation
|
||||
static let dashboard = "chart.pie.fill"
|
||||
static let sources = "list.bullet"
|
||||
static let charts = "chart.xyaxis.line"
|
||||
static let settings = "gearshape.fill"
|
||||
|
||||
// Actions
|
||||
static let add = "plus"
|
||||
static let edit = "pencil"
|
||||
static let delete = "trash"
|
||||
static let share = "square.and.arrow.up"
|
||||
static let export = "arrow.up.doc"
|
||||
|
||||
// Status
|
||||
static let checkmark = "checkmark.circle.fill"
|
||||
static let warning = "exclamationmark.triangle.fill"
|
||||
static let error = "xmark.circle.fill"
|
||||
static let info = "info.circle.fill"
|
||||
|
||||
// Financial
|
||||
static let trendUp = "arrow.up.right"
|
||||
static let trendDown = "arrow.down.right"
|
||||
static let money = "eurosign.circle.fill"
|
||||
static let chart = "chart.line.uptrend.xyaxis"
|
||||
|
||||
// Categories
|
||||
static let stocks = "chart.line.uptrend.xyaxis"
|
||||
static let bonds = "building.columns.fill"
|
||||
static let realEstate = "house.fill"
|
||||
static let crypto = "bitcoinsign.circle.fill"
|
||||
static let cash = "banknote.fill"
|
||||
static let etf = "chart.bar.fill"
|
||||
static let retirement = "person.fill"
|
||||
static let other = "ellipsis.circle.fill"
|
||||
|
||||
// Premium
|
||||
static let premium = "crown.fill"
|
||||
static let lock = "lock.fill"
|
||||
static let unlock = "lock.open.fill"
|
||||
|
||||
// Misc
|
||||
static let calendar = "calendar"
|
||||
static let notification = "bell.fill"
|
||||
static let refresh = "arrow.clockwise"
|
||||
static let close = "xmark"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
enum CurrencyFormatter {
|
||||
static func currentCurrencyCode() -> String {
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
return AppSettings.getOrCreate(in: context).currency
|
||||
}
|
||||
|
||||
static func format(_ decimal: Decimal, style: NumberFormatter.Style = .currency, maximumFractionDigits: Int = 2) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = style
|
||||
formatter.currencyCode = currentCurrencyCode()
|
||||
formatter.maximumFractionDigits = maximumFractionDigits
|
||||
return formatter.string(from: decimal as NSDecimalNumber) ?? "\(decimal)"
|
||||
}
|
||||
|
||||
static func symbol(for code: String) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
formatter.currencyCode = code
|
||||
return formatter.currencySymbol ?? code
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
enum CurrencyPicker {
|
||||
static let commonCodes: [String] = [
|
||||
"EUR", "USD", "GBP", "CHF", "JPY",
|
||||
"CAD", "AUD", "SEK", "NOK", "DKK"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import Foundation
|
||||
|
||||
struct DashboardSectionConfig: Identifiable, Codable, Hashable {
|
||||
let id: String
|
||||
var isVisible: Bool
|
||||
var isCollapsed: Bool
|
||||
}
|
||||
|
||||
enum DashboardSection: String, CaseIterable, Identifiable {
|
||||
case totalValue
|
||||
case monthlyCheckIn
|
||||
case momentumStreaks
|
||||
case monthlySummary
|
||||
case evolution
|
||||
case categoryBreakdown
|
||||
case goals
|
||||
case pendingUpdates
|
||||
case periodReturns
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .totalValue:
|
||||
return "Total Portfolio Value"
|
||||
case .monthlyCheckIn:
|
||||
return "Monthly Check-in"
|
||||
case .momentumStreaks:
|
||||
return "Momentum & Streaks"
|
||||
case .monthlySummary:
|
||||
return "Cashflow vs Growth"
|
||||
case .evolution:
|
||||
return "Portfolio Evolution"
|
||||
case .categoryBreakdown:
|
||||
return "By Category"
|
||||
case .goals:
|
||||
return "Goals"
|
||||
case .pendingUpdates:
|
||||
return "Pending Updates"
|
||||
case .periodReturns:
|
||||
return "Returns"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum DashboardLayoutStore {
|
||||
private static let storageKey = "dashboardLayoutConfig"
|
||||
|
||||
static func load() -> [DashboardSectionConfig] {
|
||||
let defaults = defaultConfigs()
|
||||
guard let data = UserDefaults.standard.data(forKey: storageKey),
|
||||
let decoded = try? JSONDecoder().decode([DashboardSectionConfig].self, from: data) else {
|
||||
return defaults
|
||||
}
|
||||
|
||||
var merged: [DashboardSectionConfig] = []
|
||||
for config in decoded {
|
||||
if let section = DashboardSection(rawValue: config.id) {
|
||||
merged.append(config)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
for section in DashboardSection.allCases {
|
||||
if !merged.contains(where: { $0.id == section.id }) {
|
||||
merged.append(defaults.first(where: { $0.id == section.id }) ?? DashboardSectionConfig(
|
||||
id: section.id,
|
||||
isVisible: true,
|
||||
isCollapsed: false
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
static func save(_ configs: [DashboardSectionConfig]) {
|
||||
guard let data = try? JSONEncoder().encode(configs) else { return }
|
||||
UserDefaults.standard.set(data, forKey: storageKey)
|
||||
}
|
||||
|
||||
static func reset() {
|
||||
UserDefaults.standard.removeObject(forKey: storageKey)
|
||||
}
|
||||
|
||||
private static func defaultConfigs() -> [DashboardSectionConfig] {
|
||||
DashboardSection.allCases.map { section in
|
||||
DashboardSectionConfig(
|
||||
id: section.id,
|
||||
isVisible: true,
|
||||
isCollapsed: false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
// MARK: - Hex Initialization
|
||||
|
||||
init?(hex: String) {
|
||||
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
|
||||
|
||||
var rgb: UInt64 = 0
|
||||
|
||||
guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let length = hexSanitized.count
|
||||
|
||||
switch length {
|
||||
case 6:
|
||||
self.init(
|
||||
red: Double((rgb & 0xFF0000) >> 16) / 255.0,
|
||||
green: Double((rgb & 0x00FF00) >> 8) / 255.0,
|
||||
blue: Double(rgb & 0x0000FF) / 255.0
|
||||
)
|
||||
case 8:
|
||||
self.init(
|
||||
red: Double((rgb & 0xFF000000) >> 24) / 255.0,
|
||||
green: Double((rgb & 0x00FF0000) >> 16) / 255.0,
|
||||
blue: Double((rgb & 0x0000FF00) >> 8) / 255.0,
|
||||
opacity: Double(rgb & 0x000000FF) / 255.0
|
||||
)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hex String Output
|
||||
|
||||
var hexString: String {
|
||||
guard let components = UIColor(self).cgColor.components else {
|
||||
return "#000000"
|
||||
}
|
||||
|
||||
let r = Int(components[0] * 255.0)
|
||||
let g = Int(components[1] * 255.0)
|
||||
let b = Int(components[2] * 255.0)
|
||||
|
||||
return String(format: "#%02X%02X%02X", r, g, b)
|
||||
}
|
||||
|
||||
// MARK: - App Colors
|
||||
|
||||
static let appPrimary = Color(hex: "#3B82F6") ?? .blue
|
||||
static let appSecondary = Color(hex: "#10B981") ?? .green
|
||||
static let appAccent = Color(hex: "#F59E0B") ?? .orange
|
||||
static let appError = Color(hex: "#EF4444") ?? .red
|
||||
static let appSuccess = Color(hex: "#10B981") ?? .green
|
||||
static let appWarning = Color(hex: "#F59E0B") ?? .orange
|
||||
|
||||
// MARK: - Financial Colors
|
||||
|
||||
static let positiveGreen = Color(hex: "#10B981") ?? .green
|
||||
static let negativeRed = Color(hex: "#EF4444") ?? .red
|
||||
static let neutralGray = Color(hex: "#6B7280") ?? .gray
|
||||
|
||||
static func financialColor(for value: Decimal) -> Color {
|
||||
if value > 0 {
|
||||
return .positiveGreen
|
||||
} else if value < 0 {
|
||||
return .negativeRed
|
||||
} else {
|
||||
return .neutralGray
|
||||
}
|
||||
}
|
||||
|
||||
static func financialColor(for value: Double) -> Color {
|
||||
if value > 0 {
|
||||
return .positiveGreen
|
||||
} else if value < 0 {
|
||||
return .negativeRed
|
||||
} else {
|
||||
return .neutralGray
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Colors
|
||||
|
||||
static let categoryColors: [String] = [
|
||||
"#3B82F6", // Blue
|
||||
"#10B981", // Green
|
||||
"#F59E0B", // Amber
|
||||
"#EF4444", // Red
|
||||
"#8B5CF6", // Purple
|
||||
"#EC4899", // Pink
|
||||
"#14B8A6", // Teal
|
||||
"#F97316", // Orange
|
||||
"#6366F1", // Indigo
|
||||
"#84CC16", // Lime
|
||||
"#06B6D4", // Cyan
|
||||
"#A855F7" // Violet
|
||||
]
|
||||
|
||||
static func categoryColor(at index: Int) -> Color {
|
||||
let hex = categoryColors[index % categoryColors.count]
|
||||
return Color(hex: hex) ?? .blue
|
||||
}
|
||||
|
||||
// MARK: - Chart Colors
|
||||
|
||||
static let chartColors: [Color] = categoryColors.compactMap { Color(hex: $0) }
|
||||
|
||||
// MARK: - Adjustments
|
||||
|
||||
func lighter(by percentage: Double = 0.2) -> Color {
|
||||
adjustBrightness(by: abs(percentage))
|
||||
}
|
||||
|
||||
func darker(by percentage: Double = 0.2) -> Color {
|
||||
adjustBrightness(by: -abs(percentage))
|
||||
}
|
||||
|
||||
private func adjustBrightness(by percentage: Double) -> Color {
|
||||
var hue: CGFloat = 0
|
||||
var saturation: CGFloat = 0
|
||||
var brightness: CGFloat = 0
|
||||
var alpha: CGFloat = 0
|
||||
|
||||
UIColor(self).getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
|
||||
|
||||
let newBrightness = max(0, min(1, brightness + CGFloat(percentage)))
|
||||
|
||||
return Color(UIColor(hue: hue, saturation: saturation, brightness: newBrightness, alpha: alpha))
|
||||
}
|
||||
|
||||
// MARK: - Opacity Variants
|
||||
|
||||
var soft: Color {
|
||||
self.opacity(0.1)
|
||||
}
|
||||
|
||||
var medium: Color {
|
||||
self.opacity(0.5)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Gradient Extensions
|
||||
|
||||
extension LinearGradient {
|
||||
static let appPrimaryGradient = LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appPrimary.lighter()],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
static let positiveGradient = LinearGradient(
|
||||
colors: [Color.positiveGreen.lighter(), Color.positiveGreen],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
|
||||
static let negativeGradient = LinearGradient(
|
||||
colors: [Color.negativeRed.lighter(), Color.negativeRed],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
// MARK: - Formatting
|
||||
|
||||
var shortDateString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
formatter.timeStyle = .none
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
var mediumDateString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .none
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
var longDateString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .long
|
||||
formatter.timeStyle = .none
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
var monthYearString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM yyyy"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
var yearString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
var iso8601String: String {
|
||||
ISO8601DateFormatter().string(from: self)
|
||||
}
|
||||
|
||||
// MARK: - Components
|
||||
|
||||
var startOfDay: Date {
|
||||
Calendar.current.startOfDay(for: self)
|
||||
}
|
||||
|
||||
var startOfMonth: Date {
|
||||
let components = Calendar.current.dateComponents([.year, .month], from: self)
|
||||
return Calendar.current.date(from: components) ?? self
|
||||
}
|
||||
|
||||
var startOfYear: Date {
|
||||
let components = Calendar.current.dateComponents([.year], from: self)
|
||||
return Calendar.current.date(from: components) ?? self
|
||||
}
|
||||
|
||||
var endOfMonth: Date {
|
||||
let startOfNextMonth = Calendar.current.date(
|
||||
byAdding: .month,
|
||||
value: 1,
|
||||
to: startOfMonth
|
||||
) ?? self
|
||||
return Calendar.current.date(byAdding: .day, value: -1, to: startOfNextMonth) ?? self
|
||||
}
|
||||
|
||||
// MARK: - Comparisons
|
||||
|
||||
func isSameDay(as other: Date) -> Bool {
|
||||
Calendar.current.isDate(self, inSameDayAs: other)
|
||||
}
|
||||
|
||||
func isSameMonth(as other: Date) -> Bool {
|
||||
let selfComponents = Calendar.current.dateComponents([.year, .month], from: self)
|
||||
let otherComponents = Calendar.current.dateComponents([.year, .month], from: other)
|
||||
return selfComponents.year == otherComponents.year &&
|
||||
selfComponents.month == otherComponents.month
|
||||
}
|
||||
|
||||
func isSameYear(as other: Date) -> Bool {
|
||||
Calendar.current.component(.year, from: self) ==
|
||||
Calendar.current.component(.year, from: other)
|
||||
}
|
||||
|
||||
var isToday: Bool {
|
||||
Calendar.current.isDateInToday(self)
|
||||
}
|
||||
|
||||
var isYesterday: Bool {
|
||||
Calendar.current.isDateInYesterday(self)
|
||||
}
|
||||
|
||||
var isThisMonth: Bool {
|
||||
isSameMonth(as: Date())
|
||||
}
|
||||
|
||||
var isThisYear: Bool {
|
||||
isSameYear(as: Date())
|
||||
}
|
||||
|
||||
// MARK: - Calculations
|
||||
|
||||
func adding(days: Int) -> Date {
|
||||
Calendar.current.date(byAdding: .day, value: days, to: self) ?? self
|
||||
}
|
||||
|
||||
func adding(months: Int) -> Date {
|
||||
Calendar.current.date(byAdding: .month, value: months, to: self) ?? self
|
||||
}
|
||||
|
||||
func adding(years: Int) -> Date {
|
||||
Calendar.current.date(byAdding: .year, value: years, to: self) ?? self
|
||||
}
|
||||
|
||||
func monthsBetween(_ other: Date) -> Int {
|
||||
let components = Calendar.current.dateComponents([.month], from: self, to: other)
|
||||
return components.month ?? 0
|
||||
}
|
||||
|
||||
func daysBetween(_ other: Date) -> Int {
|
||||
let components = Calendar.current.dateComponents([.day], from: self, to: other)
|
||||
return components.day ?? 0
|
||||
}
|
||||
|
||||
func yearsBetween(_ other: Date) -> Double {
|
||||
let days = Double(daysBetween(other))
|
||||
return days / 365.25
|
||||
}
|
||||
|
||||
// MARK: - Relative Description
|
||||
|
||||
var relativeDescription: String {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
return formatter.localizedString(for: self, relativeTo: Date())
|
||||
}
|
||||
|
||||
var friendlyDescription: String {
|
||||
if isToday {
|
||||
return String(localized: "date_today")
|
||||
} else if isYesterday {
|
||||
return String(localized: "date_yesterday")
|
||||
} else if isThisMonth {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEEE, d"
|
||||
return formatter.string(from: self)
|
||||
} else if isThisYear {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM d"
|
||||
return formatter.string(from: self)
|
||||
} else {
|
||||
return mediumDateString
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Date Range
|
||||
|
||||
struct DateRange {
|
||||
let start: Date
|
||||
let end: Date
|
||||
|
||||
static var thisMonth: DateRange {
|
||||
let now = Date()
|
||||
return DateRange(start: now.startOfMonth, end: now)
|
||||
}
|
||||
|
||||
static var lastMonth: DateRange {
|
||||
let now = Date()
|
||||
let lastMonth = now.adding(months: -1)
|
||||
return DateRange(start: lastMonth.startOfMonth, end: lastMonth.endOfMonth)
|
||||
}
|
||||
|
||||
static var thisYear: DateRange {
|
||||
let now = Date()
|
||||
return DateRange(start: now.startOfYear, end: now)
|
||||
}
|
||||
|
||||
static var lastYear: DateRange {
|
||||
let now = Date()
|
||||
let lastYear = now.adding(years: -1)
|
||||
return DateRange(start: lastYear.startOfYear, end: now.startOfYear.adding(days: -1))
|
||||
}
|
||||
|
||||
static func month(containing date: Date) -> DateRange {
|
||||
DateRange(start: date.startOfMonth, end: date.endOfMonth)
|
||||
}
|
||||
|
||||
static func last(months: Int) -> DateRange {
|
||||
let now = Date()
|
||||
let start = now.adding(months: -months)
|
||||
return DateRange(start: start, end: now)
|
||||
}
|
||||
|
||||
func contains(_ date: Date) -> Bool {
|
||||
date >= start && date <= end
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import Foundation
|
||||
|
||||
extension Decimal {
|
||||
// MARK: - Performance: Shared formatters (avoid creating on every call)
|
||||
private static let percentFormatter: NumberFormatter = {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .percent
|
||||
formatter.maximumFractionDigits = 2
|
||||
formatter.multiplier = 1
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let decimalFormatter: NumberFormatter = {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.maximumFractionDigits = 2
|
||||
return formatter
|
||||
}()
|
||||
|
||||
// MARK: - Performance: Cached currency symbol
|
||||
private static var _cachedCurrencySymbol: String?
|
||||
private static var currencySymbolCacheTime: Date?
|
||||
|
||||
private static var cachedCurrencySymbol: String {
|
||||
// Refresh cache every 60 seconds to pick up settings changes
|
||||
let now = Date()
|
||||
if let cached = _cachedCurrencySymbol,
|
||||
let cacheTime = currencySymbolCacheTime,
|
||||
now.timeIntervalSince(cacheTime) < 60 {
|
||||
return cached
|
||||
}
|
||||
let symbol = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol
|
||||
_cachedCurrencySymbol = symbol
|
||||
currencySymbolCacheTime = now
|
||||
return symbol
|
||||
}
|
||||
|
||||
/// Call this when currency settings change to invalidate the cache
|
||||
static func invalidateCurrencyCache() {
|
||||
_cachedCurrencySymbol = nil
|
||||
currencySymbolCacheTime = nil
|
||||
}
|
||||
|
||||
// MARK: - Formatting
|
||||
|
||||
var currencyString: String {
|
||||
CurrencyFormatter.format(self, style: .currency, maximumFractionDigits: 2)
|
||||
}
|
||||
|
||||
var compactCurrencyString: String {
|
||||
CurrencyFormatter.format(self, style: .currency, maximumFractionDigits: 0)
|
||||
}
|
||||
|
||||
var shortCurrencyString: String {
|
||||
let value = NSDecimalNumber(decimal: self).doubleValue
|
||||
let symbol = Self.cachedCurrencySymbol
|
||||
|
||||
switch Swift.abs(value) {
|
||||
case 1_000_000...:
|
||||
return String(format: "%@%.1fM", symbol, value / 1_000_000)
|
||||
case 1_000...:
|
||||
return String(format: "%@%.1fK", symbol, value / 1_000)
|
||||
default:
|
||||
return compactCurrencyString
|
||||
}
|
||||
}
|
||||
|
||||
var percentageString: String {
|
||||
Self.percentFormatter.string(from: self as NSDecimalNumber) ?? "0%"
|
||||
}
|
||||
|
||||
var signedPercentageString: String {
|
||||
let prefix = self >= 0 ? "+" : ""
|
||||
return prefix + percentageString
|
||||
}
|
||||
|
||||
var decimalString: String {
|
||||
Self.decimalFormatter.string(from: self as NSDecimalNumber) ?? "0"
|
||||
}
|
||||
|
||||
// MARK: - Conversions
|
||||
|
||||
var doubleValue: Double {
|
||||
NSDecimalNumber(decimal: self).doubleValue
|
||||
}
|
||||
|
||||
var intValue: Int {
|
||||
NSDecimalNumber(decimal: self).intValue
|
||||
}
|
||||
|
||||
// MARK: - Math Operations
|
||||
|
||||
var abs: Decimal {
|
||||
self < 0 ? -self : self
|
||||
}
|
||||
|
||||
func rounded(scale: Int = 2) -> Decimal {
|
||||
var result = Decimal()
|
||||
var mutableSelf = self
|
||||
NSDecimalRound(&result, &mutableSelf, scale, .plain)
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Comparisons
|
||||
|
||||
var isPositive: Bool {
|
||||
self > 0
|
||||
}
|
||||
|
||||
var isNegative: Bool {
|
||||
self < 0
|
||||
}
|
||||
|
||||
var isZero: Bool {
|
||||
self == 0
|
||||
}
|
||||
|
||||
// MARK: - Static Helpers
|
||||
|
||||
static func from(_ double: Double) -> Decimal {
|
||||
Decimal(double)
|
||||
}
|
||||
|
||||
static func from(_ string: String) -> Decimal? {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
return formatter.number(from: string)?.decimalValue
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NSDecimalNumber Extension
|
||||
|
||||
extension NSDecimalNumber {
|
||||
var currencyString: String {
|
||||
decimalValue.currencyString
|
||||
}
|
||||
|
||||
var compactCurrencyString: String {
|
||||
decimalValue.compactCurrencyString
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Optional Decimal
|
||||
|
||||
extension Optional where Wrapped == Decimal {
|
||||
var orZero: Decimal {
|
||||
self ?? Decimal.zero
|
||||
}
|
||||
|
||||
var currencyString: String {
|
||||
(self ?? Decimal.zero).currencyString
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
enum FreemiumLimits {
|
||||
static let maxSources = 5
|
||||
static let maxHistoricalMonths = 12
|
||||
}
|
||||
|
||||
class FreemiumValidator: ObservableObject {
|
||||
private let iapService: IAPService
|
||||
|
||||
init(iapService: IAPService) {
|
||||
self.iapService = iapService
|
||||
}
|
||||
|
||||
// MARK: - Source Limits
|
||||
|
||||
var isPremium: Bool {
|
||||
iapService.isPremium
|
||||
}
|
||||
|
||||
func canAddSource(currentCount: Int) -> Bool {
|
||||
if iapService.isPremium { return true }
|
||||
return currentCount < FreemiumLimits.maxSources
|
||||
}
|
||||
|
||||
func remainingSources(currentCount: Int) -> Int {
|
||||
if iapService.isPremium { return Int.max }
|
||||
return max(0, FreemiumLimits.maxSources - currentCount)
|
||||
}
|
||||
|
||||
var sourceLimit: Int {
|
||||
iapService.isPremium ? Int.max : FreemiumLimits.maxSources
|
||||
}
|
||||
|
||||
var sourceLimitDescription: String {
|
||||
if iapService.isPremium {
|
||||
return "Unlimited"
|
||||
}
|
||||
return "\(FreemiumLimits.maxSources) sources"
|
||||
}
|
||||
|
||||
// MARK: - Historical Data Limits
|
||||
|
||||
func filterSnapshots(_ snapshots: [Snapshot]) -> [Snapshot] {
|
||||
if iapService.isPremium { return snapshots }
|
||||
|
||||
let cutoffDate = Calendar.current.date(
|
||||
byAdding: .month,
|
||||
value: -FreemiumLimits.maxHistoricalMonths,
|
||||
to: Date()
|
||||
) ?? Date()
|
||||
|
||||
return snapshots.filter { $0.date >= cutoffDate }
|
||||
}
|
||||
|
||||
func isSnapshotAccessible(_ snapshot: Snapshot) -> Bool {
|
||||
if iapService.isPremium { return true }
|
||||
|
||||
let cutoffDate = Calendar.current.date(
|
||||
byAdding: .month,
|
||||
value: -FreemiumLimits.maxHistoricalMonths,
|
||||
to: Date()
|
||||
) ?? Date()
|
||||
|
||||
return snapshot.date >= cutoffDate
|
||||
}
|
||||
|
||||
var historicalLimit: Int {
|
||||
iapService.isPremium ? Int.max : FreemiumLimits.maxHistoricalMonths
|
||||
}
|
||||
|
||||
var historicalLimitDescription: String {
|
||||
if iapService.isPremium {
|
||||
return "Full history"
|
||||
}
|
||||
return "Last \(FreemiumLimits.maxHistoricalMonths) months"
|
||||
}
|
||||
|
||||
// MARK: - Feature Access
|
||||
|
||||
func canExport() -> Bool {
|
||||
return iapService.isPremium
|
||||
}
|
||||
|
||||
func canViewPredictions() -> Bool {
|
||||
return iapService.isPremium
|
||||
}
|
||||
|
||||
func canViewAdvancedCharts() -> Bool {
|
||||
return iapService.isPremium
|
||||
}
|
||||
|
||||
func canAccessFeature(_ feature: PremiumFeature) -> Bool {
|
||||
if iapService.isPremium { return true }
|
||||
|
||||
switch feature {
|
||||
case .multipleAccounts:
|
||||
return false
|
||||
case .unlimitedSources:
|
||||
return false
|
||||
case .fullHistory:
|
||||
return false
|
||||
case .advancedCharts:
|
||||
return false
|
||||
case .predictions:
|
||||
return false
|
||||
case .export:
|
||||
return false
|
||||
case .noAds:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Premium Features Enum
|
||||
|
||||
enum PremiumFeature: String, CaseIterable, Identifiable {
|
||||
case multipleAccounts = "multiple_accounts"
|
||||
case unlimitedSources = "unlimited_sources"
|
||||
case fullHistory = "full_history"
|
||||
case advancedCharts = "advanced_charts"
|
||||
case predictions = "predictions"
|
||||
case export = "export"
|
||||
case noAds = "no_ads"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .multipleAccounts: return "Multiple Accounts"
|
||||
case .unlimitedSources: return "Unlimited Sources"
|
||||
case .fullHistory: return "Full History"
|
||||
case .advancedCharts: return "Advanced Charts"
|
||||
case .predictions: return "Predictions"
|
||||
case .export: return "Export Data"
|
||||
case .noAds: return "No Ads"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .multipleAccounts: return "person.2"
|
||||
case .unlimitedSources: return "infinity"
|
||||
case .fullHistory: return "clock.arrow.circlepath"
|
||||
case .advancedCharts: return "chart.bar.xaxis"
|
||||
case .predictions: return "wand.and.stars"
|
||||
case .export: return "square.and.arrow.up"
|
||||
case .noAds: return "xmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .multipleAccounts:
|
||||
return "Track separate portfolios for family or business"
|
||||
case .unlimitedSources:
|
||||
return "Track as many investment sources as you want"
|
||||
case .fullHistory:
|
||||
return "Access your complete investment history"
|
||||
case .advancedCharts:
|
||||
return "5 types of detailed analytics charts"
|
||||
case .predictions:
|
||||
return "AI-powered 12-month forecasts"
|
||||
case .export:
|
||||
return "Export to CSV and JSON formats"
|
||||
case .noAds:
|
||||
return "Ad-free experience forever"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Paywall Triggers
|
||||
|
||||
enum PaywallTrigger: String {
|
||||
case sourceLimit = "source_limit"
|
||||
case historyLimit = "history_limit"
|
||||
case advancedCharts = "advanced_charts"
|
||||
case predictions = "predictions"
|
||||
case export = "export"
|
||||
case settingsUpgrade = "settings_upgrade"
|
||||
case manualTap = "manual_tap"
|
||||
}
|
||||
|
||||
func shouldShowPaywall(for trigger: PaywallTrigger) -> Bool {
|
||||
guard !iapService.isPremium else { return false }
|
||||
|
||||
switch trigger {
|
||||
case .sourceLimit, .historyLimit, .advancedCharts, .predictions, .export:
|
||||
return true
|
||||
case .settingsUpgrade, .manualTap:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
enum KeychainService {
|
||||
private static let service = "PortfolioJournal"
|
||||
private static let pinKey = "appLockPin"
|
||||
|
||||
static func savePin(_ pin: String) -> Bool {
|
||||
guard let data = pin.data(using: .utf8) else { return false }
|
||||
deletePin()
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: pinKey,
|
||||
kSecValueData: data
|
||||
]
|
||||
return SecItemAdd(query as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
|
||||
static func readPin() -> String? {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: pinKey,
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitOne
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess,
|
||||
let data = result as? Data,
|
||||
let pin = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return pin
|
||||
}
|
||||
|
||||
static func deletePin() {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: pinKey
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import Foundation
|
||||
|
||||
enum MonthlyCheckInStore {
|
||||
private static let notesKey = "monthlyCheckInNotes"
|
||||
private static let completionsKey = "monthlyCheckInCompletions"
|
||||
private static let legacyLastCheckInKey = "lastCheckInDate"
|
||||
private static let entriesKey = "monthlyCheckInEntries"
|
||||
|
||||
// MARK: - Public Accessors
|
||||
|
||||
static func note(for date: Date) -> String {
|
||||
entry(for: date)?.note ?? ""
|
||||
}
|
||||
|
||||
static func setNote(_ note: String, for date: Date) {
|
||||
updateEntry(for: date) { entry in
|
||||
let trimmed = note.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
entry.note = trimmed.isEmpty ? nil : note
|
||||
}
|
||||
}
|
||||
|
||||
static func rating(for date: Date) -> Int? {
|
||||
entry(for: date)?.rating
|
||||
}
|
||||
|
||||
static func setRating(_ rating: Int?, for date: Date) {
|
||||
updateEntry(for: date) { entry in
|
||||
if let rating, rating > 0 {
|
||||
entry.rating = min(max(1, rating), 5)
|
||||
} else {
|
||||
entry.rating = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func mood(for date: Date) -> MonthlyCheckInMood? {
|
||||
entry(for: date)?.mood
|
||||
}
|
||||
|
||||
static func setMood(_ mood: MonthlyCheckInMood?, for date: Date) {
|
||||
updateEntry(for: date) { entry in
|
||||
entry.mood = mood
|
||||
}
|
||||
}
|
||||
|
||||
static func monthKey(for date: Date) -> String {
|
||||
Self.monthFormatter.string(from: date)
|
||||
}
|
||||
|
||||
static func allNotes() -> [(date: Date, note: String)] {
|
||||
loadEntries()
|
||||
.compactMap { key, entry in
|
||||
guard let date = Self.monthFormatter.date(from: key) else { return nil }
|
||||
return (date: date, note: entry.note ?? "")
|
||||
}
|
||||
.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
static func entry(for date: Date) -> MonthlyCheckInEntry? {
|
||||
loadEntries()[monthKey(for: date)]
|
||||
}
|
||||
|
||||
static func allEntries() -> [(date: Date, entry: MonthlyCheckInEntry)] {
|
||||
loadEntries()
|
||||
.compactMap { key, entry in
|
||||
guard let date = Self.monthFormatter.date(from: key) else { return nil }
|
||||
return (date: date, entry: entry)
|
||||
}
|
||||
.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
static func completionDate(for date: Date) -> Date? {
|
||||
entry(for: date)?.completionDate
|
||||
}
|
||||
|
||||
static func setCompletionDate(_ completionDate: Date, for month: Date) {
|
||||
updateEntry(for: month) { entry in
|
||||
entry.completionTime = completionDate.timeIntervalSince1970
|
||||
}
|
||||
}
|
||||
|
||||
static func latestCompletionDate() -> Date? {
|
||||
let latestEntryDate = loadEntries().values
|
||||
.compactMap { $0.completionDate }
|
||||
.max()
|
||||
|
||||
if let latestEntryDate {
|
||||
return latestEntryDate
|
||||
}
|
||||
|
||||
let legacy = UserDefaults.standard.double(forKey: legacyLastCheckInKey)
|
||||
guard legacy > 0 else { return nil }
|
||||
return Date(timeIntervalSince1970: legacy)
|
||||
}
|
||||
|
||||
static func stats(referenceDate: Date = Date()) -> MonthlyCheckInStats {
|
||||
let cutoff = referenceDate.endOfMonth
|
||||
let entries = allEntries().filter { $0.date <= cutoff }
|
||||
let completions: [(month: Date, completion: Date, mood: MonthlyCheckInMood?)] = entries.compactMap { entry in
|
||||
guard let completion = entry.entry.completionDate else { return nil }
|
||||
return (month: entry.date.startOfMonth, completion: completion, mood: entry.entry.mood)
|
||||
}
|
||||
|
||||
guard !completions.isEmpty else { return .empty }
|
||||
|
||||
let deadlineDiffs = completions.map { item -> Double in
|
||||
let deadline = item.month.endOfMonth
|
||||
return deadline.timeIntervalSince(item.completion) / 86_400
|
||||
}
|
||||
|
||||
let onTimeCompletions = completions.filter { item in
|
||||
item.completion <= item.month.endOfMonth
|
||||
}
|
||||
let onTimeMonths = Set(onTimeCompletions.map { $0.month })
|
||||
let totalCheckIns = completions.count
|
||||
let onTimeCount = onTimeMonths.count
|
||||
|
||||
// Current streak counts consecutive on-time months up to the reference month.
|
||||
var currentStreak = 0
|
||||
var cursor = referenceDate.startOfMonth
|
||||
while onTimeMonths.contains(cursor) {
|
||||
currentStreak += 1
|
||||
cursor = cursor.adding(months: -1).startOfMonth
|
||||
}
|
||||
|
||||
// Best streak across history.
|
||||
let sortedMonths = onTimeMonths.sorted()
|
||||
var bestStreak = 0
|
||||
var running = 0
|
||||
var previousMonth: Date?
|
||||
for month in sortedMonths {
|
||||
if let previousMonth, month == previousMonth.adding(months: 1).startOfMonth {
|
||||
running += 1
|
||||
} else {
|
||||
running = 1
|
||||
}
|
||||
bestStreak = max(bestStreak, running)
|
||||
previousMonth = month
|
||||
}
|
||||
|
||||
let averageDaysBeforeDeadline = onTimeCount > 0
|
||||
? deadlineDiffs
|
||||
.filter { $0 >= 0 }
|
||||
.average()
|
||||
: nil
|
||||
let closestCutoffDays = onTimeCount > 0
|
||||
? deadlineDiffs.filter { $0 >= 0 }.min()
|
||||
: nil
|
||||
|
||||
let recentMood = completions.sorted { $0.month > $1.month }.first?.mood
|
||||
let achievements = buildAchievements(
|
||||
currentStreak: currentStreak,
|
||||
bestStreak: bestStreak,
|
||||
onTimeCount: onTimeCount,
|
||||
totalCheckIns: totalCheckIns,
|
||||
closestCutoffDays: closestCutoffDays,
|
||||
averageDaysBeforeDeadline: averageDaysBeforeDeadline
|
||||
)
|
||||
|
||||
return MonthlyCheckInStats(
|
||||
currentStreak: currentStreak,
|
||||
bestStreak: bestStreak,
|
||||
onTimeCount: onTimeCount,
|
||||
totalCheckIns: totalCheckIns,
|
||||
averageDaysBeforeDeadline: averageDaysBeforeDeadline,
|
||||
closestCutoffDays: closestCutoffDays,
|
||||
recentMood: recentMood,
|
||||
achievements: achievements
|
||||
)
|
||||
}
|
||||
|
||||
static func achievementStatuses(referenceDate: Date = Date()) -> [MonthlyCheckInAchievementStatus] {
|
||||
let stats = stats(referenceDate: referenceDate)
|
||||
return achievementStatuses(for: stats)
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private static func updateEntry(for date: Date, mutate: (inout MonthlyCheckInEntry) -> Void) {
|
||||
let key = monthKey(for: date)
|
||||
var entries = loadEntries()
|
||||
var entry = entries[key] ?? MonthlyCheckInEntry(
|
||||
note: nil,
|
||||
rating: nil,
|
||||
mood: nil,
|
||||
completionTime: legacyCompletion(for: key),
|
||||
createdAt: Date().timeIntervalSince1970
|
||||
)
|
||||
|
||||
mutate(&entry)
|
||||
|
||||
if entry.note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true {
|
||||
entry.note = nil
|
||||
}
|
||||
|
||||
let isEmpty = entry.note == nil && entry.rating == nil && entry.mood == nil && entry.completionTime == nil
|
||||
if isEmpty {
|
||||
entries.removeValue(forKey: key)
|
||||
} else {
|
||||
entries[key] = entry
|
||||
}
|
||||
|
||||
saveEntries(entries)
|
||||
persistLegacyMirrors(entries)
|
||||
}
|
||||
|
||||
private static func loadEntries() -> [String: MonthlyCheckInEntry] {
|
||||
guard let data = UserDefaults.standard.data(forKey: entriesKey),
|
||||
let decoded = try? JSONDecoder().decode([String: MonthlyCheckInEntry].self, from: data) else {
|
||||
return migrateLegacyData()
|
||||
}
|
||||
|
||||
// Ensure legacy data is merged if it existed before this release.
|
||||
return mergeLegacy(into: decoded)
|
||||
}
|
||||
|
||||
private static func saveEntries(_ entries: [String: MonthlyCheckInEntry]) {
|
||||
guard let data = try? JSONEncoder().encode(entries) else { return }
|
||||
UserDefaults.standard.set(data, forKey: entriesKey)
|
||||
}
|
||||
|
||||
private static func migrateLegacyData() -> [String: MonthlyCheckInEntry] {
|
||||
let notes = loadNotes()
|
||||
let completions = loadCompletions()
|
||||
guard !notes.isEmpty || !completions.isEmpty else { return [:] }
|
||||
|
||||
var entries: [String: MonthlyCheckInEntry] = [:]
|
||||
let now = Date().timeIntervalSince1970
|
||||
|
||||
for (key, note) in notes {
|
||||
entries[key] = MonthlyCheckInEntry(
|
||||
note: note,
|
||||
rating: nil,
|
||||
mood: nil,
|
||||
completionTime: completions[key],
|
||||
createdAt: now
|
||||
)
|
||||
}
|
||||
|
||||
for (key, completion) in completions where entries[key] == nil {
|
||||
entries[key] = MonthlyCheckInEntry(
|
||||
note: nil,
|
||||
rating: nil,
|
||||
mood: nil,
|
||||
completionTime: completion,
|
||||
createdAt: completion
|
||||
)
|
||||
}
|
||||
|
||||
saveEntries(entries)
|
||||
return entries
|
||||
}
|
||||
|
||||
private static func mergeLegacy(into entries: [String: MonthlyCheckInEntry]) -> [String: MonthlyCheckInEntry] {
|
||||
var merged = entries
|
||||
let notes = loadNotes()
|
||||
let completions = loadCompletions()
|
||||
var shouldSave = false
|
||||
|
||||
for (key, note) in notes where merged[key]?.note == nil {
|
||||
var entry = merged[key] ?? MonthlyCheckInEntry(
|
||||
note: nil,
|
||||
rating: nil,
|
||||
mood: nil,
|
||||
completionTime: completions[key],
|
||||
createdAt: Date().timeIntervalSince1970
|
||||
)
|
||||
entry.note = note
|
||||
merged[key] = entry
|
||||
shouldSave = true
|
||||
}
|
||||
|
||||
for (key, completion) in completions where merged[key]?.completionTime == nil {
|
||||
var entry = merged[key] ?? MonthlyCheckInEntry(
|
||||
note: nil,
|
||||
rating: nil,
|
||||
mood: nil,
|
||||
completionTime: nil,
|
||||
createdAt: completion
|
||||
)
|
||||
entry.completionTime = completion
|
||||
merged[key] = entry
|
||||
shouldSave = true
|
||||
}
|
||||
|
||||
if shouldSave {
|
||||
saveEntries(merged)
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
private static func persistLegacyMirrors(_ entries: [String: MonthlyCheckInEntry]) {
|
||||
var notes: [String: String] = [:]
|
||||
var completions: [String: Double] = [:]
|
||||
|
||||
for (key, entry) in entries {
|
||||
if let note = entry.note {
|
||||
notes[key] = note
|
||||
}
|
||||
if let completion = entry.completionTime {
|
||||
completions[key] = completion
|
||||
}
|
||||
}
|
||||
|
||||
saveNotes(notes)
|
||||
saveCompletions(completions)
|
||||
}
|
||||
|
||||
private static func loadNotes() -> [String: String] {
|
||||
guard let data = UserDefaults.standard.data(forKey: notesKey),
|
||||
let decoded = try? JSONDecoder().decode([String: String].self, from: data) else {
|
||||
return [:]
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
private static func saveNotes(_ notes: [String: String]) {
|
||||
guard let data = try? JSONEncoder().encode(notes) else { return }
|
||||
UserDefaults.standard.set(data, forKey: notesKey)
|
||||
}
|
||||
|
||||
private static func loadCompletions() -> [String: Double] {
|
||||
guard let data = UserDefaults.standard.data(forKey: completionsKey),
|
||||
let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else {
|
||||
return [:]
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
private static func saveCompletions(_ completions: [String: Double]) {
|
||||
guard let data = try? JSONEncoder().encode(completions) else { return }
|
||||
UserDefaults.standard.set(data, forKey: completionsKey)
|
||||
}
|
||||
|
||||
private static func legacyCompletion(for key: String) -> Double? {
|
||||
loadCompletions()[key]
|
||||
}
|
||||
|
||||
private struct MonthlyCheckInAchievementRule {
|
||||
let achievement: MonthlyCheckInAchievement
|
||||
let isUnlocked: (Int, Int, Int, Int, Double?, Double?) -> Bool
|
||||
}
|
||||
|
||||
private static let achievementRules: [MonthlyCheckInAchievementRule] = [
|
||||
MonthlyCheckInAchievementRule(
|
||||
achievement: MonthlyCheckInAchievement(
|
||||
key: "streak_3",
|
||||
title: String(localized: "achievement_streak_3_title"),
|
||||
detail: String(localized: "achievement_streak_3_detail"),
|
||||
icon: "flame.fill"
|
||||
),
|
||||
isUnlocked: { currentStreak, _, _, _, _, _ in currentStreak >= 3 }
|
||||
),
|
||||
MonthlyCheckInAchievementRule(
|
||||
achievement: MonthlyCheckInAchievement(
|
||||
key: "streak_6",
|
||||
title: String(localized: "achievement_streak_6_title"),
|
||||
detail: String(localized: "achievement_streak_6_detail"),
|
||||
icon: "bolt.heart.fill"
|
||||
),
|
||||
isUnlocked: { currentStreak, _, _, _, _, _ in currentStreak >= 6 }
|
||||
),
|
||||
MonthlyCheckInAchievementRule(
|
||||
achievement: MonthlyCheckInAchievement(
|
||||
key: "streak_12",
|
||||
title: String(localized: "achievement_streak_12_title"),
|
||||
detail: String(localized: "achievement_streak_12_detail"),
|
||||
icon: "calendar.circle.fill"
|
||||
),
|
||||
isUnlocked: { _, bestStreak, _, _, _, _ in bestStreak >= 12 }
|
||||
),
|
||||
MonthlyCheckInAchievementRule(
|
||||
achievement: MonthlyCheckInAchievement(
|
||||
key: "perfect_on_time",
|
||||
title: String(localized: "achievement_perfect_on_time_title"),
|
||||
detail: String(localized: "achievement_perfect_on_time_detail"),
|
||||
icon: "checkmark.seal.fill"
|
||||
),
|
||||
isUnlocked: { _, _, onTimeCount, totalCheckIns, _, _ in
|
||||
onTimeCount == totalCheckIns && totalCheckIns >= 3
|
||||
}
|
||||
),
|
||||
MonthlyCheckInAchievementRule(
|
||||
achievement: MonthlyCheckInAchievement(
|
||||
key: "clutch_finish",
|
||||
title: String(localized: "achievement_clutch_finish_title"),
|
||||
detail: String(localized: "achievement_clutch_finish_detail"),
|
||||
icon: "hourglass"
|
||||
),
|
||||
isUnlocked: { _, _, _, _, closestCutoffDays, _ in
|
||||
if let closestCutoffDays {
|
||||
return closestCutoffDays <= 2
|
||||
}
|
||||
return false
|
||||
}
|
||||
),
|
||||
MonthlyCheckInAchievementRule(
|
||||
achievement: MonthlyCheckInAchievement(
|
||||
key: "early_bird",
|
||||
title: String(localized: "achievement_early_bird_title"),
|
||||
detail: String(localized: "achievement_early_bird_detail"),
|
||||
icon: "sun.max.fill"
|
||||
),
|
||||
isUnlocked: { _, _, _, totalCheckIns, _, averageDaysBeforeDeadline in
|
||||
if let averageDaysBeforeDeadline {
|
||||
return averageDaysBeforeDeadline >= 10 && totalCheckIns >= 3
|
||||
}
|
||||
return false
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
private static func achievementStatuses(for stats: MonthlyCheckInStats) -> [MonthlyCheckInAchievementStatus] {
|
||||
achievementRules.map { rule in
|
||||
MonthlyCheckInAchievementStatus(
|
||||
achievement: rule.achievement,
|
||||
isUnlocked: rule.isUnlocked(
|
||||
stats.currentStreak,
|
||||
stats.bestStreak,
|
||||
stats.onTimeCount,
|
||||
stats.totalCheckIns,
|
||||
stats.closestCutoffDays,
|
||||
stats.averageDaysBeforeDeadline
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func buildAchievements(
|
||||
currentStreak: Int,
|
||||
bestStreak: Int,
|
||||
onTimeCount: Int,
|
||||
totalCheckIns: Int,
|
||||
closestCutoffDays: Double?,
|
||||
averageDaysBeforeDeadline: Double?
|
||||
) -> [MonthlyCheckInAchievement] {
|
||||
achievementRules.compactMap { rule in
|
||||
rule.isUnlocked(
|
||||
currentStreak,
|
||||
bestStreak,
|
||||
onTimeCount,
|
||||
totalCheckIns,
|
||||
closestCutoffDays,
|
||||
averageDaysBeforeDeadline
|
||||
) ? rule.achievement : nil
|
||||
}
|
||||
}
|
||||
|
||||
private static var monthFormatter: DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM"
|
||||
return formatter
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
final class TabSelectionStore: ObservableObject {
|
||||
@Published var selectedTab = 0
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
class ChartsViewModel: ObservableObject {
|
||||
// MARK: - Chart Types
|
||||
|
||||
enum ChartType: String, CaseIterable, Identifiable {
|
||||
case evolution = "Evolution"
|
||||
case allocation = "Allocation"
|
||||
case performance = "Performance"
|
||||
case contributions = "Contributions"
|
||||
case rollingReturn = "Rolling 12M"
|
||||
case riskReturn = "Risk vs Return"
|
||||
case cashflow = "Net vs Contributions"
|
||||
case drawdown = "Drawdown"
|
||||
case volatility = "Volatility"
|
||||
case prediction = "Prediction"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .evolution: return "chart.line.uptrend.xyaxis"
|
||||
case .allocation: return "chart.pie.fill"
|
||||
case .performance: return "chart.bar.fill"
|
||||
case .contributions: return "tray.and.arrow.down.fill"
|
||||
case .rollingReturn: return "arrow.triangle.2.circlepath"
|
||||
case .riskReturn: return "dot.square"
|
||||
case .cashflow: return "chart.bar.xaxis"
|
||||
case .drawdown: return "arrow.down.right.circle"
|
||||
case .volatility: return "waveform.path.ecg"
|
||||
case .prediction: return "wand.and.stars"
|
||||
}
|
||||
}
|
||||
|
||||
var isPremium: Bool {
|
||||
switch self {
|
||||
case .evolution:
|
||||
return false
|
||||
case .allocation, .performance, .contributions, .rollingReturn, .riskReturn, .cashflow,
|
||||
.drawdown, .volatility, .prediction:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .evolution:
|
||||
return "Track your portfolio value over time"
|
||||
case .allocation:
|
||||
return "See how your investments are distributed"
|
||||
case .performance:
|
||||
return "Compare returns across categories"
|
||||
case .contributions:
|
||||
return "Review monthly inflows over time"
|
||||
case .rollingReturn:
|
||||
return "See rolling 12-month performance"
|
||||
case .riskReturn:
|
||||
return "Compare volatility vs return"
|
||||
case .cashflow:
|
||||
return "Compare growth vs contributions"
|
||||
case .drawdown:
|
||||
return "Analyze declines from peak values"
|
||||
case .volatility:
|
||||
return "Understand investment risk levels"
|
||||
case .prediction:
|
||||
return "View 12-month forecasts"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func availableChartTypes(calmModeEnabled: Bool) -> [ChartType] {
|
||||
let types: [ChartType] = calmModeEnabled
|
||||
? [.evolution, .allocation, .performance, .contributions]
|
||||
: ChartType.allCases
|
||||
if !types.contains(selectedChartType) {
|
||||
selectedChartType = .evolution
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
// MARK: - Published Properties
|
||||
|
||||
@Published var selectedChartType: ChartType = .evolution
|
||||
@Published var selectedCategory: Category?
|
||||
@Published var selectedTimeRange: TimeRange = .year
|
||||
@Published var selectedAccount: Account?
|
||||
@Published var showAllAccounts = true
|
||||
|
||||
@Published var evolutionData: [(date: Date, value: Decimal)] = []
|
||||
@Published var categoryEvolutionData: [CategoryEvolutionPoint] = []
|
||||
@Published var allocationData: [(category: String, value: Decimal, color: String)] = []
|
||||
@Published var performanceData: [(category: String, cagr: Double, color: String)] = []
|
||||
@Published var contributionsData: [(date: Date, amount: Decimal)] = []
|
||||
@Published var rollingReturnData: [(date: Date, value: Double)] = []
|
||||
@Published var riskReturnData: [(category: String, cagr: Double, volatility: Double, color: String)] = []
|
||||
@Published var cashflowData: [(date: Date, contributions: Decimal, netPerformance: Decimal)] = []
|
||||
@Published var drawdownData: [(date: Date, drawdown: Double)] = []
|
||||
@Published var volatilityData: [(date: Date, volatility: Double)] = []
|
||||
@Published var predictionData: [Prediction] = []
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var showingPaywall = false
|
||||
@Published private var predictionMonthsAhead = 12
|
||||
|
||||
// MARK: - Time Range
|
||||
|
||||
enum TimeRange: String, CaseIterable, Identifiable {
|
||||
case month = "1M"
|
||||
case quarter = "3M"
|
||||
case halfYear = "6M"
|
||||
case year = "1Y"
|
||||
case all = "All"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var months: Int? {
|
||||
switch self {
|
||||
case .month: return 1
|
||||
case .quarter: return 3
|
||||
case .halfYear: return 6
|
||||
case .year: return 12
|
||||
case .all: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let sourceRepository: InvestmentSourceRepository
|
||||
private let categoryRepository: CategoryRepository
|
||||
private let snapshotRepository: SnapshotRepository
|
||||
private let calculationService: CalculationService
|
||||
private let predictionEngine: PredictionEngine
|
||||
private let freemiumValidator: FreemiumValidator
|
||||
private let maxHistoryMonths = 60
|
||||
private let maxStackedCategories = 6
|
||||
private let maxChartPoints = 500
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var allCategories: [Category] {
|
||||
categoryRepository.categories
|
||||
}
|
||||
|
||||
// MARK: - Performance: Caching and State
|
||||
private var lastChartType: ChartType?
|
||||
private var lastTimeRange: TimeRange?
|
||||
private var lastCategoryId: UUID?
|
||||
private var lastAccountId: UUID?
|
||||
private var lastShowAllAccounts: Bool = true
|
||||
private var cachedSnapshots: [Snapshot]?
|
||||
private var cachedSnapshotsBySource: [UUID: [Snapshot]]?
|
||||
private var isUpdateInProgress = false
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
sourceRepository: InvestmentSourceRepository? = nil,
|
||||
categoryRepository: CategoryRepository? = nil,
|
||||
snapshotRepository: SnapshotRepository? = nil,
|
||||
calculationService: CalculationService? = nil,
|
||||
predictionEngine: PredictionEngine? = nil,
|
||||
iapService: IAPService
|
||||
) {
|
||||
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
|
||||
self.categoryRepository = categoryRepository ?? CategoryRepository()
|
||||
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
|
||||
self.calculationService = calculationService ?? .shared
|
||||
self.predictionEngine = predictionEngine ?? .shared
|
||||
self.freemiumValidator = FreemiumValidator(iapService: iapService)
|
||||
|
||||
setupObservers()
|
||||
loadData()
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupObservers() {
|
||||
// Performance: Combine all selection changes into a single debounced stream
|
||||
// This prevents multiple rapid updates when switching between views
|
||||
Publishers.CombineLatest4($selectedChartType, $selectedCategory, $selectedTimeRange, $selectedAccount)
|
||||
.combineLatest($showAllAccounts)
|
||||
.debounce(for: .milliseconds(150), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] combined, showAll in
|
||||
guard let self else { return }
|
||||
let (chartType, category, timeRange, _) = combined
|
||||
|
||||
// Performance: Skip update if nothing meaningful changed
|
||||
let hasChanges = self.lastChartType != chartType ||
|
||||
self.lastTimeRange != timeRange ||
|
||||
self.lastCategoryId != category?.id ||
|
||||
self.lastAccountId != self.selectedAccount?.id ||
|
||||
self.lastShowAllAccounts != showAll
|
||||
|
||||
if hasChanges {
|
||||
self.lastChartType = chartType
|
||||
self.lastTimeRange = timeRange
|
||||
self.lastCategoryId = category?.id
|
||||
self.lastAccountId = self.selectedAccount?.id
|
||||
self.lastShowAllAccounts = showAll
|
||||
self.cachedSnapshots = nil // Invalidate cache on meaningful changes
|
||||
self.cachedSnapshotsBySource = nil
|
||||
self.updateChartData(chartType: chartType, category: category, timeRange: timeRange)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Data Loading
|
||||
|
||||
func loadData() {
|
||||
updateChartData(
|
||||
chartType: selectedChartType,
|
||||
category: selectedCategory,
|
||||
timeRange: selectedTimeRange
|
||||
)
|
||||
|
||||
FirebaseService.shared.logScreenView(screenName: "Charts")
|
||||
}
|
||||
|
||||
func selectChart(_ chartType: ChartType) {
|
||||
if chartType.isPremium && !freemiumValidator.isPremium {
|
||||
showingPaywall = true
|
||||
FirebaseService.shared.logPaywallShown(trigger: "advanced_charts")
|
||||
return
|
||||
}
|
||||
|
||||
selectedChartType = chartType
|
||||
FirebaseService.shared.logChartViewed(
|
||||
chartType: chartType.rawValue,
|
||||
isPremium: chartType.isPremium
|
||||
)
|
||||
}
|
||||
|
||||
private func updateChartData(chartType: ChartType, category: Category?, timeRange: TimeRange) {
|
||||
// Performance: Prevent re-entrancy
|
||||
guard !isUpdateInProgress else { return }
|
||||
isUpdateInProgress = true
|
||||
isLoading = true
|
||||
|
||||
defer {
|
||||
isLoading = false
|
||||
isUpdateInProgress = false
|
||||
}
|
||||
|
||||
let sources: [InvestmentSource]
|
||||
if let category = category {
|
||||
sources = sourceRepository.fetchSources(for: category).filter { shouldIncludeSource($0) }
|
||||
} else {
|
||||
sources = sourceRepository.sources.filter { shouldIncludeSource($0) }
|
||||
}
|
||||
|
||||
let monthsLimit = timeRange.months ?? maxHistoryMonths
|
||||
let sourceIds = sources.compactMap { $0.id }
|
||||
|
||||
// Performance: Reuse cached snapshots when possible
|
||||
var snapshots: [Snapshot]
|
||||
if let cached = cachedSnapshots {
|
||||
snapshots = cached
|
||||
} else {
|
||||
snapshots = snapshotRepository.fetchSnapshots(
|
||||
for: sourceIds,
|
||||
months: monthsLimit
|
||||
)
|
||||
snapshots = freemiumValidator.filterSnapshots(snapshots)
|
||||
cachedSnapshots = snapshots
|
||||
}
|
||||
|
||||
// Performance: Cache snapshotsBySource
|
||||
let snapshotsBySource: [UUID: [Snapshot]]
|
||||
if let cached = cachedSnapshotsBySource {
|
||||
snapshotsBySource = cached
|
||||
} else {
|
||||
var grouped: [UUID: [Snapshot]] = [:]
|
||||
grouped.reserveCapacity(sources.count)
|
||||
for snapshot in snapshots {
|
||||
guard let id = snapshot.source?.id else { continue }
|
||||
grouped[id, default: []].append(snapshot)
|
||||
}
|
||||
snapshotsBySource = grouped
|
||||
cachedSnapshotsBySource = grouped
|
||||
}
|
||||
|
||||
// Performance: Only calculate data for the selected chart type
|
||||
switch chartType {
|
||||
case .evolution:
|
||||
calculateEvolutionData(from: snapshots)
|
||||
let categoriesForChart = categoriesForStackedChart(
|
||||
sources: sources,
|
||||
selectedCategory: selectedCategory
|
||||
)
|
||||
calculateCategoryEvolutionData(from: snapshots, categories: categoriesForChart)
|
||||
case .allocation:
|
||||
calculateAllocationData(for: sources)
|
||||
case .performance:
|
||||
calculatePerformanceData(for: sources, snapshotsBySource: snapshotsBySource)
|
||||
case .contributions:
|
||||
calculateContributionsData(from: snapshots)
|
||||
case .rollingReturn:
|
||||
calculateRollingReturnData(from: snapshots)
|
||||
case .riskReturn:
|
||||
calculateRiskReturnData(for: sources, snapshotsBySource: snapshotsBySource)
|
||||
case .cashflow:
|
||||
calculateCashflowData(from: snapshots)
|
||||
case .drawdown:
|
||||
calculateDrawdownData(from: snapshots)
|
||||
case .volatility:
|
||||
calculateVolatilityData(from: snapshots)
|
||||
case .prediction:
|
||||
calculatePredictionData(from: snapshots)
|
||||
}
|
||||
|
||||
if let selected = selectedCategory,
|
||||
!availableCategories(for: chartType, sources: sources).contains(where: { $0.id == selected.id }) {
|
||||
selectedCategory = nil
|
||||
}
|
||||
}
|
||||
|
||||
func availableCategories(
|
||||
for chartType: ChartType,
|
||||
sources: [InvestmentSource]? = nil
|
||||
) -> [Category] {
|
||||
let relevantSources = sources ?? sourceRepository.sources.filter { shouldIncludeSource($0) }
|
||||
let categoriesWithData = Set(relevantSources.compactMap { $0.category?.id })
|
||||
let filtered = allCategories.filter { categoriesWithData.contains($0.id) }
|
||||
|
||||
switch chartType {
|
||||
case .evolution, .prediction:
|
||||
return filtered
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldIncludeSource(_ source: InvestmentSource) -> Bool {
|
||||
if showAllAccounts || selectedAccount == nil {
|
||||
return true
|
||||
}
|
||||
return source.account?.id == selectedAccount?.id
|
||||
}
|
||||
|
||||
private func categoriesForStackedChart(
|
||||
sources: [InvestmentSource],
|
||||
selectedCategory: Category?
|
||||
) -> [Category] {
|
||||
var totals: [UUID: Decimal] = [:]
|
||||
|
||||
for source in sources {
|
||||
guard let categoryId = source.category?.id else { continue }
|
||||
totals[categoryId, default: 0] += source.latestValue
|
||||
}
|
||||
|
||||
var topCategoryIds = Set(
|
||||
totals.sorted { $0.value > $1.value }
|
||||
.prefix(maxStackedCategories)
|
||||
.map { $0.key }
|
||||
)
|
||||
|
||||
if let selectedCategory {
|
||||
topCategoryIds.insert(selectedCategory.id)
|
||||
}
|
||||
|
||||
return categoryRepository.categories.filter { topCategoryIds.contains($0.id) }
|
||||
}
|
||||
|
||||
private func downsampleSeries(
|
||||
_ data: [(date: Date, value: Decimal)],
|
||||
maxPoints: Int
|
||||
) -> [(date: Date, value: Decimal)] {
|
||||
guard data.count > maxPoints, maxPoints > 0 else { return data }
|
||||
let bucketSize = max(1, Int(ceil(Double(data.count) / Double(maxPoints))))
|
||||
var sampled: [(date: Date, value: Decimal)] = []
|
||||
sampled.reserveCapacity(maxPoints)
|
||||
|
||||
var index = 0
|
||||
while index < data.count {
|
||||
let end = min(index + bucketSize, data.count)
|
||||
let bucket = data[index..<end]
|
||||
if let last = bucket.last {
|
||||
sampled.append(last)
|
||||
}
|
||||
index += bucketSize
|
||||
}
|
||||
return sampled
|
||||
}
|
||||
|
||||
private func downsampleDates(_ dates: [Date], maxPoints: Int) -> [Date] {
|
||||
guard dates.count > maxPoints, maxPoints > 0 else { return dates }
|
||||
let bucketSize = max(1, Int(ceil(Double(dates.count) / Double(maxPoints))))
|
||||
var sampled: [Date] = []
|
||||
sampled.reserveCapacity(maxPoints)
|
||||
|
||||
var index = 0
|
||||
while index < dates.count {
|
||||
let end = min(index + bucketSize, dates.count)
|
||||
let bucket = dates[index..<end]
|
||||
if let last = bucket.last {
|
||||
sampled.append(last)
|
||||
}
|
||||
index += bucketSize
|
||||
}
|
||||
return sampled
|
||||
}
|
||||
|
||||
// MARK: - Chart Calculations
|
||||
|
||||
private func calculateEvolutionData(from snapshots: [Snapshot]) {
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
|
||||
var dateValues: [Date: Decimal] = [:]
|
||||
|
||||
for snapshot in sortedSnapshots {
|
||||
let day = Calendar.current.startOfDay(for: snapshot.date)
|
||||
dateValues[day, default: 0] += snapshot.decimalValue
|
||||
}
|
||||
|
||||
let series = dateValues
|
||||
.map { (date: $0.key, value: $0.value) }
|
||||
.sorted { $0.date < $1.date }
|
||||
evolutionData = downsampleSeries(series, maxPoints: maxChartPoints)
|
||||
}
|
||||
|
||||
private func calculateCategoryEvolutionData(from snapshots: [Snapshot], categories: [Category]) {
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
let categoriesWithData = Set(sortedSnapshots.compactMap { $0.source?.category?.id })
|
||||
let filteredCategories = categories.filter { categoriesWithData.contains($0.id) }
|
||||
let snapshotsByDay = Dictionary(grouping: sortedSnapshots) {
|
||||
Calendar.current.startOfDay(for: $0.date)
|
||||
}
|
||||
let uniqueDates = downsampleDates(snapshotsByDay.keys.sorted(), maxPoints: maxChartPoints)
|
||||
|
||||
var latestBySource: [UUID: Snapshot] = [:]
|
||||
var points: [CategoryEvolutionPoint] = []
|
||||
|
||||
for date in uniqueDates {
|
||||
if let daySnapshots = snapshotsByDay[date] {
|
||||
for snapshot in daySnapshots {
|
||||
guard let sourceId = snapshot.source?.id else { continue }
|
||||
latestBySource[sourceId] = snapshot
|
||||
}
|
||||
}
|
||||
|
||||
var valuesByCategory: [UUID: Decimal] = [:]
|
||||
for snapshot in latestBySource.values {
|
||||
guard let category = snapshot.source?.category else { continue }
|
||||
valuesByCategory[category.id, default: 0] += snapshot.decimalValue
|
||||
}
|
||||
|
||||
for category in filteredCategories {
|
||||
let value = valuesByCategory[category.id] ?? 0
|
||||
points.append(CategoryEvolutionPoint(
|
||||
date: date,
|
||||
categoryName: category.name,
|
||||
colorHex: category.colorHex,
|
||||
value: value
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
categoryEvolutionData = points
|
||||
}
|
||||
|
||||
private func calculateAllocationData(for sources: [InvestmentSource]) {
|
||||
let categories = categoryRepository.categories
|
||||
let valuesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
|
||||
|
||||
allocationData = categories.compactMap { category in
|
||||
let categorySources = valuesByCategory[category.id] ?? []
|
||||
let categoryValue = categorySources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
guard categoryValue > 0 else { return nil }
|
||||
|
||||
return (
|
||||
category: category.name,
|
||||
value: categoryValue,
|
||||
color: category.colorHex
|
||||
)
|
||||
}.sorted { $0.value > $1.value }
|
||||
}
|
||||
|
||||
private func calculatePerformanceData(
|
||||
for sources: [InvestmentSource],
|
||||
snapshotsBySource: [UUID: [Snapshot]]
|
||||
) {
|
||||
let categories = categoryRepository.categories
|
||||
let sourcesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
|
||||
|
||||
performanceData = categories.compactMap { category in
|
||||
let categorySources = sourcesByCategory[category.id] ?? []
|
||||
let snapshots = categorySources.compactMap { source -> [Snapshot]? in
|
||||
let id = source.id
|
||||
return snapshotsBySource[id]
|
||||
}.flatMap { $0 }
|
||||
guard snapshots.count >= 2 else { return nil }
|
||||
|
||||
let metrics = calculationService.calculateMetrics(for: snapshots)
|
||||
|
||||
return (
|
||||
category: category.name,
|
||||
cagr: metrics.cagr,
|
||||
color: category.colorHex
|
||||
)
|
||||
}.sorted { $0.cagr > $1.cagr }
|
||||
}
|
||||
|
||||
private func calculateContributionsData(from snapshots: [Snapshot]) {
|
||||
let grouped = Dictionary(grouping: snapshots) { $0.date.startOfMonth }
|
||||
contributionsData = grouped.map { date, items in
|
||||
let total = items.reduce(Decimal.zero) { $0 + $1.decimalContribution }
|
||||
return (date: date, amount: total)
|
||||
}
|
||||
.sorted { $0.date < $1.date }
|
||||
}
|
||||
|
||||
private func calculateRollingReturnData(from snapshots: [Snapshot]) {
|
||||
let monthlyTotals = monthlyTotals(from: snapshots)
|
||||
guard monthlyTotals.count >= 13 else {
|
||||
rollingReturnData = []
|
||||
return
|
||||
}
|
||||
|
||||
var returns: [(date: Date, value: Double)] = []
|
||||
for index in 12..<monthlyTotals.count {
|
||||
let current = monthlyTotals[index]
|
||||
let base = monthlyTotals[index - 12]
|
||||
guard base.totalValue > 0 else { continue }
|
||||
let change = current.totalValue - base.totalValue
|
||||
let percent = NSDecimalNumber(decimal: change / base.totalValue).doubleValue * 100
|
||||
returns.append((date: current.date, value: percent))
|
||||
}
|
||||
rollingReturnData = returns
|
||||
}
|
||||
|
||||
private func calculateRiskReturnData(
|
||||
for sources: [InvestmentSource],
|
||||
snapshotsBySource: [UUID: [Snapshot]]
|
||||
) {
|
||||
let categories = categoryRepository.categories
|
||||
let sourcesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
|
||||
|
||||
riskReturnData = categories.compactMap { category in
|
||||
let categorySources = sourcesByCategory[category.id] ?? []
|
||||
let snapshots = categorySources.compactMap { source -> [Snapshot]? in
|
||||
let id = source.id
|
||||
return snapshotsBySource[id]
|
||||
}.flatMap { $0 }
|
||||
guard snapshots.count >= 3 else { return nil }
|
||||
let metrics = calculationService.calculateMetrics(for: snapshots)
|
||||
return (
|
||||
category: category.name,
|
||||
cagr: metrics.cagr,
|
||||
volatility: metrics.volatility,
|
||||
color: category.colorHex
|
||||
)
|
||||
}.sorted { $0.cagr > $1.cagr }
|
||||
}
|
||||
|
||||
private func calculateCashflowData(from snapshots: [Snapshot]) {
|
||||
let monthlyTotals = monthlyTotals(from: snapshots)
|
||||
let contributionsByMonth = Dictionary(grouping: snapshots) { $0.date.startOfMonth }
|
||||
.mapValues { items in
|
||||
items.reduce(Decimal.zero) { $0 + $1.decimalContribution }
|
||||
}
|
||||
|
||||
var data: [(date: Date, contributions: Decimal, netPerformance: Decimal)] = []
|
||||
for index in 0..<monthlyTotals.count {
|
||||
let current = monthlyTotals[index]
|
||||
let previousTotal = index > 0 ? monthlyTotals[index - 1].totalValue : 0
|
||||
let contributions = contributionsByMonth[current.date] ?? 0
|
||||
let netPerformance = current.totalValue - previousTotal - contributions
|
||||
data.append((date: current.date, contributions: contributions, netPerformance: netPerformance))
|
||||
}
|
||||
cashflowData = data
|
||||
}
|
||||
|
||||
private func calculateDrawdownData(from snapshots: [Snapshot]) {
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
guard !sortedSnapshots.isEmpty else {
|
||||
drawdownData = []
|
||||
return
|
||||
}
|
||||
|
||||
var peak = sortedSnapshots.first!.decimalValue
|
||||
var data: [(date: Date, drawdown: Double)] = []
|
||||
|
||||
for snapshot in sortedSnapshots {
|
||||
let value = snapshot.decimalValue
|
||||
if value > peak {
|
||||
peak = value
|
||||
}
|
||||
|
||||
let drawdown = peak > 0
|
||||
? NSDecimalNumber(decimal: (peak - value) / peak).doubleValue * 100
|
||||
: 0
|
||||
|
||||
data.append((date: snapshot.date, drawdown: -drawdown))
|
||||
}
|
||||
|
||||
drawdownData = data
|
||||
}
|
||||
|
||||
private func calculateVolatilityData(from snapshots: [Snapshot]) {
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
guard sortedSnapshots.count >= 3 else {
|
||||
volatilityData = []
|
||||
return
|
||||
}
|
||||
|
||||
var data: [(date: Date, volatility: Double)] = []
|
||||
let windowSize = 3
|
||||
|
||||
for i in windowSize..<sortedSnapshots.count {
|
||||
let window = Array(sortedSnapshots[(i - windowSize)..<i])
|
||||
let values = window.map { NSDecimalNumber(decimal: $0.decimalValue).doubleValue }
|
||||
|
||||
let mean = values.reduce(0, +) / Double(values.count)
|
||||
let variance = values.map { pow($0 - mean, 2) }.reduce(0, +) / Double(values.count)
|
||||
let stdDev = sqrt(variance)
|
||||
let volatility = mean > 0 ? (stdDev / mean) * 100 : 0
|
||||
|
||||
data.append((date: sortedSnapshots[i].date, volatility: volatility))
|
||||
}
|
||||
|
||||
volatilityData = data
|
||||
}
|
||||
|
||||
private func calculatePredictionData(from snapshots: [Snapshot]) {
|
||||
guard freemiumValidator.canViewPredictions() else {
|
||||
predictionData = []
|
||||
return
|
||||
}
|
||||
|
||||
let result = predictionEngine.predict(snapshots: snapshots, monthsAhead: predictionMonthsAhead)
|
||||
predictionData = result.predictions
|
||||
}
|
||||
|
||||
private func monthlyTotals(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
let months = Array(Set(sortedSnapshots.map { $0.date.startOfMonth })).sorted()
|
||||
guard !months.isEmpty else { return [] }
|
||||
|
||||
var snapshotsBySource: [UUID: [Snapshot]] = [:]
|
||||
for snapshot in sortedSnapshots {
|
||||
guard let sourceId = snapshot.source?.id else { continue }
|
||||
snapshotsBySource[sourceId, default: []].append(snapshot)
|
||||
}
|
||||
|
||||
var indices: [UUID: Int] = [:]
|
||||
var totals: [(date: Date, totalValue: Decimal)] = []
|
||||
|
||||
for (index, month) in months.enumerated() {
|
||||
let nextMonth = index + 1 < months.count ? months[index + 1] : Date.distantFuture
|
||||
var total: Decimal = 0
|
||||
|
||||
for (sourceId, sourceSnapshots) in snapshotsBySource {
|
||||
var currentIndex = indices[sourceId] ?? 0
|
||||
var latest: Snapshot?
|
||||
|
||||
while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextMonth {
|
||||
latest = sourceSnapshots[currentIndex]
|
||||
currentIndex += 1
|
||||
}
|
||||
|
||||
indices[sourceId] = currentIndex
|
||||
total += latest?.decimalValue ?? 0
|
||||
}
|
||||
|
||||
totals.append((date: month, totalValue: total))
|
||||
}
|
||||
|
||||
return totals
|
||||
}
|
||||
|
||||
func updatePredictionTargetDate(_ goals: [Goal]) {
|
||||
let futureGoalDates = goals.compactMap { $0.targetDate }.filter { $0 > Date() }
|
||||
guard let latestGoalDate = futureGoalDates.max(),
|
||||
let lastSnapshotDate = evolutionData.last?.date else {
|
||||
predictionMonthsAhead = 12
|
||||
return
|
||||
}
|
||||
|
||||
let months = max(1, lastSnapshotDate.startOfMonth.monthsBetween(latestGoalDate.startOfMonth))
|
||||
predictionMonthsAhead = max(12, months)
|
||||
if selectedChartType == .prediction {
|
||||
updateChartData(chartType: selectedChartType, category: selectedCategory, timeRange: selectedTimeRange)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
var categories: [Category] {
|
||||
categoryRepository.categories
|
||||
}
|
||||
|
||||
var availableChartTypes: [ChartType] {
|
||||
ChartType.allCases
|
||||
}
|
||||
|
||||
var isPremium: Bool {
|
||||
freemiumValidator.isPremium
|
||||
}
|
||||
|
||||
var hasData: Bool {
|
||||
!sourceRepository.sources.filter { shouldIncludeSource($0) }.isEmpty
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
|
||||
@MainActor
|
||||
class DashboardViewModel: ObservableObject {
|
||||
// MARK: - Published Properties
|
||||
|
||||
@Published var portfolioSummary: PortfolioSummary = .empty
|
||||
@Published var monthlySummary: MonthlySummary = .empty
|
||||
@Published var categoryMetrics: [CategoryMetrics] = []
|
||||
@Published var categoryEvolutionData: [CategoryEvolutionPoint] = []
|
||||
@Published var recentSnapshots: [Snapshot] = []
|
||||
@Published var sourcesNeedingUpdate: [InvestmentSource] = []
|
||||
@Published var latestPortfolioChange: PortfolioChange = .empty
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var selectedAccount: Account?
|
||||
@Published var showAllAccounts = true
|
||||
|
||||
// MARK: - Chart Data
|
||||
|
||||
@Published var evolutionData: [(date: Date, value: Decimal)] = []
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let categoryRepository: CategoryRepository
|
||||
private let sourceRepository: InvestmentSourceRepository
|
||||
private let snapshotRepository: SnapshotRepository
|
||||
private let calculationService: CalculationService
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var isRefreshing = false
|
||||
private var refreshQueued = false
|
||||
private let maxHistoryMonths = 60
|
||||
|
||||
// MARK: - Performance: Caching
|
||||
private var cachedFilteredSources: [InvestmentSource]?
|
||||
private var cachedSourcesHash: Int = 0
|
||||
private var lastAccountId: UUID?
|
||||
private var lastShowAllAccounts: Bool = true
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
categoryRepository: CategoryRepository? = nil,
|
||||
sourceRepository: InvestmentSourceRepository? = nil,
|
||||
snapshotRepository: SnapshotRepository? = nil,
|
||||
calculationService: CalculationService? = nil
|
||||
) {
|
||||
self.categoryRepository = categoryRepository ?? CategoryRepository()
|
||||
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
|
||||
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
|
||||
self.calculationService = calculationService ?? .shared
|
||||
|
||||
setupObservers()
|
||||
loadData()
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupObservers() {
|
||||
// Performance: Combine multiple publishers to reduce redundant refresh calls
|
||||
// Use dropFirst to avoid initial trigger, and debounce to coalesce rapid changes
|
||||
Publishers.Merge(
|
||||
categoryRepository.$categories.map { _ in () },
|
||||
sourceRepository.$sources.map { _ in () }
|
||||
)
|
||||
.dropFirst(2) // Skip initial values from both publishers
|
||||
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.invalidateCache()
|
||||
self?.refreshData()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Observe Core Data changes with coalescing
|
||||
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)
|
||||
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
|
||||
.compactMap { [weak self] notification -> Void? in
|
||||
guard let self, self.isRelevantChange(notification) else { return nil }
|
||||
return ()
|
||||
}
|
||||
.sink { [weak self] _ in
|
||||
self?.invalidateCache()
|
||||
self?.refreshData()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func isRelevantChange(_ notification: Notification) -> Bool {
|
||||
guard let info = notification.userInfo else { return false }
|
||||
let keys: [String] = [
|
||||
NSInsertedObjectsKey,
|
||||
NSUpdatedObjectsKey,
|
||||
NSDeletedObjectsKey,
|
||||
NSRefreshedObjectsKey
|
||||
]
|
||||
|
||||
for key in keys {
|
||||
if let objects = info[key] as? Set<NSManagedObject> {
|
||||
if objects.contains(where: { $0 is Snapshot || $0 is InvestmentSource || $0 is Category }) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Data Loading
|
||||
|
||||
func loadData() {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
queueRefresh(updateLoadingFlag: true)
|
||||
}
|
||||
|
||||
func refreshData() {
|
||||
queueRefresh(updateLoadingFlag: false)
|
||||
}
|
||||
|
||||
private func queueRefresh(updateLoadingFlag: Bool) {
|
||||
refreshQueued = true
|
||||
if updateLoadingFlag {
|
||||
isLoading = true
|
||||
}
|
||||
|
||||
guard !isRefreshing else { return }
|
||||
|
||||
isRefreshing = true
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
while self.refreshQueued {
|
||||
self.refreshQueued = false
|
||||
await self.refreshAllData()
|
||||
}
|
||||
self.isRefreshing = false
|
||||
if updateLoadingFlag {
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshAllData() async {
|
||||
let categories = categoryRepository.categories
|
||||
let sources = filteredSources()
|
||||
let allSnapshots = filteredSnapshots(for: sources)
|
||||
|
||||
// Calculate portfolio summary
|
||||
portfolioSummary = calculationService.calculatePortfolioSummary(
|
||||
from: sources,
|
||||
snapshots: allSnapshots
|
||||
)
|
||||
|
||||
monthlySummary = calculationService.calculateMonthlySummary(
|
||||
sources: sources,
|
||||
snapshots: allSnapshots
|
||||
)
|
||||
|
||||
// Calculate category metrics
|
||||
categoryMetrics = calculationService.calculateCategoryMetrics(
|
||||
for: categories,
|
||||
sources: sources,
|
||||
totalPortfolioValue: portfolioSummary.totalValue
|
||||
).sorted { $0.totalValue > $1.totalValue }
|
||||
|
||||
// Get recent snapshots
|
||||
recentSnapshots = Array(allSnapshots.prefix(10))
|
||||
|
||||
// Get sources needing update
|
||||
let accountFilter = showAllAccounts ? nil : selectedAccount
|
||||
sourcesNeedingUpdate = sourceRepository.fetchSourcesNeedingUpdate(for: accountFilter)
|
||||
|
||||
// Calculate evolution data for chart
|
||||
updateEvolutionData(from: allSnapshots, categories: categories)
|
||||
latestPortfolioChange = calculateLatestChange(from: evolutionData)
|
||||
|
||||
// Log screen view
|
||||
FirebaseService.shared.logScreenView(screenName: "Dashboard")
|
||||
}
|
||||
|
||||
private func filteredSources() -> [InvestmentSource] {
|
||||
// Performance: Cache filtered sources to avoid repeated filtering
|
||||
let currentHash = sourceRepository.sources.count
|
||||
let accountChanged = lastAccountId != selectedAccount?.id || lastShowAllAccounts != showAllAccounts
|
||||
|
||||
if !accountChanged && cachedFilteredSources != nil && cachedSourcesHash == currentHash {
|
||||
return cachedFilteredSources!
|
||||
}
|
||||
|
||||
lastAccountId = selectedAccount?.id
|
||||
lastShowAllAccounts = showAllAccounts
|
||||
cachedSourcesHash = currentHash
|
||||
|
||||
if showAllAccounts || selectedAccount == nil {
|
||||
cachedFilteredSources = sourceRepository.sources
|
||||
} else {
|
||||
cachedFilteredSources = sourceRepository.sources.filter { $0.account?.id == selectedAccount?.id }
|
||||
}
|
||||
return cachedFilteredSources!
|
||||
}
|
||||
|
||||
/// Invalidates cached data when underlying data changes
|
||||
private func invalidateCache() {
|
||||
cachedFilteredSources = nil
|
||||
cachedSourcesHash = 0
|
||||
}
|
||||
|
||||
private func filteredSnapshots(for sources: [InvestmentSource]) -> [Snapshot] {
|
||||
let sourceIds = sources.compactMap { $0.id }
|
||||
return snapshotRepository.fetchSnapshots(
|
||||
for: sourceIds,
|
||||
months: maxHistoryMonths
|
||||
)
|
||||
}
|
||||
|
||||
private func updateEvolutionData(from snapshots: [Snapshot], categories: [Category]) {
|
||||
let summary = calculateEvolutionSummary(from: snapshots)
|
||||
let categoriesWithData = Set(summary.categoryTotals.keys)
|
||||
let categoryLookup = Dictionary(uniqueKeysWithValues: categories.map { ($0.id, $0) })
|
||||
|
||||
evolutionData = summary.evolutionData
|
||||
categoryEvolutionData = summary.categorySeries.flatMap { entry in
|
||||
entry.valuesByCategory.compactMap { categoryId, value in
|
||||
guard categoriesWithData.contains(categoryId),
|
||||
let category = categoryLookup[categoryId] else {
|
||||
return nil
|
||||
}
|
||||
return CategoryEvolutionPoint(
|
||||
date: entry.date,
|
||||
categoryName: category.name,
|
||||
colorHex: category.colorHex,
|
||||
value: value
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct EvolutionSummary {
|
||||
let evolutionData: [(date: Date, value: Decimal)]
|
||||
let categorySeries: [(date: Date, valuesByCategory: [UUID: Decimal])]
|
||||
let categoryTotals: [UUID: Decimal]
|
||||
}
|
||||
|
||||
private func calculateEvolutionSummary(from snapshots: [Snapshot]) -> EvolutionSummary {
|
||||
guard !snapshots.isEmpty else {
|
||||
return EvolutionSummary(evolutionData: [], categorySeries: [], categoryTotals: [:])
|
||||
}
|
||||
|
||||
// Performance: Pre-allocate capacity and use more efficient data structures
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
|
||||
// Use dictionary to deduplicate dates more efficiently
|
||||
var uniqueDateSet = Set<Date>()
|
||||
uniqueDateSet.reserveCapacity(sortedSnapshots.count)
|
||||
for snapshot in sortedSnapshots {
|
||||
uniqueDateSet.insert(Calendar.current.startOfDay(for: snapshot.date))
|
||||
}
|
||||
let uniqueDates = uniqueDateSet.sorted()
|
||||
|
||||
guard !uniqueDates.isEmpty else {
|
||||
return EvolutionSummary(evolutionData: [], categorySeries: [], categoryTotals: [:])
|
||||
}
|
||||
|
||||
// Pre-allocate dictionaries with estimated capacity
|
||||
var snapshotsBySource: [UUID: [(date: Date, value: Decimal, categoryId: UUID?)]] = [:]
|
||||
snapshotsBySource.reserveCapacity(Set(sortedSnapshots.compactMap { $0.source?.id }).count)
|
||||
var categoryTotals: [UUID: Decimal] = [:]
|
||||
|
||||
for snapshot in sortedSnapshots {
|
||||
guard let sourceId = snapshot.source?.id else { continue }
|
||||
let categoryId = snapshot.source?.category?.id
|
||||
snapshotsBySource[sourceId, default: []].append(
|
||||
(date: snapshot.date, value: snapshot.decimalValue, categoryId: categoryId)
|
||||
)
|
||||
if let categoryId {
|
||||
categoryTotals[categoryId, default: 0] += snapshot.decimalValue
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-allocate result arrays
|
||||
var indices: [UUID: Int] = [:]
|
||||
indices.reserveCapacity(snapshotsBySource.count)
|
||||
var evolution: [(date: Date, value: Decimal)] = []
|
||||
evolution.reserveCapacity(uniqueDates.count)
|
||||
var series: [(date: Date, valuesByCategory: [UUID: Decimal])] = []
|
||||
series.reserveCapacity(uniqueDates.count)
|
||||
|
||||
// Track last known value per source for carry-forward optimization
|
||||
var lastValues: [UUID: (value: Decimal, categoryId: UUID?)] = [:]
|
||||
lastValues.reserveCapacity(snapshotsBySource.count)
|
||||
|
||||
for (index, date) in uniqueDates.enumerated() {
|
||||
let nextDate = index + 1 < uniqueDates.count
|
||||
? uniqueDates[index + 1]
|
||||
: Date.distantFuture
|
||||
var total: Decimal = 0
|
||||
var valuesByCategory: [UUID: Decimal] = [:]
|
||||
|
||||
for (sourceId, sourceSnapshots) in snapshotsBySource {
|
||||
var currentIndex = indices[sourceId] ?? 0
|
||||
|
||||
while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextDate {
|
||||
let snap = sourceSnapshots[currentIndex]
|
||||
lastValues[sourceId] = (value: snap.value, categoryId: snap.categoryId)
|
||||
currentIndex += 1
|
||||
}
|
||||
|
||||
indices[sourceId] = currentIndex
|
||||
|
||||
// Use last known value (carry-forward)
|
||||
if let lastValue = lastValues[sourceId] {
|
||||
total += lastValue.value
|
||||
if let categoryId = lastValue.categoryId {
|
||||
valuesByCategory[categoryId, default: 0] += lastValue.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
evolution.append((date: date, value: total))
|
||||
series.append((date: date, valuesByCategory: valuesByCategory))
|
||||
}
|
||||
|
||||
return EvolutionSummary(
|
||||
evolutionData: evolution,
|
||||
categorySeries: series,
|
||||
categoryTotals: categoryTotals
|
||||
)
|
||||
}
|
||||
|
||||
private func calculateLatestChange(from data: [(date: Date, value: Decimal)]) -> PortfolioChange {
|
||||
guard data.count >= 2 else {
|
||||
return PortfolioChange(absolute: 0, percentage: 0, label: "since last update")
|
||||
}
|
||||
let last = data[data.count - 1]
|
||||
let previous = data[data.count - 2]
|
||||
let absolute = last.value - previous.value
|
||||
let percentage = previous.value > 0
|
||||
? NSDecimalNumber(decimal: absolute / previous.value).doubleValue * 100
|
||||
: 0
|
||||
return PortfolioChange(absolute: absolute, percentage: percentage, label: "since last update")
|
||||
}
|
||||
|
||||
func goalEtaText(for goal: Goal, currentValue: Decimal) -> String? {
|
||||
let target = goal.targetDecimal
|
||||
let lastValue = evolutionData.last?.value ?? currentValue
|
||||
|
||||
if currentValue >= target {
|
||||
return "Goal reached. Keep it steady."
|
||||
}
|
||||
|
||||
guard let months = estimateMonthsToGoal(target: target, lastValue: lastValue) else {
|
||||
return "Keep going. Consistency pays off."
|
||||
}
|
||||
|
||||
if months == 0 {
|
||||
return "Almost there. One more check-in."
|
||||
}
|
||||
|
||||
let baseDate = evolutionData.last?.date ?? Date()
|
||||
let estimatedDate = Calendar.current.date(byAdding: .month, value: months, to: baseDate)
|
||||
if months >= 72 {
|
||||
return "Long journey, strong discipline. You're building momentum."
|
||||
}
|
||||
|
||||
if let estimatedDate = estimatedDate {
|
||||
return "Estimated: \(estimatedDate.monthYearString)"
|
||||
}
|
||||
|
||||
return "Estimated: \(months) months"
|
||||
}
|
||||
|
||||
private func estimateMonthsToGoal(target: Decimal, lastValue: Decimal) -> Int? {
|
||||
guard evolutionData.count >= 3 else { return nil }
|
||||
let recent = Array(evolutionData.suffix(6))
|
||||
guard let first = recent.first, let last = recent.last else { return nil }
|
||||
let monthsBetween = max(1, first.date.monthsBetween(last.date))
|
||||
let delta = last.value - first.value
|
||||
guard delta > 0 else { return nil }
|
||||
let monthlyGain = delta / Decimal(monthsBetween)
|
||||
guard monthlyGain > 0 else { return nil }
|
||||
let remaining = target - lastValue
|
||||
guard remaining > 0 else { return 0 }
|
||||
let months = NSDecimalNumber(decimal: remaining / monthlyGain).doubleValue
|
||||
return Int(ceil(months))
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
var hasData: Bool {
|
||||
!filteredSources().isEmpty
|
||||
}
|
||||
|
||||
var totalSourceCount: Int {
|
||||
sourceRepository.sourceCount
|
||||
}
|
||||
|
||||
var totalCategoryCount: Int {
|
||||
categoryRepository.categories.count
|
||||
}
|
||||
|
||||
var pendingUpdatesCount: Int {
|
||||
sourcesNeedingUpdate.count
|
||||
}
|
||||
|
||||
var topCategories: [CategoryMetrics] {
|
||||
Array(categoryMetrics.prefix(5))
|
||||
}
|
||||
|
||||
// MARK: - Formatting
|
||||
|
||||
var formattedTotalValue: String {
|
||||
portfolioSummary.formattedTotalValue
|
||||
}
|
||||
|
||||
var formattedDayChange: String {
|
||||
portfolioSummary.formattedDayChange
|
||||
}
|
||||
|
||||
var formattedMonthChange: String {
|
||||
portfolioSummary.formattedMonthChange
|
||||
}
|
||||
|
||||
var formattedYearChange: String {
|
||||
portfolioSummary.formattedYearChange
|
||||
}
|
||||
|
||||
var isDayChangePositive: Bool {
|
||||
portfolioSummary.dayChange >= 0
|
||||
}
|
||||
|
||||
var isMonthChangePositive: Bool {
|
||||
portfolioSummary.monthChange >= 0
|
||||
}
|
||||
|
||||
var isYearChangePositive: Bool {
|
||||
portfolioSummary.yearChange >= 0
|
||||
}
|
||||
|
||||
var formattedLastUpdate: String {
|
||||
portfolioSummary.lastUpdated?.friendlyDescription ?? "Not yet"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
|
||||
@MainActor
|
||||
class GoalsViewModel: ObservableObject {
|
||||
@Published var goals: [Goal] = []
|
||||
@Published var totalValue: Decimal = Decimal.zero
|
||||
@Published var selectedAccount: Account?
|
||||
@Published var showAllAccounts = true
|
||||
|
||||
private let goalRepository: GoalRepository
|
||||
private let sourceRepository: InvestmentSourceRepository
|
||||
private let snapshotRepository: SnapshotRepository
|
||||
private let maxHistoryMonths = 60
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: - Performance: Caching
|
||||
private var cachedEvolutionData: [UUID: [(date: Date, value: Decimal)]] = [:]
|
||||
private var cachedCompletionDates: [UUID: Date?] = [:]
|
||||
private var lastSourcesHash: Int = 0
|
||||
|
||||
init(
|
||||
goalRepository: GoalRepository = GoalRepository(),
|
||||
sourceRepository: InvestmentSourceRepository = InvestmentSourceRepository(),
|
||||
snapshotRepository: SnapshotRepository = SnapshotRepository()
|
||||
) {
|
||||
self.goalRepository = goalRepository
|
||||
self.sourceRepository = sourceRepository
|
||||
self.snapshotRepository = snapshotRepository
|
||||
|
||||
setupObservers()
|
||||
refresh()
|
||||
}
|
||||
|
||||
private func setupObservers() {
|
||||
goalRepository.$goals
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] goals in
|
||||
self?.updateGoals(using: goals)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
sourceRepository.$sources
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.updateTotalValue()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
// Performance: Invalidate caches when refreshing
|
||||
let currentHash = sourceRepository.sources.count
|
||||
if currentHash != lastSourcesHash {
|
||||
cachedEvolutionData.removeAll()
|
||||
cachedCompletionDates.removeAll()
|
||||
lastSourcesHash = currentHash
|
||||
}
|
||||
loadGoals()
|
||||
updateGoals(using: goalRepository.goals)
|
||||
}
|
||||
|
||||
func progress(for goal: Goal) -> Double {
|
||||
let currentTotal = totalValue(for: goal)
|
||||
guard goal.targetDecimal > 0 else { return 0 }
|
||||
let current = min(currentTotal, goal.targetDecimal)
|
||||
return NSDecimalNumber(decimal: current / goal.targetDecimal).doubleValue
|
||||
}
|
||||
|
||||
func totalValue(for goal: Goal) -> Decimal {
|
||||
if let account = goal.account {
|
||||
return sourceRepository.sources
|
||||
.filter { $0.account?.id == account.id }
|
||||
.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
}
|
||||
return totalValue
|
||||
}
|
||||
|
||||
func paceStatus(for goal: Goal) -> GoalPaceStatus? {
|
||||
guard let targetDate = goal.targetDate else { return nil }
|
||||
let targetDay = targetDate.startOfDay
|
||||
let actualProgress = progress(for: goal)
|
||||
let startDate = goal.createdAt.startOfDay
|
||||
let totalDays = max(1, startDate.daysBetween(targetDay))
|
||||
|
||||
if actualProgress >= 1 {
|
||||
return GoalPaceStatus(
|
||||
expectedProgress: 1,
|
||||
delta: 0,
|
||||
isBehind: false,
|
||||
statusText: "Goal reached"
|
||||
)
|
||||
}
|
||||
|
||||
if let estimatedCompletionDate = estimateCompletionDate(for: goal) {
|
||||
let estimatedDay = estimatedCompletionDate.startOfDay
|
||||
let deltaDays = targetDay.daysBetween(estimatedDay)
|
||||
let deltaPercent = min(abs(Double(deltaDays)) / Double(totalDays) * 100, 999)
|
||||
let isBehind = estimatedDay > targetDay
|
||||
let statusText = abs(deltaPercent) < 1
|
||||
? "On track"
|
||||
: isBehind
|
||||
? String(format: "Behind by %.1f%%", deltaPercent)
|
||||
: String(format: "Ahead by %.1f%%", deltaPercent)
|
||||
|
||||
return GoalPaceStatus(
|
||||
expectedProgress: actualProgress,
|
||||
delta: isBehind ? -deltaPercent / 100 : deltaPercent / 100,
|
||||
isBehind: isBehind,
|
||||
statusText: statusText
|
||||
)
|
||||
}
|
||||
|
||||
let elapsedDays = max(0, startDate.daysBetween(Date()))
|
||||
let expectedProgress = min(Double(elapsedDays) / Double(totalDays), 1)
|
||||
let delta = actualProgress - expectedProgress
|
||||
let isOverdue = Date() > targetDay && actualProgress < 1
|
||||
let isBehind = isOverdue || delta < -0.03
|
||||
let deltaPercent = abs(delta) * 100
|
||||
let statusText = isOverdue
|
||||
? "Behind schedule • target passed"
|
||||
: delta >= 0
|
||||
? String(format: "Ahead by %.1f%%", deltaPercent)
|
||||
: String(format: "Behind by %.1f%%", deltaPercent)
|
||||
|
||||
return GoalPaceStatus(
|
||||
expectedProgress: expectedProgress,
|
||||
delta: delta,
|
||||
isBehind: isBehind,
|
||||
statusText: statusText
|
||||
)
|
||||
}
|
||||
|
||||
func deleteGoal(_ goal: Goal) {
|
||||
goalRepository.deleteGoal(goal)
|
||||
}
|
||||
|
||||
private func estimateCompletionDate(for goal: Goal) -> Date? {
|
||||
// Performance: Use cached completion date if available
|
||||
if let cached = cachedCompletionDates[goal.id] {
|
||||
return cached
|
||||
}
|
||||
|
||||
let sources: [InvestmentSource]
|
||||
if let account = goal.account {
|
||||
sources = sourceRepository.sources.filter { $0.account?.id == account.id }
|
||||
} else {
|
||||
sources = sourceRepository.sources
|
||||
}
|
||||
let sourceIds = sources.compactMap { $0.id }
|
||||
guard !sourceIds.isEmpty else {
|
||||
cachedCompletionDates[goal.id] = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Performance: Use cached evolution data if available
|
||||
let cacheKey = goal.account?.id ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
|
||||
let evolutionData: [(date: Date, value: Decimal)]
|
||||
if let cached = cachedEvolutionData[cacheKey] {
|
||||
evolutionData = cached
|
||||
} else {
|
||||
let snapshots = snapshotRepository.fetchSnapshots(
|
||||
for: sourceIds,
|
||||
months: maxHistoryMonths
|
||||
)
|
||||
evolutionData = calculateEvolutionData(from: snapshots)
|
||||
cachedEvolutionData[cacheKey] = evolutionData
|
||||
}
|
||||
|
||||
guard evolutionData.count >= 3,
|
||||
let first = evolutionData.suffix(6).first,
|
||||
let last = evolutionData.suffix(6).last else {
|
||||
cachedCompletionDates[goal.id] = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
let monthsBetween = max(1, first.date.monthsBetween(last.date))
|
||||
let delta = last.value - first.value
|
||||
guard delta > 0 else {
|
||||
cachedCompletionDates[goal.id] = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
let monthlyGain = delta / Decimal(monthsBetween)
|
||||
guard monthlyGain > 0 else {
|
||||
cachedCompletionDates[goal.id] = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
let currentValue = totalValue(for: goal)
|
||||
let remaining = goal.targetDecimal - currentValue
|
||||
guard remaining > 0 else {
|
||||
let result = Date()
|
||||
cachedCompletionDates[goal.id] = result
|
||||
return result
|
||||
}
|
||||
|
||||
let months = NSDecimalNumber(decimal: remaining / monthlyGain).doubleValue
|
||||
let monthsRounded = Int(ceil(months))
|
||||
let result = Calendar.current.date(byAdding: .month, value: monthsRounded, to: last.date)
|
||||
cachedCompletionDates[goal.id] = result
|
||||
return result
|
||||
}
|
||||
|
||||
private func calculateEvolutionData(from snapshots: [Snapshot]) -> [(date: Date, value: Decimal)] {
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
let uniqueDates = Array(Set(sortedSnapshots.map { Calendar.current.startOfDay(for: $0.date) }))
|
||||
.sorted()
|
||||
guard !uniqueDates.isEmpty else { return [] }
|
||||
|
||||
var snapshotsBySource: [UUID: [(date: Date, value: Decimal)]] = [:]
|
||||
for snapshot in sortedSnapshots {
|
||||
guard let sourceId = snapshot.source?.id else { continue }
|
||||
snapshotsBySource[sourceId, default: []].append(
|
||||
(date: snapshot.date, value: snapshot.decimalValue)
|
||||
)
|
||||
}
|
||||
|
||||
var indices: [UUID: Int] = [:]
|
||||
var evolution: [(date: Date, value: Decimal)] = []
|
||||
|
||||
for (index, date) in uniqueDates.enumerated() {
|
||||
let nextDate = index + 1 < uniqueDates.count
|
||||
? uniqueDates[index + 1]
|
||||
: Date.distantFuture
|
||||
var total: Decimal = 0
|
||||
|
||||
for (sourceId, sourceSnapshots) in snapshotsBySource {
|
||||
var currentIndex = indices[sourceId] ?? 0
|
||||
var latest: (date: Date, value: Decimal)?
|
||||
|
||||
while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextDate {
|
||||
latest = sourceSnapshots[currentIndex]
|
||||
currentIndex += 1
|
||||
}
|
||||
|
||||
indices[sourceId] = currentIndex
|
||||
|
||||
if let latest {
|
||||
total += latest.value
|
||||
}
|
||||
}
|
||||
|
||||
evolution.append((date: date, value: total))
|
||||
}
|
||||
|
||||
return evolution
|
||||
}
|
||||
|
||||
// MARK: - Private helpers
|
||||
|
||||
private func loadGoals() {
|
||||
if showAllAccounts || selectedAccount == nil {
|
||||
goalRepository.fetchGoals()
|
||||
} else if let account = selectedAccount {
|
||||
goalRepository.fetchGoals(for: account)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateGoals(using repositoryGoals: [Goal]) {
|
||||
if showAllAccounts || selectedAccount == nil {
|
||||
goals = repositoryGoals
|
||||
} else if let account = selectedAccount {
|
||||
goals = repositoryGoals.filter { $0.account?.id == account.id }
|
||||
} else {
|
||||
goals = repositoryGoals
|
||||
}
|
||||
updateTotalValue()
|
||||
}
|
||||
|
||||
private func updateTotalValue() {
|
||||
if showAllAccounts || selectedAccount == nil {
|
||||
totalValue = sourceRepository.sources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
return
|
||||
}
|
||||
|
||||
if let account = selectedAccount {
|
||||
totalValue = sourceRepository.sources
|
||||
.filter { $0.account?.id == account.id }
|
||||
.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GoalPaceStatus {
|
||||
let expectedProgress: Double
|
||||
let delta: Double
|
||||
let isBehind: Bool
|
||||
let statusText: String
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
|
||||
@MainActor
|
||||
class JournalViewModel: ObservableObject {
|
||||
@Published var snapshotNotes: [Snapshot] = []
|
||||
@Published var monthlyNotes: [MonthlyNoteItem] = []
|
||||
|
||||
private let snapshotRepository: SnapshotRepository
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(snapshotRepository: SnapshotRepository? = nil) {
|
||||
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
|
||||
setupObservers()
|
||||
refresh()
|
||||
}
|
||||
|
||||
private func setupObservers() {
|
||||
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)
|
||||
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
snapshotNotes = snapshotRepository.fetchAllSnapshots()
|
||||
.sorted { $0.date > $1.date }
|
||||
let snapshotMonths = Set(snapshotNotes.map { $0.date.startOfMonth })
|
||||
let noteMonths = Set(MonthlyCheckInStore.allNotes().map { $0.date.startOfMonth })
|
||||
let allMonths = snapshotMonths.union(noteMonths)
|
||||
|
||||
monthlyNotes = allMonths
|
||||
.sorted(by: >)
|
||||
.map { date -> MonthlyNoteItem in
|
||||
let entry = MonthlyCheckInStore.entry(for: date)
|
||||
return MonthlyNoteItem(
|
||||
date: date,
|
||||
note: entry?.note ?? "",
|
||||
rating: entry?.rating,
|
||||
mood: entry?.mood
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MonthlyNoteItem: Identifiable, Equatable {
|
||||
var id: Date { date }
|
||||
let date: Date
|
||||
let note: String
|
||||
let rating: Int?
|
||||
let mood: MonthlyCheckInMood?
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
|
||||
@MainActor
|
||||
class MonthlyCheckInViewModel: ObservableObject {
|
||||
@Published var sourcesNeedingUpdate: [InvestmentSource] = []
|
||||
@Published var sources: [InvestmentSource] = []
|
||||
@Published var monthlySummary: MonthlySummary = .empty
|
||||
@Published var recentNotes: [Snapshot] = []
|
||||
@Published var isLoading = false
|
||||
@Published var selectedAccount: Account?
|
||||
@Published var showAllAccounts = true
|
||||
@Published var selectedRange: DateRange = .thisMonth
|
||||
@Published var checkInStats: MonthlyCheckInStats = .empty
|
||||
|
||||
private let sourceRepository: InvestmentSourceRepository
|
||||
private let snapshotRepository: SnapshotRepository
|
||||
private let calculationService: CalculationService
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
@MainActor
|
||||
init(
|
||||
sourceRepository: InvestmentSourceRepository? = nil,
|
||||
snapshotRepository: SnapshotRepository? = nil,
|
||||
calculationService: CalculationService? = nil
|
||||
) {
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository(context: context)
|
||||
self.snapshotRepository = snapshotRepository ?? SnapshotRepository(context: context)
|
||||
self.calculationService = calculationService ?? .shared
|
||||
|
||||
setupObservers()
|
||||
refresh()
|
||||
}
|
||||
|
||||
private func setupObservers() {
|
||||
sourceRepository.$sources
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)
|
||||
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
isLoading = true
|
||||
let filtered = filteredSources()
|
||||
let snapshots = filteredSnapshots(for: filtered)
|
||||
|
||||
let accountFilter = showAllAccounts ? nil : selectedAccount
|
||||
sourcesNeedingUpdate = sourceRepository.fetchSourcesNeedingUpdate(for: accountFilter)
|
||||
sources = filtered
|
||||
|
||||
monthlySummary = calculationService.calculateMonthlySummary(
|
||||
sources: filtered,
|
||||
snapshots: snapshots,
|
||||
range: selectedRange
|
||||
)
|
||||
|
||||
checkInStats = MonthlyCheckInStore.stats(referenceDate: selectedRange.end)
|
||||
|
||||
recentNotes = snapshots
|
||||
.filter { ($0.notes?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
&& selectedRange.contains($0.date) }
|
||||
.sorted { $0.date > $1.date }
|
||||
.prefix(5)
|
||||
.map { $0 }
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func duplicatePreviousMonthSnapshots(referenceDate: Date) {
|
||||
let targetRange = DateRange.month(containing: referenceDate)
|
||||
let previousRange = DateRange.month(containing: referenceDate.adding(months: -1))
|
||||
let sources = filteredSources()
|
||||
guard !sources.isEmpty else { return }
|
||||
|
||||
let targetSnapshots = snapshotRepository.fetchSnapshots(
|
||||
from: targetRange.start,
|
||||
to: targetRange.end
|
||||
)
|
||||
let targetSourceIds = Set(targetSnapshots.compactMap { $0.source?.id })
|
||||
|
||||
let now = Date()
|
||||
let targetDate = targetRange.contains(now) ? now : targetRange.end
|
||||
|
||||
for source in sources {
|
||||
let sourceId = source.id
|
||||
if targetSourceIds.contains(sourceId) { continue }
|
||||
|
||||
let previousSnapshots = snapshotRepository.fetchSnapshots(for: source)
|
||||
guard let previousSnapshot = previousSnapshots.first(where: { previousRange.contains($0.date) }) else {
|
||||
continue
|
||||
}
|
||||
|
||||
snapshotRepository.createSnapshot(
|
||||
for: source,
|
||||
date: targetDate,
|
||||
value: previousSnapshot.decimalValue,
|
||||
contribution: previousSnapshot.contribution?.decimalValue,
|
||||
notes: previousSnapshot.notes
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func filteredSources() -> [InvestmentSource] {
|
||||
if showAllAccounts || selectedAccount == nil {
|
||||
return sourceRepository.sources
|
||||
}
|
||||
return sourceRepository.sources.filter { $0.account?.id == selectedAccount?.id }
|
||||
}
|
||||
|
||||
private func filteredSnapshots(for sources: [InvestmentSource]) -> [Snapshot] {
|
||||
let sourceIds = Set(sources.compactMap { $0.id })
|
||||
return snapshotRepository.fetchAllSnapshots().filter { snapshot in
|
||||
let sourceId = snapshot.source?.id
|
||||
return sourceId.map(sourceIds.contains) ?? false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
class SettingsViewModel: ObservableObject {
|
||||
// MARK: - Published Properties
|
||||
|
||||
@Published var isPremium = false
|
||||
@Published var isFamilyShared = false
|
||||
@Published var notificationsEnabled = false
|
||||
@Published var defaultNotificationTime = Date()
|
||||
@Published var analyticsEnabled = true
|
||||
@Published var currencyCode = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
|
||||
@Published var inputMode: InputMode = .simple
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var showingPaywall = false
|
||||
@Published var showingExportOptions = false
|
||||
@Published var showingImportSheet = false
|
||||
@Published var showingResetConfirmation = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var successMessage: String?
|
||||
|
||||
// MARK: - Statistics
|
||||
|
||||
@Published var totalSources = 0
|
||||
@Published var totalSnapshots = 0
|
||||
@Published var totalCategories = 0
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let iapService: IAPService
|
||||
private let notificationService: NotificationService
|
||||
private let sourceRepository: InvestmentSourceRepository
|
||||
private let categoryRepository: CategoryRepository
|
||||
private let freemiumValidator: FreemiumValidator
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
iapService: IAPService,
|
||||
notificationService: NotificationService? = nil,
|
||||
sourceRepository: InvestmentSourceRepository? = nil,
|
||||
categoryRepository: CategoryRepository? = nil
|
||||
) {
|
||||
self.iapService = iapService
|
||||
self.notificationService = notificationService ?? .shared
|
||||
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
|
||||
self.categoryRepository = categoryRepository ?? CategoryRepository()
|
||||
self.freemiumValidator = FreemiumValidator(iapService: iapService)
|
||||
|
||||
setupObservers()
|
||||
loadSettings()
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupObservers() {
|
||||
iapService.$isPremium
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$isPremium)
|
||||
|
||||
iapService.$isFamilyShared
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$isFamilyShared)
|
||||
|
||||
notificationService.$isAuthorized
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$notificationsEnabled)
|
||||
}
|
||||
|
||||
// MARK: - Data Loading
|
||||
|
||||
func loadSettings() {
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
let settings = AppSettings.getOrCreate(in: context)
|
||||
|
||||
defaultNotificationTime = settings.defaultNotificationTime ?? Date()
|
||||
analyticsEnabled = settings.enableAnalytics
|
||||
currencyCode = settings.currency
|
||||
inputMode = InputMode(rawValue: settings.inputMode) ?? .simple
|
||||
|
||||
// Load statistics
|
||||
totalSources = sourceRepository.sourceCount
|
||||
totalCategories = categoryRepository.categories.count
|
||||
totalSnapshots = sourceRepository.sources.reduce(0) { $0 + $1.snapshotCount }
|
||||
|
||||
FirebaseService.shared.logScreenView(screenName: "Settings")
|
||||
}
|
||||
|
||||
// MARK: - Premium Actions
|
||||
|
||||
func upgradeToPremium() {
|
||||
showingPaywall = true
|
||||
FirebaseService.shared.logPaywallShown(trigger: "settings_upgrade")
|
||||
}
|
||||
|
||||
func restorePurchases() async {
|
||||
isLoading = true
|
||||
await iapService.restorePurchases()
|
||||
isLoading = false
|
||||
|
||||
if isPremium {
|
||||
successMessage = "Purchases restored successfully!"
|
||||
FirebaseService.shared.logRestorePurchases(success: true)
|
||||
} else {
|
||||
errorMessage = "No purchases to restore"
|
||||
FirebaseService.shared.logRestorePurchases(success: false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification Settings
|
||||
|
||||
func requestNotificationPermission() async {
|
||||
let granted = await notificationService.requestAuthorization()
|
||||
if !granted {
|
||||
errorMessage = "Please enable notifications in Settings"
|
||||
}
|
||||
}
|
||||
|
||||
func updateNotificationTime(_ time: Date) {
|
||||
defaultNotificationTime = time
|
||||
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
let settings = AppSettings.getOrCreate(in: context)
|
||||
settings.defaultNotificationTime = time
|
||||
CoreDataStack.shared.save()
|
||||
|
||||
// Reschedule all notifications with new time
|
||||
let sources = sourceRepository.fetchActiveSources()
|
||||
notificationService.scheduleAllReminders(for: sources)
|
||||
}
|
||||
|
||||
func openSystemSettings() {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Export
|
||||
|
||||
func exportData(format: ExportService.ExportFormat) {
|
||||
guard freemiumValidator.canExport() else {
|
||||
showingPaywall = true
|
||||
FirebaseService.shared.logPaywallShown(trigger: "export")
|
||||
return
|
||||
}
|
||||
|
||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let viewController = windowScene.windows.first?.rootViewController else {
|
||||
return
|
||||
}
|
||||
|
||||
ExportService.shared.share(
|
||||
format: format,
|
||||
sources: sourceRepository.sources,
|
||||
categories: categoryRepository.categories,
|
||||
from: viewController
|
||||
)
|
||||
}
|
||||
|
||||
var canExport: Bool {
|
||||
freemiumValidator.canExport()
|
||||
}
|
||||
|
||||
// MARK: - Analytics
|
||||
|
||||
func toggleAnalytics(_ enabled: Bool) {
|
||||
analyticsEnabled = enabled
|
||||
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
let settings = AppSettings.getOrCreate(in: context)
|
||||
settings.enableAnalytics = enabled
|
||||
CoreDataStack.shared.save()
|
||||
|
||||
// Note: In production, you'd also update Firebase Analytics consent
|
||||
}
|
||||
|
||||
// MARK: - Currency
|
||||
|
||||
func updateCurrency(_ code: String) {
|
||||
currencyCode = code
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
let settings = AppSettings.getOrCreate(in: context)
|
||||
settings.currency = code
|
||||
CoreDataStack.shared.save()
|
||||
}
|
||||
|
||||
// MARK: - Input Mode
|
||||
|
||||
func updateInputMode(_ mode: InputMode) {
|
||||
inputMode = mode
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
let settings = AppSettings.getOrCreate(in: context)
|
||||
settings.inputMode = mode.rawValue
|
||||
CoreDataStack.shared.save()
|
||||
}
|
||||
|
||||
// MARK: - Data Management
|
||||
|
||||
func resetAllData() {
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
|
||||
// Delete all snapshots, sources, and categories
|
||||
for source in sourceRepository.sources {
|
||||
context.delete(source)
|
||||
}
|
||||
for category in categoryRepository.categories {
|
||||
context.delete(category)
|
||||
}
|
||||
let assetRequest: NSFetchRequest<Asset> = Asset.fetchRequest()
|
||||
let accountRequest: NSFetchRequest<Account> = Account.fetchRequest()
|
||||
let goalRequest: NSFetchRequest<Goal> = Goal.fetchRequest()
|
||||
if let assets = try? context.fetch(assetRequest) {
|
||||
assets.forEach { context.delete($0) }
|
||||
}
|
||||
if let accounts = try? context.fetch(accountRequest) {
|
||||
accounts.forEach { context.delete($0) }
|
||||
}
|
||||
if let goals = try? context.fetch(goalRequest) {
|
||||
goals.forEach { context.delete($0) }
|
||||
}
|
||||
|
||||
CoreDataStack.shared.save()
|
||||
|
||||
// Clear notifications
|
||||
notificationService.cancelAllReminders()
|
||||
|
||||
// Recreate default categories
|
||||
categoryRepository.createDefaultCategoriesIfNeeded()
|
||||
_ = AccountRepository().createDefaultAccountIfNeeded()
|
||||
|
||||
// Reload data
|
||||
loadSettings()
|
||||
|
||||
successMessage = "All data has been reset"
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
var appVersion: String {
|
||||
"\(AppConstants.appVersion) (\(AppConstants.buildNumber))"
|
||||
}
|
||||
|
||||
var premiumStatusText: String {
|
||||
if isPremium {
|
||||
return isFamilyShared ? "Premium (Family)" : "Premium"
|
||||
}
|
||||
return "Free"
|
||||
}
|
||||
|
||||
var sourceLimitText: String {
|
||||
if isPremium {
|
||||
return "Unlimited"
|
||||
}
|
||||
return "\(totalSources)/\(FreemiumLimits.maxSources)"
|
||||
}
|
||||
|
||||
var historyLimitText: String {
|
||||
if isPremium {
|
||||
return "Full history"
|
||||
}
|
||||
return "Last \(FreemiumLimits.maxHistoricalMonths) months"
|
||||
}
|
||||
|
||||
var storageUsedText: String {
|
||||
let bytes = calculateStorageUsed()
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.countStyle = .file
|
||||
return formatter.string(fromByteCount: Int64(bytes))
|
||||
}
|
||||
|
||||
private func calculateStorageUsed() -> Int {
|
||||
guard let storeURL = CoreDataStack.sharedStoreURL else { return 0 }
|
||||
|
||||
do {
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: storeURL.path)
|
||||
return attributes[.size] as? Int ?? 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
class SnapshotFormViewModel: ObservableObject {
|
||||
// MARK: - Published Properties
|
||||
|
||||
@Published var date = Date()
|
||||
@Published var valueString = ""
|
||||
@Published var contributionString = ""
|
||||
@Published var notes = ""
|
||||
@Published var includeContribution = false
|
||||
@Published var inputMode: InputMode = .simple
|
||||
@Published var currencySymbol = "€"
|
||||
|
||||
@Published var isValid = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
// MARK: - Mode
|
||||
|
||||
enum Mode {
|
||||
case add
|
||||
case edit(Snapshot)
|
||||
}
|
||||
|
||||
let mode: Mode
|
||||
let source: InvestmentSource
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(source: InvestmentSource, mode: Mode = .add) {
|
||||
self.source = source
|
||||
self.mode = mode
|
||||
let settings = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext)
|
||||
if let accountCurrency = source.account?.currency, !accountCurrency.isEmpty {
|
||||
currencySymbol = CurrencyFormatter.symbol(for: accountCurrency)
|
||||
} else {
|
||||
currencySymbol = settings.currencySymbol
|
||||
}
|
||||
if let accountMode = InputMode(rawValue: source.account?.inputMode ?? "") {
|
||||
inputMode = accountMode
|
||||
} else {
|
||||
inputMode = InputMode(rawValue: settings.inputMode) ?? .simple
|
||||
}
|
||||
includeContribution = inputMode == .detailed
|
||||
|
||||
setupValidation()
|
||||
|
||||
// Pre-fill for edit mode
|
||||
if case .edit(let snapshot) = mode {
|
||||
date = snapshot.date
|
||||
valueString = formatDecimalForInput(snapshot.decimalValue)
|
||||
if let contribution = snapshot.contribution {
|
||||
includeContribution = true
|
||||
contributionString = formatDecimalForInput(contribution.decimalValue)
|
||||
}
|
||||
notes = snapshot.notes ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
private func setupValidation() {
|
||||
Publishers.CombineLatest3($valueString, $contributionString, $includeContribution)
|
||||
.map { [weak self] valueStr, contribStr, includeContrib in
|
||||
self?.validateInputs(
|
||||
valueString: valueStr,
|
||||
contributionString: contribStr,
|
||||
includeContribution: includeContrib
|
||||
) ?? false
|
||||
}
|
||||
.assign(to: &$isValid)
|
||||
}
|
||||
|
||||
private func validateInputs(
|
||||
valueString: String,
|
||||
contributionString: String,
|
||||
includeContribution: Bool
|
||||
) -> Bool {
|
||||
// Value is required
|
||||
guard let value = parseDecimal(valueString), value >= 0 else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Contribution is optional but must be valid if included
|
||||
if includeContribution {
|
||||
guard let contribution = parseDecimal(contributionString), contribution >= 0 else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Parsing
|
||||
|
||||
private func parseDecimal(_ string: String) -> Decimal? {
|
||||
let cleaned = string
|
||||
.replacingOccurrences(of: currencySymbol, with: "")
|
||||
.replacingOccurrences(of: ",", with: ".")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.locale = Locale(identifier: "en_US")
|
||||
|
||||
return formatter.number(from: cleaned)?.decimalValue
|
||||
}
|
||||
|
||||
private func formatDecimalForInput(_ decimal: Decimal) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.minimumFractionDigits = 2
|
||||
formatter.maximumFractionDigits = 2
|
||||
formatter.groupingSeparator = ""
|
||||
return formatter.string(from: decimal as NSDecimalNumber) ?? ""
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
var value: Decimal? {
|
||||
parseDecimal(valueString)
|
||||
}
|
||||
|
||||
var contribution: Decimal? {
|
||||
guard includeContribution else { return nil }
|
||||
return parseDecimal(contributionString)
|
||||
}
|
||||
|
||||
var formattedValue: String {
|
||||
guard let value = value else { return CurrencyFormatter.format(Decimal.zero) }
|
||||
return value.currencyString
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch mode {
|
||||
case .add:
|
||||
return "Add Snapshot"
|
||||
case .edit:
|
||||
return "Edit Snapshot"
|
||||
}
|
||||
}
|
||||
|
||||
var buttonTitle: String {
|
||||
switch mode {
|
||||
case .add:
|
||||
return "Add Snapshot"
|
||||
case .edit:
|
||||
return "Save Changes"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previous Value Reference
|
||||
|
||||
var previousValue: Decimal? {
|
||||
source.latestSnapshot?.decimalValue
|
||||
}
|
||||
|
||||
var previousValueString: String {
|
||||
guard let previous = previousValue else { return "No previous value" }
|
||||
return "Previous: \(previous.currencyString)"
|
||||
}
|
||||
|
||||
var changeFromPrevious: Decimal? {
|
||||
guard let current = value, let previous = previousValue else { return nil }
|
||||
return current - previous
|
||||
}
|
||||
|
||||
var changePercentageFromPrevious: Double? {
|
||||
guard let current = value,
|
||||
let previous = previousValue,
|
||||
previous != 0 else { return nil }
|
||||
|
||||
return NSDecimalNumber(decimal: (current - previous) / previous).doubleValue * 100
|
||||
}
|
||||
|
||||
var formattedChange: String? {
|
||||
guard let change = changeFromPrevious else { return nil }
|
||||
let prefix = change >= 0 ? "+" : ""
|
||||
return "\(prefix)\(change.currencyString)"
|
||||
}
|
||||
|
||||
var formattedChangePercentage: String? {
|
||||
guard let percentage = changePercentageFromPrevious else { return nil }
|
||||
let prefix = percentage >= 0 ? "+" : ""
|
||||
return String(format: "\(prefix)%.2f%%", percentage)
|
||||
}
|
||||
|
||||
// MARK: - Duplicate Previous
|
||||
|
||||
func prefillFromPreviousSnapshot() {
|
||||
guard case .add = mode,
|
||||
let previous = source.latestSnapshot else { return }
|
||||
|
||||
valueString = formatDecimalForInput(previous.decimalValue)
|
||||
if let contribution = previous.contribution {
|
||||
includeContribution = true
|
||||
contributionString = formatDecimalForInput(contribution.decimalValue)
|
||||
}
|
||||
notes = previous.notes ?? ""
|
||||
date = Date()
|
||||
}
|
||||
|
||||
// MARK: - Date Validation
|
||||
|
||||
var isDateInFuture: Bool {
|
||||
date > Date()
|
||||
}
|
||||
|
||||
var dateWarning: String? {
|
||||
if isDateInFuture {
|
||||
return "Date is in the future"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
|
||||
@MainActor
|
||||
class SourceDetailViewModel: ObservableObject {
|
||||
// MARK: - Published Properties
|
||||
|
||||
@Published var source: InvestmentSource
|
||||
@Published var snapshots: [Snapshot] = []
|
||||
@Published var metrics: InvestmentMetrics = .empty
|
||||
@Published var predictions: [Prediction] = []
|
||||
@Published var predictionResult: PredictionResult?
|
||||
@Published var transactions: [Transaction] = []
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var showingAddSnapshot = false
|
||||
@Published var showingEditSource = false
|
||||
@Published var showingPaywall = false
|
||||
@Published var showingAddTransaction = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
// MARK: - Chart Data
|
||||
|
||||
@Published var chartData: [(date: Date, value: Decimal)] = []
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let snapshotRepository: SnapshotRepository
|
||||
private let sourceRepository: InvestmentSourceRepository
|
||||
private let transactionRepository: TransactionRepository
|
||||
private let calculationService: CalculationService
|
||||
private let predictionEngine: PredictionEngine
|
||||
private let freemiumValidator: FreemiumValidator
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var isRefreshing = false
|
||||
private var refreshQueued = false
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
source: InvestmentSource,
|
||||
snapshotRepository: SnapshotRepository? = nil,
|
||||
sourceRepository: InvestmentSourceRepository? = nil,
|
||||
transactionRepository: TransactionRepository? = nil,
|
||||
calculationService: CalculationService? = nil,
|
||||
predictionEngine: PredictionEngine? = nil,
|
||||
iapService: IAPService
|
||||
) {
|
||||
self.source = source
|
||||
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
|
||||
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
|
||||
self.transactionRepository = transactionRepository ?? TransactionRepository()
|
||||
self.calculationService = calculationService ?? .shared
|
||||
self.predictionEngine = predictionEngine ?? .shared
|
||||
self.freemiumValidator = FreemiumValidator(iapService: iapService)
|
||||
|
||||
loadData()
|
||||
setupObservers()
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupObservers() {
|
||||
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)
|
||||
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] notification in
|
||||
guard let self,
|
||||
self.isRelevantChange(notification) else { return }
|
||||
self.refreshData()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Data Loading
|
||||
|
||||
func loadData() {
|
||||
isLoading = true
|
||||
refreshData()
|
||||
isLoading = false
|
||||
|
||||
FirebaseService.shared.logScreenView(screenName: "SourceDetail")
|
||||
}
|
||||
|
||||
func refreshData() {
|
||||
refreshQueued = true
|
||||
guard !isRefreshing else { return }
|
||||
isRefreshing = true
|
||||
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
while self.refreshQueued {
|
||||
self.refreshQueued = false
|
||||
|
||||
// Fetch snapshots (filtered by freemium limits)
|
||||
let allSnapshots = snapshotRepository.fetchSnapshots(for: source)
|
||||
let filteredSnapshots = freemiumValidator.filterSnapshots(allSnapshots)
|
||||
|
||||
// Performance: Only update if data actually changed
|
||||
let snapshotsChanged = filteredSnapshots.count != self.snapshots.count ||
|
||||
filteredSnapshots.first?.date != self.snapshots.first?.date
|
||||
|
||||
if snapshotsChanged {
|
||||
self.snapshots = filteredSnapshots
|
||||
|
||||
// Calculate metrics
|
||||
self.metrics = calculationService.calculateMetrics(for: filteredSnapshots)
|
||||
|
||||
// Performance: Pre-sort once and reuse
|
||||
let sortedSnapshots = filteredSnapshots.sorted { $0.date < $1.date }
|
||||
|
||||
// Prepare chart data - avoid creating new array if possible
|
||||
self.chartData = sortedSnapshots.map { (date: $0.date, value: $0.decimalValue) }
|
||||
|
||||
// Calculate predictions if premium - only if we have enough data
|
||||
if freemiumValidator.canViewPredictions() && sortedSnapshots.count >= 3 {
|
||||
self.predictionResult = predictionEngine.predict(snapshots: sortedSnapshots)
|
||||
self.predictions = self.predictionResult?.predictions ?? []
|
||||
} else {
|
||||
self.predictions = []
|
||||
self.predictionResult = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Transactions update independently
|
||||
self.transactions = transactionRepository.fetchTransactions(for: source)
|
||||
}
|
||||
self.isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Snapshot Actions
|
||||
|
||||
func addSnapshot(date: Date, value: Decimal, contribution: Decimal?, notes: String?) {
|
||||
snapshotRepository.createSnapshot(
|
||||
for: source,
|
||||
date: date,
|
||||
value: value,
|
||||
contribution: contribution,
|
||||
notes: notes
|
||||
)
|
||||
|
||||
// Reschedule notification
|
||||
NotificationService.shared.scheduleReminder(for: source)
|
||||
|
||||
// Log analytics
|
||||
FirebaseService.shared.logSnapshotAdded(sourceName: source.name, value: value)
|
||||
|
||||
showingAddSnapshot = false
|
||||
refreshData()
|
||||
}
|
||||
|
||||
func deleteSnapshot(_ snapshot: Snapshot) {
|
||||
snapshotRepository.deleteSnapshot(snapshot)
|
||||
refreshData()
|
||||
}
|
||||
|
||||
func deleteSnapshot(at offsets: IndexSet) {
|
||||
snapshotRepository.deleteSnapshot(at: offsets, from: snapshots)
|
||||
refreshData()
|
||||
}
|
||||
|
||||
// MARK: - Transaction Actions
|
||||
|
||||
func addTransaction(
|
||||
type: TransactionType,
|
||||
date: Date,
|
||||
shares: Decimal?,
|
||||
price: Decimal?,
|
||||
amount: Decimal?,
|
||||
notes: String?
|
||||
) {
|
||||
transactionRepository.createTransaction(
|
||||
source: source,
|
||||
type: type,
|
||||
date: date,
|
||||
shares: shares,
|
||||
price: price,
|
||||
amount: amount,
|
||||
notes: notes
|
||||
)
|
||||
refreshData()
|
||||
}
|
||||
|
||||
func deleteTransaction(_ transaction: Transaction) {
|
||||
transactionRepository.deleteTransaction(transaction)
|
||||
refreshData()
|
||||
}
|
||||
|
||||
// MARK: - Source Actions
|
||||
|
||||
func updateSource(
|
||||
name: String,
|
||||
category: Category,
|
||||
frequency: NotificationFrequency,
|
||||
customMonths: Int
|
||||
) {
|
||||
sourceRepository.updateSource(
|
||||
source,
|
||||
name: name,
|
||||
category: category,
|
||||
notificationFrequency: frequency,
|
||||
customFrequencyMonths: customMonths
|
||||
)
|
||||
|
||||
// Update notification
|
||||
NotificationService.shared.scheduleReminder(for: source)
|
||||
|
||||
showingEditSource = false
|
||||
}
|
||||
|
||||
// MARK: - Predictions
|
||||
|
||||
func showPredictions() {
|
||||
if freemiumValidator.canViewPredictions() {
|
||||
// Already loaded, just navigate
|
||||
FirebaseService.shared.logPredictionViewed(
|
||||
algorithm: predictionResult?.algorithm.rawValue ?? "unknown"
|
||||
)
|
||||
} else {
|
||||
showingPaywall = true
|
||||
FirebaseService.shared.logPaywallShown(trigger: "predictions")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
var currentValue: Decimal {
|
||||
source.latestValue
|
||||
}
|
||||
|
||||
var formattedCurrentValue: String {
|
||||
currentValue.currencyString
|
||||
}
|
||||
|
||||
var totalReturn: Decimal {
|
||||
metrics.absoluteReturn
|
||||
}
|
||||
|
||||
var formattedTotalReturn: String {
|
||||
metrics.formattedAbsoluteReturn
|
||||
}
|
||||
|
||||
var percentageReturn: Decimal {
|
||||
metrics.percentageReturn
|
||||
}
|
||||
|
||||
var formattedPercentageReturn: String {
|
||||
metrics.formattedPercentageReturn
|
||||
}
|
||||
|
||||
var isPositiveReturn: Bool {
|
||||
totalReturn >= 0
|
||||
}
|
||||
|
||||
var categoryName: String {
|
||||
source.category?.name ?? "Uncategorized"
|
||||
}
|
||||
|
||||
var categoryColor: String {
|
||||
source.category?.colorHex ?? "#3B82F6"
|
||||
}
|
||||
|
||||
var lastUpdated: String {
|
||||
source.latestSnapshot?.date.friendlyDescription ?? "Never"
|
||||
}
|
||||
|
||||
var snapshotCount: Int {
|
||||
snapshots.count
|
||||
}
|
||||
|
||||
var hasEnoughDataForPredictions: Bool {
|
||||
snapshots.count >= 3
|
||||
}
|
||||
|
||||
var canViewPredictions: Bool {
|
||||
freemiumValidator.canViewPredictions()
|
||||
}
|
||||
|
||||
var isHistoryLimited: Bool {
|
||||
!freemiumValidator.isPremium && hiddenSnapshotCount > 0
|
||||
}
|
||||
|
||||
var hiddenSnapshotCount: Int {
|
||||
guard let limit = snapshotDisplayLimit else { return 0 }
|
||||
return max(0, snapshots.count - min(limit, snapshots.count))
|
||||
}
|
||||
|
||||
var snapshotDisplayLimit: Int? {
|
||||
freemiumValidator.isPremium ? nil : 10
|
||||
}
|
||||
|
||||
var visibleSnapshots: [Snapshot] {
|
||||
guard let limit = snapshotDisplayLimit else { return snapshots }
|
||||
return Array(snapshots.prefix(limit))
|
||||
}
|
||||
|
||||
private func isRelevantChange(_ notification: Notification) -> Bool {
|
||||
guard let info = notification.userInfo else { return false }
|
||||
let keys: [String] = [
|
||||
NSInsertedObjectsKey,
|
||||
NSUpdatedObjectsKey,
|
||||
NSDeletedObjectsKey,
|
||||
NSRefreshedObjectsKey
|
||||
]
|
||||
|
||||
for key in keys {
|
||||
if let objects = info[key] as? Set<NSManagedObject> {
|
||||
if objects.contains(where: { obj in
|
||||
if let snapshot = obj as? Snapshot {
|
||||
return snapshot.source?.id == source.id
|
||||
}
|
||||
if let investmentSource = obj as? InvestmentSource {
|
||||
return investmentSource.id == source.id
|
||||
}
|
||||
return false
|
||||
}) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
|
||||
@MainActor
|
||||
class SourceListViewModel: ObservableObject {
|
||||
// MARK: - Published Properties
|
||||
|
||||
@Published var sources: [InvestmentSource] = []
|
||||
@Published var categories: [Category] = []
|
||||
@Published var selectedCategory: Category?
|
||||
@Published var searchText = ""
|
||||
@Published var selectedAccount: Account?
|
||||
@Published var showAllAccounts = true
|
||||
@Published var isLoading = false
|
||||
@Published var showingAddSource = false
|
||||
@Published var showingPaywall = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let sourceRepository: InvestmentSourceRepository
|
||||
private let categoryRepository: CategoryRepository
|
||||
private let freemiumValidator: FreemiumValidator
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
sourceRepository: InvestmentSourceRepository? = nil,
|
||||
categoryRepository: CategoryRepository? = nil,
|
||||
iapService: IAPService
|
||||
) {
|
||||
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
|
||||
self.categoryRepository = categoryRepository ?? CategoryRepository()
|
||||
self.freemiumValidator = FreemiumValidator(iapService: iapService)
|
||||
|
||||
setupObservers()
|
||||
loadData()
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupObservers() {
|
||||
// Performance: Update categories separately (less frequent)
|
||||
categoryRepository.$categories
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] categories in
|
||||
self?.categories = categories
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Performance: Combine all filter-triggering publishers into one stream
|
||||
// This prevents multiple rapid filter operations when state changes
|
||||
Publishers.CombineLatest4(
|
||||
sourceRepository.$sources,
|
||||
$searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main),
|
||||
$selectedCategory,
|
||||
$selectedAccount
|
||||
)
|
||||
.combineLatest($showAllAccounts)
|
||||
.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] combined, _ in
|
||||
let (sources, _, _, _) = combined
|
||||
self?.filterAndSortSources(sources)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Data Loading
|
||||
|
||||
func loadData() {
|
||||
isLoading = true
|
||||
categoryRepository.createDefaultCategoriesIfNeeded()
|
||||
sourceRepository.fetchSources()
|
||||
categoryRepository.fetchCategories()
|
||||
isLoading = false
|
||||
|
||||
FirebaseService.shared.logScreenView(screenName: "SourceList")
|
||||
}
|
||||
|
||||
private func filterAndSortSources(_ allSources: [InvestmentSource]) {
|
||||
var filtered = allSources
|
||||
|
||||
if !showAllAccounts, let account = selectedAccount {
|
||||
filtered = filtered.filter { $0.account?.id == account.id }
|
||||
}
|
||||
|
||||
// Filter by category
|
||||
if let category = selectedCategory {
|
||||
filtered = filtered.filter { $0.category?.id == category.id }
|
||||
}
|
||||
|
||||
// Filter by search text
|
||||
if !searchText.isEmpty {
|
||||
filtered = filtered.filter {
|
||||
$0.name.localizedCaseInsensitiveContains(searchText) ||
|
||||
($0.category?.name.localizedCaseInsensitiveContains(searchText) ?? false)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by value descending
|
||||
sources = filtered.sorted { $0.latestValue > $1.latestValue }
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
func addSourceTapped() {
|
||||
if freemiumValidator.canAddSource(currentCount: sourceRepository.sourceCount) {
|
||||
showingAddSource = true
|
||||
} else {
|
||||
showingPaywall = true
|
||||
FirebaseService.shared.logPaywallShown(trigger: "source_limit")
|
||||
}
|
||||
}
|
||||
|
||||
func createSource(
|
||||
name: String,
|
||||
category: Category,
|
||||
frequency: NotificationFrequency,
|
||||
customMonths: Int = 1,
|
||||
account: Account? = nil
|
||||
) {
|
||||
let source = sourceRepository.createSource(
|
||||
name: name,
|
||||
category: category,
|
||||
notificationFrequency: frequency,
|
||||
customFrequencyMonths: customMonths,
|
||||
account: account
|
||||
)
|
||||
|
||||
// Schedule notification
|
||||
NotificationService.shared.scheduleReminder(for: source)
|
||||
|
||||
// Log analytics
|
||||
FirebaseService.shared.logSourceAdded(
|
||||
categoryName: category.name,
|
||||
sourceCount: sourceRepository.sourceCount
|
||||
)
|
||||
|
||||
showingAddSource = false
|
||||
}
|
||||
|
||||
func deleteSource(_ source: InvestmentSource) {
|
||||
let categoryName = source.category?.name ?? "Unknown"
|
||||
|
||||
// Cancel notifications
|
||||
NotificationService.shared.cancelReminder(for: source)
|
||||
|
||||
// Delete source
|
||||
sourceRepository.deleteSource(source)
|
||||
|
||||
// Log analytics
|
||||
FirebaseService.shared.logSourceDeleted(categoryName: categoryName)
|
||||
}
|
||||
|
||||
func deleteSource(at offsets: IndexSet) {
|
||||
for index in offsets {
|
||||
guard index < sources.count else { continue }
|
||||
deleteSource(sources[index])
|
||||
}
|
||||
}
|
||||
|
||||
func toggleSourceActive(_ source: InvestmentSource) {
|
||||
sourceRepository.toggleActive(source)
|
||||
|
||||
if source.isActive {
|
||||
NotificationService.shared.scheduleReminder(for: source)
|
||||
} else {
|
||||
NotificationService.shared.cancelReminder(for: source)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
var canAddSource: Bool {
|
||||
freemiumValidator.canAddSource(currentCount: sourceRepository.sourceCount)
|
||||
}
|
||||
|
||||
var remainingSources: Int {
|
||||
freemiumValidator.remainingSources(currentCount: sourceRepository.sourceCount)
|
||||
}
|
||||
|
||||
var sourceLimitReached: Bool {
|
||||
!canAddSource
|
||||
}
|
||||
|
||||
var totalValue: Decimal {
|
||||
sources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
}
|
||||
|
||||
var formattedTotalValue: String {
|
||||
totalValue.currencyString
|
||||
}
|
||||
|
||||
var sourcesByCategory: [Category: [InvestmentSource]] {
|
||||
Dictionary(grouping: sources) { $0.category ?? categories.first! }
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
sources.isEmpty && searchText.isEmpty && selectedCategory == nil
|
||||
}
|
||||
|
||||
var isFiltered: Bool {
|
||||
!searchText.isEmpty || selectedCategory != nil
|
||||
}
|
||||
|
||||
// MARK: - Category Filter
|
||||
|
||||
func selectCategory(_ category: Category?) {
|
||||
selectedCategory = category
|
||||
}
|
||||
|
||||
func clearFilters() {
|
||||
searchText = ""
|
||||
selectedCategory = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AccountEditorView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var accountStore: AccountStore
|
||||
let account: Account?
|
||||
|
||||
@State private var name = ""
|
||||
@State private var currencyCode = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
|
||||
@State private var inputMode: InputMode = .simple
|
||||
@State private var notificationFrequency: NotificationFrequency = .monthly
|
||||
@State private var customFrequencyMonths = 1
|
||||
|
||||
private let accountRepository = AccountRepository()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Account name", text: $name)
|
||||
Picker("Currency", selection: $currencyCode) {
|
||||
ForEach(CurrencyPicker.commonCodes, id: \.self) { code in
|
||||
Text(code).tag(code)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Account")
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Input Mode", selection: $inputMode) {
|
||||
ForEach(InputMode.allCases) { mode in
|
||||
Text(mode.title).tag(mode)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Input")
|
||||
} footer: {
|
||||
Text(inputMode.description)
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Reminder Frequency", selection: $notificationFrequency) {
|
||||
ForEach(NotificationFrequency.allCases) { frequency in
|
||||
Text(frequency.displayName).tag(frequency)
|
||||
}
|
||||
}
|
||||
|
||||
if notificationFrequency == .custom {
|
||||
Stepper(
|
||||
"Every \(customFrequencyMonths) month\(customFrequencyMonths > 1 ? "s" : "")",
|
||||
value: $customFrequencyMonths,
|
||||
in: 1...24
|
||||
)
|
||||
}
|
||||
} header: {
|
||||
Text("Account Reminders")
|
||||
} footer: {
|
||||
Text("Reminders apply to the whole account.")
|
||||
}
|
||||
}
|
||||
.navigationTitle(account == nil ? "New Account" : "Edit Account")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") { saveAccount() }
|
||||
.disabled(!isValid)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadAccount()
|
||||
}
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
}
|
||||
|
||||
private var isValid: Bool {
|
||||
!name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
|
||||
private func loadAccount() {
|
||||
guard let account else { return }
|
||||
name = account.name
|
||||
currencyCode = account.currencyCode ?? currencyCode
|
||||
inputMode = InputMode(rawValue: account.inputMode) ?? .simple
|
||||
notificationFrequency = account.frequency
|
||||
customFrequencyMonths = Int(account.customFrequencyMonths)
|
||||
}
|
||||
|
||||
private func saveAccount() {
|
||||
if let account {
|
||||
accountRepository.updateAccount(
|
||||
account,
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
currency: currencyCode,
|
||||
inputMode: inputMode,
|
||||
notificationFrequency: notificationFrequency,
|
||||
customFrequencyMonths: customFrequencyMonths
|
||||
)
|
||||
} else {
|
||||
_ = accountRepository.createAccount(
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
currency: currencyCode,
|
||||
inputMode: inputMode,
|
||||
notificationFrequency: notificationFrequency,
|
||||
customFrequencyMonths: customFrequencyMonths
|
||||
)
|
||||
}
|
||||
|
||||
accountStore.persistSelection()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AccountEditorView(account: nil)
|
||||
.environmentObject(AccountStore(iapService: IAPService()))
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AccountsView: View {
|
||||
@EnvironmentObject private var iapService: IAPService
|
||||
@EnvironmentObject private var accountStore: AccountStore
|
||||
@StateObject private var accountRepository = AccountRepository()
|
||||
@State private var showingAddAccount = false
|
||||
@State private var selectedAccount: Account?
|
||||
@State private var showingPaywall = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
List {
|
||||
Section {
|
||||
ForEach(accountRepository.accounts) { account in
|
||||
Button {
|
||||
selectedAccount = account
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(account.name)
|
||||
.font(.headline)
|
||||
Text(account.currencyCode ?? AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if accountStore.selectedAccount?.id == account.id && !accountStore.showAllAccounts {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Accounts")
|
||||
} footer: {
|
||||
Text(iapService.isPremium ? "Create multiple accounts for business, family, or goals." : "Free users can create one account.")
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.navigationTitle("Accounts")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
if accountStore.canAddAccount() {
|
||||
showingAddAccount = true
|
||||
} else {
|
||||
showingPaywall = true
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAddAccount) {
|
||||
AccountEditorView(account: nil)
|
||||
}
|
||||
.sheet(isPresented: $showingPaywall) {
|
||||
PaywallView()
|
||||
}
|
||||
.sheet(item: $selectedAccount) { account in
|
||||
AccountEditorView(account: account)
|
||||
}
|
||||
.onAppear {
|
||||
accountRepository.fetchAccounts()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AccountsView()
|
||||
.environmentObject(IAPService())
|
||||
.environmentObject(AccountStore(iapService: IAPService()))
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct AllocationPieChart: View {
|
||||
let data: [(category: String, value: Decimal, color: String)]
|
||||
|
||||
@State private var selectedSlice: String?
|
||||
|
||||
var total: Decimal {
|
||||
data.reduce(Decimal.zero) { $0 + $1.value }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Asset Allocation")
|
||||
.font(.headline)
|
||||
|
||||
if !data.isEmpty {
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
// Pie Chart
|
||||
Chart(data, id: \.category) { item in
|
||||
SectorMark(
|
||||
angle: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue),
|
||||
innerRadius: .ratio(0.6),
|
||||
angularInset: 1.5
|
||||
)
|
||||
.foregroundStyle(Color(hex: item.color) ?? .gray)
|
||||
.cornerRadius(4)
|
||||
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
|
||||
}
|
||||
.chartLegend(.hidden)
|
||||
.frame(width: 180, height: 180)
|
||||
.overlay {
|
||||
// Center content
|
||||
VStack(spacing: 2) {
|
||||
if let selected = selectedSlice,
|
||||
let item = data.first(where: { $0.category == selected }) {
|
||||
Text(selected)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(item.value.compactCurrencyString)
|
||||
.font(.headline)
|
||||
let percentage = total > 0
|
||||
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
|
||||
: 0
|
||||
Text(String(format: "%.1f%%", percentage))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("Total")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(total.compactCurrencyString)
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
}
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
// Simple tap detection
|
||||
}
|
||||
)
|
||||
|
||||
// Legend
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(data, id: \.category) { item in
|
||||
Button {
|
||||
if selectedSlice == item.category {
|
||||
selectedSlice = nil
|
||||
} else {
|
||||
selectedSlice = item.category
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(Color(hex: item.color) ?? .gray)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(item.category)
|
||||
.font(.caption)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
let percentage = total > 0
|
||||
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
|
||||
: 0
|
||||
Text(String(format: "%.1f%%", percentage))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
AllocationTargetsComparisonChart(data: data)
|
||||
} else {
|
||||
Text("No allocation data available")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 200)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Allocation Targets Comparison
|
||||
|
||||
struct AllocationTargetsComparisonChart: View {
|
||||
let data: [(category: String, value: Decimal, color: String)]
|
||||
@StateObject private var categoryRepository = CategoryRepository()
|
||||
|
||||
private var total: Decimal {
|
||||
data.reduce(Decimal.zero) { $0 + $1.value }
|
||||
}
|
||||
|
||||
private var targetData: [(category: String, actual: Double, target: Double, color: Color)] {
|
||||
data.map { item in
|
||||
let actual = total > 0
|
||||
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
|
||||
: 0
|
||||
let target = AllocationTargetStore.target(for: categoryId(for: item.category)) ?? 0
|
||||
let color = Color(hex: item.color) ?? .gray
|
||||
return (item.category, actual, target, color)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Targets vs Actual")
|
||||
.font(.headline)
|
||||
|
||||
if targetData.allSatisfy({ $0.target == 0 }) {
|
||||
Text("Set allocation targets to compare your portfolio against your plan.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(targetData, id: \.category) { item in
|
||||
BarMark(
|
||||
x: .value("Category", item.category),
|
||||
y: .value("Actual", item.actual)
|
||||
)
|
||||
.foregroundStyle(item.color)
|
||||
|
||||
BarMark(
|
||||
x: .value("Category", item.category),
|
||||
y: .value("Target", item.target)
|
||||
)
|
||||
.foregroundStyle(Color.gray.opacity(0.35))
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.0f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks { value in
|
||||
AxisValueLabel()
|
||||
}
|
||||
}
|
||||
.frame(height: 220)
|
||||
|
||||
ForEach(targetData, id: \.category) { item in
|
||||
let drift = item.actual - item.target
|
||||
let prefix = drift >= 0 ? "+" : ""
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(item.color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(item.category)
|
||||
.font(.caption)
|
||||
Spacer()
|
||||
Text("Actual \(String(format: "%.1f%%", item.actual))")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Text("Target \(String(format: "%.0f%%", item.target))")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(prefix)\(String(format: "%.1f%%", drift))")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(drift >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private func categoryId(for name: String) -> UUID {
|
||||
if let category = categoryRepository.categories.first(where: { $0.name == name }) {
|
||||
return category.id
|
||||
}
|
||||
return UUID()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Allocation List View (Alternative)
|
||||
|
||||
struct AllocationListView: View {
|
||||
let data: [(category: String, value: Decimal, color: String)]
|
||||
|
||||
var total: Decimal {
|
||||
data.reduce(Decimal.zero) { $0 + $1.value }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Asset Allocation")
|
||||
.font(.headline)
|
||||
|
||||
ForEach(data, id: \.category) { item in
|
||||
VStack(spacing: 4) {
|
||||
HStack {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(Color(hex: item.color) ?? .gray)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
Text(item.category)
|
||||
.font(.subheadline)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
let percentage = total > 0
|
||||
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
|
||||
: 0
|
||||
|
||||
Text(String(format: "%.1f%%", percentage))
|
||||
.font(.subheadline.weight(.medium))
|
||||
|
||||
Text(item.value.compactCurrencyString)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 70, alignment: .trailing)
|
||||
}
|
||||
|
||||
// Progress bar
|
||||
GeometryReader { geometry in
|
||||
let percentage = total > 0
|
||||
? NSDecimalNumber(decimal: item.value / total).doubleValue
|
||||
: 0
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.1))
|
||||
.frame(height: 6)
|
||||
.cornerRadius(3)
|
||||
|
||||
Rectangle()
|
||||
.fill(Color(hex: item.color) ?? .gray)
|
||||
.frame(width: geometry.size.width * percentage, height: 6)
|
||||
.cornerRadius(3)
|
||||
}
|
||||
}
|
||||
.frame(height: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let sampleData: [(category: String, value: Decimal, color: String)] = [
|
||||
("Stocks", 50000, "#10B981"),
|
||||
("Bonds", 25000, "#3B82F6"),
|
||||
("Real Estate", 15000, "#F59E0B"),
|
||||
("Crypto", 10000, "#8B5CF6")
|
||||
]
|
||||
|
||||
return VStack(spacing: 20) {
|
||||
AllocationPieChart(data: sampleData)
|
||||
AllocationListView(data: sampleData)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,811 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct ChartsContainerView: View {
|
||||
@EnvironmentObject var accountStore: AccountStore
|
||||
@StateObject private var viewModel: ChartsViewModel
|
||||
@StateObject private var goalsViewModel = GoalsViewModel()
|
||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
||||
|
||||
init(iapService: IAPService) {
|
||||
_viewModel = StateObject(wrappedValue: ChartsViewModel(iapService: iapService))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// Chart Type Selector
|
||||
chartTypeSelector
|
||||
|
||||
// Time Range Selector
|
||||
if viewModel.selectedChartType != .allocation &&
|
||||
viewModel.selectedChartType != .performance &&
|
||||
viewModel.selectedChartType != .riskReturn {
|
||||
timeRangeSelector
|
||||
}
|
||||
|
||||
// Category Filter
|
||||
if viewModel.selectedChartType == .evolution ||
|
||||
viewModel.selectedChartType == .prediction {
|
||||
categoryFilter
|
||||
}
|
||||
|
||||
// Chart Content
|
||||
chartContent
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Charts")
|
||||
.sheet(isPresented: $viewModel.showingPaywall) {
|
||||
PaywallView()
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
accountFilterMenu
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.selectedAccount = accountStore.selectedAccount
|
||||
viewModel.showAllAccounts = accountStore.showAllAccounts
|
||||
viewModel.loadData()
|
||||
goalsViewModel.selectedAccount = accountStore.selectedAccount
|
||||
goalsViewModel.showAllAccounts = accountStore.showAllAccounts
|
||||
goalsViewModel.refresh()
|
||||
viewModel.updatePredictionTargetDate(goalsViewModel.goals)
|
||||
}
|
||||
// Performance: Use onChange instead of onReceive for cleaner state updates
|
||||
.onChange(of: accountStore.selectedAccount) { _, newAccount in
|
||||
viewModel.selectedAccount = newAccount
|
||||
goalsViewModel.selectedAccount = newAccount
|
||||
goalsViewModel.refresh()
|
||||
viewModel.updatePredictionTargetDate(goalsViewModel.goals)
|
||||
}
|
||||
.onChange(of: accountStore.showAllAccounts) { _, showAll in
|
||||
viewModel.showAllAccounts = showAll
|
||||
goalsViewModel.showAllAccounts = showAll
|
||||
goalsViewModel.refresh()
|
||||
viewModel.updatePredictionTargetDate(goalsViewModel.goals)
|
||||
}
|
||||
.onChange(of: goalsViewModel.goals) { _, goals in
|
||||
viewModel.updatePredictionTargetDate(goals)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chart Type Selector
|
||||
|
||||
private var chartTypeSelector: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(viewModel.availableChartTypes(calmModeEnabled: calmModeEnabled)) { chartType in
|
||||
ChartTypeButton(
|
||||
chartType: chartType,
|
||||
isSelected: viewModel.selectedChartType == chartType,
|
||||
isPremium: chartType.isPremium,
|
||||
userIsPremium: viewModel.isPremium
|
||||
) {
|
||||
viewModel.selectChart(chartType)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Time Range Selector
|
||||
|
||||
private var timeRangeSelector: some View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(ChartsViewModel.TimeRange.allCases) { range in
|
||||
Button {
|
||||
viewModel.selectedTimeRange = range
|
||||
} label: {
|
||||
Text(range.rawValue)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
viewModel.selectedTimeRange == range
|
||||
? Color.appPrimary
|
||||
: Color.gray.opacity(0.1)
|
||||
)
|
||||
.foregroundColor(
|
||||
viewModel.selectedTimeRange == range
|
||||
? .white
|
||||
: .primary
|
||||
)
|
||||
.cornerRadius(20)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Filter
|
||||
|
||||
@ViewBuilder
|
||||
private var categoryFilter: some View {
|
||||
let availableCategories = viewModel.availableCategories(for: viewModel.selectedChartType)
|
||||
if !availableCategories.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
if availableCategories.count > 1 {
|
||||
Button {
|
||||
viewModel.selectedCategory = nil
|
||||
} label: {
|
||||
Text("All")
|
||||
.font(.caption.weight(.medium))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
viewModel.selectedCategory == nil
|
||||
? Color.appPrimary
|
||||
: Color.gray.opacity(0.1)
|
||||
)
|
||||
.foregroundColor(
|
||||
viewModel.selectedCategory == nil
|
||||
? .white
|
||||
: .primary
|
||||
)
|
||||
.cornerRadius(16)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(availableCategories) { category in
|
||||
Button {
|
||||
viewModel.selectedCategory = category
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(category.color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(category.name)
|
||||
}
|
||||
.font(.caption.weight(.medium))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
viewModel.selectedCategory?.id == category.id
|
||||
? category.color
|
||||
: Color.gray.opacity(0.1)
|
||||
)
|
||||
.foregroundColor(
|
||||
viewModel.selectedCategory?.id == category.id
|
||||
? .white
|
||||
: .primary
|
||||
)
|
||||
.cornerRadius(16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chart Content
|
||||
|
||||
@ViewBuilder
|
||||
private var chartContent: some View {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.frame(height: 300)
|
||||
} else if !viewModel.hasData {
|
||||
emptyStateView
|
||||
} else {
|
||||
switch viewModel.selectedChartType {
|
||||
case .evolution:
|
||||
EvolutionChartView(
|
||||
data: viewModel.evolutionData,
|
||||
categoryData: viewModel.categoryEvolutionData,
|
||||
goals: goalsViewModel.goals
|
||||
)
|
||||
case .allocation:
|
||||
AllocationPieChart(data: viewModel.allocationData)
|
||||
case .performance:
|
||||
PerformanceBarChart(data: viewModel.performanceData)
|
||||
case .contributions:
|
||||
ContributionsChartView(data: viewModel.contributionsData)
|
||||
case .rollingReturn:
|
||||
RollingReturnChartView(data: viewModel.rollingReturnData)
|
||||
case .riskReturn:
|
||||
RiskReturnChartView(data: viewModel.riskReturnData)
|
||||
case .cashflow:
|
||||
CashflowStackedChartView(data: viewModel.cashflowData)
|
||||
case .drawdown:
|
||||
DrawdownChart(data: viewModel.drawdownData)
|
||||
case .volatility:
|
||||
VolatilityChartView(data: viewModel.volatilityData)
|
||||
case .prediction:
|
||||
PredictionChartView(
|
||||
predictions: viewModel.predictionData,
|
||||
historicalData: viewModel.evolutionData
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyStateView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "chart.bar.xaxis")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("No Data Available")
|
||||
.font(.headline)
|
||||
|
||||
Text("Add some investment sources and snapshots to see charts.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(height: 300)
|
||||
}
|
||||
|
||||
private var accountFilterMenu: some View {
|
||||
Menu {
|
||||
Button {
|
||||
accountStore.selectAllAccounts()
|
||||
} label: {
|
||||
HStack {
|
||||
Text("All Accounts")
|
||||
if accountStore.showAllAccounts {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(accountStore.accounts) { account in
|
||||
Button {
|
||||
accountStore.selectAccount(account)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(account.name)
|
||||
if accountStore.selectedAccount?.id == account.id && !accountStore.showAllAccounts {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "person.2.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chart Type Button
|
||||
|
||||
struct ChartTypeButton: View {
|
||||
let chartType: ChartsViewModel.ChartType
|
||||
let isSelected: Bool
|
||||
let isPremium: Bool
|
||||
let userIsPremium: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 8) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(systemName: chartType.icon)
|
||||
.font(.title2)
|
||||
|
||||
if isPremium && !userIsPremium {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.appWarning)
|
||||
.offset(x: 8, y: -4)
|
||||
}
|
||||
}
|
||||
|
||||
Text(chartType.rawValue)
|
||||
.font(.caption)
|
||||
}
|
||||
.frame(width: 80, height: 70)
|
||||
.background(
|
||||
isSelected
|
||||
? LinearGradient.appPrimaryGradient
|
||||
: LinearGradient(
|
||||
colors: [
|
||||
Color(.systemBackground).opacity(0.85),
|
||||
Color(.systemBackground).opacity(0.85)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.foregroundColor(isSelected ? .white : .primary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Evolution Chart View
|
||||
|
||||
struct EvolutionChartView: View {
|
||||
let data: [(date: Date, value: Decimal)]
|
||||
let categoryData: [CategoryEvolutionPoint]
|
||||
let goals: [Goal]
|
||||
|
||||
@State private var selectedDataPoint: (date: Date, value: Decimal)?
|
||||
@State private var chartMode: ChartMode = .total
|
||||
@State private var showGoalLines = false
|
||||
|
||||
enum ChartMode: String, CaseIterable, Identifiable {
|
||||
case total = "Total"
|
||||
case byCategory = "By Category"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
private var stackedCategoryData: [CategoryEvolutionPoint] {
|
||||
guard !categoryData.isEmpty else { return [] }
|
||||
|
||||
let totalsByCategory = categoryData.reduce(into: [String: Decimal]()) { result, point in
|
||||
result[point.categoryName, default: 0] += point.value
|
||||
}
|
||||
|
||||
let categoryOrder = totalsByCategory
|
||||
.sorted { $0.value > $1.value }
|
||||
.map { $0.key }
|
||||
let orderIndex = Dictionary(uniqueKeysWithValues: categoryOrder.enumerated().map { ($0.element, $0.offset) })
|
||||
|
||||
let groupedByDate = Dictionary(grouping: categoryData) { $0.date }
|
||||
let sortedDates = groupedByDate.keys.sorted()
|
||||
|
||||
var stacked: [CategoryEvolutionPoint] = []
|
||||
|
||||
for date in sortedDates {
|
||||
guard let points = groupedByDate[date] else { continue }
|
||||
let sortedPoints = points.sorted {
|
||||
(orderIndex[$0.categoryName] ?? Int.max) < (orderIndex[$1.categoryName] ?? Int.max)
|
||||
}
|
||||
|
||||
var running: Decimal = 0
|
||||
for point in sortedPoints {
|
||||
running += point.value
|
||||
stacked.append(CategoryEvolutionPoint(
|
||||
date: point.date,
|
||||
categoryName: point.categoryName,
|
||||
colorHex: point.colorHex,
|
||||
value: running
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return stacked
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
headerView
|
||||
modePicker
|
||||
chartSection
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var headerView: some View {
|
||||
HStack {
|
||||
Text("Portfolio Evolution")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let selected = selectedDataPoint, chartMode == .total {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(selected.value.compactCurrencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(selected.date.monthYearString)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
showGoalLines.toggle()
|
||||
} label: {
|
||||
Image(systemName: showGoalLines ? "target" : "slash.circle")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.disabled(goals.isEmpty)
|
||||
.accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals")
|
||||
}
|
||||
}
|
||||
|
||||
private var modePicker: some View {
|
||||
Picker("Evolution Mode", selection: $chartMode) {
|
||||
ForEach(ChartMode.allCases) { mode in
|
||||
Text(mode.rawValue).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var chartSection: some View {
|
||||
if data.count >= 2 {
|
||||
chartView
|
||||
} else {
|
||||
Text("Not enough data")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 300)
|
||||
}
|
||||
}
|
||||
|
||||
private var chartView: some View {
|
||||
Chart {
|
||||
chartMarks
|
||||
}
|
||||
.chartForegroundStyleScale(domain: chartCategoryNames, range: chartCategoryColors)
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: 2)) { value in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(Decimal(doubleValue).shortCurrencyString)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartOverlay { proxy in
|
||||
if chartMode == .total {
|
||||
GeometryReader { geometry in
|
||||
Rectangle()
|
||||
.fill(.clear)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
guard let plotFrameAnchor = proxy.plotFrame else { return }
|
||||
let plotFrame = geometry[plotFrameAnchor]
|
||||
let x = value.location.x - plotFrame.origin.x
|
||||
guard let date: Date = proxy.value(atX: x) else { return }
|
||||
|
||||
if let closest = data.min(by: {
|
||||
abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date))
|
||||
}) {
|
||||
selectedDataPoint = closest
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
selectedDataPoint = nil
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 300)
|
||||
// Performance: Use GPU rendering for smoother scrolling on older devices
|
||||
.drawingGroup()
|
||||
}
|
||||
|
||||
@ChartContentBuilder
|
||||
private var chartMarks: some ChartContent {
|
||||
switch chartMode {
|
||||
case .total:
|
||||
ForEach(data, id: \.date) { item in
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
PointMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.symbolSize(30)
|
||||
|
||||
AreaMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
}
|
||||
case .byCategory:
|
||||
ForEach(categoryData) { item in
|
||||
AreaMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(by: .value("Category", item.categoryName))
|
||||
.interpolationMethod(.catmullRom)
|
||||
.opacity(0.5)
|
||||
}
|
||||
|
||||
ForEach(stackedCategoryData) { item in
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(by: .value("Category", item.categoryName))
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
PointMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(by: .value("Category", item.categoryName))
|
||||
.symbolSize(18)
|
||||
}
|
||||
}
|
||||
|
||||
if showGoalLines {
|
||||
ForEach(goals) { goal in
|
||||
RuleMark(y: .value("Goal", NSDecimalNumber(decimal: goal.targetDecimal).doubleValue))
|
||||
.foregroundStyle(Color.appSecondary.opacity(0.4))
|
||||
.lineStyle(StrokeStyle(lineWidth: 1, dash: [6, 4]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var chartCategoryNames: [String] {
|
||||
let names = Array(Set(categoryData.map { $0.categoryName })).sorted()
|
||||
return names
|
||||
}
|
||||
|
||||
private var chartCategoryColors: [Color] {
|
||||
chartCategoryNames.map { name in
|
||||
if let hex = categoryData.first(where: { $0.categoryName == name })?.colorHex {
|
||||
return Color(hex: hex) ?? .gray
|
||||
}
|
||||
return .gray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Contributions Chart
|
||||
|
||||
struct ContributionsChartView: View {
|
||||
let data: [(date: Date, amount: Decimal)]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Contributions")
|
||||
.font(.headline)
|
||||
|
||||
if data.isEmpty {
|
||||
Text("No contributions yet.")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 260)
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(data, id: \.date) { item in
|
||||
BarMark(
|
||||
x: .value("Month", item.date),
|
||||
y: .value("Amount", NSDecimalNumber(decimal: item.amount).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: 2)) { value in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(Decimal(doubleValue).shortCurrencyString)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rolling 12-Month Return
|
||||
|
||||
struct RollingReturnChartView: View {
|
||||
let data: [(date: Date, value: Double)]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Rolling 12-Month Return")
|
||||
.font(.headline)
|
||||
|
||||
if data.isEmpty {
|
||||
Text("Not enough data for rolling returns.")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 260)
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(data, id: \.date) { item in
|
||||
LineMark(
|
||||
x: .value("Month", item.date),
|
||||
y: .value("Return", item.value)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
PointMark(
|
||||
x: .value("Month", item.date),
|
||||
y: .value("Return", item.value)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.symbolSize(24)
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: 2)) { value in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.1f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Risk vs Return
|
||||
|
||||
struct RiskReturnChartView: View {
|
||||
let data: [(category: String, cagr: Double, volatility: Double, color: String)]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Risk vs Return")
|
||||
.font(.headline)
|
||||
|
||||
if data.isEmpty {
|
||||
Text("Not enough data to compare categories.")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 260)
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(data, id: \.category) { item in
|
||||
PointMark(
|
||||
x: .value("Volatility", item.volatility),
|
||||
y: .value("CAGR", item.cagr)
|
||||
)
|
||||
.foregroundStyle(Color(hex: item.color) ?? .gray)
|
||||
.symbolSize(60)
|
||||
.annotation(position: .top) {
|
||||
Text(item.category)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(position: .bottom) { value in
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.1f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.1f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Net Performance vs Contributions
|
||||
|
||||
struct CashflowStackedChartView: View {
|
||||
let data: [(date: Date, contributions: Decimal, netPerformance: Decimal)]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Net Performance vs Contributions")
|
||||
.font(.headline)
|
||||
|
||||
if data.isEmpty {
|
||||
Text("Not enough data to compare cashflow.")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 260)
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(data, id: \.date) { item in
|
||||
let contributionValue = NSDecimalNumber(decimal: item.contributions).doubleValue
|
||||
let netValue = NSDecimalNumber(decimal: item.netPerformance).doubleValue
|
||||
let stackedEnd = contributionValue + netValue
|
||||
|
||||
BarMark(
|
||||
x: .value("Month", item.date),
|
||||
yStart: .value("Start", 0),
|
||||
yEnd: .value("Contributions", contributionValue)
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary.opacity(0.8))
|
||||
|
||||
BarMark(
|
||||
x: .value("Month", item.date),
|
||||
yStart: .value("Start", contributionValue),
|
||||
yEnd: .value("Net", stackedEnd)
|
||||
)
|
||||
.foregroundStyle(netValue >= 0 ? Color.positiveGreen : Color.negativeRed)
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: 2)) { value in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(Decimal(doubleValue).shortCurrencyString)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ChartsContainerView(iapService: IAPService())
|
||||
.environmentObject(AccountStore(iapService: IAPService()))
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct DrawdownChart: View {
|
||||
let data: [(date: Date, drawdown: Double)]
|
||||
|
||||
var maxDrawdown: Double {
|
||||
abs(data.map { $0.drawdown }.min() ?? 0)
|
||||
}
|
||||
|
||||
var currentDrawdown: Double {
|
||||
abs(data.last?.drawdown ?? 0)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Text("Drawdown Analysis")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("Max Drawdown")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(String(format: "%.1f%%", maxDrawdown))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.negativeRed)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Shows percentage decline from peak values")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if data.count >= 2 {
|
||||
Chart(data, id: \.date) { item in
|
||||
AreaMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Drawdown", item.drawdown)
|
||||
)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Color.negativeRed.opacity(0.5), Color.negativeRed.opacity(0.1)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Drawdown", item.drawdown)
|
||||
)
|
||||
.foregroundStyle(Color.negativeRed)
|
||||
.interpolationMethod(.catmullRom)
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: 2)) { value in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks { value in
|
||||
AxisGridLine()
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.0f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartYScale(domain: (data.map { $0.drawdown }.min() ?? -50)...0)
|
||||
.frame(height: 250)
|
||||
|
||||
// Statistics
|
||||
HStack(spacing: 20) {
|
||||
DrawdownStatView(
|
||||
title: "Current",
|
||||
value: String(format: "%.1f%%", currentDrawdown),
|
||||
isHighlighted: currentDrawdown > maxDrawdown * 0.8
|
||||
)
|
||||
|
||||
DrawdownStatView(
|
||||
title: "Maximum",
|
||||
value: String(format: "%.1f%%", maxDrawdown),
|
||||
isHighlighted: true
|
||||
)
|
||||
|
||||
DrawdownStatView(
|
||||
title: "Average",
|
||||
value: String(format: "%.1f%%", averageDrawdown),
|
||||
isHighlighted: false
|
||||
)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
} else {
|
||||
Text("Not enough data for drawdown analysis")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 250)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var averageDrawdown: Double {
|
||||
guard !data.isEmpty else { return 0 }
|
||||
let sum = data.reduce(0.0) { $0 + abs($1.drawdown) }
|
||||
return sum / Double(data.count)
|
||||
}
|
||||
}
|
||||
|
||||
struct DrawdownStatView: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let isHighlighted: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(isHighlighted ? .negativeRed : .primary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Volatility Chart View
|
||||
|
||||
struct VolatilityChartView: View {
|
||||
let data: [(date: Date, volatility: Double)]
|
||||
|
||||
var currentVolatility: Double {
|
||||
data.last?.volatility ?? 0
|
||||
}
|
||||
|
||||
var averageVolatility: Double {
|
||||
guard !data.isEmpty else { return 0 }
|
||||
return data.reduce(0.0) { $0 + $1.volatility } / Double(data.count)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Text("Volatility")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("Current")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(String(format: "%.1f%%", currentVolatility))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(volatilityColor(currentVolatility))
|
||||
}
|
||||
}
|
||||
|
||||
Text("Measures price variability over time")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if data.count >= 2 {
|
||||
Chart(data, id: \.date) { item in
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Volatility", item.volatility)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
AreaMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Volatility", item.volatility)
|
||||
)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
// Average line
|
||||
RuleMark(y: .value("Average", averageVolatility))
|
||||
.foregroundStyle(Color.gray.opacity(0.5))
|
||||
.lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
|
||||
.annotation(position: .top, alignment: .leading) {
|
||||
Text("Avg: \(String(format: "%.1f%%", averageVolatility))")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: 2)) { value in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks { value in
|
||||
AxisGridLine()
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.0f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 250)
|
||||
|
||||
// Volatility interpretation
|
||||
HStack(spacing: 16) {
|
||||
VolatilityLevelView(level: "Low", range: "0-10%", color: .positiveGreen)
|
||||
VolatilityLevelView(level: "Medium", range: "10-20%", color: .appWarning)
|
||||
VolatilityLevelView(level: "High", range: "20%+", color: .negativeRed)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
} else {
|
||||
Text("Not enough data for volatility analysis")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 250)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private func volatilityColor(_ volatility: Double) -> Color {
|
||||
switch volatility {
|
||||
case 0..<10:
|
||||
return .positiveGreen
|
||||
case 10..<20:
|
||||
return .appWarning
|
||||
default:
|
||||
return .negativeRed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VolatilityLevelView: View {
|
||||
let level: String
|
||||
let range: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(level)
|
||||
.font(.caption2)
|
||||
Text(range)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let drawdownData: [(date: Date, drawdown: Double)] = [
|
||||
(Date().adding(months: -6), -5),
|
||||
(Date().adding(months: -5), -8),
|
||||
(Date().adding(months: -4), -3),
|
||||
(Date().adding(months: -3), -15),
|
||||
(Date().adding(months: -2), -10),
|
||||
(Date().adding(months: -1), -7),
|
||||
(Date(), -4)
|
||||
]
|
||||
|
||||
let volatilityData: [(date: Date, volatility: Double)] = [
|
||||
(Date().adding(months: -6), 12),
|
||||
(Date().adding(months: -5), 15),
|
||||
(Date().adding(months: -4), 10),
|
||||
(Date().adding(months: -3), 22),
|
||||
(Date().adding(months: -2), 18),
|
||||
(Date().adding(months: -1), 14),
|
||||
(Date(), 11)
|
||||
]
|
||||
|
||||
return VStack(spacing: 20) {
|
||||
DrawdownChart(data: drawdownData)
|
||||
VolatilityChartView(data: volatilityData)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct PerformanceBarChart: View {
|
||||
let data: [(category: String, cagr: Double, color: String)]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Performance by Category")
|
||||
.font(.headline)
|
||||
|
||||
Text("Compound Annual Growth Rate (CAGR)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if !data.isEmpty {
|
||||
Chart(data, id: \.category) { item in
|
||||
BarMark(
|
||||
x: .value("Category", item.category),
|
||||
y: .value("CAGR", item.cagr)
|
||||
)
|
||||
.foregroundStyle(Color(hex: item.color) ?? .gray)
|
||||
.cornerRadius(4)
|
||||
.annotation(position: item.cagr >= 0 ? .top : .bottom) {
|
||||
Text(String(format: "%.1f%%", item.cagr))
|
||||
.font(.caption2)
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks { value in
|
||||
AxisValueLabel {
|
||||
if let category = value.as(String.self) {
|
||||
Text(category)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks { value in
|
||||
AxisGridLine()
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.0f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 250)
|
||||
|
||||
// Legend / Details
|
||||
VStack(spacing: 8) {
|
||||
ForEach(data.sorted(by: { $0.cagr > $1.cagr }), id: \.category) { item in
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: item.color) ?? .gray)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
Text(item.category)
|
||||
.font(.subheadline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(String(format: "%.2f%%", item.cagr))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
} else {
|
||||
Text("No performance data available")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 250)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Horizontal Bar Version
|
||||
|
||||
struct HorizontalPerformanceChart: View {
|
||||
let data: [(category: String, cagr: Double, color: String)]
|
||||
|
||||
var sortedData: [(category: String, cagr: Double, color: String)] {
|
||||
data.sorted { $0.cagr > $1.cagr }
|
||||
}
|
||||
|
||||
var maxValue: Double {
|
||||
max(abs(data.map { $0.cagr }.max() ?? 0), abs(data.map { $0.cagr }.min() ?? 0))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Performance by Category")
|
||||
.font(.headline)
|
||||
|
||||
ForEach(sortedData, id: \.category) { item in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(item.category)
|
||||
.font(.subheadline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(String(format: "%.2f%%", item.cagr))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
|
||||
GeometryReader { geometry in
|
||||
let normalizedValue = maxValue > 0 ? abs(item.cagr) / maxValue : 0
|
||||
let barWidth = geometry.size.width * normalizedValue
|
||||
|
||||
ZStack(alignment: item.cagr >= 0 ? .leading : .trailing) {
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.1))
|
||||
.frame(height: 8)
|
||||
.cornerRadius(4)
|
||||
|
||||
Rectangle()
|
||||
.fill(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
|
||||
.frame(width: barWidth, height: 8)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
.frame(height: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let sampleData: [(category: String, cagr: Double, color: String)] = [
|
||||
("Stocks", 12.5, "#10B981"),
|
||||
("Bonds", 4.2, "#3B82F6"),
|
||||
("Real Estate", 8.1, "#F59E0B"),
|
||||
("Crypto", -5.3, "#8B5CF6")
|
||||
]
|
||||
|
||||
return VStack(spacing: 20) {
|
||||
PerformanceBarChart(data: sampleData)
|
||||
HorizontalPerformanceChart(data: sampleData)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct PredictionChartView: View {
|
||||
let predictions: [Prediction]
|
||||
let historicalData: [(date: Date, value: Decimal)]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Text(predictions.isEmpty ? "Prediction" : "\(predictions.count)-Month Prediction")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let lastPrediction = predictions.last {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("Forecast")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(lastPrediction.formattedValue)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let algorithm = predictions.first?.algorithm {
|
||||
Text("Algorithm: \(algorithm.displayName)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if !predictions.isEmpty && historicalData.count >= 2 {
|
||||
Chart {
|
||||
// Historical data
|
||||
ForEach(historicalData, id: \.date) { item in
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
PointMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.symbolSize(24)
|
||||
}
|
||||
|
||||
// Confidence interval area
|
||||
ForEach(predictions) { prediction in
|
||||
AreaMark(
|
||||
x: .value("Date", prediction.date),
|
||||
yStart: .value("Lower", NSDecimalNumber(decimal: prediction.confidenceInterval.lower).doubleValue),
|
||||
yEnd: .value("Upper", NSDecimalNumber(decimal: prediction.confidenceInterval.upper).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary.opacity(0.2))
|
||||
}
|
||||
|
||||
// Prediction line
|
||||
ForEach(predictions) { prediction in
|
||||
LineMark(
|
||||
x: .value("Date", prediction.date),
|
||||
y: .value("Predicted", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 5]))
|
||||
|
||||
PointMark(
|
||||
x: .value("Date", prediction.date),
|
||||
y: .value("Predicted", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.symbolSize(24)
|
||||
}
|
||||
|
||||
// Connect historical to prediction
|
||||
if let lastHistorical = historicalData.last,
|
||||
let firstPrediction = predictions.first {
|
||||
LineMark(
|
||||
x: .value("Date", lastHistorical.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: lastHistorical.value).doubleValue),
|
||||
series: .value("Connection", "connect")
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 5]))
|
||||
|
||||
LineMark(
|
||||
x: .value("Date", firstPrediction.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: firstPrediction.predictedValue).doubleValue),
|
||||
series: .value("Connection", "connect")
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 5]))
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: 3)) { value in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits))
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(Decimal(doubleValue).shortCurrencyString)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 280)
|
||||
|
||||
// Legend
|
||||
HStack(spacing: 20) {
|
||||
HStack(spacing: 6) {
|
||||
Rectangle()
|
||||
.fill(Color.appPrimary)
|
||||
.frame(width: 20, height: 3)
|
||||
Text("Historical")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Rectangle()
|
||||
.fill(Color.appSecondary)
|
||||
.frame(width: 20, height: 3)
|
||||
.mask(
|
||||
HStack(spacing: 2) {
|
||||
ForEach(0..<5, id: \.self) { _ in
|
||||
Rectangle()
|
||||
.frame(width: 3)
|
||||
}
|
||||
}
|
||||
)
|
||||
Text("Prediction")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Rectangle()
|
||||
.fill(Color.appSecondary.opacity(0.3))
|
||||
.frame(width: 20, height: 10)
|
||||
Text("Confidence")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Prediction details
|
||||
if let lastPrediction = predictions.last {
|
||||
Divider()
|
||||
.padding(.vertical, 8)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
Text("12-Month Forecast")
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
Text(lastPrediction.formattedValue)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Confidence Range")
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
Text(lastPrediction.formattedConfidenceRange)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let currentValue = historicalData.last?.value {
|
||||
let change = lastPrediction.predictedValue - currentValue
|
||||
let changePercent = currentValue > 0
|
||||
? NSDecimalNumber(decimal: change / currentValue).doubleValue * 100
|
||||
: 0
|
||||
|
||||
HStack {
|
||||
Text("Expected Change")
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: change >= 0 ? "arrow.up.right" : "arrow.down.right")
|
||||
.font(.caption)
|
||||
Text(String(format: "%+.1f%%", changePercent))
|
||||
.font(.subheadline.weight(.medium))
|
||||
}
|
||||
.foregroundColor(change >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "wand.and.stars")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("Not enough data for predictions")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("Add at least 3 snapshots to generate predictions")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(height: 280)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let historicalData: [(date: Date, value: Decimal)] = [
|
||||
(Date().adding(months: -6), 10000),
|
||||
(Date().adding(months: -5), 10500),
|
||||
(Date().adding(months: -4), 10200),
|
||||
(Date().adding(months: -3), 11000),
|
||||
(Date().adding(months: -2), 11500),
|
||||
(Date().adding(months: -1), 11200),
|
||||
(Date(), 12000)
|
||||
]
|
||||
|
||||
let predictions = [
|
||||
Prediction(
|
||||
date: Date().adding(months: 3),
|
||||
predictedValue: 13000,
|
||||
algorithm: .linear,
|
||||
confidenceInterval: Prediction.ConfidenceInterval(lower: 12000, upper: 14000)
|
||||
),
|
||||
Prediction(
|
||||
date: Date().adding(months: 6),
|
||||
predictedValue: 14000,
|
||||
algorithm: .linear,
|
||||
confidenceInterval: Prediction.ConfidenceInterval(lower: 12500, upper: 15500)
|
||||
),
|
||||
Prediction(
|
||||
date: Date().adding(months: 9),
|
||||
predictedValue: 15000,
|
||||
algorithm: .linear,
|
||||
confidenceInterval: Prediction.ConfidenceInterval(lower: 13000, upper: 17000)
|
||||
),
|
||||
Prediction(
|
||||
date: Date().adding(months: 12),
|
||||
predictedValue: 16000,
|
||||
algorithm: .linear,
|
||||
confidenceInterval: Prediction.ConfidenceInterval(lower: 13500, upper: 18500)
|
||||
)
|
||||
]
|
||||
|
||||
return PredictionChartView(predictions: predictions, historicalData: historicalData)
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AppBackground: View {
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.appPrimary.opacity(0.15),
|
||||
Color.appSecondary.opacity(0.12),
|
||||
Color.appAccent.opacity(0.1)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.18))
|
||||
.frame(width: proxy.size.width * 0.7)
|
||||
.offset(x: -proxy.size.width * 0.35, y: -proxy.size.height * 0.35)
|
||||
|
||||
RoundedRectangle(cornerRadius: 120, style: .continuous)
|
||||
.fill(Color.appAccent.opacity(0.12))
|
||||
.frame(width: proxy.size.width * 0.8, height: proxy.size.height * 0.35)
|
||||
.rotationEffect(.degrees(-12))
|
||||
.offset(x: proxy.size.width * 0.2, y: proxy.size.height * 0.45)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LoadingView: View {
|
||||
var message: String = "Loading..."
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
.scaleEffect(1.5)
|
||||
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.systemBackground))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Skeleton Loading
|
||||
|
||||
struct SkeletonView: View {
|
||||
@State private var isAnimating = false
|
||||
|
||||
var body: some View {
|
||||
Rectangle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.gray.opacity(0.1),
|
||||
Color.gray.opacity(0.2),
|
||||
Color.gray.opacity(0.1)
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.offset(x: isAnimating ? 200 : -200)
|
||||
.animation(
|
||||
Animation.linear(duration: 1.5)
|
||||
.repeatForever(autoreverses: false),
|
||||
value: isAnimating
|
||||
)
|
||||
.mask(Rectangle())
|
||||
.onAppear {
|
||||
isAnimating = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SkeletonCardView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SkeletonView()
|
||||
.frame(width: 100, height: 16)
|
||||
.cornerRadius(4)
|
||||
|
||||
SkeletonView()
|
||||
.frame(height: 32)
|
||||
.cornerRadius(4)
|
||||
|
||||
HStack {
|
||||
SkeletonView()
|
||||
.frame(width: 80, height: 14)
|
||||
.cornerRadius(4)
|
||||
|
||||
Spacer()
|
||||
|
||||
SkeletonView()
|
||||
.frame(width: 60, height: 14)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty State View
|
||||
|
||||
struct EmptyStateView: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let message: String
|
||||
var actionTitle: String?
|
||||
var action: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(title)
|
||||
.font(.title2.weight(.semibold))
|
||||
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
|
||||
if let actionTitle = actionTitle, let action = action {
|
||||
Button(action: action) {
|
||||
Text(actionTitle)
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.padding()
|
||||
.frame(maxWidth: 200)
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error View
|
||||
|
||||
struct ErrorView: View {
|
||||
let message: String
|
||||
var retryAction: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.appWarning)
|
||||
|
||||
Text("Something went wrong")
|
||||
.font(.headline)
|
||||
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
|
||||
if let retryAction = retryAction {
|
||||
Button(action: retryAction) {
|
||||
Label("Try Again", systemImage: "arrow.clockwise")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(20)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Success View
|
||||
|
||||
struct SuccessView: View {
|
||||
let title: String
|
||||
let message: String
|
||||
var action: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.positiveGreen.opacity(0.1))
|
||||
.frame(width: 100, height: 100)
|
||||
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.positiveGreen)
|
||||
}
|
||||
|
||||
Text(title)
|
||||
.font(.title2.weight(.bold))
|
||||
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
|
||||
if let action = action {
|
||||
Button(action: action) {
|
||||
Text("Continue")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.padding()
|
||||
.frame(maxWidth: 200)
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toast View
|
||||
|
||||
struct ToastView: View {
|
||||
enum ToastType {
|
||||
case success, error, info
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .success: return "checkmark.circle.fill"
|
||||
case .error: return "xmark.circle.fill"
|
||||
case .info: return "info.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .success: return .positiveGreen
|
||||
case .error: return .negativeRed
|
||||
case .info: return .appPrimary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let message: String
|
||||
let type: ToastType
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: type.icon)
|
||||
.foregroundColor(type.color)
|
||||
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.1), radius: 10, y: 5)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: 20) {
|
||||
LoadingView()
|
||||
.frame(height: 100)
|
||||
|
||||
SkeletonCardView()
|
||||
|
||||
EmptyStateView(
|
||||
icon: "tray",
|
||||
title: "No Data",
|
||||
message: "Start adding your investments to see them here.",
|
||||
actionTitle: "Get Started",
|
||||
action: {}
|
||||
)
|
||||
.frame(height: 300)
|
||||
|
||||
ToastView(message: "Successfully saved!", type: .success)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CategoryBreakdownCard: View {
|
||||
@EnvironmentObject private var iapService: IAPService
|
||||
let categories: [CategoryMetrics]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("By Category")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
NavigationLink {
|
||||
ChartsContainerView(iapService: iapService)
|
||||
} label: {
|
||||
Text("See All")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(categories) { category in
|
||||
CategoryRowView(category: category)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
struct CategoryRowView: View {
|
||||
let category: CategoryMetrics
|
||||
private var targetPercentage: Double? {
|
||||
AllocationTargetStore.target(for: category.id)
|
||||
}
|
||||
|
||||
private var driftText: String? {
|
||||
guard let target = targetPercentage else { return nil }
|
||||
let drift = category.percentageOfPortfolio - target
|
||||
let prefix = drift >= 0 ? "+" : ""
|
||||
return "\(prefix)\(String(format: "%.1f%%", drift))"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill((Color(hex: category.colorHex) ?? .gray).opacity(0.2))
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
Image(systemName: category.icon)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(Color(hex: category.colorHex) ?? .gray)
|
||||
}
|
||||
|
||||
// Name and percentage
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(category.categoryName)
|
||||
.font(.subheadline.weight(.medium))
|
||||
|
||||
Text(category.formattedPercentage)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if let target = targetPercentage {
|
||||
Text("Target \(String(format: "%.0f%%", target)) | Drift \(driftText ?? "")")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Value and return
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(category.formattedTotalValue)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Text("CAGR")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Text(category.metrics.formattedCAGR)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(category.metrics.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Progress Bar
|
||||
|
||||
struct CategoryProgressBar: View {
|
||||
let category: CategoryMetrics
|
||||
let maxValue: Decimal
|
||||
|
||||
var progress: Double {
|
||||
guard maxValue > 0 else { return 0 }
|
||||
return NSDecimalNumber(decimal: category.totalValue / maxValue).doubleValue
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(Color(hex: category.colorHex) ?? .gray)
|
||||
.frame(width: 8, height: 8)
|
||||
|
||||
Text(category.categoryName)
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(category.formattedPercentage)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .leading) {
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.1))
|
||||
.frame(height: 6)
|
||||
.cornerRadius(3)
|
||||
|
||||
Rectangle()
|
||||
.fill(Color(hex: category.colorHex) ?? .gray)
|
||||
.frame(width: geometry.size.width * progress, height: 6)
|
||||
.cornerRadius(3)
|
||||
}
|
||||
}
|
||||
.frame(height: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Simple Category List
|
||||
|
||||
struct SimpleCategoryList: View {
|
||||
let categories: [CategoryMetrics]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(categories) { category in
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: category.colorHex) ?? .gray)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
Text(category.categoryName)
|
||||
.font(.subheadline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(category.formattedTotalValue)
|
||||
.font(.subheadline.weight(.medium))
|
||||
|
||||
Text("(\(category.formattedPercentage))")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let sampleCategories = [
|
||||
CategoryMetrics(
|
||||
id: UUID(),
|
||||
categoryName: "Stocks",
|
||||
colorHex: "#10B981",
|
||||
icon: "chart.line.uptrend.xyaxis",
|
||||
totalValue: 50000,
|
||||
percentageOfPortfolio: 50,
|
||||
metrics: .empty
|
||||
),
|
||||
CategoryMetrics(
|
||||
id: UUID(),
|
||||
categoryName: "Bonds",
|
||||
colorHex: "#3B82F6",
|
||||
icon: "building.columns.fill",
|
||||
totalValue: 30000,
|
||||
percentageOfPortfolio: 30,
|
||||
metrics: .empty
|
||||
),
|
||||
CategoryMetrics(
|
||||
id: UUID(),
|
||||
categoryName: "Real Estate",
|
||||
colorHex: "#F59E0B",
|
||||
icon: "house.fill",
|
||||
totalValue: 20000,
|
||||
percentageOfPortfolio: 20,
|
||||
metrics: .empty
|
||||
)
|
||||
]
|
||||
|
||||
return CategoryBreakdownCard(categories: sampleCategories)
|
||||
.padding()
|
||||
.environmentObject(IAPService())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,299 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct EvolutionChartCard: View {
|
||||
let data: [(date: Date, value: Decimal)]
|
||||
let categoryData: [CategoryEvolutionPoint]
|
||||
let goals: [Goal]
|
||||
|
||||
@State private var selectedDataPoint: (date: Date, value: Decimal)?
|
||||
@State private var chartMode: ChartMode = .total
|
||||
@State private var showGoalLines = true
|
||||
|
||||
enum ChartMode: String, CaseIterable, Identifiable {
|
||||
case total = "Total"
|
||||
case byCategory = "By Category"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
headerView
|
||||
modePicker
|
||||
chartSection
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var headerView: some View {
|
||||
HStack {
|
||||
Text("Portfolio Evolution")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
showGoalLines.toggle()
|
||||
} label: {
|
||||
Image(systemName: showGoalLines ? "target" : "slash.circle")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals")
|
||||
|
||||
if let selected = selectedDataPoint, chartMode == .total {
|
||||
VStack(alignment: .trailing) {
|
||||
Text(selected.value.compactCurrencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(selected.date.monthYearString)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var modePicker: some View {
|
||||
Picker("Evolution Mode", selection: $chartMode) {
|
||||
ForEach(ChartMode.allCases) { mode in
|
||||
Text(mode.rawValue).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var chartSection: some View {
|
||||
if data.count >= 2 {
|
||||
chartView
|
||||
} else {
|
||||
Text("Not enough data to display chart")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 200)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private var chartView: some View {
|
||||
Chart {
|
||||
chartMarks
|
||||
}
|
||||
.chartForegroundStyleScale(domain: chartCategoryNames, range: chartCategoryColors)
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: 3)) { value in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(Decimal(doubleValue).shortCurrencyString)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartOverlay { proxy in
|
||||
GeometryReader { geometry in
|
||||
Rectangle()
|
||||
.fill(.clear)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
guard let plotFrameAnchor = proxy.plotFrame else { return }
|
||||
let plotFrame = geometry[plotFrameAnchor]
|
||||
let x = value.location.x - plotFrame.origin.x
|
||||
guard let date: Date = proxy.value(atX: x) else { return }
|
||||
|
||||
if let closest = data.min(by: {
|
||||
abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date))
|
||||
}) {
|
||||
selectedDataPoint = closest
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
selectedDataPoint = nil
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(height: 200)
|
||||
// Performance: Use GPU rendering for smoother scrolling
|
||||
.drawingGroup()
|
||||
}
|
||||
|
||||
@ChartContentBuilder
|
||||
private var chartMarks: some ChartContent {
|
||||
switch chartMode {
|
||||
case .total:
|
||||
ForEach(data, id: \.date) { item in
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
PointMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.symbolSize(26)
|
||||
|
||||
AreaMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
}
|
||||
case .byCategory:
|
||||
ForEach(stackedCategoryData) { item in
|
||||
AreaMark(
|
||||
x: .value("Date", item.date),
|
||||
yStart: .value("Start", NSDecimalNumber(decimal: item.start).doubleValue),
|
||||
yEnd: .value("End", NSDecimalNumber(decimal: item.end).doubleValue)
|
||||
)
|
||||
.foregroundStyle(by: .value("Category", item.categoryName))
|
||||
.interpolationMethod(.catmullRom)
|
||||
}
|
||||
}
|
||||
|
||||
if showGoalLines {
|
||||
ForEach(goals) { goal in
|
||||
RuleMark(y: .value("Goal", NSDecimalNumber(decimal: goal.targetDecimal).doubleValue))
|
||||
.foregroundStyle(Color.appSecondary.opacity(0.5))
|
||||
.lineStyle(StrokeStyle(lineWidth: 1, dash: [6, 4]))
|
||||
.annotation(position: .topTrailing) {
|
||||
Text(goal.name)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let selected = selectedDataPoint, chartMode == .total {
|
||||
RuleMark(x: .value("Selected", selected.date))
|
||||
.foregroundStyle(Color.gray.opacity(0.3))
|
||||
.lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
|
||||
|
||||
PointMark(
|
||||
x: .value("Date", selected.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: selected.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.symbolSize(100)
|
||||
}
|
||||
}
|
||||
|
||||
private var chartCategoryNames: [String] {
|
||||
let names = Array(Set(categoryData.map { $0.categoryName })).sorted()
|
||||
return names
|
||||
}
|
||||
|
||||
private struct StackedCategoryPoint: Identifiable {
|
||||
let date: Date
|
||||
let categoryName: String
|
||||
let colorHex: String
|
||||
let start: Decimal
|
||||
let end: Decimal
|
||||
|
||||
var id: String {
|
||||
"\(categoryName)-\(date.timeIntervalSince1970)"
|
||||
}
|
||||
}
|
||||
|
||||
private var stackedCategoryData: [StackedCategoryPoint] {
|
||||
let grouped = Dictionary(grouping: categoryData) { $0.date }
|
||||
let dates = grouped.keys.sorted()
|
||||
let categories = chartCategoryNames
|
||||
var stacked: [StackedCategoryPoint] = []
|
||||
|
||||
for date in dates {
|
||||
let points = grouped[date] ?? []
|
||||
var running: Decimal = 0
|
||||
|
||||
for category in categories {
|
||||
let value = points.first(where: { $0.categoryName == category })?.value ?? 0
|
||||
let start = running
|
||||
let end = running + value
|
||||
running = end
|
||||
|
||||
if let colorHex = points.first(where: { $0.categoryName == category })?.colorHex {
|
||||
stacked.append(StackedCategoryPoint(
|
||||
date: date,
|
||||
categoryName: category,
|
||||
colorHex: colorHex,
|
||||
start: start,
|
||||
end: end
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stacked
|
||||
}
|
||||
|
||||
private var chartCategoryColors: [Color] {
|
||||
chartCategoryNames.map { name in
|
||||
if let hex = categoryData.first(where: { $0.categoryName == name })?.colorHex {
|
||||
return Color(hex: hex) ?? .gray
|
||||
}
|
||||
return .gray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mini Sparkline
|
||||
|
||||
struct SparklineView: View {
|
||||
let data: [(date: Date, value: Decimal)]
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
if data.count >= 2 {
|
||||
Chart(data, id: \.date) { item in
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(color)
|
||||
.interpolationMethod(.catmullRom)
|
||||
}
|
||||
.chartXAxis(.hidden)
|
||||
.chartYAxis(.hidden)
|
||||
.chartLegend(.hidden)
|
||||
} else {
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let sampleData: [(date: Date, value: Decimal)] = [
|
||||
(Date().adding(months: -6), 10000),
|
||||
(Date().adding(months: -5), 10500),
|
||||
(Date().adding(months: -4), 10200),
|
||||
(Date().adding(months: -3), 11000),
|
||||
(Date().adding(months: -2), 11500),
|
||||
(Date().adding(months: -1), 11200),
|
||||
(Date(), 12000)
|
||||
]
|
||||
|
||||
return EvolutionChartCard(data: sampleData, categoryData: [], goals: [])
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,629 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MonthlyCheckInView: View {
|
||||
@EnvironmentObject var accountStore: AccountStore
|
||||
@StateObject private var viewModel = MonthlyCheckInViewModel()
|
||||
let referenceDate: Date
|
||||
let duplicatePrevious: Bool
|
||||
|
||||
@State private var monthlyNote: String
|
||||
@State private var starRating: Int
|
||||
@State private var selectedMood: MonthlyCheckInMood?
|
||||
@FocusState private var noteFocused: Bool
|
||||
@State private var editingSnapshot: Snapshot?
|
||||
@State private var addingSource: InvestmentSource?
|
||||
@State private var didApplyDuplicate = false
|
||||
|
||||
init(referenceDate: Date = Date(), duplicatePrevious: Bool = false) {
|
||||
self.referenceDate = referenceDate
|
||||
self.duplicatePrevious = duplicatePrevious
|
||||
_monthlyNote = State(initialValue: MonthlyCheckInStore.note(for: referenceDate))
|
||||
_starRating = State(initialValue: MonthlyCheckInStore.rating(for: referenceDate) ?? 0)
|
||||
_selectedMood = State(initialValue: MonthlyCheckInStore.mood(for: referenceDate))
|
||||
}
|
||||
|
||||
private var lastCompletionDate: Date? {
|
||||
MonthlyCheckInStore.latestCompletionDate()
|
||||
}
|
||||
|
||||
private var checkInProgress: Double {
|
||||
guard let last = lastCompletionDate,
|
||||
let nextDate = nextCheckInDate else { return 1 }
|
||||
let totalDays = Double(max(1, last.startOfDay.daysBetween(nextDate.startOfDay)))
|
||||
guard totalDays > 0 else { return 1 }
|
||||
let elapsedDays = Double(last.startOfDay.daysBetween(Date()))
|
||||
return min(max(elapsedDays / totalDays, 0), 1)
|
||||
}
|
||||
|
||||
private var checkInIntervalMonths: Int {
|
||||
if accountStore.showAllAccounts || accountStore.selectedAccount == nil {
|
||||
return NotificationFrequency.monthly.months
|
||||
}
|
||||
let account = accountStore.selectedAccount
|
||||
let frequency = account?.frequency ?? .monthly
|
||||
if frequency == .custom {
|
||||
return max(1, Int(account?.customFrequencyMonths ?? 1))
|
||||
}
|
||||
if frequency == .never {
|
||||
return NotificationFrequency.monthly.months
|
||||
}
|
||||
return frequency.months
|
||||
}
|
||||
|
||||
private var nextCheckInDate: Date? {
|
||||
guard let last = lastCompletionDate else { return nil }
|
||||
return last.adding(months: checkInIntervalMonths)
|
||||
}
|
||||
|
||||
private var canAddNewCheckIn: Bool {
|
||||
lastCompletionDate == nil || checkInProgress >= 0.7
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
headerCard
|
||||
summaryCard
|
||||
reflectionCard
|
||||
sourcesCard
|
||||
notesCard
|
||||
journalCard
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Monthly Check-in")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
viewModel.selectedAccount = accountStore.selectedAccount
|
||||
viewModel.showAllAccounts = accountStore.showAllAccounts
|
||||
viewModel.selectedRange = DateRange.month(containing: referenceDate)
|
||||
if duplicatePrevious, !didApplyDuplicate {
|
||||
viewModel.duplicatePreviousMonthSnapshots(referenceDate: referenceDate)
|
||||
didApplyDuplicate = true
|
||||
}
|
||||
viewModel.refresh()
|
||||
monthlyNote = MonthlyCheckInStore.note(for: referenceDate)
|
||||
starRating = MonthlyCheckInStore.rating(for: referenceDate) ?? 0
|
||||
selectedMood = MonthlyCheckInStore.mood(for: referenceDate)
|
||||
}
|
||||
.onReceive(accountStore.$selectedAccount) { account in
|
||||
viewModel.selectedAccount = account
|
||||
viewModel.selectedRange = DateRange.month(containing: referenceDate)
|
||||
viewModel.refresh()
|
||||
}
|
||||
.onReceive(accountStore.$showAllAccounts) { showAll in
|
||||
viewModel.showAllAccounts = showAll
|
||||
viewModel.selectedRange = DateRange.month(containing: referenceDate)
|
||||
viewModel.refresh()
|
||||
}
|
||||
.sheet(item: $editingSnapshot) { snapshot in
|
||||
if let source = snapshot.source {
|
||||
AddSnapshotView(source: source, snapshot: snapshot)
|
||||
}
|
||||
}
|
||||
.sheet(item: $addingSource) { source in
|
||||
AddSnapshotView(source: source)
|
||||
}
|
||||
.onChange(of: starRating) { _, newValue in
|
||||
MonthlyCheckInStore.setRating(newValue == 0 ? nil : newValue, for: referenceDate)
|
||||
}
|
||||
.onChange(of: selectedMood) { _, newValue in
|
||||
MonthlyCheckInStore.setMood(newValue, for: referenceDate)
|
||||
}
|
||||
}
|
||||
|
||||
private var headerCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("This Month")
|
||||
.font(.headline)
|
||||
|
||||
if let date = lastCompletionDate {
|
||||
Text(
|
||||
String(
|
||||
format: NSLocalizedString("last_check_in", comment: ""),
|
||||
date.friendlyDescription
|
||||
)
|
||||
)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("No check-in yet this month")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ProgressView(value: checkInProgress)
|
||||
.tint(.appSecondary)
|
||||
|
||||
if let nextDate = nextCheckInDate {
|
||||
Text(
|
||||
String(
|
||||
format: NSLocalizedString("next_check_in", comment: ""),
|
||||
nextDate.mediumDateString
|
||||
)
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("Start your first check-in anytime.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
let now = Date()
|
||||
let completionDate = referenceDate.isSameMonth(as: now)
|
||||
? now
|
||||
: min(referenceDate.endOfMonth, now)
|
||||
MonthlyCheckInStore.setCompletionDate(completionDate, for: referenceDate)
|
||||
viewModel.refresh()
|
||||
} label: {
|
||||
Text("Mark Check-in Complete")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
.disabled(!canAddNewCheckIn)
|
||||
|
||||
if !canAddNewCheckIn {
|
||||
Text("Editing stays open. New check-ins unlock after 70% of the month.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var reflectionCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Monthly Pulse")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text("Optional")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Rate this month")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
HStack(spacing: 8) {
|
||||
ForEach(1...5, id: \.self) { value in
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: AppConstants.Animation.shortDuration)) {
|
||||
starRating = value == starRating ? 0 : value
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: value <= starRating ? "star.fill" : "star")
|
||||
.font(.title3)
|
||||
.foregroundColor(value <= starRating ? .appSecondary : .secondary)
|
||||
.padding(8)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(value <= starRating ? Color.appSecondary.opacity(0.12) : Color.gray.opacity(0.08))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: AppConstants.Animation.shortDuration)) {
|
||||
starRating = 0
|
||||
}
|
||||
} label: {
|
||||
Text("Skip")
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.gray.opacity(0.12))
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("How did it feel?")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(MonthlyCheckInMood.allCases) { mood in
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: AppConstants.Animation.shortDuration)) {
|
||||
selectedMood = selectedMood == mood ? nil : mood
|
||||
}
|
||||
} label: {
|
||||
moodPill(for: mood)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var summaryCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Monthly Summary")
|
||||
.font(.headline)
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Starting")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(viewModel.monthlySummary.formattedStartingValue)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text("Ending")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(viewModel.monthlySummary.formattedEndingValue)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Contributions")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(viewModel.monthlySummary.formattedContributions)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text("Net Performance")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(viewModel.monthlySummary.formattedNetPerformance) (\(viewModel.monthlySummary.formattedNetPerformancePercentage))")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(viewModel.monthlySummary.netPerformance >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var sourcesCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Update Sources")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text("\(viewModel.sources.count)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if viewModel.sources.isEmpty {
|
||||
Text("Add sources to start your monthly check-in.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(viewModel.sources) { source in
|
||||
let latestSnapshot = source.latestSnapshot
|
||||
let updatedThisCycle = isSnapshotInCurrentCycle(latestSnapshot)
|
||||
Button {
|
||||
if updatedThisCycle, let snapshot = latestSnapshot {
|
||||
editingSnapshot = snapshot
|
||||
} else {
|
||||
addingSource = source
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(source.category?.color ?? .gray)
|
||||
.frame(width: 8, height: 8)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(source.name)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text(updatedThisCycle ? "Updated this cycle" : "Needs update")
|
||||
.font(.caption2)
|
||||
.foregroundColor(updatedThisCycle ? .positiveGreen : .secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(latestSnapshot?.date.relativeDescription ?? String(localized: "date_never"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var notesCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Monthly Note")
|
||||
.font(.headline)
|
||||
|
||||
TextEditor(text: $monthlyNote)
|
||||
.frame(minHeight: 120)
|
||||
.padding(8)
|
||||
.background(Color.gray.opacity(0.08))
|
||||
.cornerRadius(12)
|
||||
.focused($noteFocused)
|
||||
.onChange(of: monthlyNote) { _, newValue in
|
||||
MonthlyCheckInStore.setNote(newValue, for: referenceDate)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
NavigationLink {
|
||||
MonthlyNoteEditorView(date: referenceDate, note: $monthlyNote)
|
||||
} label: {
|
||||
Text("Open Full Note")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.appSecondary.opacity(0.12))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
viewModel.duplicatePreviousMonthSnapshots(referenceDate: referenceDate)
|
||||
viewModel.refresh()
|
||||
} label: {
|
||||
Text("Duplicate Previous")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.appPrimary.opacity(0.12))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var journalCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Snapshot Notes")
|
||||
.font(.headline)
|
||||
|
||||
if viewModel.recentNotes.isEmpty {
|
||||
Text("No snapshot notes for this month.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(viewModel.recentNotes) { snapshot in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(snapshot.source?.name ?? "Source")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(snapshot.notes ?? "")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
Text(snapshot.date.friendlyDescription)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if snapshot.id != viewModel.recentNotes.last?.id {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
struct AchievementsView: View {
|
||||
let referenceDate: Date
|
||||
|
||||
private var achievementStatuses: [MonthlyCheckInAchievementStatus] {
|
||||
MonthlyCheckInStore.achievementStatuses(referenceDate: referenceDate)
|
||||
}
|
||||
|
||||
private var unlockedAchievements: [MonthlyCheckInAchievementStatus] {
|
||||
achievementStatuses.filter { $0.isUnlocked }
|
||||
}
|
||||
|
||||
private var lockedAchievements: [MonthlyCheckInAchievementStatus] {
|
||||
achievementStatuses.filter { !$0.isUnlocked }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
headerCard
|
||||
|
||||
achievementSection(
|
||||
title: String(localized: "achievements_unlocked_title"),
|
||||
subtitle: unlockedAchievements.isEmpty
|
||||
? String(localized: "achievements_unlocked_empty")
|
||||
: nil,
|
||||
achievements: unlockedAchievements,
|
||||
isLocked: false
|
||||
)
|
||||
|
||||
achievementSection(
|
||||
title: String(localized: "achievements_locked_title"),
|
||||
subtitle: lockedAchievements.isEmpty
|
||||
? String(localized: "achievements_locked_empty")
|
||||
: nil,
|
||||
achievements: lockedAchievements,
|
||||
isLocked: true
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle(String(localized: "achievements_nav_title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var headerCard: some View {
|
||||
let total = max(achievementStatuses.count, 1)
|
||||
let unlockedCount = unlockedAchievements.count
|
||||
let progress = Double(unlockedCount) / Double(total)
|
||||
|
||||
return VStack(alignment: .leading, spacing: 8) {
|
||||
Text(String(localized: "achievements_progress_title"))
|
||||
.font(.headline)
|
||||
ProgressView(value: progress)
|
||||
.tint(.appSecondary)
|
||||
Text(
|
||||
String(
|
||||
format: NSLocalizedString("achievements_unlocked_count", comment: ""),
|
||||
unlockedCount,
|
||||
achievementStatuses.count
|
||||
)
|
||||
)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private func achievementSection(
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
achievements: [MonthlyCheckInAchievementStatus],
|
||||
isLocked: Bool
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if achievements.isEmpty {
|
||||
EmptyView()
|
||||
} else {
|
||||
ForEach(achievements) { status in
|
||||
achievementRow(status, isLocked: isLocked)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private func achievementRow(_ status: MonthlyCheckInAchievementStatus, isLocked: Bool) -> some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isLocked ? Color.gray.opacity(0.2) : Color.appSecondary.opacity(0.18))
|
||||
.frame(width: 42, height: 42)
|
||||
Image(systemName: status.achievement.icon)
|
||||
.font(.headline)
|
||||
.foregroundColor(isLocked ? .secondary : .appSecondary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(status.achievement.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(status.achievement.detail)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isLocked {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(isLocked ? Color.gray.opacity(0.08) : Color.appSecondary.opacity(0.12))
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
}
|
||||
}
|
||||
|
||||
private extension MonthlyCheckInView {
|
||||
func isSnapshotInCurrentCycle(_ snapshot: Snapshot?) -> Bool {
|
||||
guard let snapshot, let last = lastCompletionDate else { return false }
|
||||
return snapshot.date >= Calendar.current.startOfDay(for: last)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func moodPill(for mood: MonthlyCheckInMood) -> some View {
|
||||
let isSelected = selectedMood == mood
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
Image(systemName: mood.iconName)
|
||||
.font(.body)
|
||||
.foregroundColor(isSelected ? moodColor(for: mood) : .secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(mood.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(mood.detail)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(isSelected ? moodColor(for: mood).opacity(0.16) : Color.gray.opacity(0.08))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius)
|
||||
.stroke(isSelected ? moodColor(for: mood) : Color.clear, lineWidth: 1)
|
||||
)
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
}
|
||||
|
||||
func moodColor(for mood: MonthlyCheckInMood) -> Color {
|
||||
switch mood {
|
||||
case .energized: return .appSecondary
|
||||
case .confident: return .appPrimary
|
||||
case .balanced: return .teal
|
||||
case .cautious: return .orange
|
||||
case .stressed: return .red
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
MonthlyCheckInView()
|
||||
.environmentObject(AccountStore(iapService: IAPService()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GoalEditorView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var name = ""
|
||||
@State private var targetAmount = ""
|
||||
@State private var targetDate = Date()
|
||||
@State private var includeTargetDate = false
|
||||
@State private var didLoadGoal = false
|
||||
|
||||
let account: Account?
|
||||
let goal: Goal?
|
||||
private let goalRepository = GoalRepository()
|
||||
|
||||
init(account: Account?, goal: Goal? = nil) {
|
||||
self.account = account
|
||||
self.goal = goal
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Goal name", text: $name)
|
||||
TextField("Target amount", text: $targetAmount)
|
||||
.keyboardType(.decimalPad)
|
||||
|
||||
Toggle("Add target date", isOn: $includeTargetDate)
|
||||
if includeTargetDate {
|
||||
DatePicker("Target date", selection: $targetDate, displayedComponents: .date)
|
||||
.datePickerStyle(.graphical)
|
||||
}
|
||||
} header: {
|
||||
Text("Goal Details")
|
||||
}
|
||||
}
|
||||
.navigationTitle(goal == nil ? "New Goal" : "Edit Goal")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") { saveGoal() }
|
||||
.disabled(!isValid)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
.onAppear {
|
||||
guard let goal, !didLoadGoal else { return }
|
||||
name = goal.name ?? ""
|
||||
if let amount = goal.targetAmount?.decimalValue {
|
||||
targetAmount = NSDecimalNumber(decimal: amount).stringValue
|
||||
}
|
||||
if let target = goal.targetDate {
|
||||
includeTargetDate = true
|
||||
targetDate = target
|
||||
}
|
||||
didLoadGoal = true
|
||||
}
|
||||
}
|
||||
|
||||
private var isValid: Bool {
|
||||
!name.trimmingCharacters(in: .whitespaces).isEmpty && parseDecimal(targetAmount) != nil
|
||||
}
|
||||
|
||||
private func saveGoal() {
|
||||
guard let value = parseDecimal(targetAmount) else { return }
|
||||
if let goal {
|
||||
goalRepository.updateGoal(
|
||||
goal,
|
||||
name: name,
|
||||
targetAmount: value,
|
||||
targetDate: includeTargetDate ? targetDate : nil,
|
||||
clearTargetDate: !includeTargetDate
|
||||
)
|
||||
} else {
|
||||
goalRepository.createGoal(
|
||||
name: name,
|
||||
targetAmount: value,
|
||||
targetDate: includeTargetDate ? targetDate : nil,
|
||||
account: account
|
||||
)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func parseDecimal(_ value: String) -> Decimal? {
|
||||
let cleaned = value
|
||||
.replacingOccurrences(of: ",", with: ".")
|
||||
.replacingOccurrences(of: " ", with: "")
|
||||
return Decimal(string: cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
GoalEditorView(account: nil)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GoalProgressBar: View {
|
||||
let progress: Double
|
||||
var tint: Color = .appSecondary
|
||||
var background: Color = Color.gray.opacity(0.15)
|
||||
var iconName: String = "flag.checkered"
|
||||
var iconColor: Color = .appSecondary
|
||||
|
||||
private var clampedProgress: CGFloat {
|
||||
CGFloat(min(max(progress, 0), 1))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let width = geometry.size.width
|
||||
let iconOffset = max(0, width * clampedProgress - 10)
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(background)
|
||||
Capsule()
|
||||
.fill(tint)
|
||||
.frame(width: width * clampedProgress)
|
||||
}
|
||||
.overlay(alignment: .leading) {
|
||||
Image(systemName: iconName)
|
||||
.font(.caption2)
|
||||
.foregroundColor(iconColor)
|
||||
.offset(x: iconOffset)
|
||||
}
|
||||
}
|
||||
.frame(height: 8)
|
||||
.accessibilityLabel("Goal progress")
|
||||
.accessibilityValue("\(Int(progress * 100)) percent")
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
GoalProgressBar(progress: 0.45)
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GoalShareCardView: View {
|
||||
let name: String
|
||||
let progress: Double
|
||||
let currentValue: Decimal
|
||||
let targetValue: Decimal
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Goal Progress")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
Text(name)
|
||||
.font(.title2.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "sparkles")
|
||||
.font(.title2)
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
}
|
||||
|
||||
GoalProgressBar(
|
||||
progress: progress,
|
||||
tint: .white.opacity(0.9),
|
||||
background: .white.opacity(0.2),
|
||||
iconName: "sparkles",
|
||||
iconColor: .white
|
||||
)
|
||||
.frame(height: 10)
|
||||
|
||||
HStack {
|
||||
Text(currentValue.currencyString)
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
Text("of \(targetValue.currencyString)")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
|
||||
HStack {
|
||||
Image(systemName: "arrow.up.right")
|
||||
Text("Track yours in Portfolio Journal")
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 320, height: 220)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appSecondary],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
GoalShareCardView(
|
||||
name: "1M Goal",
|
||||
progress: 0.42,
|
||||
currentValue: 420_000,
|
||||
targetValue: 1_000_000
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GoalsView: View {
|
||||
@EnvironmentObject private var accountStore: AccountStore
|
||||
@StateObject private var viewModel = GoalsViewModel()
|
||||
@State private var showingAddGoal = false
|
||||
@State private var editingGoal: Goal?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
List {
|
||||
if viewModel.goals.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
Section {
|
||||
ForEach(viewModel.goals) { goal in
|
||||
GoalRowView(
|
||||
goal: goal,
|
||||
progress: viewModel.progress(for: goal),
|
||||
totalValue: viewModel.totalValue(for: goal),
|
||||
paceStatus: viewModel.paceStatus(for: goal)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
editingGoal = goal
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
viewModel.deleteGoal(goal)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: false) {
|
||||
Button {
|
||||
editingGoal = goal
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.appSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.navigationTitle("Goals")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
showingAddGoal = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAddGoal) {
|
||||
GoalEditorView(account: accountStore.showAllAccounts ? nil : accountStore.selectedAccount)
|
||||
}
|
||||
.sheet(item: $editingGoal) { goal in
|
||||
GoalEditorView(account: goal.account, goal: goal)
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.selectedAccount = accountStore.selectedAccount
|
||||
viewModel.showAllAccounts = accountStore.showAllAccounts
|
||||
viewModel.refresh()
|
||||
}
|
||||
.onReceive(accountStore.$selectedAccount) { account in
|
||||
viewModel.selectedAccount = account
|
||||
viewModel.refresh()
|
||||
}
|
||||
.onReceive(accountStore.$showAllAccounts) { showAll in
|
||||
viewModel.showAllAccounts = showAll
|
||||
viewModel.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "target")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.secondary)
|
||||
Text("Set your first goal")
|
||||
.font(.headline)
|
||||
Text("Track progress toward milestones like \(AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol)1M and share your wins.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
}
|
||||
}
|
||||
|
||||
struct GoalRowView: View {
|
||||
let goal: Goal
|
||||
let progress: Double
|
||||
let totalValue: Decimal
|
||||
let paceStatus: GoalPaceStatus?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text(goal.name)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
GoalShareService.shared.shareGoal(
|
||||
name: goal.name,
|
||||
progress: progress,
|
||||
currentValue: totalValue,
|
||||
targetValue: goal.targetDecimal
|
||||
)
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
GoalProgressBar(progress: progress, tint: .appSecondary, iconColor: .appSecondary)
|
||||
|
||||
HStack {
|
||||
Text(totalValue.currencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
Text("of \(goal.targetDecimal.currencyString)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let targetDate = goal.targetDate {
|
||||
Text("Target date: \(targetDate.mediumDateString)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let paceStatus {
|
||||
Text(paceStatus.statusText)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(paceStatus.isBehind ? .negativeRed : .positiveGreen)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
GoalsView()
|
||||
.environmentObject(AccountStore(iapService: IAPService()))
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import SwiftUI
|
||||
|
||||
struct JournalView: View {
|
||||
@StateObject private var viewModel = JournalViewModel()
|
||||
@State private var searchText = ""
|
||||
@State private var scrubberActive = false
|
||||
@State private var scrubberLabel = ""
|
||||
@State private var scrubberOffset: CGFloat = 0
|
||||
@State private var currentVisibleMonth: Date?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollViewReader { proxy in
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
List {
|
||||
Section("Monthly Check-ins") {
|
||||
if filteredMonthlyNotes.isEmpty {
|
||||
Text(searchText.isEmpty ? "No monthly notes yet." : "No matching notes.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(filteredMonthlyNotes) { entry in
|
||||
NavigationLink {
|
||||
MonthlyCheckInView(referenceDate: entry.date)
|
||||
} label: {
|
||||
monthlyNoteRow(entry)
|
||||
}
|
||||
.id(entry.date)
|
||||
.onAppear {
|
||||
currentVisibleMonth = entry.date
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.overlay(alignment: .trailing) {
|
||||
monthScrubber(proxy: proxy)
|
||||
}
|
||||
.navigationTitle("Journal")
|
||||
.searchable(text: $searchText, prompt: "Search monthly notes")
|
||||
.onAppear {
|
||||
viewModel.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredMonthlyNotes: [MonthlyNoteItem] {
|
||||
let trimmedQuery = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let hasQuery = !trimmedQuery.isEmpty
|
||||
let query = trimmedQuery.lowercased()
|
||||
return viewModel.monthlyNotes.filter { entry in
|
||||
!hasQuery
|
||||
|| entry.note.lowercased().contains(query)
|
||||
|| entry.date.monthYearString.lowercased().contains(query)
|
||||
}
|
||||
}
|
||||
|
||||
private func monthlyNoteRow(_ entry: MonthlyNoteItem) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(entry.date.monthYearString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
if let mood = entry.mood {
|
||||
moodPill(mood)
|
||||
} else {
|
||||
Text("Mood not set")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
starRow(rating: entry.rating)
|
||||
if entry.rating == nil {
|
||||
Text("No rating")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Text(entry.note.isEmpty ? "No note yet." : entry.note)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func starRow(rating: Int?) -> some View {
|
||||
let value = rating ?? 0
|
||||
return HStack(spacing: 4) {
|
||||
ForEach(1...5, id: \.self) { index in
|
||||
Image(systemName: index <= value ? "star.fill" : "star")
|
||||
.foregroundColor(index <= value ? .yellow : .secondary)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(
|
||||
Text(String(format: NSLocalizedString("rating_accessibility", comment: ""), value))
|
||||
)
|
||||
}
|
||||
|
||||
private func moodPill(_ mood: MonthlyCheckInMood) -> some View {
|
||||
Label(mood.title, systemImage: mood.iconName)
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.gray.opacity(0.15))
|
||||
.foregroundColor(.primary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
private func monthScrubber(proxy: ScrollViewProxy) -> some View {
|
||||
GeometryReader { geometry in
|
||||
let height = geometry.size.height
|
||||
let count = filteredMonthlyNotes.count
|
||||
let currentIndex = currentVisibleMonth.flatMap { month in
|
||||
filteredMonthlyNotes.firstIndex(where: { $0.date.isSameMonth(as: month) })
|
||||
}
|
||||
|
||||
ZStack(alignment: .trailing) {
|
||||
if let currentVisibleMonth, !scrubberActive {
|
||||
Text(currentVisibleMonth.monthYearString)
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(.systemBackground).opacity(0.95))
|
||||
.cornerRadius(12)
|
||||
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
|
||||
.offset(x: -16)
|
||||
}
|
||||
|
||||
if scrubberActive {
|
||||
Text(scrubberLabel)
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(.systemBackground).opacity(0.95))
|
||||
.cornerRadius(12)
|
||||
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
|
||||
.offset(x: -16, y: scrubberOffset - height / 2)
|
||||
}
|
||||
|
||||
if let currentIndex, count > 1 {
|
||||
let progress = CGFloat(currentIndex) / CGFloat(max(count - 1, 1))
|
||||
Circle()
|
||||
.fill(Color.appSecondary.opacity(0.9))
|
||||
.frame(width: 12, height: 12)
|
||||
.offset(y: progress * height - height / 2)
|
||||
}
|
||||
|
||||
Capsule()
|
||||
.fill(Color.secondary.opacity(0.35))
|
||||
.frame(width: 6)
|
||||
.frame(maxHeight: .infinity, alignment: .trailing)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
|
||||
.padding(.trailing, 8)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
guard count > 0 else { return }
|
||||
scrubberActive = true
|
||||
let clampedY = min(max(value.location.y, 0), height)
|
||||
scrubberOffset = clampedY
|
||||
let ratio = clampedY / max(height, 1)
|
||||
let index = min(max(Int(round(ratio * CGFloat(count - 1))), 0), count - 1)
|
||||
let entry = filteredMonthlyNotes[index]
|
||||
scrubberLabel = entry.date.monthYearString
|
||||
proxy.scrollTo(entry.id, anchor: .top)
|
||||
}
|
||||
.onEnded { _ in
|
||||
scrubberActive = false
|
||||
}
|
||||
)
|
||||
.allowsHitTesting(count > 0)
|
||||
}
|
||||
.frame(width: 72)
|
||||
}
|
||||
}
|
||||
|
||||
struct MonthlyNoteEditorView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let date: Date
|
||||
@Binding var note: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(date.monthYearString)
|
||||
.font(.headline)
|
||||
|
||||
TextEditor(text: $note)
|
||||
.frame(minHeight: 200)
|
||||
.padding(8)
|
||||
.background(Color.gray.opacity(0.08))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Monthly Note")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
MonthlyCheckInStore.setNote(note, for: date)
|
||||
dismiss()
|
||||
}
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
JournalView()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OnboardingView: View {
|
||||
@Binding var onboardingCompleted: Bool
|
||||
|
||||
@State private var currentPage = 0
|
||||
@State private var selectedCurrency = Locale.current.currency?.identifier ?? "EUR"
|
||||
@State private var useSampleData = true
|
||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
||||
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
|
||||
@State private var showingImportSheet = false
|
||||
@State private var showingAddSource = false
|
||||
|
||||
private let pages: [OnboardingPage] = [
|
||||
OnboardingPage(
|
||||
icon: "chart.pie.fill",
|
||||
title: "Long-Term Tracking",
|
||||
description: "A calm, offline-first portfolio tracker for investors who update monthly.",
|
||||
color: .appPrimary
|
||||
),
|
||||
OnboardingPage(
|
||||
icon: "calendar.circle.fill",
|
||||
title: "Monthly Check-ins",
|
||||
description: "Build a deliberate habit. Update sources, log contributions, and add a short note.",
|
||||
color: .positiveGreen
|
||||
),
|
||||
OnboardingPage(
|
||||
icon: "bell.badge.fill",
|
||||
title: "Gentle Reminders",
|
||||
description: "Get a monthly nudge to review your portfolio without realtime noise.",
|
||||
color: .appWarning
|
||||
),
|
||||
OnboardingPage(
|
||||
icon: "leaf.fill",
|
||||
title: "Calm Mode",
|
||||
description: "Hide short-term swings and focus on contributions and long-term growth.",
|
||||
color: .appSecondary
|
||||
)
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
// Pages
|
||||
TabView(selection: $currentPage) {
|
||||
ForEach(0..<pages.count, id: \.self) { index in
|
||||
OnboardingPageView(page: pages[index])
|
||||
.tag(index)
|
||||
}
|
||||
|
||||
OnboardingQuickStartView(
|
||||
selectedCurrency: $selectedCurrency,
|
||||
useSampleData: $useSampleData,
|
||||
calmModeEnabled: $calmModeEnabled,
|
||||
cloudSyncEnabled: $cloudSyncEnabled,
|
||||
onImport: { showingImportSheet = true },
|
||||
onAddSource: { showingAddSource = true }
|
||||
)
|
||||
.tag(pages.count)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.animation(.easeInOut, value: currentPage)
|
||||
|
||||
// Page indicators
|
||||
HStack(spacing: 8) {
|
||||
ForEach(0..<(pages.count + 1), id: \.self) { index in
|
||||
Circle()
|
||||
.fill(currentPage == index ? Color.appPrimary : Color.gray.opacity(0.3))
|
||||
.frame(width: 8, height: 8)
|
||||
.animation(.easeInOut, value: currentPage)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 20)
|
||||
|
||||
// Buttons
|
||||
VStack(spacing: 12) {
|
||||
if currentPage < pages.count {
|
||||
Button {
|
||||
withAnimation {
|
||||
currentPage += 1
|
||||
}
|
||||
} label: {
|
||||
Text("Continue")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
|
||||
Button {
|
||||
completeOnboarding()
|
||||
} label: {
|
||||
Text("Skip")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
completeOnboarding()
|
||||
} label: {
|
||||
Text("Get Started")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
.background(AppBackground())
|
||||
.onAppear {
|
||||
selectedCurrency = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
|
||||
}
|
||||
.sheet(isPresented: $showingImportSheet) {
|
||||
ImportDataView()
|
||||
}
|
||||
.sheet(isPresented: $showingAddSource) {
|
||||
AddSourceView()
|
||||
}
|
||||
}
|
||||
|
||||
private func completeOnboarding() {
|
||||
// Create default categories
|
||||
let categoryRepository = CategoryRepository()
|
||||
categoryRepository.createDefaultCategoriesIfNeeded()
|
||||
|
||||
// Create default account if needed
|
||||
AccountRepository().createDefaultAccountIfNeeded()
|
||||
|
||||
// Mark onboarding as complete
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
let settings = AppSettings.getOrCreate(in: context)
|
||||
settings.currency = selectedCurrency
|
||||
settings.onboardingCompleted = true
|
||||
CoreDataStack.shared.save()
|
||||
|
||||
if useSampleData {
|
||||
SampleDataService.shared.seedSampleData()
|
||||
}
|
||||
|
||||
// Log analytics
|
||||
FirebaseService.shared.logOnboardingCompleted(stepCount: pages.count + 1)
|
||||
|
||||
// Request notification permission
|
||||
Task {
|
||||
await NotificationService.shared.requestAuthorization()
|
||||
}
|
||||
|
||||
withAnimation {
|
||||
onboardingCompleted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Onboarding Page Model
|
||||
|
||||
struct OnboardingPage {
|
||||
let icon: String
|
||||
let title: String
|
||||
let description: String
|
||||
let color: Color
|
||||
}
|
||||
|
||||
// MARK: - Onboarding Page View
|
||||
|
||||
struct OnboardingPageView: View {
|
||||
let page: OnboardingPage
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 32) {
|
||||
Spacer()
|
||||
|
||||
// Icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(page.color.opacity(0.15))
|
||||
.frame(width: 140, height: 140)
|
||||
|
||||
Circle()
|
||||
.fill(page.color.opacity(0.3))
|
||||
.frame(width: 100, height: 100)
|
||||
|
||||
Image(systemName: page.icon)
|
||||
.font(.system(size: 50))
|
||||
.foregroundColor(page.color)
|
||||
}
|
||||
|
||||
// Content
|
||||
VStack(spacing: 16) {
|
||||
Text(page.title)
|
||||
.font(.title.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(page.description)
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Quick Start
|
||||
|
||||
struct OnboardingQuickStartView: View {
|
||||
@Binding var selectedCurrency: String
|
||||
@Binding var useSampleData: Bool
|
||||
@Binding var calmModeEnabled: Bool
|
||||
@Binding var cloudSyncEnabled: Bool
|
||||
let onImport: () -> Void
|
||||
let onAddSource: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Image("BrandMark")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 72, height: 72)
|
||||
|
||||
Text("Quick Start")
|
||||
.font(.title.weight(.bold))
|
||||
|
||||
Text("Pick your currency and start with sample data or import your own.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 30)
|
||||
}
|
||||
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
Text("Currency")
|
||||
Spacer()
|
||||
Picker("Currency", selection: $selectedCurrency) {
|
||||
ForEach(CurrencyPicker.commonCodes, id: \.self) { code in
|
||||
Text(code).tag(code)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
Toggle("Load sample portfolio", isOn: $useSampleData)
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
Toggle("Enable Calm Mode (recommended)", isOn: $calmModeEnabled)
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
Toggle("Sync with iCloud (optional)", isOn: $cloudSyncEnabled)
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
if cloudSyncEnabled {
|
||||
Text("iCloud sync starts after you restart the app.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
onImport()
|
||||
} label: {
|
||||
Label("Import", systemImage: "square.and.arrow.down")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
Button {
|
||||
onAddSource()
|
||||
} label: {
|
||||
Label("Add Source", systemImage: "plus")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.appSecondary.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - First Launch Welcome
|
||||
|
||||
struct WelcomeView: View {
|
||||
@Binding var showWelcome: Bool
|
||||
let userName: String?
|
||||
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "hand.wave.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.appPrimary)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
if let name = userName {
|
||||
Text("Welcome back, \(name)!")
|
||||
.font(.title.weight(.bold))
|
||||
} else {
|
||||
Text("Welcome!")
|
||||
.font(.title.weight(.bold))
|
||||
}
|
||||
|
||||
Text(cloudSyncEnabled
|
||||
? "Your investment data has been synced from iCloud."
|
||||
: "Your investment data stays on this device.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
withAnimation {
|
||||
showWelcome = false
|
||||
}
|
||||
} label: {
|
||||
Text("Continue")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
.background(Color(.systemBackground))
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
OnboardingView(onboardingCompleted: .constant(false))
|
||||
.environmentObject(AccountStore(iapService: IAPService()))
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PaywallView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject var iapService: IAPService
|
||||
|
||||
@State private var isPurchasing = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showingError = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// Header
|
||||
headerSection
|
||||
|
||||
// Features List
|
||||
featuresSection
|
||||
|
||||
// Price Card
|
||||
priceCard
|
||||
|
||||
// Purchase Button
|
||||
purchaseButton
|
||||
|
||||
// Restore Button
|
||||
restoreButton
|
||||
|
||||
// Legal
|
||||
legalSection
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Upgrade to Premium")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Error", isPresented: $showingError) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(errorMessage ?? "An error occurred")
|
||||
}
|
||||
.onChange(of: iapService.isPremium) { _, isPremium in
|
||||
if isPremium {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header Section
|
||||
|
||||
private var headerSection: some View {
|
||||
VStack(spacing: 16) {
|
||||
// Crown icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color.yellow.opacity(0.3), Color.orange.opacity(0.3)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(width: 80, height: 80)
|
||||
|
||||
Image(systemName: "crown.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [.yellow, .orange],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Text("Unlock Full Potential")
|
||||
.font(.title.weight(.bold))
|
||||
|
||||
Text("Get unlimited access to all features with a one-time purchase")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Features Section
|
||||
|
||||
private var featuresSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(IAPService.premiumFeatures, id: \.title) { feature in
|
||||
FeatureRow(
|
||||
icon: feature.icon,
|
||||
title: feature.title,
|
||||
description: feature.description
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
// MARK: - Price Card
|
||||
|
||||
private var priceCard: some View {
|
||||
VStack(spacing: 8) {
|
||||
HStack(alignment: .top, spacing: 4) {
|
||||
Text("€")
|
||||
.font(.title2.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
|
||||
Text("4.69")
|
||||
.font(.system(size: 48, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
|
||||
Text("One-time purchase")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.caption)
|
||||
Text("Includes Family Sharing")
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundColor(.appSecondary)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 24)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)
|
||||
.fill(Color.appPrimary.opacity(0.1))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)
|
||||
.stroke(Color.appPrimary, lineWidth: 2)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Purchase Button
|
||||
|
||||
private var purchaseButton: some View {
|
||||
Button {
|
||||
purchase()
|
||||
} label: {
|
||||
HStack {
|
||||
if isPurchasing {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
} else {
|
||||
Text("Upgrade Now")
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
.disabled(isPurchasing)
|
||||
}
|
||||
|
||||
// MARK: - Restore Button
|
||||
|
||||
private var restoreButton: some View {
|
||||
Button {
|
||||
restore()
|
||||
} label: {
|
||||
Text("Restore Purchases")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
.disabled(isPurchasing)
|
||||
}
|
||||
|
||||
// MARK: - Legal Section
|
||||
|
||||
private var legalSection: some View {
|
||||
VStack(spacing: 8) {
|
||||
Text("Payment will be charged to your Apple ID account. By purchasing, you agree to our Terms of Service and Privacy Policy.")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack(spacing: 16) {
|
||||
Link("Terms", destination: URL(string: AppConstants.URLs.termsOfService)!)
|
||||
.font(.caption)
|
||||
|
||||
Link("Privacy", destination: URL(string: AppConstants.URLs.privacyPolicy)!)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func purchase() {
|
||||
isPurchasing = true
|
||||
FirebaseService.shared.logPurchaseAttempt(productId: IAPService.premiumProductID)
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await iapService.purchase()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showingError = true
|
||||
}
|
||||
isPurchasing = false
|
||||
}
|
||||
}
|
||||
|
||||
private func restore() {
|
||||
isPurchasing = true
|
||||
|
||||
Task {
|
||||
await iapService.restorePurchases()
|
||||
|
||||
if !iapService.isPremium {
|
||||
errorMessage = "No purchases found to restore"
|
||||
showingError = true
|
||||
}
|
||||
isPurchasing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Feature Row
|
||||
|
||||
struct FeatureRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let description: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.1))
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18))
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.positiveGreen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compact Paywall (for inline use)
|
||||
|
||||
struct CompactPaywallBanner: View {
|
||||
@Binding var showingPaywall: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "crown.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [.yellow, .orange],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Unlock Premium")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
Text("Get unlimited access to all features")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
showingPaywall = true
|
||||
} label: {
|
||||
Text("€4.69")
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(16)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Premium Lock Overlay
|
||||
|
||||
struct PremiumLockOverlay: View {
|
||||
let feature: String
|
||||
@Binding var showingPaywall: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 32))
|
||||
.foregroundColor(.appWarning)
|
||||
|
||||
Text("Premium Feature")
|
||||
.font(.headline)
|
||||
|
||||
Text(feature)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button {
|
||||
showingPaywall = true
|
||||
} label: {
|
||||
Label("Unlock", systemImage: "crown.fill")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(20)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.systemBackground).opacity(0.95))
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
PaywallView()
|
||||
.environmentObject(IAPService())
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AppLockView: View {
|
||||
@Binding var isUnlocked: Bool
|
||||
@AppStorage("faceIdEnabled") private var faceIdEnabled = false
|
||||
@AppStorage("pinEnabled") private var pinEnabled = false
|
||||
|
||||
@State private var pin = ""
|
||||
@State private var errorMessage: String?
|
||||
@State private var didAttemptBiometrics = false
|
||||
@FocusState private var pinFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(.systemBackground)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 20) {
|
||||
Spacer()
|
||||
|
||||
Image("BrandMark")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 64, height: 64)
|
||||
|
||||
Text("Locked")
|
||||
.font(.title.weight(.bold))
|
||||
|
||||
Text("Unlock Portfolio Journal to view your data.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 30)
|
||||
|
||||
if faceIdEnabled {
|
||||
Button {
|
||||
authenticateWithBiometrics()
|
||||
} label: {
|
||||
Label("Unlock with Face ID", systemImage: "faceid")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
if pinEnabled {
|
||||
VStack(spacing: 10) {
|
||||
SecureField("4-digit PIN", text: $pin)
|
||||
.keyboardType(.numberPad)
|
||||
.textContentType(.oneTimeCode)
|
||||
.multilineTextAlignment(.center)
|
||||
.font(.title3.weight(.semibold))
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
.focused($pinFocused)
|
||||
.onChange(of: pin) { _, newValue in
|
||||
pin = String(newValue.filter(\.isNumber).prefix(4))
|
||||
errorMessage = nil
|
||||
if pin.count == 4 {
|
||||
validatePin()
|
||||
}
|
||||
}
|
||||
|
||||
Button("Unlock") {
|
||||
validatePin()
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.disabled(pin.count < 4)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.caption)
|
||||
.foregroundColor(.negativeRed)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if faceIdEnabled && !didAttemptBiometrics {
|
||||
didAttemptBiometrics = true
|
||||
authenticateWithBiometrics()
|
||||
} else if pinEnabled {
|
||||
pinFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func authenticateWithBiometrics() {
|
||||
AppLockService.authenticate(reason: "Unlock your portfolio") { success in
|
||||
if success {
|
||||
isUnlocked = true
|
||||
} else if pinEnabled {
|
||||
pinFocused = true
|
||||
} else {
|
||||
errorMessage = "Face ID failed. Please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func validatePin() {
|
||||
guard let storedPin = KeychainService.readPin() else {
|
||||
errorMessage = "PIN not set."
|
||||
pin = ""
|
||||
return
|
||||
}
|
||||
if pin == storedPin {
|
||||
isUnlocked = true
|
||||
pin = ""
|
||||
errorMessage = nil
|
||||
} else {
|
||||
errorMessage = "Incorrect PIN."
|
||||
pin = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user